""" Market data fetcher using yfinance + free public APIs. All functions are async-compatible where possible. """ import yfinance as yf import pandas as pd import numpy as np from datetime import datetime, timedelta from typing import Dict, List, Optional, Any import feedparser import httpx import asyncio from collections import deque import threading # ── Rolling history cache (for multi-period blended signals) ───────────────── _rolling_closes: Dict[str, List[float]] = {} # gauge_id → last ~40 daily closes _rolling_cache_ts: Optional[datetime] = None _rolling_cache_lock = threading.Lock() # ── Regime scoring history (Bayesian smoothing + persistence) ───────────────── _regime_history: deque = deque(maxlen=25) # each: {date, scores, dominant} _regime_history_lock = threading.Lock() _bootstrap_done: bool = False # ── Watchlist by asset class ────────────────────────────────────────────────── WATCHLIST: Dict[str, List[Dict[str, str]]] = { "energy": [ {"symbol": "CL=F", "name": "WTI Crude Oil", "currency": "USD"}, {"symbol": "BZ=F", "name": "Brent Crude Oil", "currency": "USD"}, {"symbol": "NG=F", "name": "Natural Gas", "currency": "USD"}, {"symbol": "XLE", "name": "Energy ETF (XLE)", "currency": "USD"}, {"symbol": "UNG", "name": "US Natural Gas ETF", "currency": "USD"}, ], "metals": [ {"symbol": "GC=F", "name": "Gold Futures", "currency": "USD"}, {"symbol": "SI=F", "name": "Silver Futures", "currency": "USD"}, {"symbol": "HG=F", "name": "Copper Futures", "currency": "USD"}, {"symbol": "PL=F", "name": "Platinum Futures", "currency": "USD"}, {"symbol": "GDX", "name": "Gold Miners ETF", "currency": "USD"}, ], "agriculture": [ {"symbol": "ZC=F", "name": "Corn Futures", "currency": "USD"}, {"symbol": "ZW=F", "name": "Wheat Futures", "currency": "USD"}, {"symbol": "ZS=F", "name": "Soybean Futures", "currency": "USD"}, {"symbol": "KC=F", "name": "Coffee Futures", "currency": "USD"}, {"symbol": "SB=F", "name": "Sugar #11 Futures", "currency": "USD"}, ], "indices": [ {"symbol": "^GSPC", "name": "S&P 500", "currency": "USD"}, {"symbol": "^NDX", "name": "NASDAQ 100", "currency": "USD"}, {"symbol": "^DJI", "name": "Dow Jones", "currency": "USD"}, {"symbol": "^STOXX50E", "name": "Euro Stoxx 50", "currency": "EUR"}, {"symbol": "^N225", "name": "Nikkei 225", "currency": "JPY"}, {"symbol": "^VIX", "name": "VIX Volatility", "currency": "USD"}, ], "etfs": [ {"symbol": "SPY", "name": "S&P 500 ETF (SPY)", "currency": "USD"}, {"symbol": "QQQ", "name": "NASDAQ 100 ETF (QQQ)", "currency": "USD"}, {"symbol": "IWM", "name": "Russell 2000 ETF (IWM)", "currency": "USD"}, {"symbol": "TLT", "name": "20Y Treasury ETF (TLT)", "currency": "USD"}, {"symbol": "IEF", "name": "7-10Y Treasury ETF (IEF)", "currency": "USD"}, {"symbol": "HYG", "name": "High Yield Bond (HYG)", "currency": "USD"}, {"symbol": "GLD", "name": "Gold ETF (GLD)", "currency": "USD"}, {"symbol": "SLV", "name": "Silver ETF (SLV)", "currency": "USD"}, {"symbol": "USO", "name": "Oil ETF (USO)", "currency": "USD"}, {"symbol": "EEM", "name": "EM ETF (EEM)", "currency": "USD"}, {"symbol": "FXI", "name": "China Large Cap (FXI)", "currency": "USD"}, {"symbol": "EWG", "name": "Germany ETF (EWG)", "currency": "USD"}, {"symbol": "EWJ", "name": "Japan ETF (EWJ)", "currency": "USD"}, {"symbol": "EWU", "name": "UK ETF (EWU)", "currency": "USD"}, {"symbol": "EWZ", "name": "Brazil ETF (EWZ)", "currency": "USD"}, {"symbol": "XLF", "name": "Financials ETF (XLF)", "currency": "USD"}, {"symbol": "SMH", "name": "Semiconductors (SMH)", "currency": "USD"}, {"symbol": "KWEB", "name": "China Internet (KWEB)", "currency": "USD"}, {"symbol": "UUP", "name": "US Dollar ETF (UUP)", "currency": "USD"}, {"symbol": "BIL", "name": "T-Bill 1-3M (BIL)", "currency": "USD"}, {"symbol": "TUR", "name": "Turkey ETF (TUR)", "currency": "USD"}, {"symbol": "WEAT", "name": "Wheat ETF (WEAT)", "currency": "USD"}, {"symbol": "CORN", "name": "Corn ETF (CORN)", "currency": "USD"}, ], "equities": [ {"symbol": "XOM", "name": "Exxon Mobil", "currency": "USD"}, {"symbol": "CVX", "name": "Chevron", "currency": "USD"}, {"symbol": "LMT", "name": "Lockheed Martin", "currency": "USD"}, {"symbol": "RTX", "name": "Raytheon", "currency": "USD"}, {"symbol": "BA", "name": "Boeing", "currency": "USD"}, ], "forex": [ {"symbol": "EURUSD=X", "name": "EUR/USD", "currency": "USD"}, {"symbol": "USDJPY=X", "name": "USD/JPY", "currency": "JPY"}, {"symbol": "GBP=X", "name": "GBP/USD", "currency": "USD"}, {"symbol": "USDCHF=X", "name": "USD/CHF", "currency": "CHF"}, {"symbol": "UUP", "name": "US Dollar ETF (UUP)", "currency": "USD"}, ], } def get_quote(symbol: str) -> Optional[Dict[str, Any]]: for period in ("5d", "1mo"): try: ticker = yf.Ticker(symbol) hist = ticker.history(period=period, interval="1d", auto_adjust=True) if hist.empty: continue # Drop rows where Close is NaN hist = hist.dropna(subset=["Close"]) if hist.empty: continue price = float(hist["Close"].iloc[-1]) prev = float(hist["Close"].iloc[-2]) if len(hist) > 1 else price change = price - prev change_pct = (change / prev * 100) if prev else 0 return { "symbol": symbol, "price": round(price, 4), "change": round(change, 4), "change_pct": round(change_pct, 2), "volume": int(hist["Volume"].iloc[-1]) if "Volume" in hist.columns else 0, "timestamp": datetime.utcnow().isoformat(), } except Exception: continue return {"symbol": symbol, "price": None, "error": "no data"} def get_all_quotes() -> Dict[str, List[Dict[str, Any]]]: result = {} for asset_class, assets in WATCHLIST.items(): quotes = [] for asset in assets: q = get_quote(asset["symbol"]) if q: q["name"] = asset["name"] q["asset_class"] = asset_class quotes.append(q) result[asset_class] = quotes # User-added custom tickers — placed in their detected asset class group try: from services.database import get_market_custom_tickers custom_entries = get_market_custom_tickers() for entry in (custom_entries or []): q = get_quote(entry["ticker"]) if not q: continue q["name"] = entry["name"] or entry["ticker"] grp = entry.get("asset_class") or "custom" q["asset_class"] = grp if grp not in result: result[grp] = [] # avoid duplicates (ticker already in built-in group) existing_symbols = {x["symbol"] for x in result[grp]} if q["symbol"] not in existing_symbols: result[grp].append(q) except Exception: pass return result def get_historical(symbol: str, period: str = "1y", interval: str = "1d") -> List[Dict[str, Any]]: try: from urllib.parse import unquote symbol = unquote(symbol) ticker = yf.Ticker(symbol) hist = ticker.history(period=period, interval=interval) if hist.empty: return [] hist = hist.reset_index() records = [] for _, row in hist.iterrows(): records.append({ "date": row["Date"].isoformat() if hasattr(row["Date"], "isoformat") else str(row["Date"]), "open": round(float(row["Open"]), 4), "high": round(float(row["High"]), 4), "low": round(float(row["Low"]), 4), "close": round(float(row["Close"]), 4), "volume": int(row["Volume"]) if "Volume" in row else 0, }) return records except Exception as e: return [] def compute_historical_iv(symbol: str, window: int = 30) -> float: """Estimate realized vol as proxy for IV when options data unavailable.""" try: ticker = yf.Ticker(symbol) hist = ticker.history(period="3mo", interval="1d") if len(hist) < 10: return 0.25 returns = np.log(hist["Close"] / hist["Close"].shift(1)).dropna() return float(returns.rolling(window).std().iloc[-1] * np.sqrt(252)) except Exception: return 0.25 # ── News feeds ──────────────────────────────────────────────────────────────── GEO_RSS_FEEDS = [ {"name": "Reuters World", "url": "https://feeds.reuters.com/reuters/worldNews"}, {"name": "Reuters Business", "url": "https://feeds.reuters.com/reuters/businessNews"}, {"name": "Reuters Commodities", "url": "https://feeds.reuters.com/reuters/USenergyNews"}, {"name": "AP Top News", "url": "https://feeds.apnews.com/rss/apf-topnews"}, {"name": "Al Jazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml"}, {"name": "Financial Times", "url": "https://www.ft.com/rss/home"}, {"name": "Bloomberg Markets", "url": "https://feeds.bloomberg.com/markets/news.rss"}, ] GEO_KEYWORDS = { "military": ["war", "attack", "missile", "troops", "conflict", "invasion", "airstrike", "NATO", "ceasefire"], "energy": ["OPEC", "oil production", "gas pipeline", "LNG", "energy sanctions", "crude", "petroleum"], "sanctions": ["sanctions", "embargo", "tariff", "trade ban", "export control", "blacklist"], "elections": ["election", "poll", "vote", "presidency", "referendum", "coup"], "natural_disaster": ["earthquake", "hurricane", "flood", "drought", "wildfire", "tsunami", "volcano"], "health_crisis": ["pandemic", "outbreak", "epidemic", "WHO", "virus", "quarantine", "lockdown"], "resource_scarcity": ["shortage", "supply chain", "famine", "water crisis", "food security", "rare earth"], "trade_war": ["trade war", "tariff", "WTO", "dumping", "protectionism", "trade deal"], "political_speech": ["Trump", "Biden", "Xi Jinping", "Putin", "Macron", "Zelensky", "Fed", "ECB"], } def fetch_geo_news() -> List[Dict[str, Any]]: news = [] for feed_info in GEO_RSS_FEEDS: try: feed = feedparser.parse(feed_info["url"]) for entry in feed.entries[:10]: title = entry.get("title", "") summary = entry.get("summary", entry.get("description", "")) published = entry.get("published", "") link = entry.get("link", "") category = classify_news(title + " " + summary) impact = estimate_impact(title + " " + summary, category) news.append({ "id": link, "title": title, "summary": summary[:300], "source": feed_info["name"], "category": category, "impact_score": impact, "asset_impacts": compute_asset_impacts(category, impact), "date": published, "tags": extract_tags(title + " " + summary), "url": link, }) except Exception: pass return news[:50] def classify_news(text: str) -> str: text_lower = text.lower() scores = {} for cat, keywords in GEO_KEYWORDS.items(): scores[cat] = sum(1 for kw in keywords if kw.lower() in text_lower) best = max(scores, key=scores.get) return best if scores[best] > 0 else "general" def estimate_impact(text: str, category: str) -> float: high_impact = ["attack", "invasion", "collapse", "crisis", "war", "ban", "default", "Trump", "Fed", "ceasefire", "truce", "nuclear", "coup", "massacre", "bombed", "strike"] medium_impact = ["tension", "sanctions", "shortage", "election", "rate", "OPEC", "peace", "deal", "agreement", "accord", "treaty", "negotiation"] text_lower = text.lower() score = 0.1 for word in high_impact: if word.lower() in text_lower: score += 0.2 for word in medium_impact: if word.lower() in text_lower: score += 0.1 return min(1.0, round(score, 2)) def compute_asset_impacts(category: str, impact: float) -> Dict[str, float]: impact_map = { "military": {"energy": 0.8, "metals": 0.6, "forex": 0.4, "indices": -0.5, "agriculture": 0.3}, "energy": {"energy": 0.9, "metals": 0.2, "forex": 0.3, "indices": -0.3}, "sanctions": {"forex": 0.6, "energy": 0.5, "metals": 0.3, "indices": -0.4}, "elections": {"forex": 0.7, "indices": 0.4, "equities": 0.3}, "natural_disaster": {"agriculture": 0.8, "energy": 0.4, "indices": -0.3}, "health_crisis": {"indices": -0.8, "agriculture": 0.5, "metals": 0.4}, "resource_scarcity": {"agriculture": 0.9, "metals": 0.7, "energy": 0.5}, "trade_war": {"indices": -0.6, "forex": 0.5, "agriculture": -0.3}, "political_speech": {"forex": 0.5, "indices": 0.4, "energy": 0.3}, } base = impact_map.get(category, {}) return {k: round(v * impact, 3) for k, v in base.items()} def extract_tags(text: str) -> List[str]: all_tags = [ "Trump", "Russia", "Ukraine", "China", "Iran", "Israel", "Gaza", "NATO", "OPEC", "Fed", "ECB", "Biden", "Xi", "Putin", "Zelensky", "Macron", "oil", "gold", "wheat", "dollar", "yuan", "euro", "S&P", "VIX", ] return [tag for tag in all_tags if tag.lower() in text.lower()] # ── Economic calendar (using free Trading Economics RSS or static) ──────────── _FRED_SERIES_TO_CALENDAR = { "PAYEMS": "US Non-Farm Payrolls", "CPIAUCSL": "US CPI (Consumer Price Index)", "UNRATE": "US Unemployment Rate", "GDP": "US GDP (Preliminary)", "ICSA": "US Initial Jobless Claims", "FEDFUNDS": "Fed Funds Rate (FOMC)", "T10Y2Y": "10Y-2Y Yield Spread", } def get_economic_calendar() -> List[Dict[str, Any]]: """Return recent + upcoming major economic events, enriched with FRED actuals from DB.""" from datetime import date, timedelta today = date.today() upcoming_static = [ {"title": "US Non-Farm Payrolls", "country": "US", "importance": "high", "date": (today + timedelta(days=(4 - today.weekday()) % 7 + 7)).isoformat(), "asset_impact": ["indices", "forex", "rates"]}, {"title": "US CPI (Consumer Price Index)", "country": "US", "importance": "high", "date": (today + timedelta(days=12)).isoformat(), "asset_impact": ["indices", "forex", "metals"]}, {"title": "FOMC Meeting / Fed Rate Decision","country": "US", "importance": "high", "date": (today + timedelta(days=18)).isoformat(), "asset_impact": ["indices", "forex", "metals", "energy"]}, {"title": "ECB Rate Decision", "country": "EU", "importance": "high", "date": (today + timedelta(days=20)).isoformat(), "asset_impact": ["forex", "indices"]}, {"title": "US GDP (Preliminary)", "country": "US", "importance": "high", "date": (today + timedelta(days=25)).isoformat(), "asset_impact": ["indices", "forex"]}, {"title": "OPEC+ Meeting", "country": "Global", "importance": "high", "date": (today + timedelta(days=14)).isoformat(), "asset_impact": ["energy"]}, {"title": "US Crude Oil Inventories (EIA)", "country": "US", "importance": "medium", "date": (today + timedelta(days=3)).isoformat(), "asset_impact": ["energy"]}, {"title": "EU Inflation (CPI)", "country": "EU", "importance": "medium", "date": (today + timedelta(days=8)).isoformat(), "asset_impact": ["forex", "indices"]}, {"title": "China Trade Balance", "country": "CN", "importance": "medium", "date": (today + timedelta(days=10)).isoformat(), "asset_impact": ["metals", "agriculture", "forex"]}, {"title": "US Unemployment Claims", "country": "US", "importance": "medium", "date": (today + timedelta(days=2)).isoformat(), "asset_impact": ["forex", "indices"]}, {"title": "USDA Crop Report", "country": "US", "importance": "medium", "date": (today + timedelta(days=6)).isoformat(), "asset_impact": ["agriculture"]}, {"title": "G7 Summit", "country": "Global", "importance": "high", "date": (today + timedelta(days=30)).isoformat(), "asset_impact": ["forex", "indices", "metals"]}, ] # Merge past FRED actuals from DB try: from services.database import get_economic_events_for_calendar db_events = get_economic_events_for_calendar(days_back=45, days_forward=0) past_events = [] for ev in db_events: actual = ev.get("actual_value") forecast = ev.get("forecast_value") previous = ev.get("previous_value") unit = ev.get("actual_unit", "") zscore = ev.get("surprise_zscore") past_events.append({ "title": ev.get("event_name", ev.get("series_id", "")), "country": "US", "importance": "high" if abs(zscore or 0) >= 1.5 else "medium", "date": ev.get("event_date", ""), "asset_impact": ev.get("assets_impacted", []), "actual": f"{actual:.2f}{unit}" if actual is not None else None, "forecast": f"{forecast:.2f}{unit}" if forecast is not None else None, "previous": f"{previous:.2f}{unit}" if previous is not None else None, "surprise_zscore": zscore, "surprise_direction": ev.get("surprise_direction"), "source": "FRED", }) except Exception: past_events = [] all_events = past_events + upcoming_static # Deduplicate by date+title (past events take precedence) seen = set() result = [] for ev in sorted(all_events, key=lambda x: x["date"], reverse=True): key = (ev["date"], ev["title"][:20]) if key not in seen: seen.add(key) result.append(ev) return sorted(result, key=lambda x: x["date"]) # ── Macro Gauges & Scenario Scoring ────────────────────────────────────────── MACRO_GAUGE_CONFIG = [ # (id, label, ticker, unit, bloc) # ── Liquidité / Taux ───────────────────────────────────────────────────── ("dxy", "Dollar DXY", "DX-Y.NYB", "index", "liquidite"), ("us10y", "UST 10Y", "^TNX", "%", "liquidite"), ("us3m", "UST 3M", "^IRX", "%", "liquidite"), ("tips", "TIPS ETF", "TIP", "$", "liquidite"), ("tlt", "Obligations 20Y+ (TLT)", "TLT", "$", "liquidite"), # ── Crédit ─────────────────────────────────────────────────────────────── ("vix", "VIX", "^VIX", "pts", "credit"), ("hyg", "HY Bonds (HYG)", "HYG", "$", "credit"), ("lqd", "IG Bonds (LQD)", "LQD", "$", "credit"), ("ief", "Trésor 7-10Y (IEF)", "IEF", "$", "credit"), # ── Énergie ────────────────────────────────────────────────────────────── ("brent", "Brent", "BZ=F", "$", "energie"), ("ng", "Gaz naturel", "NG=F", "$", "energie"), # ── Métaux ─────────────────────────────────────────────────────────────── ("gold", "Or", "GC=F", "$", "metaux"), ("silver", "Argent (Silver)", "SI=F", "$/oz", "metaux"), ("copper", "Cuivre", "HG=F", "$/lb", "metaux"), # ── Croissance US ───────────────────────────────────────────────────────── ("spx", "S&P 500", "^GSPC", "pts", "croissance"), ("iwm", "Russell 2000", "IWM", "$", "croissance"), ("xli", "Industriels XLI", "XLI", "$", "croissance"), # ── Secteurs US (rotation) ──────────────────────────────────────────────── ("xlk", "Tech (XLK)", "XLK", "$", "secteurs"), ("xlf", "Financières (XLF)", "XLF", "$", "secteurs"), ("xlp", "Conso. défensif (XLP)", "XLP", "$", "secteurs"), ("xlu", "Utilities (XLU)", "XLU", "$", "secteurs"), # ── Volatilité de surface ───────────────────────────────────────────────── ("vvix", "VVIX (vol-of-vol)", "^VVIX", "pts", "volatilite"), ("skew", "CBOE SKEW", "^SKEW", "pts", "volatilite"), ("ovx", "Pétrole Vol (OVX)", "^OVX", "pts", "volatilite"), ("gvz", "Or Vol (GVZ)", "^GVZ", "pts", "volatilite"), # ── Global / EM ─────────────────────────────────────────────────────────── ("eem", "EM Actions (EEM)", "EEM", "$", "global"), ("emb", "EM Bonds (EMB)", "EMB", "$", "global"), ("fxi", "Chine Large Cap (FXI)", "FXI", "$", "global"), # ── Forex risk-off ──────────────────────────────────────────────────────── ("usdjpy", "USD/JPY", "USDJPY=X", "pts", "forex_ro"), ] SCENARIO_META = { "goldilocks": {"label": "Goldilocks", "color": "#10b981", "emoji": "🟢"}, "desinflation": {"label": "Désinflation / Baisse taux","color": "#3b82f6", "emoji": "🔵"}, "soft_landing": {"label": "Soft Landing", "color": "#06b6d4", "emoji": "🔷"}, "reflation": {"label": "Reflation", "color": "#f97316", "emoji": "🟠"}, "stagflation": {"label": "Stagflation", "color": "#f59e0b", "emoji": "🟡"}, "inflation_shock": {"label": "Choc Inflationniste", "color": "#dc2626", "emoji": "🔥"}, "recession": {"label": "Récession", "color": "#ef4444", "emoji": "🔴"}, "crise_liquidite": {"label": "Crise de liquidité", "color": "#7c3aed", "emoji": "🟣"}, } SCENARIO_ASSET_BIAS = { "goldilocks": {"energy": "neutral", "metals": "bullish", "indices": "bullish+", "equities": "bullish+", "forex": "neutral", "agriculture": "neutral"}, "desinflation": {"energy": "bearish", "metals": "bullish+", "indices": "bullish+", "equities": "bullish", "forex": "neutral", "agriculture": "neutral"}, "soft_landing": {"energy": "neutral", "metals": "bullish", "indices": "bullish+", "equities": "bullish", "forex": "neutral", "agriculture": "neutral"}, "reflation": {"energy": "bullish+", "metals": "bullish+", "indices": "bullish", "equities": "bullish+", "forex": "neutral", "agriculture": "bullish+"}, "stagflation": {"energy": "bullish+", "metals": "bullish", "indices": "bearish", "equities": "bearish", "forex": "defensive", "agriculture": "bullish"}, "inflation_shock": {"energy": "bullish+", "metals": "bullish+", "indices": "bearish", "equities": "bearish", "forex": "defensive", "agriculture": "bullish+"}, "recession": {"energy": "bearish", "metals": "neutral", "indices": "bearish+", "equities": "bearish+", "forex": "defensive", "agriculture": "neutral"}, "crise_liquidite": {"energy": "neutral", "metals": "bullish+", "indices": "bearish+", "equities": "bearish+", "forex": "defensive", "agriculture": "neutral"}, } def _rolling_pct(gid: str, days: int) -> Optional[float]: """Return N-day % change for gauge `gid` from the rolling closes cache.""" closes = _rolling_closes.get(gid, []) if len(closes) < days + 1: return None old = closes[-(days + 1)] new = closes[-1] if not old: return None return round((new - old) / old * 100, 2) def _refresh_rolling_cache(force: bool = False) -> None: """Batch-download last 45 trading days of closes for all macro tickers (one yf call).""" global _rolling_cache_ts now = datetime.utcnow() with _rolling_cache_lock: if not force and _rolling_cache_ts and (now - _rolling_cache_ts).total_seconds() < 900: return gid_to_ticker = {gid: t for gid, _, t, _, _ in MACRO_GAUGE_CONFIG if t} all_tickers = list(gid_to_ticker.values()) try: df = yf.download( all_tickers, period="45d", interval="1d", auto_adjust=True, progress=False, group_by="ticker" ) if df.empty: return for gid, ticker in gid_to_ticker.items(): try: col = df["Close"] if len(all_tickers) == 1 else df[ticker]["Close"] _rolling_closes[gid] = col.dropna().tolist() except Exception: pass _rolling_cache_ts = now except Exception: pass def _gc_blended(gauges: Dict[str, Any], key: str) -> float: """Weighted blend of % changes: 20% × 1-day, 50% × 5-day, 30% × 10-day.""" c1 = gauges.get(key, {}).get("change_pct") or 0.0 c5 = gauges.get(key, {}).get("change_5d") or 0.0 c10 = gauges.get(key, {}).get("change_10d") or 0.0 return 0.20 * c1 + 0.50 * c5 + 0.30 * c10 def _build_pseudo_gauges(offset: int) -> Dict[str, Any]: """Build a gauge snapshot from `offset` trading days ago using rolling closes.""" pg: Dict[str, Any] = {} for gid, _, _, _, _ in MACRO_GAUGE_CONFIG: closes = _rolling_closes.get(gid, []) end = len(closes) - offset if end < 11: continue sl = closes[:end] c1 = round((sl[-1] - sl[-2]) / sl[-2] * 100, 2) if len(sl) >= 2 and sl[-2] else 0.0 c5 = round((sl[-1] - sl[-6]) / sl[-6] * 100, 2) if len(sl) >= 6 and sl[-6] else 0.0 c10 = round((sl[-1] - sl[-11]) / sl[-11] * 100, 2) if len(sl) >= 11 and sl[-11] else 0.0 pg[gid] = {"value": sl[-1], "change_pct": c1, "change_5d": c5, "change_10d": c10} # Derived: yield curve slope v10 = pg.get("us10y", {}).get("value") v3m = pg.get("us3m", {}).get("value") if v10 and v3m: if v10 > 20: v10 /= 10 if v3m > 20: v3m /= 10 pg["slope_10y3m"] = {"value": round(v10 - v3m, 3), "change_pct": 0.0, "change_5d": 0.0, "change_10d": 0.0} # Derived: Gold/Copper ratio gv_ = pg.get("gold", {}).get("value") cv = pg.get("copper", {}).get("value") if gv_ and cv: pg["gold_copper_ratio"] = {"value": round(gv_ / cv, 1), "change_pct": 0.0, "change_5d": 0.0, "change_10d": 0.0} # Derived: relative performances spx_c = pg.get("spx", {}).get("change_pct") or 0.0 iwm_c_ = pg.get("iwm", {}).get("change_pct") or 0.0 pg["iwm_spx_ratio"] = {"value": round(iwm_c_ - spx_c, 2), "change_pct": 0.0, "change_5d": 0.0, "change_10d": 0.0} xlk_c_ = pg.get("xlk", {}).get("change_pct") or 0.0 xlp_c_ = pg.get("xlp", {}).get("change_pct") or 0.0 pg["xlk_xlp_momentum"] = {"value": round(xlk_c_ - xlp_c_, 2), "change_pct": 0.0, "change_5d": 0.0, "change_10d": 0.0} return pg def bootstrap_regime_history() -> int: """Replay last N trading days from rolling closes to pre-populate _regime_history. Called once automatically on first macro gauge fetch. Returns number of days added.""" global _bootstrap_done if _bootstrap_done: return 0 _bootstrap_done = True # Set early to prevent concurrent double-bootstrap if not _rolling_closes: return 0 min_len = min((len(v) for v in _rolling_closes.values() if v), default=0) n_days = max(0, min(20, min_len - 11)) if n_days == 0: return 0 days_added = 0 with _regime_history_lock: _regime_history.clear() for offset in range(n_days, 0, -1): # oldest → most recent pg = _build_pseudo_gauges(offset) if len(pg) < 8: continue day_scores, _ = _score_raw(pg) ranked = sorted(day_scores.items(), key=lambda x: x[1], reverse=True) dom = ranked[0][0] if ranked[0][1] > 20 else "incertain" _regime_history.append({ "date": (datetime.utcnow().date() - timedelta(days=offset)).isoformat(), "scores": day_scores, "dominant": dom, }) days_added += 1 return days_added def get_macro_gauges() -> Dict[str, Any]: """Fetch macro gauges from yfinance in parallel and compute derived metrics.""" from concurrent.futures import ThreadPoolExecutor, as_completed raw: Dict[str, Any] = {} with ThreadPoolExecutor(max_workers=min(len(MACRO_GAUGE_CONFIG), 20)) as exe: futures = { exe.submit(get_quote, ticker): (gid, label, ticker, unit, bloc) for gid, label, ticker, unit, bloc in MACRO_GAUGE_CONFIG } for fut in as_completed(futures): gid, label, ticker, unit, bloc = futures[fut] try: q = fut.result() except Exception: q = None raw[gid] = { "id": gid, "label": label, "ticker": ticker, "value": q.get("price") if q else None, "change_pct": q.get("change_pct") if q else None, "unit": unit, "bloc": bloc, } # Normalize Treasury yields (yfinance sometimes returns 10x the actual %) for yid in ("us10y", "us3m"): v = raw[yid]["value"] if v is not None and v > 20: raw[yid]["value"] = round(v / 10, 3) # Derived: yield curve slope 10Y – 3M (% pts; negative = inverted) v10 = raw["us10y"]["value"] v3m = raw["us3m"]["value"] slope = round(v10 - v3m, 3) if (v10 is not None and v3m is not None) else None raw["slope_10y3m"] = { "id": "slope_10y3m", "label": "Pente 10Y–3M", "ticker": None, "value": slope, "change_pct": None, "unit": "% pts", "bloc": "liquidite", "note": ("inversée ⚠️" if slope is not None and slope < 0 else ("plate" if slope is not None and slope < 0.5 else "normale")), } # Derived: Gold / Copper ratio (oz gold / lb copper; >700 = fear, <500 = growth) gv = raw["gold"]["value"] cv = raw["copper"]["value"] gcr = round(gv / cv, 1) if (gv and cv) else None raw["gold_copper_ratio"] = { "id": "gold_copper_ratio", "label": "Ratio Or/Cuivre", "ticker": None, "value": gcr, "change_pct": None, "unit": "ratio", "bloc": "derive", "note": ("peur/récession" if gcr and gcr > 700 else ("neutre" if gcr and gcr > 550 else "croissance")), } # Derived: S&P 500 % above/below 200-day MA try: spx_hist = get_historical("^GSPC", period="1y", interval="1d") closes = [h["close"] for h in spx_hist if h.get("close")] if len(closes) >= 50: n = min(200, len(closes)) ma = sum(closes[-n:]) / n vs200 = round((closes[-1] - ma) / ma * 100, 2) else: vs200 = None except Exception: vs200 = None raw["spx_vs_200d"] = { "id": "spx_vs_200d", "label": "S&P vs 200j MA", "ticker": None, "value": vs200, "change_pct": None, "unit": "%", "bloc": "derive", "note": ("bull market" if vs200 is not None and vs200 > 5 else ("au-dessus" if vs200 is not None and vs200 > 0 else ("en-dessous ⚠️" if vs200 is not None else None))), } # Derived: Russell 2000 vs S&P 500 relative daily performance # Positive = small caps outperforming (risk-on breadth); negative = large cap defensiveness iwm_c = raw.get("iwm", {}).get("change_pct") or 0.0 spx_c_val = raw.get("spx", {}).get("change_pct") or 0.0 rel_perf = round(iwm_c - spx_c_val, 2) raw["iwm_spx_ratio"] = { "id": "iwm_spx_ratio", "label": "Russell vs S&P (perf. rel.)", "ticker": None, "value": rel_perf, "change_pct": None, "unit": "pts%", "bloc": "derive", "note": ("small caps > large (risk-on)" if rel_perf > 0.2 else ("parité" if rel_perf > -0.2 else "large caps dominants (défensif)")), } # Derived: Silver / Gold ratio (Silver oz / Gold oz — proxy risk-on metals) # <0.012 = or surperforme = risk-off; >0.016 = argent surperforme = risk-on industrie sv = raw.get("silver", {}).get("value") gv_ = raw.get("gold", {}).get("value") sgr = round(sv / gv_, 5) if (sv and gv_) else None raw["silver_gold_ratio"] = { "id": "silver_gold_ratio", "label": "Ratio Argent/Or", "ticker": None, "value": sgr, "change_pct": None, "unit": "ratio", "bloc": "derive", "note": ("argent surperforme (risk-on industriel)" if sgr and sgr > 0.016 else ("neutre" if sgr and sgr > 0.012 else ("or surperforme (risk-off)" if sgr else None))), } # Derived: Tech vs Consumer Staples relative performance (XLK vs XLP) # Positive = tech > défensifs = risk-on; negative = rotation défensive = ralentissement xlk_c = raw.get("xlk", {}).get("change_pct") or 0.0 xlp_c = raw.get("xlp", {}).get("change_pct") or 0.0 tech_vs_staples = round(xlk_c - xlp_c, 2) raw["xlk_xlp_momentum"] = { "id": "xlk_xlp_momentum", "label": "Tech vs Défensifs (XLK-XLP)", "ticker": None, "value": tech_vs_staples, "change_pct": None, "unit": "pts%", "bloc": "derive", "note": ("tech > défensifs (risk-on fort)" if tech_vs_staples > 0.5 else ("parité" if tech_vs_staples > -0.5 else "rotation défensive ⚠️")), } # Derived: Financials vs S&P relative performance (XLF vs SPX) # XLF is a leading indicator — underperformance signals credit/economic stress ahead xlf_c = raw.get("xlf", {}).get("change_pct") or 0.0 xlf_vs_spx = round(xlf_c - spx_c_val, 2) raw["xlf_spx_ratio"] = { "id": "xlf_spx_ratio", "label": "Financières vs S&P (XLF-SPX)", "ticker": None, "value": xlf_vs_spx, "change_pct": None, "unit": "pts%", "bloc": "derive", "note": ("banques > marché (expansion crédit)" if xlf_vs_spx > 0.3 else ("parité" if xlf_vs_spx > -0.3 else "banques < marché ⚠️ (stress crédit)")), } # Derived: EM vs US equity relative performance (EEM vs SPX) # Positive = global risk-on; negative = fuite vers US (dollar strength, EM stress) eem_c = raw.get("eem", {}).get("change_pct") or 0.0 eem_vs_spx = round(eem_c - spx_c_val, 2) raw["eem_spx_ratio"] = { "id": "eem_spx_ratio", "label": "EM vs S&P (EEM-SPX)", "ticker": None, "value": eem_vs_spx, "change_pct": None, "unit": "pts%", "bloc": "derive", "note": ("EM > US (croissance globale)" if eem_vs_spx > 0.3 else ("parité" if eem_vs_spx > -0.5 else "fuite vers US ⚠️ (EM stress/dollar fort)")), } # Derived: Vol surface regime — composite classification from VIX + VVIX + SKEW vix_v = raw.get("vix", {}).get("value") or 20.0 vvix_v = raw.get("vvix", {}).get("value") or 85.0 skew_v = raw.get("skew", {}).get("value") or 115.0 if vix_v < 15 and vvix_v < 90 and skew_v < 120: vol_regime = "contango_calm" # ideal pour vendre vol ou spreads bon marché elif vix_v > 30 or vvix_v > 110: vol_regime = "backwardation_panic" # stress aigu — options chères, spreads larges elif skew_v > 140 and vix_v > 20: vol_regime = "tail_risk_elevated" # marché achète protection queue = méfiance elif vix_v < 20 and skew_v > 130: vol_regime = "complacency_hedged" # calme apparent mais queues protégées else: vol_regime = "normal" raw["vol_surface_regime"] = { "id": "vol_surface_regime", "label": "Régime Surface de Vol", "ticker": None, "value": None, "change_pct": None, "unit": "regime", "bloc": "derive", "note": vol_regime, } # Enrich gauges with 5-day and 10-day rolling changes (one batch yf call) _refresh_rolling_cache() for gid in list(raw.keys()): raw[gid]["change_5d"] = _rolling_pct(gid, 5) raw[gid]["change_10d"] = _rolling_pct(gid, 10) # Bootstrap regime history on first run (uses _rolling_closes already populated) if not _bootstrap_done: bootstrap_regime_history() return _sanitize_floats(raw) def _sanitize_floats(obj: Any) -> Any: """Recursively replace NaN/Inf floats with None so json.dumps never crashes.""" import math if isinstance(obj, dict): return {k: _sanitize_floats(v) for k, v in obj.items()} if isinstance(obj, list): return [_sanitize_floats(v) for v in obj] if isinstance(obj, float) and (math.isnan(obj) or math.isinf(obj)): return None return obj def _score_raw(gauges: Dict[str, Any]) -> tuple: """Pure rule-based scoring using blended multi-period signals. Returns (scores, reasons).""" def gv(k): return gauges.get(k, {}).get("value") def gb(k): return _gc_blended(gauges, k) # 20% 1d + 50% 5d + 30% 10d vix = gv("vix") or 20.0 slope = gv("slope_10y3m") gcr = gv("gold_copper_ratio") vs200 = gv("spx_vs_200d") brent_c = gb("brent") ng_c = gb("ng") gold_c = gb("gold") copper_c = gb("copper") hyg_c = gb("hyg") lqd_c = gb("lqd") ief_c = gb("ief") dxy_c = gb("dxy") iwm_c = gb("iwm") xli_c = gb("xli") rel_perf = gv("iwm_spx_ratio") or 0.0 skew_v = gv("skew") or 115.0 vvix_v = gv("vvix") or 85.0 ovx_v = gv("ovx") or 25.0 gvz_v = gv("gvz") or 17.0 tlt_c = gb("tlt") xlk_c = gb("xlk") xlf_c = gb("xlf") xlp_c = gb("xlp") xlu_c = gb("xlu") eem_c = gb("eem") emb_c = gb("emb") fxi_c = gb("fxi") usdjpy_c = gb("usdjpy") silver_c = gb("silver") tech_vs_staples = gv("xlk_xlp_momentum") or 0.0 scores: Dict[str, int] = {} reasons: Dict[str, List[str]] = {} # GOLDILOCKS s = 0; r: List[str] = [] if vix < 15: s += 30; r.append("VIX<15") elif vix < 18: s += 20; r.append("VIX<18") elif vix < 22: s += 10 if slope is not None: if slope > 1.0: s += 20; r.append("Courbe +1%pt") elif slope > 0.3: s += 10; r.append("Courbe légèrement positive") if gcr is not None: if gcr < 500: s += 20; r.append(f"Or/Cu {gcr} (croissance)") elif gcr < 600: s += 10 if hyg_c > 0.2: s += 15; r.append("HYG↑ (crédit OK)") elif hyg_c > 0: s += 5 if vs200 is not None: if vs200 > 5: s += 15; r.append(f"S&P+{vs200}% vs 200j") elif vs200 > 0: s += 7 if copper_c > 0.5: s += 10; r.append("Cuivre↑") if skew_v < 115: s += 6; r.append(f"SKEW {skew_v:.0f} (no tail hedge)") if vvix_v < 85: s += 5; r.append(f"VVIX {vvix_v:.0f} (vol stable)") if tech_vs_staples > 0.5: s += 7; r.append("Tech > Défensifs (risk-on sectoriel)") if eem_c > 0.3: s += 6; r.append("EM↑ (croissance globale)") if usdjpy_c > 0.2: s += 4; r.append("JPY↓ (carry actif = risk-on)") scores["goldilocks"] = min(100, s); reasons["goldilocks"] = r # DÉSINFLATION / BAISSE DE TAUX s = 0; r = [] if brent_c < -1.0: s += 25; r.append("Brent↓↓ (désinflationniste)") elif brent_c < 0: s += 10 if ng_c < -1.0: s += 10; r.append("Gaz↓") if ief_c > 0.2: s += 20; r.append("IEF↑ (taux longs baissent)") elif ief_c > 0: s += 10 if vix < 20: s += 15; r.append("VIX<20") if vs200 is not None and vs200 > 0: s += 20; r.append("S&P au-dessus 200j") if hyg_c > 0: s += 10; r.append("HYG↑") if gold_c > 0 and brent_c < 0: s += 10; r.append("Or↑+Brent↓ (taux réels ↓)") if tlt_c > 0.5: s += 12; r.append("TLT↑↑ (désinflation confirmée)") elif tlt_c > 0.2: s += 6; r.append("TLT↑ (bonds longs soutiennent)") if xlf_c > 0: s += 5; r.append("XLF↑ (anticipent baisse taux)") scores["desinflation"] = min(100, s); reasons["desinflation"] = r # STAGFLATION s = 0; r = [] if brent_c > 2.0: s += 30; r.append("Brent↑↑") elif brent_c > 0.5: s += 15; r.append("Brent↑") if ng_c > 2.0: s += 15; r.append("Gaz↑↑") elif ng_c > 0.5: s += 7 if slope is not None: if slope < 0: s += 20; r.append("Courbe inversée") elif slope < 0.3: s += 10; r.append("Courbe plate") if gold_c > 0.5: s += 15; r.append("Or↑ (protection inflation)") if copper_c < 0: s += 15; r.append("Cuivre↓ (demande faible)") if vix > 18: s += 10; r.append("VIX élevé") if xlp_c > xlk_c + 0.5: s += 8; r.append("Défensifs > Tech (rotation stagflationniste)") if xlu_c > 0.4: s += 6; r.append("Utilities↑ (revenus stables)") if skew_v > 130: s += 5; r.append(f"SKEW {skew_v:.0f} (tail risk croissant)") if tlt_c < -0.3: s += 5; r.append("TLT↓ (inflation persistante)") scores["stagflation"] = min(100, s); reasons["stagflation"] = r # RÉCESSION s = 0; r = [] if slope is not None: if slope < -0.5: s += 30; r.append("Courbe fortement inversée") elif slope < 0: s += 15; r.append("Courbe inversée") if gcr is not None: if gcr > 750: s += 25; r.append(f"Or/Cu {gcr} (peur)") elif gcr > 650: s += 10 if vix > 28: s += 25; r.append("VIX>28") elif vix > 22: s += 12 if copper_c < -1.5: s += 20; r.append("Cuivre↓↓") elif copper_c < -0.5: s += 8 if hyg_c < -0.5: s += 15; r.append("HYG↓ (spreads s'écartent)") elif hyg_c < 0: s += 5 if gold_c > 0.3: s += 10; r.append("Or↑ (refuge)") if tlt_c > 0.5: s += 15; r.append("TLT↑↑ (signal recessionnaire fort)") elif tlt_c > 0.2: s += 7; r.append("TLT↑ (obligations soutenues)") if xlf_c < -1.0: s += 12; r.append("Financières↓↓ (leading indicator récession)") elif xlf_c < -0.3: s += 5 if eem_c < -1.0: s += 8; r.append("EM↓ (global slowdown)") if usdjpy_c < -1.0: s += 10; r.append("JPY↑↑ (carry unwind = risk-off global)") elif usdjpy_c < -0.5: s += 5 if skew_v > 135: s += 8; r.append(f"SKEW {skew_v:.0f} (tail risk extrême)") scores["recession"] = min(100, s); reasons["recession"] = r # CRISE DE LIQUIDITÉ s = 0; r = [] if vix > 35: s += 35; r.append("VIX>35 (panique)") elif vix > 28: s += 20; r.append("VIX>28") elif vix > 22: s += 8 if hyg_c < -1.5: s += 35; r.append("HYG↓↓ (crise crédit)") elif hyg_c < -0.5: s += 15 if lqd_c < -0.5: s += 10; r.append("IG↓ (spreads s'écartent)") if vs200 is not None: if vs200 < -10: s += 25; r.append("S&P<200j -10%") elif vs200 < -3: s += 10 if gold_c > 1.0 and copper_c < -1.0: s += 20; r.append("Or↑+Cuivre↓ (fuite sécurité)") if dxy_c > 1.0: s += 15; r.append("Dollar↑↑") if ief_c > 0.5: s += 10; r.append("Obligations souveraines↑↑") if skew_v > 145: s += 15; r.append(f"SKEW {skew_v:.0f} — tail risk extrême") elif skew_v > 135: s += 8 if vvix_v > 115: s += 15; r.append(f"VVIX {vvix_v:.0f} — vol-of-vol panique") elif vvix_v > 100: s += 8; r.append(f"VVIX {vvix_v:.0f} — vol élevée") if usdjpy_c < -1.5: s += 15; r.append("JPY↑↑↑ (carry unwind = panique globale)") elif usdjpy_c < -0.8: s += 7; r.append("JPY↑ (risk-off carry)") if xlf_c < -2.0: s += 15; r.append("Banques↓↓ (stress bancaire systémique)") elif xlf_c < -1.0: s += 7 if emb_c < -1.0: s += 10; r.append("EM Bonds↓ (fuite liquidité EM)") scores["crise_liquidite"] = min(100, s); reasons["crise_liquidite"] = r # REFLATION s = 0; r = [] if copper_c > 1.5: s += 25; r.append("Cuivre↑↑ (Dr Copper = croissance)") elif copper_c > 0.5: s += 12; r.append("Cuivre↑") if xli_c > 0.8: s += 20; r.append("Industriels↑↑ (activité mfg forte)") elif xli_c > 0.2: s += 10; r.append("Industriels↑") if brent_c > 1.5: s += 15; r.append("Brent↑ (reflation énergie)") elif brent_c > 0.3: s += 6 if vs200 is not None and vs200 > 8: s += 20; r.append(f"S&P+{vs200}% vs 200j (bull fort)") elif vs200 is not None and vs200 > 3: s += 10 if slope is not None and slope > 1.0: s += 15; r.append("Courbe pentue (croissance)") elif slope is not None and slope > 0.3: s += 6 if rel_perf > 0.3: s += 10; r.append("Small caps > large (risk-on large)") elif rel_perf > 0: s += 4 if vix < 18: s += 5 if eem_c > 1.0: s += 10; r.append("EM↑↑ (reflation globale)") elif eem_c > 0.3: s += 5; r.append("EM↑ (global risk-on)") if xlk_c > 1.0: s += 8; r.append("Tech↑↑ (croissance+momentum)") if silver_c > 1.5: s += 8; r.append("Argent↑↑ (reflation industrielle)") if usdjpy_c > 0.5: s += 6; r.append("JPY↓ (carry trades actifs)") scores["reflation"] = min(100, s); reasons["reflation"] = r # SOFT LANDING s = 0; r = [] if vs200 is not None and vs200 > 0: s += 20; r.append("S&P > MA200 (croissance intacte)") if brent_c < -0.5 and brent_c > -3: s += 20; r.append("Brent légèrement ↓ (désinflation graduelle)") elif brent_c < 0: s += 8 if vix < 20: s += 15; r.append("VIX<20 (pas de stress)") if hyg_c > 0: s += 12; r.append("HYG↑ (crédit solide)") if lqd_c > 0: s += 8; r.append("IG↑ (spreads IG calmes)") if slope is not None and slope > 0: s += 10; r.append("Courbe non-inversée") if xli_c > 0: s += 8; r.append("Industriels positifs") if copper_c > 0: s += 5; r.append("Cuivre stable") if ief_c > 0 and brent_c < 0: s += 7; r.append("Taux baissent + énergie recule") if xlf_c > 0: s += 8; r.append("Financières↑ (économie saine)") if eem_c > 0: s += 5; r.append("EM stable (croissance globale intacte)") if skew_v < 130: s += 4; r.append(f"SKEW {skew_v:.0f} (tail risk non-extrême)") scores["soft_landing"] = min(100, s); reasons["soft_landing"] = r # CHOC INFLATIONNISTE s = 0; r = [] if brent_c > 4.0: s += 40; r.append("Brent↑↑↑ (choc énergie majeur)") elif brent_c > 2.0: s += 25; r.append("Brent↑↑") elif brent_c > 0.8: s += 10 if ng_c > 4.0: s += 20; r.append("Gaz↑↑↑ (choc supply gaz)") elif ng_c > 2.0: s += 12; r.append("Gaz↑↑") if gold_c > 1.0: s += 20; r.append("Or↑↑ (refuge inflation/géo)") elif gold_c > 0.3: s += 8; r.append("Or↑") if vix > 22: s += 15; r.append("VIX↑ (stress montant)") elif vix > 18: s += 5 if copper_c < -0.5: s += 8; r.append("Cuivre↓ (demand destruction)") if ief_c < -0.2: s += 8; r.append("Trésor↓ (taux longs remontent)") if ovx_v > 45: s += 15; r.append(f"OVX {ovx_v:.0f} — vol pétrole extrême") elif ovx_v > 35: s += 8; r.append(f"OVX {ovx_v:.0f} — vol pétrole élevée") if gvz_v > 22: s += 8; r.append(f"GVZ {gvz_v:.0f} — vol or élevée") if xlp_c > 0.5: s += 6; r.append("Défensifs↑ (rotation anti-inflation)") if tlt_c < -0.5: s += 8; r.append("TLT↓↓ (anticipation inflation)") scores["inflation_shock"] = min(100, s); reasons["inflation_shock"] = r return scores, reasons def score_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]: """Score macro regimes with blended multi-period signals + Bayesian smoothing from history.""" raw_scores, reasons = _score_raw(gauges) scores = dict(raw_scores) with _regime_history_lock: history_len = len(_regime_history) # Bayesian smoothing: blend raw scores with rolling average of past N days if history_len >= 3: w_prior = min(0.45, 0.05 * history_len) # grows from 15% to 45% over 9+ days for key in scores: prior_vals = [h["scores"].get(key, 0) for h in _regime_history] prior_avg = sum(prior_vals) / len(prior_vals) scores[key] = int(round(min(100, (1 - w_prior) * scores[key] + w_prior * prior_avg))) ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) dominant = ranked[0][0] if ranked[0][1] > 20 else "incertain" # Regime persistence: resist switching unless new leader is clearly ahead if history_len >= 5 and dominant != "incertain": prev_dominant = _regime_history[-1].get("dominant", "incertain") if (prev_dominant != "incertain" and dominant != prev_dominant and scores[dominant] - scores.get(prev_dominant, 0) < 10): dominant = prev_dominant # Count consecutive days current dominant has held consecutive = 0 for h in reversed(list(_regime_history)): if h.get("dominant") == dominant: consecutive += 1 else: break # Store today's snapshot (raw scores as prior for next call) _regime_history.append({ "date": datetime.utcnow().date().isoformat(), "scores": dict(raw_scores), "dominant": dominant, }) return { "scores": scores, "ranked": [[k, v] for k, v in ranked], "dominant": dominant, "reasons": reasons, "meta": SCENARIO_META, "asset_bias": SCENARIO_ASSET_BIAS, "history_days": history_len, "regime_stability": consecutive, }