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 feedparser
|
||||||
import httpx
|
import httpx
|
||||||
import asyncio
|
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 by asset class ──────────────────────────────────────────────────
|
||||||
WATCHLIST: Dict[str, List[Dict[str, str]]] = {
|
WATCHLIST: Dict[str, List[Dict[str, str]]] = {
|
||||||
"energy": [
|
"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]:
|
def get_macro_gauges() -> Dict[str, Any]:
|
||||||
"""Fetch macro gauges from yfinance in parallel and compute derived metrics."""
|
"""Fetch macro gauges from yfinance in parallel and compute derived metrics."""
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
@@ -503,6 +636,16 @@ def get_macro_gauges() -> Dict[str, Any]:
|
|||||||
"note": vol_regime,
|
"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)
|
return _sanitize_floats(raw)
|
||||||
|
|
||||||
|
|
||||||
@@ -518,47 +661,46 @@ def _sanitize_floats(obj: Any) -> Any:
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def score_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]:
|
def _score_raw(gauges: Dict[str, Any]) -> tuple:
|
||||||
"""Rule-based scoring of the 5 macro regimes (0-100 each) from live gauge values."""
|
"""Pure rule-based scoring using blended multi-period signals. Returns (scores, reasons)."""
|
||||||
def gv(k): return gauges.get(k, {}).get("value")
|
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
|
vix = gv("vix") or 20.0
|
||||||
slope = gv("slope_10y3m")
|
slope = gv("slope_10y3m")
|
||||||
gcr = gv("gold_copper_ratio")
|
gcr = gv("gold_copper_ratio")
|
||||||
vs200 = gv("spx_vs_200d")
|
vs200 = gv("spx_vs_200d")
|
||||||
brent_c = gc("brent")
|
brent_c = gb("brent")
|
||||||
ng_c = gc("ng")
|
ng_c = gb("ng")
|
||||||
gold_c = gc("gold")
|
gold_c = gb("gold")
|
||||||
copper_c = gc("copper")
|
copper_c = gb("copper")
|
||||||
hyg_c = gc("hyg")
|
hyg_c = gb("hyg")
|
||||||
lqd_c = gc("lqd")
|
lqd_c = gb("lqd")
|
||||||
ief_c = gc("ief")
|
ief_c = gb("ief")
|
||||||
dxy_c = gc("dxy")
|
dxy_c = gb("dxy")
|
||||||
iwm_c = gc("iwm")
|
iwm_c = gb("iwm")
|
||||||
xli_c = gc("xli")
|
xli_c = gb("xli")
|
||||||
rel_perf = gv("iwm_spx_ratio") or 0.0 # Russell vs S&P relative perf
|
rel_perf = gv("iwm_spx_ratio") or 0.0
|
||||||
# ── Nouveaux signaux (phase 2 — 50 compteurs) ────────────────────────────
|
skew_v = gv("skew") or 115.0
|
||||||
skew_v = gv("skew") or 115.0 # CBOE SKEW: normal ~115, élevé >130, extrême >145
|
vvix_v = gv("vvix") or 85.0
|
||||||
vvix_v = gv("vvix") or 85.0 # Vol-of-vol: normal ~85, élevé >100, panique >115
|
ovx_v = gv("ovx") or 25.0
|
||||||
ovx_v = gv("ovx") or 25.0 # Oil vol implicite
|
gvz_v = gv("gvz") or 17.0
|
||||||
gvz_v = gv("gvz") or 17.0 # Gold vol implicite
|
tlt_c = gb("tlt")
|
||||||
tlt_c = gc("tlt") # Long bonds 20Y+ (hausse = flight to quality)
|
xlk_c = gb("xlk")
|
||||||
xlk_c = gc("xlk") # Tech (hausse = risk-on sectoriel)
|
xlf_c = gb("xlf")
|
||||||
xlf_c = gc("xlf") # Financières (baisse = stress crédit)
|
xlp_c = gb("xlp")
|
||||||
xlp_c = gc("xlp") # Conso. défensif (hausse = rotation défensive)
|
xlu_c = gb("xlu")
|
||||||
xlu_c = gc("xlu") # Utilities (hausse = rotation défensive)
|
eem_c = gb("eem")
|
||||||
eem_c = gc("eem") # EM actions (hausse = croissance globale)
|
emb_c = gb("emb")
|
||||||
emb_c = gc("emb") # EM bonds (baisse = crise liquidité EM)
|
fxi_c = gb("fxi")
|
||||||
fxi_c = gc("fxi") # Chine equities
|
usdjpy_c = gb("usdjpy")
|
||||||
usdjpy_c = gc("usdjpy") # USD/JPY (baisse = JPY s'apprécie = risk-off/panique carry)
|
silver_c = gb("silver")
|
||||||
silver_c = gc("silver") # Argent (surperf or = risk-on industriel)
|
|
||||||
tech_vs_staples = gv("xlk_xlp_momentum") or 0.0
|
tech_vs_staples = gv("xlk_xlp_momentum") or 0.0
|
||||||
|
|
||||||
scores: Dict[str, int] = {}
|
scores: Dict[str, int] = {}
|
||||||
reasons: Dict[str, List[str]] = {}
|
reasons: Dict[str, List[str]] = {}
|
||||||
|
|
||||||
# GOLDILOCKS — croissance + faible volatilité + crédit serré
|
# GOLDILOCKS
|
||||||
s = 0; r: List[str] = []
|
s = 0; r: List[str] = []
|
||||||
if vix < 15: s += 30; r.append("VIX<15")
|
if vix < 15: s += 30; r.append("VIX<15")
|
||||||
elif vix < 18: s += 20; r.append("VIX<18")
|
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")
|
if slope > 1.0: s += 20; r.append("Courbe +1%pt")
|
||||||
elif slope > 0.3: s += 10; r.append("Courbe légèrement positive")
|
elif slope > 0.3: s += 10; r.append("Courbe légèrement positive")
|
||||||
if gcr is not None:
|
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
|
elif gcr < 600: s += 10
|
||||||
if hyg_c > 0.2: s += 15; r.append("HYG↑ (crédit OK)")
|
if hyg_c > 0.2: s += 15; r.append("HYG↑ (crédit OK)")
|
||||||
elif hyg_c > 0: s += 5
|
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")
|
if vs200 > 5: s += 15; r.append(f"S&P+{vs200}% vs 200j")
|
||||||
elif vs200 > 0: s += 7
|
elif vs200 > 0: s += 7
|
||||||
if copper_c > 0.5: s += 10; r.append("Cuivre↑")
|
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)")
|
||||||
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 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 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 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 usdjpy_c > 0.2: s += 4; r.append("JPY↓ (carry actif = risk-on)")
|
||||||
scores["goldilocks"] = min(100, s); reasons["goldilocks"] = r
|
scores["goldilocks"] = min(100, s); reasons["goldilocks"] = r
|
||||||
|
|
||||||
# DÉSINFLATION / BAISSE DE TAUX
|
# 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 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 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 ↓)")
|
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↑↑ (désinflation confirmée)")
|
||||||
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)")
|
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
|
scores["desinflation"] = min(100, s); reasons["desinflation"] = r
|
||||||
|
|
||||||
# STAGFLATION — inflation + croissance faible
|
# STAGFLATION
|
||||||
s = 0; r = []
|
s = 0; r = []
|
||||||
if brent_c > 2.0: s += 30; r.append("Brent↑↑")
|
if brent_c > 2.0: s += 30; r.append("Brent↑↑")
|
||||||
elif brent_c > 0.5: s += 15; 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 gold_c > 0.5: s += 15; r.append("Or↑ (protection inflation)")
|
||||||
if copper_c < 0: s += 15; r.append("Cuivre↓ (demande faible)")
|
if copper_c < 0: s += 15; r.append("Cuivre↓ (demande faible)")
|
||||||
if vix > 18: s += 10; r.append("VIX élevé")
|
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 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 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
|
scores["stagflation"] = min(100, s); reasons["stagflation"] = r
|
||||||
|
|
||||||
# RÉCESSION
|
# 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")
|
if slope < -0.5: s += 30; r.append("Courbe fortement inversée")
|
||||||
elif slope < 0: s += 15; r.append("Courbe inversée")
|
elif slope < 0: s += 15; r.append("Courbe inversée")
|
||||||
if gcr is not None:
|
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
|
elif gcr > 650: s += 10
|
||||||
if vix > 28: s += 25; r.append("VIX>28")
|
if vix > 28: s += 25; r.append("VIX>28")
|
||||||
elif vix > 22: s += 12
|
elif vix > 22: s += 12
|
||||||
if copper_c < -1.5: s += 20; r.append("Cuivre↓↓")
|
if copper_c < -1.5: s += 20; r.append("Cuivre↓↓")
|
||||||
elif copper_c < -0.5: s += 8
|
elif copper_c < -0.5: s += 8
|
||||||
if hyg_c < -0.5: s += 15; r.append("HYG↓ (spreads s'écartent)")
|
if hyg_c < -0.5: s += 15; r.append("HYG↓ (spreads s'écartent)")
|
||||||
elif hyg_c < 0: s += 5
|
elif hyg_c < 0: s += 5
|
||||||
if gold_c > 0.3: s += 10; r.append("Or↑ (refuge)")
|
if gold_c > 0.3: s += 10; r.append("Or↑ (refuge)")
|
||||||
# Signaux phase 2
|
if tlt_c > 0.5: s += 15; r.append("TLT↑↑ (signal recessionnaire fort)")
|
||||||
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)")
|
||||||
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)")
|
||||||
if xlf_c < -1.0: s += 12; r.append("Financières↓↓ (banques = leading indicator récession)")
|
elif xlf_c < -0.3: s += 5
|
||||||
elif xlf_c < -0.3: s += 5
|
if eem_c < -1.0: s += 8; r.append("EM↓ (global slowdown)")
|
||||||
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)")
|
if usdjpy_c < -1.0: s += 10; r.append("JPY↑↑ (carry unwind = risk-off global)")
|
||||||
elif usdjpy_c < -0.5: s += 5
|
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
|
scores["recession"] = min(100, s); reasons["recession"] = r
|
||||||
|
|
||||||
# CRISE DE LIQUIDITÉ
|
# 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)")
|
if vix > 35: s += 35; r.append("VIX>35 (panique)")
|
||||||
elif vix > 28: s += 20; r.append("VIX>28")
|
elif vix > 28: s += 20; r.append("VIX>28")
|
||||||
elif vix > 22: s += 8
|
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
|
elif hyg_c < -0.5: s += 15
|
||||||
if lqd_c < -0.5: s += 10; r.append("IG↓ (spreads s'écartent)")
|
if lqd_c < -0.5: s += 10; r.append("IG↓ (spreads s'écartent)")
|
||||||
if vs200 is not None:
|
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 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 dxy_c > 1.0: s += 15; r.append("Dollar↑↑")
|
||||||
if ief_c > 0.5: s += 10; r.append("Obligations souveraines↑↑")
|
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} — tail risk extrême")
|
||||||
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
|
||||||
elif skew_v > 135: s += 8
|
if vvix_v > 115: s += 15; r.append(f"VVIX {vvix_v:.0f} — vol-of-vol panique")
|
||||||
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")
|
||||||
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)")
|
||||||
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)")
|
||||||
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)")
|
||||||
if xlf_c < -2.0: s += 15; r.append("Banques↓↓ (stress bancaire systémique)")
|
elif xlf_c < -1.0: s += 7
|
||||||
elif xlf_c < -1.0: s += 7
|
if emb_c < -1.0: s += 10; r.append("EM Bonds↓ (fuite liquidité EM)")
|
||||||
if emb_c < -1.0: s += 10; r.append("EM Bonds↓ (fuite liquidité EM)")
|
|
||||||
scores["crise_liquidite"] = min(100, s); reasons["crise_liquidite"] = r
|
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 = []
|
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↑")
|
elif copper_c > 0.5: s += 12; r.append("Cuivre↑")
|
||||||
if xli_c > 0.8: s += 20; r.append("Industriels↑↑ (activité mfg forte)")
|
if xli_c > 0.8: s += 20; r.append("Industriels↑↑ (activité mfg forte)")
|
||||||
elif xli_c > 0.2: s += 10; r.append("Industriels↑")
|
elif xli_c > 0.2: s += 10; r.append("Industriels↑")
|
||||||
if brent_c > 1.5: s += 15; r.append("Brent↑ (reflation énergie)")
|
if brent_c > 1.5: s += 15; r.append("Brent↑ (reflation énergie)")
|
||||||
elif brent_c > 0.3: s += 6
|
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 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
|
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
|
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)")
|
if rel_perf > 0.3: s += 10; r.append("Small caps > large (risk-on large)")
|
||||||
elif rel_perf > 0: s += 4
|
elif rel_perf > 0: s += 4
|
||||||
if vix < 18: s += 5
|
if vix < 18: s += 5
|
||||||
# Signaux phase 2
|
if eem_c > 1.0: s += 10; r.append("EM↑↑ (reflation globale)")
|
||||||
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)")
|
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 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 silver_c > 1.5: s += 8; r.append("Argent↑↑ (reflation industrielle)")
|
||||||
if usdjpy_c > 0.5: s += 6; r.append("JPY↓ (carry trades actifs = risk-on global)")
|
if usdjpy_c > 0.5: s += 6; r.append("JPY↓ (carry trades actifs)")
|
||||||
scores["reflation"] = min(100, s); reasons["reflation"] = r
|
scores["reflation"] = min(100, s); reasons["reflation"] = r
|
||||||
|
|
||||||
# SOFT LANDING — croissance positive + inflation en repli, pas encore basse
|
# SOFT LANDING
|
||||||
# Intermédiaire entre Goldilocks (idéal) et Désinflation (taux baissent fortement)
|
|
||||||
s = 0; r = []
|
s = 0; r = []
|
||||||
if vs200 is not None and vs200 > 0: s += 20; r.append("S&P > MA200 (croissance intacte)")
|
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)")
|
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 xli_c > 0: s += 8; r.append("Industriels positifs")
|
||||||
if copper_c > 0: s += 5; r.append("Cuivre stable")
|
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")
|
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↑ (économie saine)")
|
||||||
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 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)")
|
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
|
scores["soft_landing"] = min(100, s); reasons["soft_landing"] = r
|
||||||
|
|
||||||
# CHOC INFLATIONNISTE — spike énergie/supply soudain (guerre, OPEC, sécheresse)
|
# CHOC INFLATIONNISTE
|
||||||
# Différent de Stagflation : c'est un choc externe aigu, pas un régime durable
|
|
||||||
s = 0; r = []
|
s = 0; r = []
|
||||||
if brent_c > 4.0: s += 40; r.append("Brent↑↑↑ (choc énergie majeur)")
|
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 > 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
|
elif vix > 18: s += 5
|
||||||
if copper_c < -0.5: s += 8; r.append("Cuivre↓ (demand destruction)")
|
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)")
|
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 extrême")
|
||||||
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")
|
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 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
|
scores["inflation_shock"] = min(100, s); reasons["inflation_shock"] = r
|
||||||
|
|
||||||
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
return scores, reasons
|
||||||
dominant = ranked[0][0] if ranked[0][1] > 20 else "incertain"
|
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
"scores": scores,
|
"scores": scores,
|
||||||
@@ -745,4 +918,6 @@ def score_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
"meta": SCENARIO_META,
|
"meta": SCENARIO_META,
|
||||||
"asset_bias": SCENARIO_ASSET_BIAS,
|
"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 ranked: [string, number][] = scenarios.ranked ?? []
|
||||||
const reasons: Record<string, string[]> = scenarios.reasons ?? {}
|
const reasons: Record<string, string[]> = scenarios.reasons ?? {}
|
||||||
const assetBias: Record<string, Record<string, string>> = scenarios.asset_bias ?? {}
|
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 dominantMeta = meta[dominant] ?? { label: dominant, color: '#94a3b8', emoji: '?' }
|
||||||
const dominantBias: Record<string, string> = assetBias[dominant] ?? {}
|
const dominantBias: Record<string, string> = assetBias[dominant] ?? {}
|
||||||
|
|
||||||
@@ -215,6 +217,17 @@ export default function MacroRegime() {
|
|||||||
{scores[dominant]}%
|
{scores[dominant]}%
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-600">score de régime</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user