Pre-launch hardening: KOL module, Telegram, scanners, WS resilience

Big-picture changes since b941223:

KOL pipeline (new) — Substack/podcast/blog RSS → AI ticker extraction →
on-chain wallet diff → talks-vs-trades divergence detection. Daily polls,
19 feeds, divergence emits Post + Telegram fan-out.

Telegram push (new) — walletless free tier + wallet-linked Pro upgrade,
in-bot preference commands (/trump /btc /funding /kol /conf /quiet),
signed-envelope API for dashboard. Disconnect-wallet keeps free
subscription.

BTC funding-rate reversal scanner (new) — hourly cron, 30d cumulative
funding threshold + mean-revert + 7d price confirm, emits via
/api/signals/ingest. BTC bottom-reversal scanner promoted to System 2.

WS broadcast rewrite — per-client send timeout + parallel fan-out
(asyncio.gather). Fixes "Binance WS no close frame" reconnect storms +
APScheduler 11-min job misses, both caused by one slow client stalling
the kline loop.

Error visibility — three silent-error sites (trumpstruth/truth_social
fetchers, funding_reversal scanner) now include exception type name so
httpx ConnectError-style empty-message errors stop logging blank lines.
Telegram bot loop now classifies ReadTimeout vs network vs unknown +
logger.exception for the unknown bucket.

Security hygiene — trumpsignal.db untracked from git (held subscriber
wallets + encrypted HL keys + 22 bot trades); .gitignore now blocks
*.db/.next/backups. CORS only allows FRONTEND_URL in production.

New ops scripts —
  - scripts/preflight.py: env/DB/Telegram/AI auth verification gate
  - scripts/backup_db.sh: cron-friendly daily DB backup (SQLite + Postgres)
  - scripts/seed_kol_wallets.py: idempotent KOL on-chain wallet seeder

15 new Alembic migrations (007-021) covering convex strategy fields,
phase-1 safety, two-system frozen exits, invalidation prices, dynamic
SYS2 leverage, staged de-risk + pyramiding, peak gain tracking, risk
mode, auto-trade + grow flags, KOL module, KOL on-chain, KOL divergence,
Telegram bindings + walletless.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-25 00:52:56 +08:00
parent b941223c88
commit 5fb1d52026
81 changed files with 13251 additions and 158 deletions
+190
View File
@@ -0,0 +1,190 @@
"""Pure-price BTC bottom indicators (no on-chain data, no API keys).
Three independent, well-known bottom signals. Each is a simple, transparent
function of price history only — no Realized Cap, no MVRV, no paid feeds:
A. AHR999 — value/DCA index. < 0.45 = deep-value bottom zone.
B. 200-Week MA — BTC has bottomed near its 200WMA every cycle.
C. Pi Cycle Bot — 150d EMA vs 471d SMA × 0.745 crossover region.
The combiner fires only when at least 2 of the 3 agree ("三者为二"), which
historically pins genuine macro bottoms while rejecting single-indicator
noise. Long-only, low-frequency, stateless.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import date, datetime, timezone
# BTC genesis block: 2009-01-03.
_BTC_GENESIS = date(2009, 1, 3)
# AHR999 thresholds.
AHR999_BOTTOM = 0.45 # < this → deep-value / bottom zone (signal A)
AHR999_INVALIDATION = 1.2 # > this → no longer cheap, thesis dead
# 200WMA tolerance: price within +5% of the 200-week mean still counts.
WMA200_TOLERANCE = 1.05
# Pi Cycle Bottom multiplier (the canonical 0.745).
PI_CYCLE_MULT = 0.745
PI_CYCLE_EMA = 150
PI_CYCLE_SMA = 471
def _closes(candles: list[dict]) -> list[float]:
return [float(c["close"]) for c in candles if c.get("close")]
def _sma(values: list[float], n: int) -> float | None:
if len(values) < n:
return None
return sum(values[-n:]) / n
def _ema(values: list[float], n: int) -> float | None:
if len(values) < n:
return None
k = 2.0 / (n + 1)
# Seed with the SMA of the first n values, then walk forward.
ema = sum(values[:n]) / n
for v in values[n:]:
ema = v * k + ema * (1 - k)
return ema
def coin_age_days(today: date | None = None) -> int:
d = today or datetime.now(timezone.utc).date()
return max(1, (d - _BTC_GENESIS).days)
def ahr999(daily_closes: list[float], today: date | None = None) -> float | None:
"""AHR999 = (price / GM200) × (price / growth_val).
GM200 = geometric mean of the last 200 daily closes
growth_val = 10 ** (5.84 * log10(coin_age_days) - 17.01)
< 0.45 deep value · 0.451.2 DCA · > 1.2 expensive.
"""
if len(daily_closes) < 200:
return None
price = daily_closes[-1]
if price <= 0:
return None
window = daily_closes[-200:]
# Geometric mean via log-space (avoids overflow).
log_sum = sum(math.log(c) for c in window if c > 0)
gm200 = math.exp(log_sum / 200)
age = coin_age_days(today)
growth_val = 10 ** (5.84 * math.log10(age) - 17.01)
if gm200 <= 0 or growth_val <= 0:
return None
return (price / gm200) * (price / growth_val)
def below_200wma(
weekly_closes: list[float],
price: float | None = None,
) -> tuple[bool, float | None]:
"""True if price ≤ 200-week mean × 1.05. Returns (signal, wma200).
`price` is the reference price to compare. Pass the latest DAILY close so
all three indicators use the same "current price" — otherwise the last
weekly close can be up to 7 days stale and B would disagree with A/C right
at a fast-moving bottom. Falls back to the last weekly close if omitted.
"""
wma = _sma(weekly_closes, 200)
if wma is None or not weekly_closes:
return False, wma
px = price if price is not None else weekly_closes[-1]
return px <= wma * WMA200_TOLERANCE, wma
def pi_cycle_bottom(daily_closes: list[float]) -> tuple[bool, float | None, float | None]:
"""Pi Cycle Bottom: 150d EMA ≤ 471d SMA × 0.745.
Returns (signal, ema150, sma471_scaled).
"""
ema150 = _ema(daily_closes, PI_CYCLE_EMA)
sma471 = _sma(daily_closes, PI_CYCLE_SMA)
if ema150 is None or sma471 is None:
return False, ema150, None
scaled = sma471 * PI_CYCLE_MULT
return ema150 <= scaled, ema150, scaled
def trend_confirmed(
daily_closes: list[float],
daily_highs: list[float],
price: float,
sma_n: int = 200,
high_lookback: int = 20,
high_tol: float = 0.005,
) -> bool:
"""Structural confirmation that the bottom reversal has turned into an
actual uptrend — gate for pyramiding (add-to-winner):
price ≥ 200-day SMA (trend regime reclaimed)
AND price ≥ recent 20d high·(10.5%) (at/near a fresh local high)
Pure function (no I/O) so it's unit-testable; the caller fetches candles.
Conservative: both conditions must hold, evaluated at add-on time.
"""
sma = _sma(daily_closes, sma_n)
if sma is None or not daily_highs:
return False
recent_high = max(daily_highs[-high_lookback:]) if len(daily_highs) >= 1 else None
if recent_high is None or recent_high <= 0:
return False
return price >= sma and price >= recent_high * (1.0 - high_tol)
@dataclass(frozen=True)
class BottomConfluence:
fired: bool # ≥ 2 of 3 true
votes: int # 0..3
ahr999: float | None
a_value: bool # AHR999 < 0.45
b_200wma: bool # price ≤ 200WMA × 1.05
c_pi_cycle: bool # 150 EMA ≤ 471 SMA × 0.745
detail: dict
def bottom_confluence(
daily_closes: list[float],
weekly_closes: list[float],
today: date | None = None,
) -> BottomConfluence:
"""2-of-3 bottom confluence. Pure price, stateless."""
ah = ahr999(daily_closes, today)
a = ah is not None and ah < AHR999_BOTTOM
# All three indicators compare against the SAME current price (latest
# daily close), so a stale weekly close can't make B disagree with A/C.
cur_price = daily_closes[-1] if daily_closes else None
b, wma200 = below_200wma(weekly_closes, cur_price)
c, ema150, sma471s = pi_cycle_bottom(daily_closes)
votes = int(a) + int(b) + int(c)
detail = {
"ahr999": round(ah, 4) if ah is not None else None,
"ahr999_threshold": AHR999_BOTTOM,
"price": round(daily_closes[-1], 2) if daily_closes else None,
"wma200": round(wma200, 2) if wma200 is not None else None,
"wma200_band": round(wma200 * WMA200_TOLERANCE, 2) if wma200 is not None else None,
"pi_ema150": round(ema150, 2) if ema150 is not None else None,
"pi_sma471_scaled": round(sma471s, 2) if sma471s is not None else None,
"votes": votes,
"signals": {"ahr999_value": a, "below_200wma": b, "pi_cycle_bottom": c},
}
return BottomConfluence(
fired=votes >= 2,
votes=votes,
ahr999=ah,
a_value=a,
b_200wma=b,
c_pi_cycle=c,
detail=detail,
)