1032 lines
51 KiB
Python
1032 lines
51 KiB
Python
"""
|
||
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_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 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("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,
|
||
}
|