data_fetcher.py - MACRO_GAUGE_CONFIG: 15 → 29 tickers (+silver, vvix, skew, ovx, gvz, usdjpy, xlk, xlf, xlp, xlu, eem, emb, fxi, tlt) - 5 new derived metrics: silver_gold_ratio, xlk_xlp_momentum, xlf_spx_ratio, eem_spx_ratio, vol_surface_regime (composite classification) - ThreadPoolExecutor max_workers raised to 20 - score_macro_scenarios: +15 new variables; each of 8 scenarios enriched with vol-surface (SKEW, VVIX), sector rotation (XLK, XLF, XLP, XLU), EM/carry (EEM, EMB, USDJPY), long bonds (TLT), silver signals ai_analyzer.py - macro_ctx: 5 → 21 fields per pattern (vol surface, sectors, EM, carry, long bonds, silver/gold ratio — all with interpretation comments) - macro_section in scoring prompt: describes surface de vol regime, sector rotation, global/carry signals with explicit GPT instructions for pilier 3e - DEFAULT_ANALYSIS_TEMPLATE: pilier 3e expanded with SKEW/VVIX/OVX/GVZ guidance SIGNALS_FUTURES.md: reference document listing 30+ signals not yet available (FRED, CFTC COT, EIA, Baltic Dry, LME, credit spreads, hedge fund positioning, central bank balance sheets) with implementation priority and cost estimate. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
749 lines
40 KiB
Python
749 lines
40 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
|
||
|
||
|
||
# ── 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"},
|
||
],
|
||
"equities": [
|
||
{"symbol": "XOM", "name": "Exxon Mobil", "currency": "USD"},
|
||
{"symbol": "CVX", "name": "Chevron", "currency": "USD"},
|
||
{"symbol": "LMT", "name": "Lockheed Martin", "currency": "USD"},
|
||
{"symbol": "RTX", "name": "Raytheon", "currency": "USD"},
|
||
{"symbol": "BA", "name": "Boeing", "currency": "USD"},
|
||
],
|
||
"forex": [
|
||
{"symbol": "EURUSD=X", "name": "EUR/USD", "currency": "USD"},
|
||
{"symbol": "USDJPY=X", "name": "USD/JPY", "currency": "JPY"},
|
||
{"symbol": "GBP=X", "name": "GBP/USD", "currency": "USD"},
|
||
{"symbol": "USDCHF=X", "name": "USD/CHF", "currency": "CHF"},
|
||
{"symbol": "UUP", "name": "US Dollar ETF (UUP)", "currency": "USD"},
|
||
],
|
||
}
|
||
|
||
|
||
def get_quote(symbol: str) -> Optional[Dict[str, Any]]:
|
||
for period in ("5d", "1mo"):
|
||
try:
|
||
ticker = yf.Ticker(symbol)
|
||
hist = ticker.history(period=period, interval="1d", auto_adjust=True)
|
||
if hist.empty:
|
||
continue
|
||
# Drop rows where Close is NaN
|
||
hist = hist.dropna(subset=["Close"])
|
||
if hist.empty:
|
||
continue
|
||
price = float(hist["Close"].iloc[-1])
|
||
prev = float(hist["Close"].iloc[-2]) if len(hist) > 1 else price
|
||
change = price - prev
|
||
change_pct = (change / prev * 100) if prev else 0
|
||
return {
|
||
"symbol": symbol,
|
||
"price": round(price, 4),
|
||
"change": round(change, 4),
|
||
"change_pct": round(change_pct, 2),
|
||
"volume": int(hist["Volume"].iloc[-1]) if "Volume" in hist.columns else 0,
|
||
"timestamp": datetime.utcnow().isoformat(),
|
||
}
|
||
except Exception:
|
||
continue
|
||
return {"symbol": symbol, "price": None, "error": "no data"}
|
||
|
||
|
||
def get_all_quotes() -> Dict[str, List[Dict[str, Any]]]:
|
||
result = {}
|
||
for asset_class, assets in WATCHLIST.items():
|
||
quotes = []
|
||
for asset in assets:
|
||
q = get_quote(asset["symbol"])
|
||
if q:
|
||
q["name"] = asset["name"]
|
||
q["asset_class"] = asset_class
|
||
quotes.append(q)
|
||
result[asset_class] = quotes
|
||
return result
|
||
|
||
|
||
def get_historical(symbol: str, period: str = "1y", interval: str = "1d") -> List[Dict[str, Any]]:
|
||
try:
|
||
from urllib.parse import unquote
|
||
symbol = unquote(symbol)
|
||
ticker = yf.Ticker(symbol)
|
||
hist = ticker.history(period=period, interval=interval)
|
||
if hist.empty:
|
||
return []
|
||
hist = hist.reset_index()
|
||
records = []
|
||
for _, row in hist.iterrows():
|
||
records.append({
|
||
"date": row["Date"].isoformat() if hasattr(row["Date"], "isoformat") else str(row["Date"]),
|
||
"open": round(float(row["Open"]), 4),
|
||
"high": round(float(row["High"]), 4),
|
||
"low": round(float(row["Low"]), 4),
|
||
"close": round(float(row["Close"]), 4),
|
||
"volume": int(row["Volume"]) if "Volume" in row else 0,
|
||
})
|
||
return records
|
||
except Exception as e:
|
||
return []
|
||
|
||
|
||
def compute_historical_iv(symbol: str, window: int = 30) -> float:
|
||
"""Estimate realized vol as proxy for IV when options data unavailable."""
|
||
try:
|
||
ticker = yf.Ticker(symbol)
|
||
hist = ticker.history(period="3mo", interval="1d")
|
||
if len(hist) < 10:
|
||
return 0.25
|
||
returns = np.log(hist["Close"] / hist["Close"].shift(1)).dropna()
|
||
return float(returns.rolling(window).std().iloc[-1] * np.sqrt(252))
|
||
except Exception:
|
||
return 0.25
|
||
|
||
|
||
# ── News feeds ────────────────────────────────────────────────────────────────
|
||
GEO_RSS_FEEDS = [
|
||
{"name": "Reuters World", "url": "https://feeds.reuters.com/reuters/worldNews"},
|
||
{"name": "Reuters Business", "url": "https://feeds.reuters.com/reuters/businessNews"},
|
||
{"name": "Reuters Commodities", "url": "https://feeds.reuters.com/reuters/USenergyNews"},
|
||
{"name": "AP Top News", "url": "https://feeds.apnews.com/rss/apf-topnews"},
|
||
{"name": "Al Jazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml"},
|
||
{"name": "Financial Times", "url": "https://www.ft.com/rss/home"},
|
||
{"name": "Bloomberg Markets", "url": "https://feeds.bloomberg.com/markets/news.rss"},
|
||
]
|
||
|
||
GEO_KEYWORDS = {
|
||
"military": ["war", "attack", "missile", "troops", "conflict", "invasion", "airstrike", "NATO", "ceasefire"],
|
||
"energy": ["OPEC", "oil production", "gas pipeline", "LNG", "energy sanctions", "crude", "petroleum"],
|
||
"sanctions": ["sanctions", "embargo", "tariff", "trade ban", "export control", "blacklist"],
|
||
"elections": ["election", "poll", "vote", "presidency", "referendum", "coup"],
|
||
"natural_disaster": ["earthquake", "hurricane", "flood", "drought", "wildfire", "tsunami", "volcano"],
|
||
"health_crisis": ["pandemic", "outbreak", "epidemic", "WHO", "virus", "quarantine", "lockdown"],
|
||
"resource_scarcity": ["shortage", "supply chain", "famine", "water crisis", "food security", "rare earth"],
|
||
"trade_war": ["trade war", "tariff", "WTO", "dumping", "protectionism", "trade deal"],
|
||
"political_speech": ["Trump", "Biden", "Xi Jinping", "Putin", "Macron", "Zelensky", "Fed", "ECB"],
|
||
}
|
||
|
||
|
||
def fetch_geo_news() -> List[Dict[str, Any]]:
|
||
news = []
|
||
for feed_info in GEO_RSS_FEEDS:
|
||
try:
|
||
feed = feedparser.parse(feed_info["url"])
|
||
for entry in feed.entries[:10]:
|
||
title = entry.get("title", "")
|
||
summary = entry.get("summary", entry.get("description", ""))
|
||
published = entry.get("published", "")
|
||
link = entry.get("link", "")
|
||
category = classify_news(title + " " + summary)
|
||
impact = estimate_impact(title + " " + summary, category)
|
||
news.append({
|
||
"id": link,
|
||
"title": title,
|
||
"summary": summary[:300],
|
||
"source": feed_info["name"],
|
||
"category": category,
|
||
"impact_score": impact,
|
||
"asset_impacts": compute_asset_impacts(category, impact),
|
||
"date": published,
|
||
"tags": extract_tags(title + " " + summary),
|
||
"url": link,
|
||
})
|
||
except Exception:
|
||
pass
|
||
return news[:50]
|
||
|
||
|
||
def classify_news(text: str) -> str:
|
||
text_lower = text.lower()
|
||
scores = {}
|
||
for cat, keywords in GEO_KEYWORDS.items():
|
||
scores[cat] = sum(1 for kw in keywords if kw.lower() in text_lower)
|
||
best = max(scores, key=scores.get)
|
||
return best if scores[best] > 0 else "general"
|
||
|
||
|
||
def estimate_impact(text: str, category: str) -> float:
|
||
high_impact = ["attack", "invasion", "collapse", "crisis", "war", "ban", "default", "Trump", "Fed",
|
||
"ceasefire", "truce", "nuclear", "coup", "massacre", "bombed", "strike"]
|
||
medium_impact = ["tension", "sanctions", "shortage", "election", "rate", "OPEC",
|
||
"peace", "deal", "agreement", "accord", "treaty", "negotiation"]
|
||
text_lower = text.lower()
|
||
score = 0.1
|
||
for word in high_impact:
|
||
if word.lower() in text_lower:
|
||
score += 0.2
|
||
for word in medium_impact:
|
||
if word.lower() in text_lower:
|
||
score += 0.1
|
||
return min(1.0, round(score, 2))
|
||
|
||
|
||
def compute_asset_impacts(category: str, impact: float) -> Dict[str, float]:
|
||
impact_map = {
|
||
"military": {"energy": 0.8, "metals": 0.6, "forex": 0.4, "indices": -0.5, "agriculture": 0.3},
|
||
"energy": {"energy": 0.9, "metals": 0.2, "forex": 0.3, "indices": -0.3},
|
||
"sanctions": {"forex": 0.6, "energy": 0.5, "metals": 0.3, "indices": -0.4},
|
||
"elections": {"forex": 0.7, "indices": 0.4, "equities": 0.3},
|
||
"natural_disaster": {"agriculture": 0.8, "energy": 0.4, "indices": -0.3},
|
||
"health_crisis": {"indices": -0.8, "agriculture": 0.5, "metals": 0.4},
|
||
"resource_scarcity": {"agriculture": 0.9, "metals": 0.7, "energy": 0.5},
|
||
"trade_war": {"indices": -0.6, "forex": 0.5, "agriculture": -0.3},
|
||
"political_speech": {"forex": 0.5, "indices": 0.4, "energy": 0.3},
|
||
}
|
||
base = impact_map.get(category, {})
|
||
return {k: round(v * impact, 3) for k, v in base.items()}
|
||
|
||
|
||
def extract_tags(text: str) -> List[str]:
|
||
all_tags = [
|
||
"Trump", "Russia", "Ukraine", "China", "Iran", "Israel", "Gaza", "NATO",
|
||
"OPEC", "Fed", "ECB", "Biden", "Xi", "Putin", "Zelensky", "Macron",
|
||
"oil", "gold", "wheat", "dollar", "yuan", "euro", "S&P", "VIX",
|
||
]
|
||
return [tag for tag in all_tags if tag.lower() in text.lower()]
|
||
|
||
|
||
# ── Economic calendar (using free Trading Economics RSS or static) ────────────
|
||
def get_economic_calendar() -> List[Dict[str, Any]]:
|
||
"""Return next 30 days of major economic events (static + scraped)."""
|
||
from datetime import date, timedelta
|
||
today = date.today()
|
||
events = [
|
||
{"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"]},
|
||
]
|
||
return sorted(events, key=lambda x: x["date"])
|
||
|
||
|
||
# ── Macro Gauges & Scenario Scoring ──────────────────────────────────────────
|
||
|
||
MACRO_GAUGE_CONFIG = [
|
||
# (id, label, ticker, unit, bloc)
|
||
# ── Liquidité / Taux ─────────────────────────────────────────────────────
|
||
("dxy", "Dollar DXY", "DX-Y.NYB", "index", "liquidite"),
|
||
("us10y", "UST 10Y", "^TNX", "%", "liquidite"),
|
||
("us3m", "UST 3M", "^IRX", "%", "liquidite"),
|
||
("tips", "TIPS ETF", "TIP", "$", "liquidite"),
|
||
("tlt", "Obligations 20Y+ (TLT)", "TLT", "$", "liquidite"),
|
||
# ── Crédit ───────────────────────────────────────────────────────────────
|
||
("vix", "VIX", "^VIX", "pts", "credit"),
|
||
("hyg", "HY Bonds (HYG)", "HYG", "$", "credit"),
|
||
("lqd", "IG Bonds (LQD)", "LQD", "$", "credit"),
|
||
("ief", "Trésor 7-10Y (IEF)", "IEF", "$", "credit"),
|
||
# ── Énergie ──────────────────────────────────────────────────────────────
|
||
("brent", "Brent", "BZ=F", "$", "energie"),
|
||
("ng", "Gaz naturel", "NG=F", "$", "energie"),
|
||
# ── Métaux ───────────────────────────────────────────────────────────────
|
||
("gold", "Or", "GC=F", "$", "metaux"),
|
||
("silver", "Argent (Silver)", "SI=F", "$/oz", "metaux"),
|
||
("copper", "Cuivre", "HG=F", "$/lb", "metaux"),
|
||
# ── Croissance US ─────────────────────────────────────────────────────────
|
||
("spx", "S&P 500", "^GSPC", "pts", "croissance"),
|
||
("iwm", "Russell 2000", "IWM", "$", "croissance"),
|
||
("xli", "Industriels XLI", "XLI", "$", "croissance"),
|
||
# ── Secteurs US (rotation) ────────────────────────────────────────────────
|
||
("xlk", "Tech (XLK)", "XLK", "$", "secteurs"),
|
||
("xlf", "Financières (XLF)", "XLF", "$", "secteurs"),
|
||
("xlp", "Conso. défensif (XLP)", "XLP", "$", "secteurs"),
|
||
("xlu", "Utilities (XLU)", "XLU", "$", "secteurs"),
|
||
# ── Volatilité de surface ─────────────────────────────────────────────────
|
||
("vvix", "VVIX (vol-of-vol)", "^VVIX", "pts", "volatilite"),
|
||
("skew", "CBOE SKEW", "^SKEW", "pts", "volatilite"),
|
||
("ovx", "Pétrole Vol (OVX)", "^OVX", "pts", "volatilite"),
|
||
("gvz", "Or Vol (GVZ)", "^GVZ", "pts", "volatilite"),
|
||
# ── Global / EM ───────────────────────────────────────────────────────────
|
||
("eem", "EM Actions (EEM)", "EEM", "$", "global"),
|
||
("emb", "EM Bonds (EMB)", "EMB", "$", "global"),
|
||
("fxi", "Chine Large Cap (FXI)", "FXI", "$", "global"),
|
||
# ── Forex risk-off ────────────────────────────────────────────────────────
|
||
("usdjpy", "USD/JPY", "USDJPY=X", "pts", "forex_ro"),
|
||
]
|
||
|
||
SCENARIO_META = {
|
||
"goldilocks": {"label": "Goldilocks", "color": "#10b981", "emoji": "🟢"},
|
||
"desinflation": {"label": "Désinflation / Baisse taux","color": "#3b82f6", "emoji": "🔵"},
|
||
"soft_landing": {"label": "Soft Landing", "color": "#06b6d4", "emoji": "🔷"},
|
||
"reflation": {"label": "Reflation", "color": "#f97316", "emoji": "🟠"},
|
||
"stagflation": {"label": "Stagflation", "color": "#f59e0b", "emoji": "🟡"},
|
||
"inflation_shock": {"label": "Choc Inflationniste", "color": "#dc2626", "emoji": "🔥"},
|
||
"recession": {"label": "Récession", "color": "#ef4444", "emoji": "🔴"},
|
||
"crise_liquidite": {"label": "Crise de liquidité", "color": "#7c3aed", "emoji": "🟣"},
|
||
}
|
||
|
||
SCENARIO_ASSET_BIAS = {
|
||
"goldilocks": {"energy": "neutral", "metals": "bullish", "indices": "bullish+", "equities": "bullish+", "forex": "neutral", "agriculture": "neutral"},
|
||
"desinflation": {"energy": "bearish", "metals": "bullish+", "indices": "bullish+", "equities": "bullish", "forex": "neutral", "agriculture": "neutral"},
|
||
"soft_landing": {"energy": "neutral", "metals": "bullish", "indices": "bullish+", "equities": "bullish", "forex": "neutral", "agriculture": "neutral"},
|
||
"reflation": {"energy": "bullish+", "metals": "bullish+", "indices": "bullish", "equities": "bullish+", "forex": "neutral", "agriculture": "bullish+"},
|
||
"stagflation": {"energy": "bullish+", "metals": "bullish", "indices": "bearish", "equities": "bearish", "forex": "defensive", "agriculture": "bullish"},
|
||
"inflation_shock": {"energy": "bullish+", "metals": "bullish+", "indices": "bearish", "equities": "bearish", "forex": "defensive", "agriculture": "bullish+"},
|
||
"recession": {"energy": "bearish", "metals": "neutral", "indices": "bearish+", "equities": "bearish+", "forex": "defensive", "agriculture": "neutral"},
|
||
"crise_liquidite": {"energy": "neutral", "metals": "bullish+", "indices": "bearish+", "equities": "bearish+", "forex": "defensive", "agriculture": "neutral"},
|
||
}
|
||
|
||
|
||
def 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,
|
||
}
|
||
|
||
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_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Rule-based scoring of the 5 macro regimes (0-100 each) from live gauge values."""
|
||
def gv(k): return gauges.get(k, {}).get("value")
|
||
def gc(k): return gauges.get(k, {}).get("change_pct") or 0.0
|
||
|
||
vix = gv("vix") or 20.0
|
||
slope = gv("slope_10y3m")
|
||
gcr = gv("gold_copper_ratio")
|
||
vs200 = gv("spx_vs_200d")
|
||
brent_c = gc("brent")
|
||
ng_c = gc("ng")
|
||
gold_c = gc("gold")
|
||
copper_c = gc("copper")
|
||
hyg_c = gc("hyg")
|
||
lqd_c = gc("lqd")
|
||
ief_c = gc("ief")
|
||
dxy_c = gc("dxy")
|
||
iwm_c = gc("iwm")
|
||
xli_c = gc("xli")
|
||
rel_perf = gv("iwm_spx_ratio") or 0.0 # Russell vs S&P relative perf
|
||
# ── Nouveaux signaux (phase 2 — 50 compteurs) ────────────────────────────
|
||
skew_v = gv("skew") or 115.0 # CBOE SKEW: normal ~115, élevé >130, extrême >145
|
||
vvix_v = gv("vvix") or 85.0 # Vol-of-vol: normal ~85, élevé >100, panique >115
|
||
ovx_v = gv("ovx") or 25.0 # Oil vol implicite
|
||
gvz_v = gv("gvz") or 17.0 # Gold vol implicite
|
||
tlt_c = gc("tlt") # Long bonds 20Y+ (hausse = flight to quality)
|
||
xlk_c = gc("xlk") # Tech (hausse = risk-on sectoriel)
|
||
xlf_c = gc("xlf") # Financières (baisse = stress crédit)
|
||
xlp_c = gc("xlp") # Conso. défensif (hausse = rotation défensive)
|
||
xlu_c = gc("xlu") # Utilities (hausse = rotation défensive)
|
||
eem_c = gc("eem") # EM actions (hausse = croissance globale)
|
||
emb_c = gc("emb") # EM bonds (baisse = crise liquidité EM)
|
||
fxi_c = gc("fxi") # Chine equities
|
||
usdjpy_c = gc("usdjpy") # USD/JPY (baisse = JPY s'apprécie = risk-off/panique carry)
|
||
silver_c = gc("silver") # Argent (surperf or = risk-on industriel)
|
||
tech_vs_staples = gv("xlk_xlp_momentum") or 0.0
|
||
|
||
scores: Dict[str, int] = {}
|
||
reasons: Dict[str, List[str]] = {}
|
||
|
||
# GOLDILOCKS — croissance + faible volatilité + crédit serré
|
||
s = 0; r: List[str] = []
|
||
if vix < 15: s += 30; r.append("VIX<15")
|
||
elif vix < 18: s += 20; r.append("VIX<18")
|
||
elif vix < 22: s += 10
|
||
if slope is not None:
|
||
if slope > 1.0: s += 20; r.append("Courbe +1%pt")
|
||
elif slope > 0.3: s += 10; r.append("Courbe légèrement positive")
|
||
if gcr is not None:
|
||
if gcr < 500: s += 20; r.append(f"Or/Cu {gcr} (croissance)")
|
||
elif gcr < 600: s += 10
|
||
if hyg_c > 0.2: s += 15; r.append("HYG↑ (crédit OK)")
|
||
elif hyg_c > 0: s += 5
|
||
if vs200 is not None:
|
||
if vs200 > 5: s += 15; r.append(f"S&P+{vs200}% vs 200j")
|
||
elif vs200 > 0: s += 7
|
||
if copper_c > 0.5: s += 10; r.append("Cuivre↑")
|
||
# Signaux phase 2
|
||
if skew_v < 115: s += 6; r.append(f"SKEW {skew_v:.0f} (no tail hedge = complacency)")
|
||
if vvix_v < 85: s += 5; r.append(f"VVIX {vvix_v:.0f} (vol stable)")
|
||
if tech_vs_staples > 0.5: s += 7; r.append("Tech > Défensifs (risk-on sectoriel)")
|
||
if eem_c > 0.3: s += 6; r.append("EM↑ (croissance globale)")
|
||
if usdjpy_c > 0.2: s += 4; r.append("JPY↓ (carry actif = risk-on)")
|
||
scores["goldilocks"] = min(100, s); reasons["goldilocks"] = r
|
||
|
||
# DÉSINFLATION / BAISSE DE TAUX
|
||
s = 0; r = []
|
||
if brent_c < -1.0: s += 25; r.append("Brent↓↓ (désinflationniste)")
|
||
elif brent_c < 0: s += 10
|
||
if ng_c < -1.0: s += 10; r.append("Gaz↓")
|
||
if ief_c > 0.2: s += 20; r.append("IEF↑ (taux longs baissent)")
|
||
elif ief_c > 0: s += 10
|
||
if vix < 20: s += 15; r.append("VIX<20")
|
||
if vs200 is not None and vs200 > 0: s += 20; r.append("S&P au-dessus 200j")
|
||
if hyg_c > 0: s += 10; r.append("HYG↑")
|
||
if gold_c > 0 and brent_c < 0: s += 10; r.append("Or↑+Brent↓ (taux réels ↓)")
|
||
# Signaux phase 2
|
||
if tlt_c > 0.5: s += 12; r.append("TLT↑↑ (taux 20Y baissent = désinflation confirmée)")
|
||
elif tlt_c > 0.2: s += 6; r.append("TLT↑ (bonds longs soutiennent)")
|
||
if xlf_c > 0: s += 5; r.append("XLF↑ (banques = anticipent baisse taux)")
|
||
scores["desinflation"] = min(100, s); reasons["desinflation"] = r
|
||
|
||
# STAGFLATION — inflation + croissance faible
|
||
s = 0; r = []
|
||
if brent_c > 2.0: s += 30; r.append("Brent↑↑")
|
||
elif brent_c > 0.5: s += 15; r.append("Brent↑")
|
||
if ng_c > 2.0: s += 15; r.append("Gaz↑↑")
|
||
elif ng_c > 0.5: s += 7
|
||
if slope is not None:
|
||
if slope < 0: s += 20; r.append("Courbe inversée")
|
||
elif slope < 0.3: s += 10; r.append("Courbe plate")
|
||
if gold_c > 0.5: s += 15; r.append("Or↑ (protection inflation)")
|
||
if copper_c < 0: s += 15; r.append("Cuivre↓ (demande faible)")
|
||
if vix > 18: s += 10; r.append("VIX élevé")
|
||
# Signaux phase 2
|
||
if xlp_c > xlk_c + 0.5: s += 8; r.append("Défensifs > Tech (rotation stagflationniste)")
|
||
if xlu_c > 0.4: s += 6; r.append("Utilities↑ (rotation vers revenus stables)")
|
||
if skew_v > 130: s += 5; r.append(f"SKEW {skew_v:.0f} (tail risk croissant)")
|
||
if tlt_c < -0.3: s += 5; r.append("TLT↓ (taux longs remontent = inflation persistante)")
|
||
scores["stagflation"] = min(100, s); reasons["stagflation"] = r
|
||
|
||
# RÉCESSION
|
||
s = 0; r = []
|
||
if slope is not None:
|
||
if slope < -0.5: s += 30; r.append("Courbe fortement inversée")
|
||
elif slope < 0: s += 15; r.append("Courbe inversée")
|
||
if gcr is not None:
|
||
if gcr > 750: s += 25; r.append(f"Or/Cu {gcr} (peur)")
|
||
elif gcr > 650: s += 10
|
||
if vix > 28: s += 25; r.append("VIX>28")
|
||
elif vix > 22: s += 12
|
||
if copper_c < -1.5: s += 20; r.append("Cuivre↓↓")
|
||
elif copper_c < -0.5: s += 8
|
||
if hyg_c < -0.5: s += 15; r.append("HYG↓ (spreads s'écartent)")
|
||
elif hyg_c < 0: s += 5
|
||
if gold_c > 0.3: s += 10; r.append("Or↑ (refuge)")
|
||
# Signaux phase 2
|
||
if tlt_c > 0.5: s += 15; r.append("TLT↑↑ (fuite vers bonds longs = signal recessionnaire fort)")
|
||
elif tlt_c > 0.2: s += 7; r.append("TLT↑ (obligations soutenues)")
|
||
if xlf_c < -1.0: s += 12; r.append("Financières↓↓ (banques = leading indicator récession)")
|
||
elif xlf_c < -0.3: s += 5
|
||
if eem_c < -1.0: s += 8; r.append("EM↓ (global slowdown)")
|
||
if usdjpy_c < -1.0: s += 10; r.append("JPY↑↑ (carry unwind = risk-off global)")
|
||
elif usdjpy_c < -0.5: s += 5
|
||
if skew_v > 135: s += 8; r.append(f"SKEW {skew_v:.0f} (tail risk extrême)")
|
||
scores["recession"] = min(100, s); reasons["recession"] = r
|
||
|
||
# CRISE DE LIQUIDITÉ
|
||
s = 0; r = []
|
||
if vix > 35: s += 35; r.append("VIX>35 (panique)")
|
||
elif vix > 28: s += 20; r.append("VIX>28")
|
||
elif vix > 22: s += 8
|
||
if hyg_c < -1.5: s += 35; r.append("HYG↓↓ (crise crédit)")
|
||
elif hyg_c < -0.5: s += 15
|
||
if lqd_c < -0.5: s += 10; r.append("IG↓ (spreads s'écartent)")
|
||
if vs200 is not None:
|
||
if vs200 < -10: s += 25; r.append("S&P<200j -10%")
|
||
elif vs200 < -3: s += 10
|
||
if gold_c > 1.0 and copper_c < -1.0: s += 20; r.append("Or↑+Cuivre↓ (fuite sécurité)")
|
||
if dxy_c > 1.0: s += 15; r.append("Dollar↑↑")
|
||
if ief_c > 0.5: s += 10; r.append("Obligations souveraines↑↑")
|
||
# Signaux phase 2 — les meilleurs indicateurs de crise liquide
|
||
if skew_v > 145: s += 15; r.append(f"SKEW {skew_v:.0f} — extrême tail risk (panique protection)")
|
||
elif skew_v > 135: s += 8
|
||
if vvix_v > 115: s += 15; r.append(f"VVIX {vvix_v:.0f} — vol-of-vol panique")
|
||
elif vvix_v > 100: s += 8; r.append(f"VVIX {vvix_v:.0f} — vol élevée")
|
||
if usdjpy_c < -1.5: s += 15; r.append("JPY↑↑↑ (carry trade unwind = panique globale)")
|
||
elif usdjpy_c < -0.8: s += 7; r.append("JPY↑ (risk-off carry)")
|
||
if xlf_c < -2.0: s += 15; r.append("Banques↓↓ (stress bancaire systémique)")
|
||
elif xlf_c < -1.0: s += 7
|
||
if emb_c < -1.0: s += 10; r.append("EM Bonds↓ (fuite liquidité EM)")
|
||
scores["crise_liquidite"] = min(100, s); reasons["crise_liquidite"] = r
|
||
|
||
# REFLATION — croissance accélère + inflation remonte (cuivre, énergie, small caps explosent)
|
||
s = 0; r = []
|
||
if copper_c > 1.5: s += 25; r.append("Cuivre↑↑ (Dr Copper = croissance)")
|
||
elif copper_c > 0.5: s += 12; r.append("Cuivre↑")
|
||
if xli_c > 0.8: s += 20; r.append("Industriels↑↑ (activité mfg forte)")
|
||
elif xli_c > 0.2: s += 10; r.append("Industriels↑")
|
||
if brent_c > 1.5: s += 15; r.append("Brent↑ (reflation énergie)")
|
||
elif brent_c > 0.3: s += 6
|
||
if vs200 is not None and vs200 > 8: s += 20; r.append(f"S&P+{vs200}% vs 200j (bull fort)")
|
||
elif vs200 is not None and vs200 > 3: s += 10
|
||
if slope is not None and slope > 1.0: s += 15; r.append("Courbe pentue (anticipation croissance)")
|
||
elif slope is not None and slope > 0.3: s += 6
|
||
if rel_perf > 0.3: s += 10; r.append("Small caps > large (risk-on large)")
|
||
elif rel_perf > 0: s += 4
|
||
if vix < 18: s += 5
|
||
# Signaux phase 2
|
||
if eem_c > 1.0: s += 10; r.append("EM↑↑ (croissance mondiale = reflation globale)")
|
||
elif eem_c > 0.3: s += 5; r.append("EM↑ (global risk-on)")
|
||
if xlk_c > 1.0: s += 8; r.append("Tech↑↑ (croissance+momentum)")
|
||
if silver_c > 1.5: s += 8; r.append("Argent↑↑ (industrial metals = reflation industrielle)")
|
||
if usdjpy_c > 0.5: s += 6; r.append("JPY↓ (carry trades actifs = risk-on global)")
|
||
scores["reflation"] = min(100, s); reasons["reflation"] = r
|
||
|
||
# SOFT LANDING — croissance positive + inflation en repli, pas encore basse
|
||
# Intermédiaire entre Goldilocks (idéal) et Désinflation (taux baissent fortement)
|
||
s = 0; r = []
|
||
if vs200 is not None and vs200 > 0: s += 20; r.append("S&P > MA200 (croissance intacte)")
|
||
if brent_c < -0.5 and brent_c > -3: s += 20; r.append("Brent légèrement ↓ (désinflation graduelle)")
|
||
elif brent_c < 0: s += 8
|
||
if vix < 20: s += 15; r.append("VIX<20 (pas de stress)")
|
||
if hyg_c > 0: s += 12; r.append("HYG↑ (crédit solide)")
|
||
if lqd_c > 0: s += 8; r.append("IG↑ (spreads IG calmes)")
|
||
if slope is not None and slope > 0: s += 10; r.append("Courbe non-inversée")
|
||
if xli_c > 0: s += 8; r.append("Industriels positifs")
|
||
if copper_c > 0: s += 5; r.append("Cuivre stable")
|
||
if ief_c > 0 and brent_c < 0: s += 7; r.append("Taux baissent + énergie recule")
|
||
# Signaux phase 2
|
||
if xlf_c > 0: s += 8; r.append("Financières↑ (banques = économie saine, no recession)")
|
||
if eem_c > 0: s += 5; r.append("EM stable (croissance globale intacte)")
|
||
if skew_v < 130: s += 4; r.append(f"SKEW {skew_v:.0f} (tail risk non-extrême)")
|
||
scores["soft_landing"] = min(100, s); reasons["soft_landing"] = r
|
||
|
||
# CHOC INFLATIONNISTE — spike énergie/supply soudain (guerre, OPEC, sécheresse)
|
||
# Différent de Stagflation : c'est un choc externe aigu, pas un régime durable
|
||
s = 0; r = []
|
||
if brent_c > 4.0: s += 40; r.append("Brent↑↑↑ (choc énergie majeur)")
|
||
elif brent_c > 2.0: s += 25; r.append("Brent↑↑")
|
||
elif brent_c > 0.8: s += 10
|
||
if ng_c > 4.0: s += 20; r.append("Gaz↑↑↑ (choc supply gaz)")
|
||
elif ng_c > 2.0: s += 12; r.append("Gaz↑↑")
|
||
if gold_c > 1.0: s += 20; r.append("Or↑↑ (refuge inflation/géo)")
|
||
elif gold_c > 0.3: s += 8; r.append("Or↑")
|
||
if vix > 22: s += 15; r.append("VIX↑ (stress montant)")
|
||
elif vix > 18: s += 5
|
||
if copper_c < -0.5: s += 8; r.append("Cuivre↓ (demand destruction)")
|
||
if ief_c < -0.2: s += 8; r.append("Trésor↓ (taux longs remontent)")
|
||
# Signaux phase 2
|
||
if ovx_v > 45: s += 15; r.append(f"OVX {ovx_v:.0f} — vol pétrole extreme (choc supply aigu)")
|
||
elif ovx_v > 35: s += 8; r.append(f"OVX {ovx_v:.0f} — vol pétrole élevée")
|
||
if gvz_v > 22: s += 8; r.append(f"GVZ {gvz_v:.0f} — vol or élevée (inflation/géo)")
|
||
if xlp_c > 0.5: s += 6; r.append("Défensifs↑ (rotation anti-inflation)")
|
||
if tlt_c < -0.5: s += 8; r.append("TLT↓↓ (taux 20Y montent = anticipation inflation)")
|
||
scores["inflation_shock"] = min(100, s); reasons["inflation_shock"] = r
|
||
|
||
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||
dominant = ranked[0][0] if ranked[0][1] > 20 else "incertain"
|
||
|
||
return {
|
||
"scores": scores,
|
||
"ranked": [[k, v] for k, v in ranked],
|
||
"dominant": dominant,
|
||
"reasons": reasons,
|
||
"meta": SCENARIO_META,
|
||
"asset_bias": SCENARIO_ASSET_BIAS,
|
||
}
|