437ceacfc1
Behavior-preserving cleanup (345 tests pass, ruff clean):
- scheduler: replace 62 inline logger.x(json.dumps({...})) calls with a
_log_event helper, and collapse 11 identical _job_runtime dicts into an
_idle_runtime() factory over _JOB_NAMES.
- settings: add app/services/settings_store.py (get_setting/get_value/get_map/
upsert_setting) and route ~13 hand-rolled SystemSetting queries + two
identical _settings_map helpers through it.
- scoring.get_rankings: collapse the per-ticker N+1 (3-4 queries + a commit each)
into 2 bulk reads + a single conditional commit; drop the redundant re-fetch.
Lazy recompute-on-read is preserved. Adds first tests for get_rankings.
Net ~ -245 lines across the touched modules.
114 lines
3.8 KiB
Python
114 lines
3.8 KiB
Python
"""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"}
|