""" 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) ("dxy", "Dollar DXY", "DX-Y.NYB", "index", "liquidite"), ("us10y", "UST 10Y", "^TNX", "%", "liquidite"), ("us3m", "UST 3M", "^IRX", "%", "liquidite"), ("tips", "TIPS ETF", "TIP", "$", "liquidite"), ("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"), ("brent", "Brent", "BZ=F", "$", "energie"), ("ng", "Gaz naturel", "NG=F", "$", "energie"), ("gold", "Or", "GC=F", "$", "metaux"), ("copper", "Cuivre", "HG=F", "$/lb", "metaux"), ("spx", "S&P 500", "^GSPC", "pts", "croissance"), ("iwm", "Russell 2000", "IWM", "$", "croissance"), ("xli", "Industriels XLI", "XLI", "$", "croissance"), ] 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), 12)) 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)")), } 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 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↑") 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 ↓)") 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é") 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)") 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↑↑") 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 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") 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)") 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, }