Files
k 5fb1d52026 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>
2026-05-25 00:52:56 +08:00

147 lines
5.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
200-day SMA Reclaim scanner.
What it catches:
The moment a price that has been LIVING BELOW its 200-day moving average
for a sustained period climbs back ABOVE it on real volume. Historically
one of the most reliable "trend has changed" markers in any market —
hedge fund books, retail TA tools, momentum quants, everyone watches it.
Examples this would have caught:
BTC 2023-01 (~$22k, after the FTX flush)
BTC 2024-09 (after Q3 chop)
ETH 2023-01 (~$1500)
SOL 2023-02 (~$24, after FTX)
Trigger logic:
PRE-CONDITION: For the past DAYS_BELOW_REQUIRED days, daily close has been
BELOW the rolling 200-day SMA. (proves we're reversing a
sustained downtrend, not crossing a flat MA in chop)
TRIGGER: Today's close > 200-day SMA, AND
Today's volume > 1.3 × 30-day avg volume.
COOLDOWN: 30 days — false reclaims and shake-outs happen, don't
re-fire on noise.
Companion exit profile:
SL = 6%
TRAILING_ACTIVATE = 12%
TRAILING_STOP = 5%
MAX_HOLD = 90 days
The 90-day max-hold matches the holding period needed for a real trend
change to play out (~3 months is the historical median for a confirmed
200d-SMA-reclaim trend run).
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from app.config import settings
from app.services.market_data import REVERSAL_BASKET, for_asset, drop_in_progress_bar
# LIBRARY MODULE — NOT a standalone scanner. evaluate_sma_reclaim() is
# imported by btc_bottom_reversal.py as the price-reclaim entry gate. It
# deliberately does NOT register with scanner_state: no UI toggle, no
# schedule. (Old standalone scan_once/_emit_signal removed — see git log.)
logger = logging.getLogger(__name__)
# ─── Tunables ───────────────────────────────────────────────────────────────
SMA_PERIOD = 200
DAYS_BELOW_REQUIRED = 30 # how long the asset must have been under SMA
VOLUME_LOOKBACK_DAYS = 30
VOLUME_MULT_MIN = 1.3
DAYS_TO_FETCH = 260 # SMA(200) + 30d-below check + safety margin
COOLDOWN_DAYS = 30
PAYLOAD_CONFIDENCE = 85
PAYLOAD_EXPECTED_MOVE = 20.0 # historical median 90-day run after reclaim
# Cooldown via scanner_state.in_cooldown — DB-backed, restart-safe.
# ─── Signal logic ───────────────────────────────────────────────────────────
def evaluate_sma_reclaim(daily_candles: list[dict]) -> tuple[bool, dict]:
"""Pure function. Returns (is_signal, debug).
Expects `daily_candles` ordered chronologically (oldest first), each
having keys close, volume.
"""
if len(daily_candles) < SMA_PERIOD + DAYS_BELOW_REQUIRED + 2:
return False, {"reason": "insufficient_data", "bars": len(daily_candles)}
closes = [c["close"] for c in daily_candles]
volumes = [c["volume"] for c in daily_candles]
# Rolling 200-day SMA at each bar from index SMA_PERIOD-1 onwards
smas: list[Optional[float]] = [None] * len(closes)
running_sum = sum(closes[:SMA_PERIOD])
smas[SMA_PERIOD - 1] = running_sum / SMA_PERIOD
for i in range(SMA_PERIOD, len(closes)):
running_sum += closes[i] - closes[i - SMA_PERIOD]
smas[i] = running_sum / SMA_PERIOD
today_close = closes[-1]
today_sma = smas[-1]
if today_sma is None:
return False, {"reason": "sma_not_computable"}
# Bottom-reversal mode is LONG-only:
# reclaim (long): was BELOW the SMA for N days, today closes ABOVE
# We explicitly do not trade symmetric short breakdowns here. Crypto
# top-calling is a different strategy with different risk.
reclaimed = today_close > today_sma
brokedown = today_close < today_sma
if brokedown:
return False, {
"reason": "shorts_disabled",
"close": round(today_close, 4),
"sma": round(today_sma, 4),
}
if not reclaimed:
return False, {"reason": "on_sma_no_cross",
"close": round(today_close, 4), "sma": round(today_sma, 4)}
# Prior DAYS_BELOW_REQUIRED bars must ALL be on the OPPOSITE side of the
# SMA from today (a real regime flip, not chop around a flat MA).
streak = 0
for i in range(2, DAYS_BELOW_REQUIRED + 2):
sma_at = smas[-i]
if sma_at is None:
return False, {"reason": "sma_history_incomplete"}
prior_on_wrong_side = closes[-i] >= sma_at
if prior_on_wrong_side:
return False, {"reason": "regime_period_too_short", "broke_at_day": i}
streak += 1
# Volume confirmation: today >= VOLUME_MULT_MIN × 30-day avg
avg_vol_30d = sum(volumes[-(VOLUME_LOOKBACK_DAYS + 1):-1]) / VOLUME_LOOKBACK_DAYS
if avg_vol_30d <= 0:
return False, {"reason": "no_volume_baseline"}
vol_ratio = volumes[-1] / avg_vol_30d
if vol_ratio < VOLUME_MULT_MIN:
return False, {"reason": "weak_volume", "vol_ratio": round(vol_ratio, 2)}
return True, {
"direction": "buy",
"close": round(today_close, 4),
"sma_200": round(today_sma, 4),
"gap_pct": round(abs(today_close - today_sma) / today_sma * 100, 2),
"streak_days": streak,
"vol_ratio": round(vol_ratio, 2),
}