add market-regime guard (SPY trend) — inform + warn
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 36s
Deploy / deploy (push) Successful in 25s

New market_regime_service computes a benchmark (SPY) trend from its 50/200-day
SMAs, cached in a SystemSetting and refreshed by a nightly job; GET /market/regime
exposes it. Dashboard shows a regime banner; setup cards flag a counter-trend
caution when a setup fights the regime (LONG in a bearish market / SHORT in a
bullish one). Informational only — nothing is suppressed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 12:34:07 +02:00
parent 1951531453
commit c4f2673799
14 changed files with 354 additions and 6 deletions
+2
View File
@@ -483,6 +483,7 @@ VALID_JOB_NAMES = {
"ticker_universe_sync",
"outcome_evaluator",
"alerts",
"market_regime",
}
JOB_LABELS = {
@@ -493,6 +494,7 @@ JOB_LABELS = {
"ticker_universe_sync": "Ticker Universe Sync",
"outcome_evaluator": "Outcome Evaluator",
"alerts": "Alerts Dispatcher",
"market_regime": "Market Regime",
}
+115
View File
@@ -0,0 +1,115 @@
"""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.admin_service import update_setting
from app.models.settings import SystemSetting
from sqlalchemy import select
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)."""
result = await db.execute(select(SystemSetting).where(SystemSetting.key == KEY_REGIME))
setting = result.scalar_one_or_none()
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"}