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>
147 lines
5.5 KiB
Python
147 lines
5.5 KiB
Python
"""
|
||
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),
|
||
}
|
||
|
||
|