ebff19940b
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>
736 lines
27 KiB
Python
736 lines
27 KiB
Python
"""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
|