"""BTC bottom-reversal long scanner — 2-of-3 price confluence. Simple, transparent, no on-chain data and no API keys. Fires only when at least TWO of these three classic bottom signals agree: A. AHR999 < 0.45 — deep-value / bottom zone B. price ≤ 200-week MA ×1.05 — every cycle has bottomed near the 200WMA C. Pi Cycle Bottom — 150d EMA ≤ 471d SMA × 0.745 Long only, low frequency, stateless. No tight stop (real macro bottoms wick 15–30%); the position is invalidated when AHR999 climbs back above 1.2 — i.e. BTC is no longer cheap and the bottom thesis is spent. """ from __future__ import annotations import logging from datetime import datetime, timezone import httpx from app.config import settings from app.services import scanner_state from app.services.market_data import for_asset, drop_in_progress_bar from app.services.bottom_indicators import bottom_confluence SCANNER_NAME = "btc_bottom_reversal" scanner_state.register(SCANNER_NAME) logger = logging.getLogger(__name__) ASSET = "BTC" COOLDOWN_DAYS = 30 # 3 votes → max conviction; 2 votes → solid. Scale confidence with agreement. CONFIDENCE_BY_VOTES = {2: 88, 3: 94} EXPECTED_MOVE_PCT = 25.0 # Pi Cycle needs 471 daily closes; +buffer for the dropped in-progress bar. DAILY_LOOKBACK_DAYS = 520 # 200-week MA needs 200 weekly closes; +buffer. WEEKLY_LOOKBACK_WEEKS = 210 async def evaluate_once() -> tuple[bool, dict]: provider = for_asset(ASSET) raw_daily = await provider.fetch_1d(ASSET, days=DAILY_LOOKBACK_DAYS) daily = drop_in_progress_bar(raw_daily, "1d") raw_weekly = await provider.fetch_1w(ASSET, weeks=WEEKLY_LOOKBACK_WEEKS) weekly = drop_in_progress_bar(raw_weekly, "1w") if not daily: return False, {"reason": "missing_btc_daily"} if not weekly: return False, {"reason": "missing_btc_weekly"} daily_closes = [float(c["close"]) for c in daily if c.get("close")] weekly_closes = [float(c["close"]) for c in weekly if c.get("close")] conf = bottom_confluence(daily_closes, weekly_closes) debug: dict = dict(conf.detail) if not conf.fired: debug["reason"] = "confluence_not_met" return False, debug confidence = CONFIDENCE_BY_VOTES.get(conf.votes, 88) debug["confidence"] = confidence # No tight stop. Thesis is invalidated when BTC is no longer cheap; the # 200WMA band is a useful structural reference for the exit monitor. debug["invalidation_price"] = debug.get("wma200") return True, debug def _summary(debug: dict) -> str: s = debug.get("signals", {}) on = [k for k, v in s.items() if v] return ( f"BTC bottom confluence ({debug.get('votes')}/3): {', '.join(on)}. " f"AHR999 {debug.get('ahr999')} (<{debug.get('ahr999_threshold')}), " f"price {debug.get('price')}, 200WMA {debug.get('wma200')}, " f"Pi 150EMA {debug.get('pi_ema150')} vs 471SMA×0.745 " f"{debug.get('pi_sma471_scaled')}. No tight stop; " f"invalidate if AHR999 > 1.2." ) async def _emit_signal(debug: dict) -> bool: """POST the signal to the ingest endpoint. Returns True iff a Post was (idempotently) created. False means the signal was NOT recorded — caller must NOT treat the run as 'fired' (cooldown is keyed off the DB Post).""" if not settings.ingest_api_key: logger.error("BTC bottom-reversal would fire but INGEST_API_KEY empty " "— signal NOT recorded, check deploy env") return False payload = { "source": "btc_bottom_reversal", "external_id": f"btc-bottom-{datetime.now(timezone.utc).strftime('%Y%m%d')}", "text": _summary(debug), "signal": "buy", "target_asset": ASSET, "confidence": debug["confidence"], "category": "btc_bottom_reversal_long", "expected_move_pct": EXPECTED_MOVE_PCT, "invalidation_price": debug.get("invalidation_price"), } async with httpx.AsyncClient(timeout=10) as client: resp = await client.post( "http://localhost:8000/api/signals/ingest", json=payload, headers={"X-Ingest-Key": settings.ingest_api_key}, ) if resp.status_code >= 400: logger.error("BTC bottom-reversal ingest failed (%d): %s", resp.status_code, resp.text[:200]) return False logger.info("BTC bottom-reversal signal emitted: %s", resp.json()) return True async def scan_once() -> None: if not scanner_state.is_enabled(SCANNER_NAME): logger.debug("BTC bottom-reversal scanner disabled — skipping run") return if await scanner_state.in_cooldown("btc_bottom_reversal", ASSET, COOLDOWN_DAYS): logger.debug("BTC bottom-reversal cooldown active") scanner_state.record_run(SCANNER_NAME, "ok", "cooldown") return try: should_fire, debug = await evaluate_once() except Exception as exc: logger.error("BTC bottom-reversal scan failed: %s", exc) scanner_state.record_run(SCANNER_NAME, "error", str(exc)) return if should_fire: logger.info("BTC bottom-reversal FIRE — %s", debug) emitted = await _emit_signal(debug) if emitted: scanner_state.record_run(SCANNER_NAME, "fired") else: # Not recorded → no DB Post → cooldown won't arm. Surface this as # an error so a misconfigured deploy is obvious, not a silent # daily "fired" that never actually trades. scanner_state.record_run(SCANNER_NAME, "error", "ingest_failed_or_key_missing") else: logger.info("BTC bottom-reversal no — %s", debug) scanner_state.record_run(SCANNER_NAME, "ok")