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:
OpenSquared
2026-06-19 12:40:38 +02:00
parent d8c0334feb
commit 44ada3f8f1
3 changed files with 1744 additions and 92 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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,
}

View File

@@ -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>
)}