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:
@@ -123,6 +123,8 @@ def macro_regime(force: bool = False):
|
||||
_macro_cache["data"] = result
|
||||
_macro_cache["ts"] = now
|
||||
|
||||
today = now.strftime("%Y-%m-%d")
|
||||
|
||||
if force:
|
||||
# Build a compact gauge summary (key → value + change_pct) for the journal
|
||||
gauges_summary = {
|
||||
@@ -138,4 +140,31 @@ def macro_regime(force: bool = False):
|
||||
gauges_summary=gauges_summary,
|
||||
)
|
||||
|
||||
# Always persist full snapshot once per calendar day (all gauges + regime)
|
||||
from services.database import save_macro_gauge_snapshot, macro_gauge_snapshot_exists_today
|
||||
if force or not macro_gauge_snapshot_exists_today():
|
||||
save_macro_gauge_snapshot(
|
||||
snapshot_date=today,
|
||||
gauges=gauges,
|
||||
dominant=scenarios.get("dominant", "incertain"),
|
||||
regime_scores=scenarios.get("scores", {}),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/macro-gauges/at")
|
||||
def macro_gauges_at(date: str = Query(..., description="YYYY-MM-DD")):
|
||||
"""Return the macro gauge snapshot at or before a given date."""
|
||||
from services.database import get_macro_gauge_snapshot_at
|
||||
snap = get_macro_gauge_snapshot_at(date)
|
||||
if not snap:
|
||||
return {"snapshot_date": None, "gauges": {}, "dominant": "incertain", "regime_scores": {}}
|
||||
return snap
|
||||
|
||||
|
||||
@router.get("/macro-gauges/history")
|
||||
def macro_gauges_history(days: int = Query(30, ge=1, le=365)):
|
||||
"""Return gauge snapshots for the last N days (daily, most recent first)."""
|
||||
from services.database import get_macro_gauge_history
|
||||
return get_macro_gauge_history(days=days)
|
||||
|
||||
@@ -238,6 +238,20 @@ def init_db():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS macro_gauge_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_date TEXT NOT NULL,
|
||||
gauges_json TEXT NOT NULL DEFAULT '{}',
|
||||
dominant TEXT,
|
||||
regime_scores_json TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(snapshot_date)
|
||||
)""")
|
||||
try:
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_mgs_date ON macro_gauge_snapshots(snapshot_date DESC)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS geo_alert_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
@@ -1028,7 +1042,18 @@ def init_db():
|
||||
"active": 1,
|
||||
"system_prompt": "Tu analyses les surprises économiques des données macro US (FRED).",
|
||||
"instruments": json.dumps(["SPY","TLT","GLD","EURUSD=X","USO","HYG"]),
|
||||
"config": json.dumps({"z_threshold": 1.5, "days": 7}),
|
||||
"config": json.dumps({
|
||||
"z_threshold": 1.5,
|
||||
"days": 7,
|
||||
"lookback_days": 7,
|
||||
"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},
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "Fundamental Desk",
|
||||
@@ -1570,6 +1595,85 @@ def get_macro_regime_history(days: int = 15) -> List[Dict[str, Any]]:
|
||||
return result
|
||||
|
||||
|
||||
# ── Macro gauge snapshots (daily persistence of all 28+ gauges) ──────────────
|
||||
|
||||
def save_macro_gauge_snapshot(
|
||||
snapshot_date: str,
|
||||
gauges: Dict[str, Any],
|
||||
dominant: Optional[str] = None,
|
||||
regime_scores: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Save (or replace) a full gauge snapshot for a given date. Returns True if new."""
|
||||
conn = get_conn()
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM macro_gauge_snapshots WHERE snapshot_date=?", (snapshot_date,)
|
||||
).fetchone()
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO macro_gauge_snapshots "
|
||||
"(snapshot_date, gauges_json, dominant, regime_scores_json, created_at) "
|
||||
"VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
(snapshot_date, json.dumps(gauges), dominant or "",
|
||||
json.dumps(regime_scores or {}))
|
||||
)
|
||||
conn.commit()
|
||||
return existing is None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_macro_gauge_snapshot_at(date: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return the most recent snapshot whose date is <= `date`."""
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM macro_gauge_snapshots WHERE snapshot_date <= ? "
|
||||
"ORDER BY snapshot_date DESC LIMIT 1",
|
||||
(date[:10],)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
d["gauges"] = json.loads(d.pop("gauges_json", "{}"))
|
||||
d["regime_scores"] = json.loads(d.pop("regime_scores_json", "{}"))
|
||||
return d
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_macro_gauge_history(days: int = 30) -> List[Dict[str, Any]]:
|
||||
"""Return gauge snapshots for the last N days, most recent first."""
|
||||
conn = get_conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM macro_gauge_snapshots "
|
||||
"WHERE snapshot_date >= date('now', ?) "
|
||||
"ORDER BY snapshot_date DESC",
|
||||
(f"-{days} days",)
|
||||
).fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["gauges"] = json.loads(d.pop("gauges_json", "{}"))
|
||||
d["regime_scores"] = json.loads(d.pop("regime_scores_json", "{}"))
|
||||
result.append(d)
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def macro_gauge_snapshot_exists_today() -> bool:
|
||||
today = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM macro_gauge_snapshots WHERE snapshot_date=?", (today,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def log_geo_alert(geo_score: int, top_patterns: List[Dict[str, Any]], news_count: int, run_id: str):
|
||||
"""Append a geo alert snapshot tied to a scoring run."""
|
||||
conn = get_conn()
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -24,7 +24,7 @@ interface SignalDef {
|
||||
interface AIDesk {
|
||||
id?: number
|
||||
name: string
|
||||
type: 'news' | 'technical' | 'eco' | 'report'
|
||||
type: 'news' | 'fundamental' | 'technical' | 'eco' | 'report' | 'sentiment'
|
||||
active: boolean
|
||||
system_prompt: string
|
||||
instruments: string[]
|
||||
|
||||
@@ -56,6 +56,18 @@ interface MacroRegime {
|
||||
asset_bias: Record<string, any>
|
||||
}
|
||||
|
||||
interface GaugeValue {
|
||||
id: string; label: string; value: number | null; change_pct: number | null
|
||||
unit: string; bloc: string; note?: string
|
||||
}
|
||||
|
||||
interface MacroGaugeSnap {
|
||||
snapshot_date: string
|
||||
dominant: string
|
||||
regime_scores: Record<string, number>
|
||||
gauges: Record<string, GaugeValue>
|
||||
}
|
||||
|
||||
interface Snapshot {
|
||||
instrument: InstrumentConfig
|
||||
price_data: PriceCandle[]
|
||||
@@ -621,6 +633,118 @@ function DriversPanel({ instrumentId, drivers, onSave, onClose }: {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Macro gauge helpers ───────────────────────────────────────────────────────
|
||||
|
||||
const SCENARIO_META: Record<string, { label: string; emoji: string; color: string }> = {
|
||||
goldilocks: { label: 'Goldilocks', emoji: '🟢', color: '#10b981' },
|
||||
desinflation: { label: 'Désinflation', emoji: '🔵', color: '#3b82f6' },
|
||||
soft_landing: { label: 'Soft Landing', emoji: '🔷', color: '#06b6d4' },
|
||||
reflation: { label: 'Reflation', emoji: '🟠', color: '#f97316' },
|
||||
stagflation: { label: 'Stagflation', emoji: '🟡', color: '#f59e0b' },
|
||||
inflation_shock: { label: 'Choc Inflationniste', emoji: '🔥', color: '#dc2626' },
|
||||
recession: { label: 'Récession', emoji: '🔴', color: '#ef4444' },
|
||||
crise_liquidite: { label: 'Crise de liquidité', emoji: '🟣', color: '#7c3aed' },
|
||||
incertain: { label: 'Incertain', emoji: '⬜', color: '#64748b' },
|
||||
}
|
||||
|
||||
function snapToMacroRegime(snap: MacroGaugeSnap): MacroRegime {
|
||||
const scores = snap.regime_scores ?? {}
|
||||
const meta = SCENARIO_META[snap.dominant] ?? SCENARIO_META.incertain
|
||||
const ranked = Object.entries(scores)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([k]) => k)
|
||||
return {
|
||||
dominant: snap.dominant,
|
||||
label: meta.label,
|
||||
color: meta.color,
|
||||
emoji: meta.emoji,
|
||||
scores,
|
||||
ranked,
|
||||
asset_bias: {},
|
||||
}
|
||||
}
|
||||
|
||||
const BLOC_LABELS: Record<string, string> = {
|
||||
liquidite: 'Liquidité / Taux',
|
||||
credit: 'Crédit / Vol',
|
||||
energie: 'Énergie',
|
||||
metaux: 'Métaux',
|
||||
croissance: 'Croissance US',
|
||||
secteurs: 'Secteurs',
|
||||
volatilite: 'Volatilité surf.',
|
||||
global: 'Global / EM',
|
||||
forex_ro: 'Forex Risk-Off',
|
||||
derive: 'Dérivés',
|
||||
}
|
||||
|
||||
function MacroGaugePanel({ snap, dateLabel }: { snap: MacroGaugeSnap; dateLabel: string }) {
|
||||
const byBloc: Record<string, GaugeValue[]> = {}
|
||||
for (const g of Object.values(snap.gauges)) {
|
||||
if (g.value === null && g.change_pct === null) continue
|
||||
const b = g.bloc ?? 'derive'
|
||||
if (!byBloc[b]) byBloc[b] = []
|
||||
byBloc[b].push(g)
|
||||
}
|
||||
const meta = SCENARIO_META[snap.dominant] ?? SCENARIO_META.incertain
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">{meta.emoji}</span>
|
||||
<span className="text-sm font-semibold text-white">{meta.label}</span>
|
||||
<span className="text-xs text-slate-500">— contexte macro au {dateLabel}</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-600">{snap.snapshot_date}</span>
|
||||
</div>
|
||||
|
||||
{/* Regime scores mini bar */}
|
||||
<div className="flex gap-1 h-1.5">
|
||||
{Object.entries(snap.regime_scores)
|
||||
.sort(([,a],[,b]) => b - a)
|
||||
.slice(0, 6)
|
||||
.map(([k, v]) => (
|
||||
<div key={k} title={`${SCENARIO_META[k]?.label ?? k}: ${(v*100).toFixed(0)}%`}
|
||||
style={{ width: `${v*100}%`, background: SCENARIO_META[k]?.color ?? '#64748b' }}
|
||||
className="rounded-full transition-all"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Gauges by bloc */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
|
||||
{Object.entries(byBloc).map(([bloc, gauges]) => (
|
||||
<div key={bloc}>
|
||||
<div className="text-xs text-slate-600 uppercase tracking-wide mb-1">{BLOC_LABELS[bloc] ?? bloc}</div>
|
||||
<div className="space-y-0.5">
|
||||
{gauges.map(g => (
|
||||
<div key={g.id} className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-400 truncate max-w-[120px]" title={g.label}>{g.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{g.value !== null && (
|
||||
<span className="text-white font-mono">
|
||||
{g.unit === '%' ? g.value.toFixed(2) + '%'
|
||||
: g.unit === 'pts' ? g.value.toFixed(1)
|
||||
: g.unit === 'ratio' ? g.value.toFixed(3)
|
||||
: g.value.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{g.change_pct !== null && g.change_pct !== undefined && (
|
||||
<span className={clsx('font-mono', g.change_pct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{g.change_pct >= 0 ? '+' : ''}{g.change_pct.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const PERIODS = [
|
||||
@@ -641,6 +765,7 @@ export default function InstrumentDashboard() {
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
||||
const [editDrivers, setEditDrivers] = useState(false)
|
||||
const [localDrivers, setLocalDrivers] = useState<Driver[] | null>(null)
|
||||
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
|
||||
|
||||
const instrumentId = id.toUpperCase()
|
||||
|
||||
@@ -655,6 +780,7 @@ export default function InstrumentDashboard() {
|
||||
setSelectedDate(null)
|
||||
setLocalDrivers(null)
|
||||
setEditDrivers(false)
|
||||
setMacroAtDate(null)
|
||||
api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
|
||||
.then(r => {
|
||||
setSnapshot(r.data)
|
||||
@@ -691,6 +817,16 @@ export default function InstrumentDashboard() {
|
||||
return { priceMap, indMap, sortedDates, dateIndex }
|
||||
}, [snapshot])
|
||||
|
||||
// Fetch macro gauge context when crosshair date changes (after sortedDates is available)
|
||||
useEffect(() => {
|
||||
if (!selectedDate || !sortedDates.length) return
|
||||
const isLast = selectedDate === sortedDates[sortedDates.length - 1]
|
||||
if (isLast) { setMacroAtDate(null); return }
|
||||
api.get(`/market/macro-gauges/at?date=${selectedDate}`)
|
||||
.then(r => { if (r.data?.snapshot_date) setMacroAtDate(r.data) })
|
||||
.catch(() => {})
|
||||
}, [selectedDate, sortedDates])
|
||||
|
||||
const effectiveDate = useMemo(() => {
|
||||
if (selectedDate && dateIndex[selectedDate] !== undefined) return selectedDate
|
||||
return sortedDates[sortedDates.length - 1] ?? null
|
||||
@@ -882,7 +1018,7 @@ export default function InstrumentDashboard() {
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<RegimeCard
|
||||
regime={snapshot.regime}
|
||||
macroRegime={snapshot.macro_regime ?? null}
|
||||
macroRegime={macroAtDate ? snapToMacroRegime(macroAtDate) : (snapshot.macro_regime ?? null)}
|
||||
signalsAt={dateSignals}
|
||||
dateLabel={dateLabel}
|
||||
/>
|
||||
@@ -896,6 +1032,11 @@ export default function InstrumentDashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Macro gauge detail panel — shown when on a historical date */}
|
||||
{macroAtDate && (
|
||||
<MacroGaugePanel snap={macroAtDate} dateLabel={dateLabel} />
|
||||
)}
|
||||
|
||||
{editDrivers && (
|
||||
<DriversPanel
|
||||
instrumentId={instrumentId}
|
||||
|
||||
Reference in New Issue
Block a user