feat: add standalone AI/Tech regime-change monitor tab
A new /regime tab scoring how far the AI/Tech bull regime has deteriorated toward a re-rating as a single 0-100 index with per-signal breakdown and a 7/30-day trend. Intentionally decoupled: nothing reads its output to gate or score trades — the daily-pipeline membership is scheduling only. - regime_monitor_service: price sub-scores (P1-P6 via Alpaca, like market_regime), VIX + HY credit spreads via a small FRED helper, weighted aggregation over available signals (missing source -> n/a, dropped from the denominator), one snapshot row/day, and a ~90-day history backfill by replaying the already-fetched series as-of each past day. - F1/F3 fundamentals proposed by the configured grounded LLM (reuses sentiment_provider_service config resolution), with a manual override + lock. - regime_snapshots table (migration 011); endpoints on the existing market router; admin-editable weights/threshold; standalone /regime page. Data needs: prices via Alpaca, VIX/credit via FRED (optional key — signals show n/a without it). No LLM needed for history. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -519,6 +519,7 @@ VALID_JOB_NAMES = {
|
||||
"outcome_evaluator",
|
||||
"alerts",
|
||||
"market_regime",
|
||||
"regime_monitor",
|
||||
"backtest",
|
||||
"daily_pipeline",
|
||||
"intraday_pipeline",
|
||||
@@ -534,6 +535,7 @@ JOB_LABELS = {
|
||||
"outcome_evaluator": "Outcome Evaluator",
|
||||
"alerts": "Alerts Dispatcher",
|
||||
"market_regime": "Market Regime",
|
||||
"regime_monitor": "Regime Monitor",
|
||||
"backtest": "Backtest",
|
||||
"daily_pipeline": "Daily Pipeline",
|
||||
"intraday_pipeline": "Intraday Pipeline",
|
||||
@@ -546,6 +548,7 @@ PIPELINE_MEMBERS = {
|
||||
"rr_scanner",
|
||||
"outcome_evaluator",
|
||||
"market_regime",
|
||||
"regime_monitor",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,735 @@
|
||||
"""AI/Tech Regime-Change Monitor.
|
||||
|
||||
A standalone, observational tool: it scores how far the AI/Tech bull regime has
|
||||
deteriorated toward a re-rating, as a single 0-100 **index** (not a calibrated
|
||||
probability), broken down per signal. It is intentionally decoupled — nothing
|
||||
here feeds gates, scoring, alerts, or trade logic. It only computes a number for
|
||||
its own tab.
|
||||
|
||||
Design mirrors ``market_regime_service``: benchmark/sector bars are pulled
|
||||
directly via Alpaca (no Universe membership needed), macro inputs (VIX, HY credit
|
||||
spreads) come from FRED, and the daily result is persisted as one
|
||||
``RegimeSnapshot`` row per date so the UI can show a 7/30-day trend. On the first
|
||||
run the history is backfilled by replaying the (already-fetched) price/FRED series
|
||||
as-of each past day, so the trend is populated immediately.
|
||||
|
||||
Signals (sub-score 0 = healthy … 100 = regime breaking):
|
||||
P1 trend break (% under 200-DMA, SMH-led) P2 death cross + 200-slope
|
||||
P3 drawdown from 52w high P4 relative strength SMH/SPY
|
||||
P5 volatility (VIX) P6 NVDA canary divergence (opt.)
|
||||
F1 hyperscaler capex guidance (LLM/manual) F2 HY credit-spread percentile
|
||||
F3 "good news, stock down" (LLM/manual) F4 market breadth RSP/SPY
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.exceptions import ProviderError
|
||||
from app.models.regime_snapshot import RegimeSnapshot
|
||||
from app.providers.alpaca import AlpacaOHLCVProvider
|
||||
from app.services import settings_store
|
||||
from app.services.admin_service import update_setting
|
||||
from app.services.sentiment_provider_service import _resolve as resolve_llm_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
|
||||
|
||||
KEY_CONFIG = "regime_monitor_config"
|
||||
KEY_FUNDAMENTALS = "regime_fundamental_overrides"
|
||||
|
||||
# All weights/thresholds are admin-editable via the KEY_CONFIG SystemSetting.
|
||||
# Default weights sum to 100 (P6 off). SMH is the leading sensor, QQQ confirms.
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"tickers": {
|
||||
"leaders": ["SMH"], # semis — fast early signal
|
||||
"confirm": ["QQQ"], # broad tech — confirmation
|
||||
"market": "SPY",
|
||||
"breadth": "RSP", # equal-weight S&P for breadth
|
||||
"canary": "NVDA", # sector lead-stock (optional early warning)
|
||||
"hyperscalers": ["GOOGL", "AMZN", "META", "MSFT"],
|
||||
},
|
||||
"weights": {
|
||||
"P1": 12, "P2": 8, "P3": 10, "P4": 8, "P5": 7, "P6": 0,
|
||||
"F1": 25, "F2": 15, "F3": 8, "F4": 7,
|
||||
},
|
||||
"alert_threshold": 65,
|
||||
"leader_weight": 2.0, # SMH counts 2x vs QQQ where both feed a signal
|
||||
"rs_lookback": 60, # trading days for relative-strength / breadth trend
|
||||
"fundamental_staleness_days": 80,
|
||||
}
|
||||
|
||||
SIGNAL_LABELS: dict[str, str] = {
|
||||
"P1": "Trend break (200-DMA)",
|
||||
"P2": "Death cross + slope",
|
||||
"P3": "Drawdown from 52w high",
|
||||
"P4": "Relative strength SMH/SPY",
|
||||
"P5": "Volatility (VIX)",
|
||||
"P6": "NVDA canary divergence",
|
||||
"F1": "Hyperscaler capex guidance",
|
||||
"F2": "Credit spreads (HY OAS)",
|
||||
"F3": "Good news, stock down",
|
||||
"F4": "Market breadth RSP/SPY",
|
||||
}
|
||||
|
||||
_PRICE_SIGNALS = {"P1", "P2", "P3", "P4", "P6"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Small numeric helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _clamp(x: float, lo: float = 0.0, hi: float = 100.0) -> float:
|
||||
return max(lo, min(hi, x))
|
||||
|
||||
|
||||
def _sma(values: list[float], window: int) -> float | None:
|
||||
if len(values) < window:
|
||||
return None
|
||||
return sum(values[-window:]) / window
|
||||
|
||||
|
||||
def _mean(values: list[float]) -> float | None:
|
||||
return sum(values) / len(values) if values else None
|
||||
|
||||
|
||||
def _blend(leader: float | None, confirm: float | None, leader_weight: float) -> float | None:
|
||||
"""Weighted blend of a leading vs a confirming sub-score (SMH vs QQQ)."""
|
||||
parts: list[tuple[float, float]] = []
|
||||
if leader is not None:
|
||||
parts.append((leader, leader_weight))
|
||||
if confirm is not None:
|
||||
parts.append((confirm, 1.0))
|
||||
if not parts:
|
||||
return None
|
||||
num = sum(v * w for v, w in parts)
|
||||
den = sum(w for _, w in parts)
|
||||
return num / den
|
||||
|
||||
|
||||
def band_for(score: float) -> str:
|
||||
"""Map the 0-100 index onto its label band."""
|
||||
if score < 30:
|
||||
return "stable"
|
||||
if score < 60:
|
||||
return "watch"
|
||||
if score < 80:
|
||||
return "elevated"
|
||||
return "breaking"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure sub-score functions (0 = healthy, 100 = regime breaking). None = no data.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _under_200(closes: list[float]) -> float | None:
|
||||
sma200 = _sma(closes, 200)
|
||||
if sma200 is None:
|
||||
return None
|
||||
return 100.0 if closes[-1] < sma200 else 0.0
|
||||
|
||||
|
||||
def p1_trend_break(smh: list[float], qqq: list[float], leader_weight: float) -> float | None:
|
||||
"""Weighted share trading below the 200-DMA (SMH leads, QQQ confirms)."""
|
||||
return _blend(_under_200(smh), _under_200(qqq), leader_weight)
|
||||
|
||||
|
||||
def _death_cross(closes: list[float]) -> float | None:
|
||||
sma50 = _sma(closes, 50)
|
||||
sma200 = _sma(closes, 200)
|
||||
if sma50 is None or sma200 is None or len(closes) < 221 or sma200 == 0:
|
||||
return None
|
||||
gap_pct = (sma50 / sma200 - 1.0) * 100.0
|
||||
severity = 0.0 if gap_pct >= 0 else _clamp(-gap_pct * 20.0) # -5% gap -> 100
|
||||
sma200_past = _sma(closes[:-20], 200)
|
||||
slope_factor = 1.0
|
||||
if sma200_past:
|
||||
slope_pct = (sma200 / sma200_past - 1.0) * 100.0
|
||||
slope_factor = 1.0 if slope_pct < 0 else 0.5 # damp if 200 still rising
|
||||
return severity * slope_factor
|
||||
|
||||
|
||||
def p2_death_cross(smh: list[float], qqq: list[float], leader_weight: float) -> float | None:
|
||||
return _blend(_death_cross(smh), _death_cross(qqq), leader_weight)
|
||||
|
||||
|
||||
def _drawdown(closes: list[float]) -> float | None:
|
||||
if len(closes) < 30:
|
||||
return None
|
||||
window = closes[-252:]
|
||||
peak = max(window)
|
||||
if peak <= 0:
|
||||
return None
|
||||
dd_pct = (peak - closes[-1]) / peak * 100.0
|
||||
return _clamp(dd_pct * 5.0) # 20% below 52w high -> 100
|
||||
|
||||
|
||||
def p3_drawdown(smh: list[float], qqq: list[float]) -> float | None:
|
||||
vals = [v for v in (_drawdown(smh), _drawdown(qqq)) if v is not None]
|
||||
return max(vals) if vals else None
|
||||
|
||||
|
||||
def _ratio_trend(a: list[float], b: list[float], lookback: int) -> float | None:
|
||||
"""Falling a/b (a underperforming b) -> higher score. Flat -> 50."""
|
||||
if len(a) < lookback + 1 or len(b) < lookback + 1:
|
||||
return None
|
||||
if b[-1] == 0 or b[-lookback - 1] == 0:
|
||||
return None
|
||||
now = a[-1] / b[-1]
|
||||
past = a[-lookback - 1] / b[-lookback - 1]
|
||||
if past == 0:
|
||||
return None
|
||||
chg_pct = (now / past - 1.0) * 100.0
|
||||
return _clamp(50.0 - chg_pct * 5.0) # -10% -> 100, +10% -> 0
|
||||
|
||||
|
||||
def p4_relative_strength(smh: list[float], spy: list[float], lookback: int) -> float | None:
|
||||
return _ratio_trend(smh, spy, lookback)
|
||||
|
||||
|
||||
def f4_breadth(rsp: list[float], spy: list[float], lookback: int) -> float | None:
|
||||
"""Narrowing breadth (equal-weight lagging cap-weight) -> RSP/SPY falls -> higher."""
|
||||
return _ratio_trend(rsp, spy, lookback)
|
||||
|
||||
|
||||
def p5_volatility(vix: float | None) -> float | None:
|
||||
if vix is None:
|
||||
return None
|
||||
return _clamp((vix - 15.0) / 15.0 * 100.0) # <=15 -> 0, >=30 -> 100
|
||||
|
||||
|
||||
def f2_credit_spreads(oas_values: list[float]) -> float | None:
|
||||
"""Percentile rank of the latest HY OAS within the window. Wider = higher."""
|
||||
if len(oas_values) < 30:
|
||||
return None
|
||||
latest = oas_values[-1]
|
||||
below = sum(1 for v in oas_values if v <= latest)
|
||||
return _clamp(below / len(oas_values) * 100.0)
|
||||
|
||||
|
||||
def p6_canary(nvda: list[float], smh: list[float]) -> float | None:
|
||||
"""NVDA below its 50-DMA while SMH's trend is still intact = lead divergence."""
|
||||
sma50 = _sma(nvda, 50)
|
||||
if sma50 is None:
|
||||
return None
|
||||
nvda_weak = nvda[-1] < sma50
|
||||
sma200 = _sma(smh, 200)
|
||||
smh_intact = sma200 is not None and smh[-1] > sma200
|
||||
if nvda_weak and smh_intact:
|
||||
return 100.0
|
||||
return 50.0 if nvda_weak else 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aggregation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_regime_score(sub_scores: dict[str, float | None], weights: dict[str, float]) -> dict:
|
||||
"""Weighted mean over the *available* signals (weight>0 and score present).
|
||||
|
||||
Missing-data signals drop out of both numerator and denominator and are
|
||||
reported with ``available=False``. Contributions sum to the total.
|
||||
"""
|
||||
denom = sum(
|
||||
weights.get(sid, 0)
|
||||
for sid, score in sub_scores.items()
|
||||
if score is not None and weights.get(sid, 0) > 0
|
||||
)
|
||||
total = 0.0
|
||||
breakdown: list[dict] = []
|
||||
for sid in SIGNAL_LABELS:
|
||||
weight = weights.get(sid, 0)
|
||||
if weight <= 0:
|
||||
continue
|
||||
score = sub_scores.get(sid)
|
||||
available = score is not None
|
||||
contribution = (score * weight / denom) if (available and denom > 0) else 0.0
|
||||
if available:
|
||||
total += contribution
|
||||
breakdown.append({
|
||||
"id": sid,
|
||||
"label": SIGNAL_LABELS[sid],
|
||||
"sub_score": round(score, 1) if available else None,
|
||||
"weight": weight,
|
||||
"available": available,
|
||||
"contribution": round(contribution, 2),
|
||||
})
|
||||
return {"total_score": round(total, 1), "band": band_for(total), "breakdown": breakdown}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# As-of series helpers (for backfill replay)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Series = list[tuple[date, float]]
|
||||
|
||||
|
||||
def _closes_asof(series: Series, as_of: date) -> list[float]:
|
||||
return [v for d, v in series if d <= as_of]
|
||||
|
||||
|
||||
def _value_asof(series: Series | None, as_of: date) -> float | None:
|
||||
if not series:
|
||||
return None
|
||||
vals = [v for d, v in series if d <= as_of]
|
||||
return vals[-1] if vals else None
|
||||
|
||||
|
||||
def _window_asof(series: Series | None, as_of: date, years: float) -> list[float]:
|
||||
if not series:
|
||||
return []
|
||||
start = as_of - timedelta(days=int(365 * years))
|
||||
return [v for d, v in series if start <= d <= as_of]
|
||||
|
||||
|
||||
def _compute_index(
|
||||
prices: dict[str, Series],
|
||||
vix_series: Series | None,
|
||||
oas_series: Series | None,
|
||||
overrides: dict,
|
||||
config: dict,
|
||||
as_of: date,
|
||||
) -> dict:
|
||||
"""Compute the full index result as-of *as_of* from raw series."""
|
||||
t = config["tickers"]
|
||||
lw = float(config.get("leader_weight", 2.0))
|
||||
lb = int(config.get("rs_lookback", 60))
|
||||
|
||||
smh = _closes_asof(prices.get(t["leaders"][0], []), as_of) if t["leaders"] else []
|
||||
qqq = _closes_asof(prices.get(t["confirm"][0], []), as_of) if t["confirm"] else []
|
||||
spy = _closes_asof(prices.get(t["market"], []), as_of)
|
||||
rsp = _closes_asof(prices.get(t["breadth"], []), as_of)
|
||||
nvda = _closes_asof(prices.get(t["canary"], []), as_of)
|
||||
vix = _value_asof(vix_series, as_of)
|
||||
oas = _window_asof(oas_series, as_of, 3)
|
||||
|
||||
sub_scores: dict[str, float | None] = {
|
||||
"P1": p1_trend_break(smh, qqq, lw),
|
||||
"P2": p2_death_cross(smh, qqq, lw),
|
||||
"P3": p3_drawdown(smh, qqq),
|
||||
"P4": p4_relative_strength(smh, spy, lb),
|
||||
"P5": p5_volatility(vix),
|
||||
"P6": p6_canary(nvda, smh),
|
||||
"F1": overrides.get("f1_score"),
|
||||
"F2": f2_credit_spreads(oas),
|
||||
"F3": overrides.get("f3_score"),
|
||||
"F4": f4_breadth(rsp, spy, lb),
|
||||
}
|
||||
|
||||
result = compute_regime_score(sub_scores, config["weights"])
|
||||
result["date"] = as_of.isoformat()
|
||||
result["alert_threshold"] = config.get("alert_threshold", 65)
|
||||
result["inputs"] = {
|
||||
"vix": round(vix, 2) if vix is not None else None,
|
||||
"hy_oas": round(oas[-1], 2) if oas else None,
|
||||
"fundamentals_fetched_at": overrides.get("fetched_at"),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config + fundamental-override storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_regime_config(db: AsyncSession) -> dict:
|
||||
"""DEFAULT_CONFIG deep-merged with the stored override (nested for dicts)."""
|
||||
cfg = copy.deepcopy(DEFAULT_CONFIG)
|
||||
raw = await settings_store.get_value(db, KEY_CONFIG)
|
||||
if raw:
|
||||
try:
|
||||
stored = json.loads(raw)
|
||||
for k, v in stored.items():
|
||||
if isinstance(v, dict) and isinstance(cfg.get(k), dict):
|
||||
cfg[k].update(v)
|
||||
else:
|
||||
cfg[k] = v
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Corrupt %s; using defaults", KEY_CONFIG)
|
||||
return cfg
|
||||
|
||||
|
||||
async def update_regime_config(db: AsyncSession, updates: dict) -> dict:
|
||||
"""Merge *updates* into the stored config and persist. Returns the new config."""
|
||||
cfg = await get_regime_config(db)
|
||||
for k, v in (updates or {}).items():
|
||||
if isinstance(v, dict) and isinstance(cfg.get(k), dict):
|
||||
cfg[k].update(v)
|
||||
else:
|
||||
cfg[k] = v
|
||||
await update_setting(db, KEY_CONFIG, json.dumps(cfg))
|
||||
return cfg
|
||||
|
||||
|
||||
async def get_fundamental_overrides(db: AsyncSession) -> dict:
|
||||
"""Current F1/F3 override (LLM-proposed or manual). Defaults to neutral 50."""
|
||||
raw = await settings_store.get_value(db, KEY_FUNDAMENTALS)
|
||||
default = {"f1_score": 50.0, "f3_score": 50.0, "locked": False,
|
||||
"reasoning": None, "fetched_at": None, "source": "default"}
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
stored = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return {**default, **stored}
|
||||
|
||||
|
||||
async def set_fundamental_overrides(
|
||||
db: AsyncSession,
|
||||
f1_score: float | None = None,
|
||||
f3_score: float | None = None,
|
||||
locked: bool | None = None,
|
||||
) -> dict:
|
||||
"""Manual override of F1/F3. Setting any value locks out the LLM refresh
|
||||
unless ``locked`` is explicitly cleared."""
|
||||
current = await get_fundamental_overrides(db)
|
||||
if f1_score is not None:
|
||||
current["f1_score"] = _clamp(float(f1_score))
|
||||
if f3_score is not None:
|
||||
current["f3_score"] = _clamp(float(f3_score))
|
||||
if locked is not None:
|
||||
current["locked"] = bool(locked)
|
||||
elif f1_score is not None or f3_score is not None:
|
||||
current["locked"] = True
|
||||
current["source"] = "manual"
|
||||
current["fetched_at"] = datetime.now(timezone.utc).isoformat()
|
||||
await update_setting(db, KEY_FUNDAMENTALS, json.dumps(current))
|
||||
return current
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data fetching: Alpaca prices + FRED macro
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _price_symbols(config: dict) -> list[str]:
|
||||
t = config["tickers"]
|
||||
syms = list(t["leaders"]) + list(t["confirm"]) + [t["market"], t["breadth"], t["canary"]]
|
||||
seen: list[str] = []
|
||||
for s in syms:
|
||||
if s and s not in seen:
|
||||
seen.append(s)
|
||||
return seen
|
||||
|
||||
|
||||
async def _fetch_prices(config: dict, start: date, end: date) -> dict[str, Series]:
|
||||
if not settings.alpaca_api_key or not settings.alpaca_api_secret:
|
||||
return {}
|
||||
provider = AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret)
|
||||
out: dict[str, Series] = {}
|
||||
for sym in _price_symbols(config):
|
||||
try:
|
||||
bars = await provider.fetch_ohlcv(sym, start, end)
|
||||
out[sym] = sorted(((b.date, float(b.close)) for b in bars), key=lambda x: x[0])
|
||||
except Exception as exc:
|
||||
logger.warning("Regime monitor: price fetch failed for %s: %s", sym, exc)
|
||||
return out
|
||||
|
||||
|
||||
async def _fetch_fred_series(series_id: str, start: date, end: date) -> Series | None:
|
||||
"""Fetch a FRED series as [(date, value)]. None if no API key configured."""
|
||||
if not settings.fred_api_key:
|
||||
return None
|
||||
verify = _CA_BUNDLE if (_CA_BUNDLE and Path(_CA_BUNDLE).exists()) else True
|
||||
params = {
|
||||
"series_id": series_id,
|
||||
"api_key": settings.fred_api_key,
|
||||
"file_type": "json",
|
||||
"observation_start": start.isoformat(),
|
||||
"observation_end": end.isoformat(),
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30, verify=verify) as client:
|
||||
resp = await client.get(
|
||||
"https://api.stlouisfed.org/fred/series/observations", params=params
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except Exception as exc:
|
||||
logger.warning("Regime monitor: FRED fetch failed for %s: %s", series_id, exc)
|
||||
return None
|
||||
|
||||
out: Series = []
|
||||
for obs in payload.get("observations", []):
|
||||
value = obs.get("value")
|
||||
if value in (None, ".", ""):
|
||||
continue
|
||||
try:
|
||||
out.append((date.fromisoformat(obs["date"]), float(value)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return sorted(out, key=lambda x: x[0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshot persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _upsert_snapshot(db: AsyncSession, result: dict) -> None:
|
||||
d = date.fromisoformat(result["date"])
|
||||
existing = await db.execute(select(RegimeSnapshot).where(RegimeSnapshot.date == d))
|
||||
row = existing.scalar_one_or_none()
|
||||
payload = json.dumps(result)
|
||||
if row is None:
|
||||
db.add(RegimeSnapshot(
|
||||
date=d,
|
||||
total_score=result["total_score"],
|
||||
band=result["band"],
|
||||
breakdown_json=payload,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
))
|
||||
else:
|
||||
row.total_score = result["total_score"]
|
||||
row.band = result["band"]
|
||||
row.breakdown_json = payload
|
||||
|
||||
|
||||
async def _snapshot_count(db: AsyncSession) -> int:
|
||||
res = await db.execute(select(func.count()).select_from(RegimeSnapshot))
|
||||
return int(res.scalar() or 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job entrypoint + reads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def update_regime_monitor(db: AsyncSession, backfill_days: int = 90) -> dict:
|
||||
"""Compute the latest index, persist it, and backfill history on first run.
|
||||
|
||||
Job entrypoint (daily-pipeline step). Best-effort throughout: missing keys or
|
||||
a failed source degrade gracefully (signals drop to n/a) rather than abort.
|
||||
"""
|
||||
config = await get_regime_config(db)
|
||||
|
||||
# Refresh the LLM fundamentals if stale (and not manually locked). Best-effort.
|
||||
overrides = await get_fundamental_overrides(db)
|
||||
if _fundamentals_stale(overrides, config) and not overrides.get("locked"):
|
||||
try:
|
||||
overrides = await refresh_fundamental_overrides(db, config=config)
|
||||
except Exception as exc:
|
||||
logger.warning("Regime monitor: fundamentals refresh skipped: %s", exc)
|
||||
|
||||
end = date.today()
|
||||
start = end - timedelta(days=400)
|
||||
prices = await _fetch_prices(config, start, end)
|
||||
vix_series = await _fetch_fred_series("VIXCLS", start, end)
|
||||
oas_series = await _fetch_fred_series("BAMLH0A0HYM2", end - timedelta(days=1200), end)
|
||||
|
||||
# Anchor "today" on the latest actual trading day we have prices for.
|
||||
leader = config["tickers"]["leaders"][0] if config["tickers"]["leaders"] else None
|
||||
leader_series = prices.get(leader or "", [])
|
||||
latest_date = leader_series[-1][0] if leader_series else end
|
||||
|
||||
dates = {latest_date}
|
||||
if await _snapshot_count(db) < 5 and leader_series:
|
||||
cutoff = end - timedelta(days=backfill_days)
|
||||
dates |= {d for d, _ in leader_series if d >= cutoff}
|
||||
|
||||
latest_result: dict | None = None
|
||||
for d in sorted(dates):
|
||||
result = _compute_index(prices, vix_series, oas_series, overrides, config, d)
|
||||
await _upsert_snapshot(db, result)
|
||||
latest_result = result
|
||||
await db.commit()
|
||||
|
||||
logger.info(json.dumps({
|
||||
"event": "regime_monitor_updated",
|
||||
"date": latest_result["date"] if latest_result else None,
|
||||
"score": latest_result["total_score"] if latest_result else None,
|
||||
"snapshots_written": len(dates),
|
||||
}))
|
||||
return latest_result or {"available": False, "reason": "no data"}
|
||||
|
||||
|
||||
async def _score_at_or_before(db: AsyncSession, target: date) -> float | None:
|
||||
res = await db.execute(
|
||||
select(RegimeSnapshot.total_score)
|
||||
.where(RegimeSnapshot.date <= target)
|
||||
.order_by(RegimeSnapshot.date.desc())
|
||||
.limit(1)
|
||||
)
|
||||
val = res.scalar_one_or_none()
|
||||
return float(val) if val is not None else None
|
||||
|
||||
|
||||
async def get_regime_monitor(db: AsyncSession) -> dict:
|
||||
"""Latest snapshot result + 7/30-day trend deltas. Cheap (one+ row reads)."""
|
||||
res = await db.execute(
|
||||
select(RegimeSnapshot).order_by(RegimeSnapshot.date.desc()).limit(1)
|
||||
)
|
||||
latest = res.scalar_one_or_none()
|
||||
if latest is None:
|
||||
return {"available": False, "reason": "not computed yet"}
|
||||
|
||||
try:
|
||||
result = json.loads(latest.breakdown_json)
|
||||
except (TypeError, ValueError):
|
||||
result = {"date": latest.date.isoformat(), "total_score": latest.total_score,
|
||||
"band": latest.band, "breakdown": []}
|
||||
|
||||
score_7 = await _score_at_or_before(db, latest.date - timedelta(days=7))
|
||||
score_30 = await _score_at_or_before(db, latest.date - timedelta(days=30))
|
||||
result["available"] = True
|
||||
result["trend"] = {
|
||||
"delta_7": round(latest.total_score - score_7, 1) if score_7 is not None else None,
|
||||
"delta_30": round(latest.total_score - score_30, 1) if score_30 is not None else None,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F1/F3 via grounded LLM (reuses the configured sentiment provider)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CAPEX_PROMPT = """\
|
||||
You are a markets analyst. Search the web for the MOST RECENT (last reported \
|
||||
quarter) capital-expenditure (capex) guidance from these hyperscalers: {names}.
|
||||
|
||||
For each name, classify the direction of its forward capex/AI-infrastructure \
|
||||
guidance vs. the prior quarter as exactly one of: "raising", "holding", "cutting".
|
||||
|
||||
Also judge the recent "good news, stock down" dynamic: across these names and \
|
||||
the semiconductor sector, did stocks broadly FALL despite earnings/revenue beats \
|
||||
in the last reporting season? Answer "yes", "no", or "mixed".
|
||||
|
||||
Respond ONLY with a JSON object (no markdown):
|
||||
{{"capex": {{ {example} }}, "good_news_stock_down": "yes|no|mixed", \
|
||||
"reasoning": "<2-3 sentences citing the specific guidance you found>"}}
|
||||
"""
|
||||
|
||||
|
||||
def _fundamentals_stale(overrides: dict, config: dict) -> bool:
|
||||
fetched = overrides.get("fetched_at")
|
||||
if not fetched:
|
||||
return True
|
||||
try:
|
||||
ts = datetime.fromisoformat(fetched)
|
||||
except (TypeError, ValueError):
|
||||
return True
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
max_age = timedelta(days=int(config.get("fundamental_staleness_days", 80)))
|
||||
return datetime.now(timezone.utc) - ts > max_age
|
||||
|
||||
|
||||
def _strip_fences(text: str) -> str:
|
||||
clean = (text or "").strip()
|
||||
if clean.startswith("```"):
|
||||
clean = clean.split("\n", 1)[1] if "\n" in clean else clean[3:]
|
||||
if clean.endswith("```"):
|
||||
clean = clean[:-3]
|
||||
return clean.strip()
|
||||
|
||||
|
||||
def _extract_responses_text(response: object) -> str:
|
||||
for item in getattr(response, "output", []) or []:
|
||||
if getattr(item, "type", None) == "message" and getattr(item, "content", None):
|
||||
for block in item.content:
|
||||
if getattr(block, "text", None):
|
||||
return block.text
|
||||
return ""
|
||||
|
||||
|
||||
async def _call_llm_json(cfg: dict, prompt: str) -> dict:
|
||||
"""Send one grounded prompt via the configured LLM and parse its JSON reply."""
|
||||
provider, model, api_key = cfg["provider"], cfg["model"], cfg["api_key"]
|
||||
base_url = cfg.get("base_url")
|
||||
|
||||
if provider == "gemini":
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
client = genai.Client(api_key=api_key)
|
||||
resp = await client.aio.models.generate_content(
|
||||
model=model,
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
tools=[types.Tool(google_search=types.GoogleSearch())],
|
||||
response_mime_type="application/json",
|
||||
),
|
||||
)
|
||||
return json.loads(_strip_fences(resp.text))
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
verify = _CA_BUNDLE if (_CA_BUNDLE and Path(_CA_BUNDLE).exists()) else True
|
||||
client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url or None,
|
||||
http_client=httpx.AsyncClient(verify=verify),
|
||||
)
|
||||
if provider in ("openai", "xai"):
|
||||
tool = "web_search_preview" if provider == "openai" else "web_search"
|
||||
resp = await client.responses.create(
|
||||
model=model,
|
||||
tools=[{"type": tool}],
|
||||
instructions="Respond with valid JSON only, no markdown fences.",
|
||||
input=prompt,
|
||||
)
|
||||
return json.loads(_strip_fences(_extract_responses_text(resp)))
|
||||
|
||||
# deepseek / generic OpenAI-compatible: no web search, knowledge-based.
|
||||
resp = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
return json.loads(_strip_fences(resp.choices[0].message.content))
|
||||
|
||||
|
||||
_CAPEX_STATE_SCORES = {"raising": 0.0, "holding": 50.0, "cutting": 100.0}
|
||||
_GNSD_SCORES = {"yes": 100.0, "mixed": 50.0, "no": 0.0}
|
||||
|
||||
|
||||
async def refresh_fundamental_overrides(
|
||||
db: AsyncSession, config: dict | None = None, force: bool = False
|
||||
) -> dict:
|
||||
"""Ask the configured LLM to propose F1 (capex) and F3 (earnings reaction).
|
||||
|
||||
Skips (returns current) if a manual override is locked, unless ``force``.
|
||||
"""
|
||||
current = await get_fundamental_overrides(db)
|
||||
if current.get("locked") and not force:
|
||||
return current
|
||||
|
||||
config = config or await get_regime_config(db)
|
||||
cfg = await resolve_llm_config(db)
|
||||
if not cfg.get("api_key"):
|
||||
raise ProviderError(f"No API key configured for LLM provider '{cfg.get('provider')}'")
|
||||
|
||||
names = config["tickers"]["hyperscalers"]
|
||||
example = ", ".join(f'"{n}": "holding"' for n in names)
|
||||
prompt = _CAPEX_PROMPT.format(names=", ".join(names), example=example)
|
||||
parsed = await _call_llm_json(cfg, prompt)
|
||||
|
||||
capex = parsed.get("capex", {}) if isinstance(parsed, dict) else {}
|
||||
scores = [
|
||||
_CAPEX_STATE_SCORES[str(capex.get(n, "")).strip().lower()]
|
||||
for n in names
|
||||
if str(capex.get(n, "")).strip().lower() in _CAPEX_STATE_SCORES
|
||||
]
|
||||
f1 = _mean(scores) if scores else 50.0
|
||||
gnsd = str(parsed.get("good_news_stock_down", "")).strip().lower()
|
||||
f3 = _GNSD_SCORES.get(gnsd, 50.0)
|
||||
|
||||
result = {
|
||||
"f1_score": round(f1, 1),
|
||||
"f3_score": f3,
|
||||
"capex": capex,
|
||||
"good_news_stock_down": gnsd or None,
|
||||
"reasoning": parsed.get("reasoning") if isinstance(parsed, dict) else None,
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"locked": False,
|
||||
"source": cfg.get("provider"),
|
||||
}
|
||||
await update_setting(db, KEY_FUNDAMENTALS, json.dumps(result))
|
||||
logger.info(json.dumps({"event": "regime_fundamentals_refreshed", "f1": result["f1_score"], "f3": result["f3_score"]}))
|
||||
return result
|
||||
Reference in New Issue
Block a user