5fb1d52026
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>
191 lines
6.5 KiB
Python
191 lines
6.5 KiB
Python
"""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.45–1.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·(1−0.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,
|
||
)
|