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:
OpenSquared
2026-06-26 09:34:35 +02:00
parent a12d7a1ef3
commit 85eb864584
5 changed files with 513 additions and 5 deletions

View File

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

View File

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

View File

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

View File

@@ -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[]

View File

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