"""Market-regime guard. Computes a simple, robust trend regime for a benchmark (SPY by default) from its own moving averages, so the UI can warn against fighting the broad market — e.g. going long while SPY is below its 200-day average. Result is cached in a SystemSetting (JSON) and refreshed by a daily job; reads are cheap. Regime is informational: it warns, it doesn't suppress setups. """ from __future__ import annotations import json import logging from datetime import date, datetime, timedelta, timezone from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.providers.alpaca import AlpacaOHLCVProvider from app.services import settings_store from app.services.admin_service import update_setting logger = logging.getLogger(__name__) KEY_REGIME = "market_regime" BENCHMARK = "SPY" _SMA_SHORT = 50 _SMA_LONG = 200 def _sma(values: list[float], window: int) -> float | None: if len(values) < window: return None return sum(values[-window:]) / window def compute_regime(closes: list[float]) -> dict: """Derive a regime label from a benchmark's close series. bullish = price above the 200-day and the 50-day confirms (50 ≥ 200) bearish = price below the 200-day and the 50-day confirms (50 ≤ 200) neutral = mixed / chop unknown = not enough history """ if not closes: return {"label": "unknown", "reason": "no benchmark data"} price = closes[-1] sma50 = _sma(closes, _SMA_SHORT) sma200 = _sma(closes, _SMA_LONG) if sma200 is None: # Fall back to the 50-day alone when we lack a full year of history. if sma50 is None: return {"label": "unknown", "reason": "insufficient history"} label = "bullish" if price > sma50 else "bearish" return { "label": label, "price": round(price, 2), "sma50": round(sma50, 2), "sma200": None, "pct_above_200": None, "reason": "based on 50-day only (under 200 bars)", } above_200 = price > sma200 if above_200 and sma50 >= sma200: label = "bullish" elif not above_200 and sma50 <= sma200: label = "bearish" else: label = "neutral" return { "label": label, "price": round(price, 2), "sma50": round(sma50, 2), "sma200": round(sma200, 2), "pct_above_200": round((price / sma200 - 1.0) * 100, 2), "reason": None, } async def update_market_regime(db: AsyncSession) -> dict: """Fetch the benchmark, compute the regime, and cache it. Job entrypoint.""" if not settings.alpaca_api_key or not settings.alpaca_api_secret: return {"label": "unknown", "reason": "Alpaca keys not configured"} provider = AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret) end = date.today() start = end - timedelta(days=400) # ~280 trading days → covers the 200-day SMA bars = await provider.fetch_ohlcv(BENCHMARK, start, end) closes = [b.close for b in sorted(bars, key=lambda b: b.date)] regime = compute_regime(closes) regime["benchmark"] = BENCHMARK regime["computed_at"] = datetime.now(timezone.utc).isoformat() await update_setting(db, KEY_REGIME, json.dumps(regime)) logger.info(json.dumps({"event": "market_regime_updated", "regime": regime["label"]})) return regime async def get_market_regime(db: AsyncSession) -> dict: """Return the cached regime (computed by the daily job).""" setting = await settings_store.get_setting(db, KEY_REGIME) if setting is None: return {"label": "unknown", "benchmark": BENCHMARK, "reason": "not computed yet"} try: return json.loads(setting.value) except (TypeError, ValueError): return {"label": "unknown", "benchmark": BENCHMARK, "reason": "corrupt cache"}