feat: stable macro regime detection — blended signals + Bayesian smoothing
Replace 1-day change_pct with weighted blend (20% 1d / 50% 5d / 30% 10d) for all 20 scored signals. Add Bayesian prior from rolling 25-day history (weight 15%→45%) and a 10-point persistence threshold before regime switch. Bootstrap on first load: replays last 20 trading days via yf.download batch (45d) to pre-populate _regime_history, so stability is visible immediately. Frontend: adds 'stable Xj' badge and history depth indicator on regime banner. Doc: updates v4.0→v4.1, rewrites Étape 1 Régime Macro and glossary entry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1464
GeoOptions_Documentation_Complete.html
Normal file
1464
GeoOptions_Documentation_Complete.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,20 @@ 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": [
|
||||
@@ -355,6 +367,127 @@ SCENARIO_ASSET_BIAS = {
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -503,6 +636,16 @@ def get_macro_gauges() -> Dict[str, Any]:
|
||||
"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)
|
||||
|
||||
|
||||
@@ -518,47 +661,46 @@ def _sanitize_floats(obj: Any) -> Any:
|
||||
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 _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 gc(k): return gauges.get(k, {}).get("change_pct") or 0.0
|
||||
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 = 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)
|
||||
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 — croissance + faible volatilité + crédit serré
|
||||
# GOLDILOCKS
|
||||
s = 0; r: List[str] = []
|
||||
if vix < 15: s += 30; r.append("VIX<15")
|
||||
elif vix < 18: s += 20; r.append("VIX<18")
|
||||
@@ -567,7 +709,7 @@ def score_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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)")
|
||||
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
|
||||
@@ -575,12 +717,11 @@ def score_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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 skew_v < 115: s += 6; r.append(f"SKEW {skew_v:.0f} (no tail hedge)")
|
||||
if vvix_v < 85: s += 5; r.append(f"VVIX {vvix_v:.0f} (vol stable)")
|
||||
if tech_vs_staples > 0.5: s += 7; r.append("Tech > Défensifs (risk-on sectoriel)")
|
||||
if eem_c > 0.3: s += 6; r.append("EM↑ (croissance globale)")
|
||||
if usdjpy_c > 0.2: s += 4; r.append("JPY↓ (carry actif = risk-on)")
|
||||
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
|
||||
@@ -594,13 +735,12 @@ def score_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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)")
|
||||
if tlt_c > 0.5: s += 12; r.append("TLT↑↑ (désinflation confirmée)")
|
||||
elif tlt_c > 0.2: s += 6; r.append("TLT↑ (bonds longs soutiennent)")
|
||||
if xlf_c > 0: s += 5; r.append("XLF↑ (banques = anticipent baisse taux)")
|
||||
if xlf_c > 0: s += 5; r.append("XLF↑ (anticipent baisse taux)")
|
||||
scores["desinflation"] = min(100, s); reasons["desinflation"] = r
|
||||
|
||||
# STAGFLATION — inflation + croissance faible
|
||||
# STAGFLATION
|
||||
s = 0; r = []
|
||||
if brent_c > 2.0: s += 30; r.append("Brent↑↑")
|
||||
elif brent_c > 0.5: s += 15; r.append("Brent↑")
|
||||
@@ -612,11 +752,10 @@ def score_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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 xlu_c > 0.4: s += 6; r.append("Utilities↑ (revenus stables)")
|
||||
if skew_v > 130: s += 5; r.append(f"SKEW {skew_v:.0f} (tail risk croissant)")
|
||||
if tlt_c < -0.3: s += 5; r.append("TLT↓ (taux longs remontent = inflation persistante)")
|
||||
if tlt_c < -0.3: s += 5; r.append("TLT↓ (inflation persistante)")
|
||||
scores["stagflation"] = min(100, s); reasons["stagflation"] = r
|
||||
|
||||
# RÉCESSION
|
||||
@@ -625,24 +764,23 @@ def score_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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)")
|
||||
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↓↓")
|
||||
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 hyg_c < -0.5: s += 15; r.append("HYG↓ (spreads s'écartent)")
|
||||
elif hyg_c < 0: s += 5
|
||||
if gold_c > 0.3: s += 10; r.append("Or↑ (refuge)")
|
||||
if tlt_c > 0.5: s += 15; r.append("TLT↑↑ (signal recessionnaire fort)")
|
||||
elif tlt_c > 0.2: s += 7; r.append("TLT↑ (obligations soutenues)")
|
||||
if xlf_c < -1.0: s += 12; r.append("Financières↓↓ (leading indicator récession)")
|
||||
elif xlf_c < -0.3: s += 5
|
||||
if eem_c < -1.0: s += 8; r.append("EM↓ (global slowdown)")
|
||||
if usdjpy_c < -1.0: s += 10; r.append("JPY↑↑ (carry unwind = risk-off global)")
|
||||
elif usdjpy_c < -0.5: s += 5
|
||||
if skew_v > 135: s += 8; r.append(f"SKEW {skew_v:.0f} (tail risk extrême)")
|
||||
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É
|
||||
@@ -650,7 +788,7 @@ def score_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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)")
|
||||
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:
|
||||
@@ -659,43 +797,40 @@ def score_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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)")
|
||||
if skew_v > 145: s += 15; r.append(f"SKEW {skew_v:.0f} — tail risk extrême")
|
||||
elif skew_v > 135: s += 8
|
||||
if vvix_v > 115: s += 15; r.append(f"VVIX {vvix_v:.0f} — vol-of-vol panique")
|
||||
elif vvix_v > 100: s += 8; r.append(f"VVIX {vvix_v:.0f} — vol élevée")
|
||||
if usdjpy_c < -1.5: s += 15; r.append("JPY↑↑↑ (carry unwind = panique globale)")
|
||||
elif usdjpy_c < -0.8: s += 7; r.append("JPY↑ (risk-off carry)")
|
||||
if xlf_c < -2.0: s += 15; r.append("Banques↓↓ (stress bancaire systémique)")
|
||||
elif xlf_c < -1.0: s += 7
|
||||
if emb_c < -1.0: s += 10; r.append("EM Bonds↓ (fuite liquidité EM)")
|
||||
scores["crise_liquidite"] = min(100, s); reasons["crise_liquidite"] = r
|
||||
|
||||
# REFLATION — croissance accélère + inflation remonte (cuivre, énergie, small caps explosent)
|
||||
# REFLATION
|
||||
s = 0; r = []
|
||||
if copper_c > 1.5: s += 25; r.append("Cuivre↑↑ (Dr Copper = croissance)")
|
||||
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)")
|
||||
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)")
|
||||
if slope is not None and slope > 1.0: s += 15; r.append("Courbe pentue (croissance)")
|
||||
elif slope is not None and slope > 0.3: s += 6
|
||||
if rel_perf > 0.3: s += 10; r.append("Small caps > large (risk-on large)")
|
||||
elif rel_perf > 0: s += 4
|
||||
if vix < 18: s += 5
|
||||
# Signaux phase 2
|
||||
if eem_c > 1.0: s += 10; r.append("EM↑↑ (croissance mondiale = reflation globale)")
|
||||
if eem_c > 1.0: s += 10; r.append("EM↑↑ (reflation globale)")
|
||||
elif eem_c > 0.3: s += 5; r.append("EM↑ (global risk-on)")
|
||||
if xlk_c > 1.0: s += 8; r.append("Tech↑↑ (croissance+momentum)")
|
||||
if silver_c > 1.5: s += 8; r.append("Argent↑↑ (industrial metals = reflation industrielle)")
|
||||
if usdjpy_c > 0.5: s += 6; r.append("JPY↓ (carry trades actifs = risk-on global)")
|
||||
if silver_c > 1.5: s += 8; r.append("Argent↑↑ (reflation industrielle)")
|
||||
if usdjpy_c > 0.5: s += 6; r.append("JPY↓ (carry trades actifs)")
|
||||
scores["reflation"] = min(100, s); reasons["reflation"] = r
|
||||
|
||||
# SOFT LANDING — croissance positive + inflation en repli, pas encore basse
|
||||
# Intermédiaire entre Goldilocks (idéal) et Désinflation (taux baissent fortement)
|
||||
# SOFT LANDING
|
||||
s = 0; r = []
|
||||
if vs200 is not None and vs200 > 0: s += 20; r.append("S&P > MA200 (croissance intacte)")
|
||||
if brent_c < -0.5 and brent_c > -3: s += 20; r.append("Brent légèrement ↓ (désinflation graduelle)")
|
||||
@@ -707,14 +842,12 @@ def score_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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 xlf_c > 0: s += 8; r.append("Financières↑ (économie saine)")
|
||||
if eem_c > 0: s += 5; r.append("EM stable (croissance globale intacte)")
|
||||
if skew_v < 130: s += 4; r.append(f"SKEW {skew_v:.0f} (tail risk non-extrême)")
|
||||
scores["soft_landing"] = min(100, s); reasons["soft_landing"] = r
|
||||
|
||||
# CHOC INFLATIONNISTE — spike énergie/supply soudain (guerre, OPEC, sécheresse)
|
||||
# Différent de Stagflation : c'est un choc externe aigu, pas un régime durable
|
||||
# CHOC INFLATIONNISTE
|
||||
s = 0; r = []
|
||||
if brent_c > 4.0: s += 40; r.append("Brent↑↑↑ (choc énergie majeur)")
|
||||
elif brent_c > 2.0: s += 25; r.append("Brent↑↑")
|
||||
@@ -727,16 +860,56 @@ def score_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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)")
|
||||
if ovx_v > 45: s += 15; r.append(f"OVX {ovx_v:.0f} — vol pétrole extrême")
|
||||
elif ovx_v > 35: s += 8; r.append(f"OVX {ovx_v:.0f} — vol pétrole élevée")
|
||||
if gvz_v > 22: s += 8; r.append(f"GVZ {gvz_v:.0f} — vol or élevée (inflation/géo)")
|
||||
if gvz_v > 22: s += 8; r.append(f"GVZ {gvz_v:.0f} — vol or élevée")
|
||||
if xlp_c > 0.5: s += 6; r.append("Défensifs↑ (rotation anti-inflation)")
|
||||
if tlt_c < -0.5: s += 8; r.append("TLT↓↓ (taux 20Y montent = anticipation inflation)")
|
||||
if tlt_c < -0.5: s += 8; r.append("TLT↓↓ (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, 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,
|
||||
@@ -745,4 +918,6 @@ def score_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"reasons": reasons,
|
||||
"meta": SCENARIO_META,
|
||||
"asset_bias": SCENARIO_ASSET_BIAS,
|
||||
"history_days": history_len,
|
||||
"regime_stability": consecutive,
|
||||
}
|
||||
|
||||
@@ -127,6 +127,8 @@ export default function MacroRegime() {
|
||||
const ranked: [string, number][] = scenarios.ranked ?? []
|
||||
const reasons: Record<string, string[]> = scenarios.reasons ?? {}
|
||||
const assetBias: Record<string, Record<string, string>> = scenarios.asset_bias ?? {}
|
||||
const historyDays: number = scenarios.history_days ?? 0
|
||||
const regimeStability: number = scenarios.regime_stability ?? 0
|
||||
const dominantMeta = meta[dominant] ?? { label: dominant, color: '#94a3b8', emoji: '?' }
|
||||
const dominantBias: Record<string, string> = assetBias[dominant] ?? {}
|
||||
|
||||
@@ -215,6 +217,17 @@ export default function MacroRegime() {
|
||||
{scores[dominant]}%
|
||||
</div>
|
||||
<div className="text-xs text-slate-600">score de régime</div>
|
||||
{regimeStability > 0 && (
|
||||
<div className="text-[10px] mt-1 px-1.5 py-0.5 rounded-full text-center"
|
||||
style={{ background: `${dominantMeta.color}22`, color: dominantMeta.color }}>
|
||||
stable {regimeStability}j
|
||||
</div>
|
||||
)}
|
||||
{historyDays > 0 && (
|
||||
<div className="text-[10px] text-slate-700 mt-0.5 text-center">
|
||||
{historyDays}j d'historique
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user