"""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 breadth_service, 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, # Observational early-warning blend: a small Combined score = weighted mean of # the coincident index and the breadth-divergence early-warning score. Kept # separate from the index weights above so the early-warning side stays # decoupled until proven. Tunable; need not sum to 1 (normalised). "combined_weights": {"coincident": 0.6, "early_warning": 0.4}, "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 # Early-warning signal: breadth-divergence over the stored universe (leads but # noisy). Computed once here so the daily job carries it live, as a SEPARATE # score next to the coincident index — not folded into the index weights. # Best-effort: a breadth failure must not stop the index update. try: breadth = await breadth_service.compute_breadth_series(db) divergence = breadth_service.compute_divergence_series(breadth, sorted(leader_series)) except Exception as exc: logger.warning("Regime monitor: breadth/divergence skipped: %s", exc) divergence = {} # As-of lookup: the stored universe (breadth) can lag the live benchmark date # by a day or two, so an exact-date match would blank the newest snapshot. div_items = sorted(divergence.items()) cw = config.get("combined_weights") or {"coincident": 0.6, "early_warning": 0.4} 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) _attach_early_warning(result, _divergence_asof(div_items, d), cw) await _upsert_snapshot(db, result) latest_result = result # Backfill early-warning + combined onto recent existing snapshots (e.g. the # index history written before this signal existed) so their 7/30-day trends # populate immediately rather than only filling in over the coming weeks. if div_items: recent = await db.execute( select(RegimeSnapshot).where(RegimeSnapshot.date >= end - timedelta(days=120)) ) for row in recent.scalars().all(): try: res = json.loads(row.breakdown_json) except (TypeError, ValueError): continue if (res.get("early_warning") or {}).get("score") is not None: continue _attach_early_warning(res, _divergence_asof(div_items, row.date), cw) row.breakdown_json = json.dumps(res) 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"} def _divergence_asof(div_items: list[tuple[date, float]], as_of: date, max_lag_days: int = 7) -> float | None: """Latest divergence value on/before ``as_of``, tolerating a small data lag between the live benchmark and the stored universe. None if too stale/absent.""" chosen: tuple[date, float] | None = None for d, v in div_items: if d <= as_of: chosen = (d, v) else: break if chosen is None or (as_of - chosen[0]).days > max_lag_days: return None return chosen[1] def _attach_early_warning(result: dict, ew: float | None, weights: dict) -> None: """Attach the separate early-warning score and a combined blend to a snapshot. ``ew`` is the breadth-divergence value as-of this date (or None). The combined score is a normalised weighted mean of the coincident index and the early warning — observational, kept apart from the index itself. """ result["early_warning"] = { "score": round(ew, 1) if ew is not None else None, "band": band_for(ew) if ew is not None else None, } if ew is None: combined = result["total_score"] else: wc = float(weights.get("coincident", 0.6)) we = float(weights.get("early_warning", 0.4)) wsum = (wc + we) or 1.0 combined = (result["total_score"] * wc + ew * we) / wsum result["combined"] = {"score": round(combined, 1), "band": band_for(combined)} async def _result_at_or_before(db: AsyncSession, target: date) -> dict | None: """Parsed snapshot result for the latest date on/before ``target``.""" res = await db.execute( select(RegimeSnapshot.breakdown_json) .where(RegimeSnapshot.date <= target) .order_by(RegimeSnapshot.date.desc()) .limit(1) ) raw = res.scalar_one_or_none() if raw is None: return None try: return json.loads(raw) except (TypeError, ValueError): return None def _delta(curr: float | None, prev: float | None) -> float | None: return round(curr - prev, 1) if (curr is not None and prev is not None) else None async def get_regime_monitor(db: AsyncSession) -> dict: """Latest snapshot + 7/30-day trend deltas for the index, early-warning, and combined scores. Cheap (a few 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": []} r7 = await _result_at_or_before(db, latest.date - timedelta(days=7)) r30 = await _result_at_or_before(db, latest.date - timedelta(days=30)) def _nested(r: dict | None, key: str) -> float | None: return (r.get(key) or {}).get("score") if r else None result["available"] = True cur_total = result.get("total_score") result["trend"] = { "delta_7": _delta(cur_total, (r7 or {}).get("total_score")), "delta_30": _delta(cur_total, (r30 or {}).get("total_score")), } for key in ("early_warning", "combined"): block = result.get(key) or {"score": None, "band": None} block["delta_7"] = _delta(block.get("score"), _nested(r7, key)) block["delta_30"] = _delta(block.get("score"), _nested(r30, key)) result[key] = block return result async def get_regime_history(db: AsyncSession, days: int = 400) -> list[dict]: """Daily history of the index, early-warning, and combined scores for the score-over-time chart. One point per snapshot date, ascending.""" cutoff = date.today() - timedelta(days=days) res = await db.execute( select(RegimeSnapshot) .where(RegimeSnapshot.date >= cutoff) .order_by(RegimeSnapshot.date.asc()) ) out: list[dict] = [] for row in res.scalars().all(): try: data = json.loads(row.breakdown_json) except (TypeError, ValueError): data = {} out.append({ "date": row.date.isoformat(), "index": row.total_score, "early_warning": (data.get("early_warning") or {}).get("score"), "combined": (data.get("combined") or {}).get("score"), }) return out # --------------------------------------------------------------------------- # 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