Files
OpenFin/backend/services/data_fetcher.py
2026-07-21 17:23:55 +02:00

1086 lines
54 KiB
Python
Raw Permalink 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])
# Explicit D-1 close: the latest row whose calendar date differs from the
# most recent row's date, not just "the row before last" — near-24h
# instruments (FX, futures) can otherwise return two rows for the same
# session, silently comparing "today vs today" and making change_pct swing
# around against a moving reference instead of a fixed prior close.
last_date = hist.index[-1].date()
prior_rows = hist[hist.index.date < last_date]
prev = float(prior_rows["Close"].iloc[-1]) if not prior_rows.empty 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_quote_with_volatility(symbol: str, vol_window: int = 20) -> Optional[Dict[str, Any]]:
"""Like get_quote(), plus a realized volatility overlay: annualized %, rolling
`vol_window`-day stddev of log returns — same formula as the Instrument Analysis
chart's volatility overlay (services.instrument_service). Needs more history than
get_quote()'s 5d/1mo window, so it's kept as a separate function rather than slowing
down get_quote()'s many other callers that don't need volatility."""
import numpy as np
for period in ("3mo", "6mo"):
try:
ticker = yf.Ticker(symbol)
hist = ticker.history(period=period, interval="1d", auto_adjust=True)
if hist.empty:
continue
hist = hist.dropna(subset=["Close"])
if len(hist) < vol_window + 2:
continue
close = hist["Close"]
price = float(close.iloc[-1])
last_date = hist.index[-1].date()
prior_rows = hist[hist.index.date < last_date]
prev = float(prior_rows["Close"].iloc[-1]) if not prior_rows.empty else price
change = price - prev
change_pct = (change / prev * 100) if prev else 0
log_ret = np.log(close / close.shift(1))
vol_series = (log_ret.rolling(vol_window).std() * np.sqrt(252) * 100).dropna()
if vol_series.empty:
continue
volatility_pct = float(vol_series.iloc[-1])
# D-1 vol: same explicit date-comparison approach as the price above, not
# just "the point before last" (guards the same near-24h double-row case).
volatility_change_pct = None
prior_vol = vol_series[vol_series.index.date < last_date]
if not prior_vol.empty:
volatility_change_pct = round(volatility_pct - float(prior_vol.iloc[-1]), 2)
return {
"symbol": symbol,
"price": round(price, 4),
"change": round(change, 4),
"change_pct": round(change_pct, 2),
"volatility_pct": round(volatility_pct, 2),
"volatility_change_pct": volatility_change_pct,
"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": "Disinflation / Rate Cuts", "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": "Inflation Shock", "color": "#dc2626", "emoji": "🔥"},
"recession": {"label": "Recession", "color": "#ef4444", "emoji": "🔴"},
"crise_liquidite": {"label": "Liquidity Crisis", "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("Curve +1%pt")
elif slope > 0.3: s += 10; r.append("Curve slightly positive")
if gcr is not None:
if gcr < 500: s += 20; r.append(f"Gold/Cu {gcr} (growth)")
elif gcr < 600: s += 10
if hyg_c > 0.2: s += 15; r.append("HYG↑ (credit OK)")
elif hyg_c > 0: s += 5
if vs200 is not None:
if vs200 > 5: s += 15; r.append(f"S&P+{vs200}% vs 200d")
elif vs200 > 0: s += 7
if copper_c > 0.5: s += 10; r.append("Copper↑")
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 > Defensives (sector risk-on)")
if eem_c > 0.3: s += 6; r.append("EM↑ (global growth)")
if usdjpy_c > 0.2: s += 4; r.append("JPY↓ (active carry = risk-on)")
scores["goldilocks"] = min(100, s); reasons["goldilocks"] = r
# DISINFLATION / RATE CUTS
s = 0; r = []
if brent_c < -1.0: s += 25; r.append("Brent↓↓ (disinflationary)")
elif brent_c < 0: s += 10
if ng_c < -1.0: s += 10; r.append("Gas↓")
if ief_c > 0.2: s += 20; r.append("IEF↑ (long rates falling)")
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 above 200d")
if hyg_c > 0: s += 10; r.append("HYG↑")
if gold_c > 0 and brent_c < 0: s += 10; r.append("Gold↑+Brent↓ (real rates ↓)")
if tlt_c > 0.5: s += 12; r.append("TLT↑↑ (disinflation confirmed)")
elif tlt_c > 0.2: s += 6; r.append("TLT↑ (long bonds supportive)")
if xlf_c > 0: s += 5; r.append("XLF↑ (pricing in rate cuts)")
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("Gas↑↑")
elif ng_c > 0.5: s += 7
if slope is not None:
if slope < 0: s += 20; r.append("Curve inverted")
elif slope < 0.3: s += 10; r.append("Curve flat")
if gold_c > 0.5: s += 15; r.append("Gold↑ (inflation hedge)")
if copper_c < 0: s += 15; r.append("Copper↓ (weak demand)")
if vix > 18: s += 10; r.append("VIX elevated")
if xlp_c > xlk_c + 0.5: s += 8; r.append("Defensives > Tech (stagflationary rotation)")
if xlu_c > 0.4: s += 6; r.append("Utilities↑ (stable income)")
if skew_v > 130: s += 5; r.append(f"SKEW {skew_v:.0f} (rising tail risk)")
if tlt_c < -0.3: s += 5; r.append("TLT↓ (persistent inflation)")
scores["stagflation"] = min(100, s); reasons["stagflation"] = r
# RECESSION
s = 0; r = []
if slope is not None:
if slope < -0.5: s += 30; r.append("Curve deeply inverted")
elif slope < 0: s += 15; r.append("Curve inverted")
if gcr is not None:
if gcr > 750: s += 25; r.append(f"Gold/Cu {gcr} (fear)")
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("Copper↓↓")
elif copper_c < -0.5: s += 8
if hyg_c < -0.5: s += 15; r.append("HYG↓ (spreads widening)")
elif hyg_c < 0: s += 5
if gold_c > 0.3: s += 10; r.append("Gold↑ (safe haven)")
if tlt_c > 0.5: s += 15; r.append("TLT↑↑ (strong recession signal)")
elif tlt_c > 0.2: s += 7; r.append("TLT↑ (bonds supported)")
if xlf_c < -1.0: s += 12; r.append("Financials↓↓ (recession leading indicator)")
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 = global risk-off)")
elif usdjpy_c < -0.5: s += 5
if skew_v > 135: s += 8; r.append(f"SKEW {skew_v:.0f} (extreme tail risk)")
scores["recession"] = min(100, s); reasons["recession"] = r
# LIQUIDITY CRISIS
s = 0; r = []
if vix > 35: s += 35; r.append("VIX>35 (panic)")
elif vix > 28: s += 20; r.append("VIX>28")
elif vix > 22: s += 8
if hyg_c < -1.5: s += 35; r.append("HYG↓↓ (credit crisis)")
elif hyg_c < -0.5: s += 15
if lqd_c < -0.5: s += 10; r.append("IG↓ (spreads widening)")
if vs200 is not None:
if vs200 < -10: s += 25; r.append("S&P<200d -10%")
elif vs200 < -3: s += 10
if gold_c > 1.0 and copper_c < -1.0: s += 20; r.append("Gold↑+Copper↓ (flight to safety)")
if dxy_c > 1.0: s += 15; r.append("Dollar↑↑")
if ief_c > 0.5: s += 10; r.append("Sovereign bonds↑↑")
if skew_v > 145: s += 15; r.append(f"SKEW {skew_v:.0f} — extreme tail risk")
elif skew_v > 135: s += 8
if vvix_v > 115: s += 15; r.append(f"VVIX {vvix_v:.0f} — vol-of-vol panic")
elif vvix_v > 100: s += 8; r.append(f"VVIX {vvix_v:.0f} — elevated vol")
if usdjpy_c < -1.5: s += 15; r.append("JPY↑↑↑ (carry unwind = global panic)")
elif usdjpy_c < -0.8: s += 7; r.append("JPY↑ (risk-off carry)")
if xlf_c < -2.0: s += 15; r.append("Banks↓↓ (systemic banking stress)")
elif xlf_c < -1.0: s += 7
if emb_c < -1.0: s += 10; r.append("EM Bonds↓ (EM liquidity flight)")
scores["crise_liquidite"] = min(100, s); reasons["crise_liquidite"] = r
# REFLATION
s = 0; r = []
if copper_c > 1.5: s += 25; r.append("Copper↑↑ (Dr Copper = growth)")
elif copper_c > 0.5: s += 12; r.append("Copper↑")
if xli_c > 0.8: s += 20; r.append("Industrials↑↑ (strong mfg activity)")
elif xli_c > 0.2: s += 10; r.append("Industrials↑")
if brent_c > 1.5: s += 15; r.append("Brent↑ (energy reflation)")
elif brent_c > 0.3: s += 6
if vs200 is not None and vs200 > 8: s += 20; r.append(f"S&P+{vs200}% vs 200d (strong bull)")
elif vs200 is not None and vs200 > 3: s += 10
if slope is not None and slope > 1.0: s += 15; r.append("Curve steep (growth)")
elif slope is not None and slope > 0.3: s += 6
if rel_perf > 0.3: s += 10; r.append("Small caps > large (broad risk-on)")
elif rel_perf > 0: s += 4
if vix < 18: s += 5
if eem_c > 1.0: s += 10; r.append("EM↑↑ (global reflation)")
elif eem_c > 0.3: s += 5; r.append("EM↑ (global risk-on)")
if xlk_c > 1.0: s += 8; r.append("Tech↑↑ (growth+momentum)")
if silver_c > 1.5: s += 8; r.append("Silver↑↑ (industrial reflation)")
if usdjpy_c > 0.5: s += 6; r.append("JPY↓ (active carry trades)")
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 (growth intact)")
if brent_c < -0.5 and brent_c > -3: s += 20; r.append("Brent slightly ↓ (gradual disinflation)")
elif brent_c < 0: s += 8
if vix < 20: s += 15; r.append("VIX<20 (no stress)")
if hyg_c > 0: s += 12; r.append("HYG↑ (solid credit)")
if lqd_c > 0: s += 8; r.append("IG↑ (calm IG spreads)")
if slope is not None and slope > 0: s += 10; r.append("Curve not inverted")
if xli_c > 0: s += 8; r.append("Industrials positive")
if copper_c > 0: s += 5; r.append("Copper stable")
if ief_c > 0 and brent_c < 0: s += 7; r.append("Rates falling + energy retreating")
if xlf_c > 0: s += 8; r.append("Financials↑ (healthy economy)")
if eem_c > 0: s += 5; r.append("EM stable (global growth intact)")
if skew_v < 130: s += 4; r.append(f"SKEW {skew_v:.0f} (non-extreme tail risk)")
scores["soft_landing"] = min(100, s); reasons["soft_landing"] = r
# INFLATION SHOCK
s = 0; r = []
if brent_c > 4.0: s += 40; r.append("Brent↑↑↑ (major energy shock)")
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("Gas↑↑↑ (gas supply shock)")
elif ng_c > 2.0: s += 12; r.append("Gas↑↑")
if gold_c > 1.0: s += 20; r.append("Gold↑↑ (inflation/geo hedge)")
elif gold_c > 0.3: s += 8; r.append("Gold↑")
if vix > 22: s += 15; r.append("VIX↑ (rising stress)")
elif vix > 18: s += 5
if copper_c < -0.5: s += 8; r.append("Copper↓ (demand destruction)")
if ief_c < -0.2: s += 8; r.append("Treasuries↓ (long rates rising)")
if ovx_v > 45: s += 15; r.append(f"OVX {ovx_v:.0f} — extreme oil vol")
elif ovx_v > 35: s += 8; r.append(f"OVX {ovx_v:.0f} — elevated oil vol")
if gvz_v > 22: s += 8; r.append(f"GVZ {gvz_v:.0f} — elevated gold vol")
if xlp_c > 0.5: s += 6; r.append("Defensives↑ (anti-inflation rotation)")
if tlt_c < -0.5: s += 8; r.append("TLT↓↓ (inflation expectations)")
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,
}