From 85eb864584d1aa45111e52c63c9dbca8dd149ea8 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 26 Jun 2026 09:34:35 +0200 Subject: [PATCH] feat: macro gauge DB + regime triggers + date-aware instrument snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/routers/market_data.py | 29 +++ backend/services/database.py | 106 ++++++++- backend/services/market_event_detector.py | 238 ++++++++++++++++++++- frontend/src/pages/AIDesks.tsx | 2 +- frontend/src/pages/InstrumentDashboard.tsx | 143 ++++++++++++- 5 files changed, 513 insertions(+), 5 deletions(-) diff --git a/backend/routers/market_data.py b/backend/routers/market_data.py index e9dba99..7a97658 100644 --- a/backend/routers/market_data.py +++ b/backend/routers/market_data.py @@ -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) diff --git a/backend/services/database.py b/backend/services/database.py index 63be42e..c91f49b 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -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() diff --git a/backend/services/market_event_detector.py b/backend/services/market_event_detector.py index dac7069..ccc720f 100644 --- a/backend/services/market_event_detector.py +++ b/backend/services/market_event_detector.py @@ -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)), diff --git a/frontend/src/pages/AIDesks.tsx b/frontend/src/pages/AIDesks.tsx index 1a2afe7..a6c6fbc 100644 --- a/frontend/src/pages/AIDesks.tsx +++ b/frontend/src/pages/AIDesks.tsx @@ -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[] diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index 2db9bb3..1ee1d33 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -56,6 +56,18 @@ interface MacroRegime { asset_bias: Record } +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 + gauges: Record +} + interface Snapshot { instrument: InstrumentConfig price_data: PriceCandle[] @@ -621,6 +633,118 @@ function DriversPanel({ instrumentId, drivers, onSave, onClose }: { ) } +// ── Macro gauge helpers ─────────────────────────────────────────────────────── + +const SCENARIO_META: Record = { + 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 = { + 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 = {} + 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 ( +
+
+
+ {meta.emoji} + {meta.label} + — contexte macro au {dateLabel} +
+ {snap.snapshot_date} +
+ + {/* Regime scores mini bar */} +
+ {Object.entries(snap.regime_scores) + .sort(([,a],[,b]) => b - a) + .slice(0, 6) + .map(([k, v]) => ( +
+ ))} +
+ + {/* Gauges by bloc */} +
+ {Object.entries(byBloc).map(([bloc, gauges]) => ( +
+
{BLOC_LABELS[bloc] ?? bloc}
+
+ {gauges.map(g => ( +
+ {g.label} +
+ {g.value !== null && ( + + {g.unit === '%' ? g.value.toFixed(2) + '%' + : g.unit === 'pts' ? g.value.toFixed(1) + : g.unit === 'ratio' ? g.value.toFixed(3) + : g.value.toFixed(2)} + + )} + {g.change_pct !== null && g.change_pct !== undefined && ( + = 0 ? 'text-emerald-400' : 'text-red-400')}> + {g.change_pct >= 0 ? '+' : ''}{g.change_pct.toFixed(1)}% + + )} +
+
+ ))} +
+
+ ))} +
+
+ ) +} + // ── Main page ───────────────────────────────────────────────────────────────── const PERIODS = [ @@ -641,6 +765,7 @@ export default function InstrumentDashboard() { const [selectedDate, setSelectedDate] = useState(null) const [editDrivers, setEditDrivers] = useState(false) const [localDrivers, setLocalDrivers] = useState(null) + const [macroAtDate, setMacroAtDate] = useState(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() {
@@ -896,6 +1032,11 @@ export default function InstrumentDashboard() { />
+ {/* Macro gauge detail panel — shown when on a historical date */} + {macroAtDate && ( + + )} + {editDrivers && (