""" 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), }