Files
OpenFin/backend/services/data_fetcher.py
2026-07-20 21:43:35 +02:00

1025 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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",
start: Optional[str] = None, end: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""`start`/`end` (ISO date strings) take priority over `period` when given — yfinance
only accepts one or the other, never both."""
try:
from urllib.parse import unquote
symbol = unquote(symbol)
ticker = yf.Ticker(symbol)
hist = ticker.history(start=start, end=end, interval=interval) if start else 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 10Y3M", "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,
}