feat: macro gauge DB + regime triggers + date-aware instrument snapshot
DB: - New table macro_gauge_snapshots (daily snapshot of all 28+ gauges + dominant + scores) - save_macro_gauge_snapshot / get_macro_gauge_snapshot_at / get_macro_gauge_history - Auto-save once per calendar day on every macro-regime fetch (not just force=True) API: - GET /api/market/macro-gauges/at?date=YYYY-MM-DD — nearest snapshot ≤ date - GET /api/market/macro-gauges/history?days=N Detector (_check_macro_gauges in Eco Desk): - Regime transition events (goldilocks→stagflation etc.) with severity scoring - Yield curve inversion / désinversion (slope_10y3m sign change) - DXY shock (% change over lookback window) - Credit stress (HYG drop threshold) - Gold/Copper ratio regime crossings InstrumentDashboard: - macroAtDate state: fetches /api/market/macro-gauges/at when crosshair date ≠ last date - RegimeCard uses historical macro regime when on a past date - MacroGaugePanel: full breakdown of all gauges by bloc (liquidité, crédit, énergie...) visible only when on a historical date — shows value + change_pct + regime scores bar AIDesks: added fundamental + sentiment to AIDesk type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1058,6 +1058,232 @@ def _check_reports(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
return created
|
||||
|
||||
|
||||
# ── Source 7: Macro gauge transitions (Eco Desk extension) ───────────────────
|
||||
|
||||
# Scenarios ordered by risk level for determining transition severity
|
||||
_REGIME_SEVERITY = {
|
||||
"goldilocks": 1, "desinflation": 2, "soft_landing": 2,
|
||||
"reflation": 3, "stagflation": 4, "inflation_shock": 5,
|
||||
"recession": 5, "crise_liquidite": 6, "incertain": 0,
|
||||
}
|
||||
|
||||
_REGIME_LABELS = {
|
||||
"goldilocks": "Goldilocks", "desinflation": "Désinflation",
|
||||
"soft_landing": "Soft Landing", "reflation": "Reflation",
|
||||
"stagflation": "Stagflation", "inflation_shock": "Choc Inflationniste",
|
||||
"recession": "Récession", "crise_liquidite": "Crise de liquidité",
|
||||
"incertain": "Incertain",
|
||||
}
|
||||
|
||||
|
||||
def _check_macro_gauges(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Detect macro regime transitions and key gauge threshold crossings.
|
||||
Uses macro_gauge_snapshots table (daily persistence) as source.
|
||||
Falls back to macro_regime_history for regime transitions if no snapshots yet.
|
||||
"""
|
||||
from services.database import get_macro_gauge_history, get_macro_regime_history
|
||||
|
||||
gauge_signals = desk_cfg.get("gauge_signals", {})
|
||||
|
||||
def sig_on(k: str) -> Optional[Dict]:
|
||||
c = gauge_signals.get(k, {})
|
||||
return c if c.get("enabled", True) else None
|
||||
|
||||
regime_cfg = sig_on("regime_transition")
|
||||
curve_cfg = sig_on("yield_curve_inversion")
|
||||
dxy_cfg = sig_on("dxy_shock")
|
||||
credit_cfg = sig_on("credit_stress")
|
||||
gcr_cfg = sig_on("gold_copper_ratio")
|
||||
|
||||
existing = _existing_event_keys()
|
||||
created: List[Dict] = []
|
||||
|
||||
def _emit(name: str, date_str: str, category: str, sub_type: str,
|
||||
score: float, level: str, desc: str, assets: List[str]):
|
||||
if _is_dup(name, existing):
|
||||
return
|
||||
ev = {
|
||||
"name": name,
|
||||
"start_date": date_str,
|
||||
"level": level,
|
||||
"category": category,
|
||||
"sub_type": sub_type,
|
||||
"description": desc,
|
||||
"market_impact": "",
|
||||
"affected_assets": assets,
|
||||
"impact_score": score,
|
||||
"source_refs": [{
|
||||
"title": f"Macro signal: {name}",
|
||||
"source": "MacroRegime/DB",
|
||||
"url": "",
|
||||
"date": date_str,
|
||||
"original_score": score,
|
||||
}],
|
||||
"origin": "detector_macro_gauge",
|
||||
}
|
||||
result = _save_and_evaluate(ev, existing)
|
||||
if result:
|
||||
result["source"] = "eco"
|
||||
created.append(result)
|
||||
|
||||
# ── Regime transition ─────────────────────────────────────────────────────
|
||||
if regime_cfg:
|
||||
history = get_macro_gauge_history(days=14)
|
||||
if len(history) >= 2:
|
||||
latest = history[0]
|
||||
prev = history[1]
|
||||
dom_new = latest.get("dominant") or "incertain"
|
||||
dom_old = prev.get("dominant") or "incertain"
|
||||
if dom_new != dom_old and dom_new != "incertain":
|
||||
date_str = latest["snapshot_date"]
|
||||
sev_old = _REGIME_SEVERITY.get(dom_old, 0)
|
||||
sev_new = _REGIME_SEVERITY.get(dom_new, 0)
|
||||
direction = "bearish" if sev_new > sev_old else "bullish"
|
||||
score = min(0.90, 0.55 + abs(sev_new - sev_old) * 0.07)
|
||||
level = "long" if abs(sev_new - sev_old) >= 3 else "medium"
|
||||
lbl_old = _REGIME_LABELS.get(dom_old, dom_old)
|
||||
lbl_new = _REGIME_LABELS.get(dom_new, dom_new)
|
||||
name = f"Transition Régime Macro: {lbl_old} → {lbl_new} ({date_str[:7]})"
|
||||
desc = (
|
||||
f"Le régime macro dominant passe de {lbl_old} à {lbl_new}. "
|
||||
f"Sévérité: {sev_old}→{sev_new}/6. "
|
||||
f"Révision des biais d'actifs recommandée."
|
||||
)
|
||||
scores = latest.get("regime_scores", {})
|
||||
top3 = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:3]
|
||||
if top3:
|
||||
desc += " Top 3 scénarios: " + ", ".join(
|
||||
f"{_REGIME_LABELS.get(k,k)} ({v:.0%})" for k, v in top3
|
||||
)
|
||||
_emit(name, date_str, "event_calendar", "RegimeTransition",
|
||||
score, level, desc,
|
||||
["SPY","TLT","GLD","VXX","HYG","EURUSD=X"])
|
||||
|
||||
# Fallback: use macro_regime_history if no snapshots yet
|
||||
elif not history:
|
||||
hist = get_macro_regime_history(days=14)
|
||||
if len(hist) >= 2:
|
||||
latest = hist[0]
|
||||
prev = hist[1]
|
||||
dom_new = latest.get("dominant") or "incertain"
|
||||
dom_old = prev.get("dominant") or "incertain"
|
||||
if dom_new != dom_old and dom_new != "incertain":
|
||||
date_str = latest["timestamp"][:10]
|
||||
sev_old = _REGIME_SEVERITY.get(dom_old, 0)
|
||||
sev_new = _REGIME_SEVERITY.get(dom_new, 0)
|
||||
score = min(0.90, 0.55 + abs(sev_new - sev_old) * 0.07)
|
||||
level = "long" if abs(sev_new - sev_old) >= 3 else "medium"
|
||||
lbl_old = _REGIME_LABELS.get(dom_old, dom_old)
|
||||
lbl_new = _REGIME_LABELS.get(dom_new, dom_new)
|
||||
name = f"Transition Régime Macro: {lbl_old} → {lbl_new} ({date_str[:7]})"
|
||||
_emit(name, date_str, "event_calendar", "RegimeTransition",
|
||||
score, level,
|
||||
f"Transition macro: {lbl_old} → {lbl_new}. Source: macro_regime_history.",
|
||||
["SPY","TLT","GLD","VXX","HYG"])
|
||||
|
||||
# ── Gauge threshold crossings (from saved snapshots) ──────────────────────
|
||||
history = get_macro_gauge_history(days=desk_cfg.get("lookback_days", 7) + 2)
|
||||
if len(history) < 2:
|
||||
return created
|
||||
|
||||
latest_snap = history[0]
|
||||
oldest_snap = history[-1]
|
||||
latest_gauges = latest_snap.get("gauges", {})
|
||||
oldest_gauges = oldest_snap.get("gauges", {})
|
||||
latest_date = latest_snap["snapshot_date"]
|
||||
|
||||
def gauge_val(snap_gauges: Dict, gid: str) -> Optional[float]:
|
||||
g = snap_gauges.get(gid, {})
|
||||
v = g.get("value")
|
||||
return float(v) if v is not None else None
|
||||
|
||||
# ── Yield curve inversion ─────────────────────────────────────────────────
|
||||
if curve_cfg:
|
||||
threshold = float(curve_cfg.get("threshold", 0.0))
|
||||
# slope_10y3m is a derived gauge: positive = normal, negative = inverted
|
||||
slope_now = gauge_val(latest_gauges, "slope_10y3m")
|
||||
slope_old = gauge_val(oldest_gauges, "slope_10y3m")
|
||||
if slope_now is not None and slope_old is not None:
|
||||
if slope_old > threshold >= slope_now:
|
||||
name = f"Inversion Courbe 10Y-3M ({latest_date[:7]})"
|
||||
_emit(name, latest_date, "event_calendar", "YieldCurveInversion",
|
||||
0.85, "long",
|
||||
f"La courbe des taux US (10Y-3M) s'inverse à {slope_now:+.2f} pts "
|
||||
f"(précédent: {slope_old:+.2f} pts). "
|
||||
f"Signal historique de récession dans 12-18 mois.",
|
||||
["TLT","SPY","HYG","GLD","EURUSD=X"])
|
||||
elif slope_old <= threshold < slope_now:
|
||||
name = f"Désincurve 10Y-3M — Reflation ({latest_date[:7]})"
|
||||
_emit(name, latest_date, "event_calendar", "YieldCurveDesinversion",
|
||||
0.70, "medium",
|
||||
f"La courbe 10Y-3M revient positive à {slope_now:+.2f} pts. "
|
||||
f"Signal de détente des craintes de récession.",
|
||||
["SPY","XLF","IWM","TLT"])
|
||||
|
||||
# ── DXY shock ────────────────────────────────────────────────────────────
|
||||
if dxy_cfg:
|
||||
pct_thr = float(dxy_cfg.get("pct_threshold", 2.0))
|
||||
dxy_now = gauge_val(latest_gauges, "dxy")
|
||||
dxy_old = gauge_val(oldest_gauges, "dxy")
|
||||
if dxy_now and dxy_old and dxy_old > 0:
|
||||
pct_chg = (dxy_now - dxy_old) / dxy_old * 100
|
||||
if abs(pct_chg) >= pct_thr:
|
||||
sign = "+" if pct_chg > 0 else ""
|
||||
direct = "bullish" if pct_chg > 0 else "bearish"
|
||||
name = f"DXY choc {sign}{pct_chg:.1f}% ({latest_date[:7]})"
|
||||
_emit(name, latest_date, "event_calendar", "DXYShock",
|
||||
min(0.80, 0.45 + abs(pct_chg) * 0.07), "medium",
|
||||
f"Dollar DXY {sign}{pct_chg:.1f}% sur la période "
|
||||
f"({dxy_old:.1f} → {dxy_now:.1f}). "
|
||||
f"{'Appréciation USD: pression sur EM et matières premières.' if pct_chg > 0 else 'Dépréciation USD: favorable aux matières premières et EM.'}",
|
||||
["GLD","EEM","USO","EURUSD=X","USDJPY=X"])
|
||||
|
||||
# ── Credit stress (HYG) ───────────────────────────────────────────────────
|
||||
if credit_cfg:
|
||||
pct_thr = float(credit_cfg.get("pct_threshold", -1.5))
|
||||
hyg_chg = gauge_val(latest_gauges, "hyg")
|
||||
if hyg_chg is None:
|
||||
# Fallback: compute from values
|
||||
hyg_now = gauge_val(latest_gauges, "hyg")
|
||||
hyg_old = gauge_val(oldest_gauges, "hyg")
|
||||
if hyg_now and hyg_old and hyg_old > 0:
|
||||
hyg_chg = (hyg_now - hyg_old) / hyg_old * 100
|
||||
else:
|
||||
hyg_chg = float(latest_gauges.get("hyg", {}).get("change_pct") or 0)
|
||||
if hyg_chg is not None and hyg_chg <= pct_thr:
|
||||
name = f"Stress Crédit HYG ({latest_date[:7]})"
|
||||
_emit(name, latest_date, "event_calendar", "CreditStress",
|
||||
min(0.80, 0.45 + abs(hyg_chg) * 0.1), "medium",
|
||||
f"HYG (High Yield) chute de {hyg_chg:.1f}% — signal de stress crédit. "
|
||||
f"Spreads HY en élargissement. Surveiller LQD et TLT pour contagion.",
|
||||
["HYG","LQD","SPY","TLT","VXX"])
|
||||
|
||||
# ── Gold/Copper ratio regime ──────────────────────────────────────────────
|
||||
if gcr_cfg:
|
||||
fear_thr = float(gcr_cfg.get("fear_threshold", 700))
|
||||
growth_thr = float(gcr_cfg.get("growth_threshold", 500))
|
||||
gcr_now = gauge_val(latest_gauges, "gold_copper_ratio")
|
||||
gcr_old = gauge_val(oldest_gauges, "gold_copper_ratio")
|
||||
if gcr_now and gcr_old:
|
||||
if gcr_old < fear_thr <= gcr_now:
|
||||
name = f"Ratio Or/Cuivre zone peur > {fear_thr:.0f} ({latest_date[:7]})"
|
||||
_emit(name, latest_date, "event_calendar", "GoldCopperRatio",
|
||||
0.70, "medium",
|
||||
f"Ratio Or/Cuivre franchit {fear_thr:.0f} ({gcr_old:.0f} → {gcr_now:.0f}). "
|
||||
f"L'or surperforme le cuivre — signal de risk-off, craintes de récession.",
|
||||
["GLD","SPY","EEM","USO"])
|
||||
elif gcr_old > growth_thr >= gcr_now:
|
||||
name = f"Ratio Or/Cuivre zone croissance < {growth_thr:.0f} ({latest_date[:7]})"
|
||||
_emit(name, latest_date, "event_calendar", "GoldCopperRatio",
|
||||
0.60, "medium",
|
||||
f"Ratio Or/Cuivre sous {growth_thr:.0f} ({gcr_old:.0f} → {gcr_now:.0f}). "
|
||||
f"Le cuivre surperforme l'or — signal de risk-on, expansion économique.",
|
||||
["EEM","XLI","SPY","GLD"])
|
||||
|
||||
return created
|
||||
|
||||
|
||||
# ── Main entry point ──────────────────────────────────────────────────────────
|
||||
|
||||
def check_new_market_events(
|
||||
@@ -1109,7 +1335,15 @@ def check_new_market_events(
|
||||
"dedup_enabled": True, "dedup_lookback_days": 3,
|
||||
})
|
||||
eco_cfg = _desk_cfg(eco_desk, {
|
||||
"z_threshold": eco_z_threshold, "days": eco_days,
|
||||
"z_threshold": eco_z_threshold,
|
||||
"days": eco_days,
|
||||
"gauge_signals": {
|
||||
"regime_transition": {"enabled": True},
|
||||
"yield_curve_inversion": {"enabled": True, "threshold": 0.0},
|
||||
"dxy_shock": {"enabled": True, "pct_threshold": 2.0},
|
||||
"credit_stress": {"enabled": True, "pct_threshold": -1.5},
|
||||
"gold_copper_ratio": {"enabled": True, "fear_threshold": 700, "growth_threshold": 500},
|
||||
},
|
||||
})
|
||||
tech_cfg = _desk_cfg(tech_desk, {
|
||||
"lookback_days": technical_lookback_days,
|
||||
@@ -1142,7 +1376,7 @@ def check_new_market_events(
|
||||
runners = [
|
||||
("news", lambda: _check_news(news_cfg)),
|
||||
("fundamental", lambda: _check_fundamental(fundamental_cfg)),
|
||||
("eco", lambda: _check_eco(eco_cfg)),
|
||||
("eco", lambda: _check_eco(eco_cfg) + _check_macro_gauges(eco_cfg)),
|
||||
("technical", lambda: _check_technical(tech_cfg)),
|
||||
("reports", lambda: _check_reports(report_cfg)),
|
||||
("sentiment", lambda: _check_sentiment(sentiment_cfg)),
|
||||
|
||||
Reference in New Issue
Block a user