diff --git a/backend/routers/instrument_models.py b/backend/routers/instrument_models.py index 10e2867..47ae4d7 100644 --- a/backend/routers/instrument_models.py +++ b/backend/routers/instrument_models.py @@ -13,6 +13,16 @@ class OverrideBody(BaseModel): note: Optional[str] = "" +class BulkOverrideItem(BaseModel): + node_id: str + value: float + note: Optional[str] = "" + + +class BulkOverrideBody(BaseModel): + overrides: List[BulkOverrideItem] + + @router.get("", response_model=List[Dict[str, Any]]) def list_instrument_models(): from services.database import get_conn @@ -44,6 +54,67 @@ def list_instrument_models(): conn.close() +@router.get("/{instrument}/gauge-suggestions") +def get_gauge_suggestions(instrument: str) -> Dict[str, Any]: + """Suggestions de valeurs depuis les derniers gauges de marché (macro_regime_history).""" + from services.database import get_conn + from services.gauge_sync import suggest_from_gauges, get_latest_gauges + from services.instrument_models import INSTRUMENT_MODELS + conn = get_conn() + try: + inst = instrument.upper() + if inst not in INSTRUMENT_MODELS: + raise HTTPException(status_code=404, detail=f"Instrument {inst} non supporté") + gauges, snap_date = get_latest_gauges(conn) + if not gauges: + raise HTTPException(status_code=404, detail="Aucun snapshot de gauges disponible") + suggestions = suggest_from_gauges(inst, gauges) + # Enrich with node label/unit from graph if missing + return { + "instrument": inst, + "gauge_date": snap_date, + "n_suggestions": len(suggestions), + "suggestions": suggestions, + "gauges_available": list(gauges.keys()), + } + finally: + conn.close() + + +@router.post("/{instrument}/apply-suggestions") +def apply_gauge_suggestions( + instrument: str, + body: BulkOverrideBody, +) -> Dict[str, Any]: + """Applique une liste d'overrides en bulk (depuis gauge sync ou saisie rapide).""" + from services.database import get_conn + from services.instrument_models import set_node_override + conn = get_conn() + try: + inst = instrument.upper() + saved = [] + for item in body.overrides: + set_node_override(conn, inst, item.node_id, item.value, item.note or "") + saved.append(item.node_id) + return {"ok": True, "instrument": inst, "saved": saved, "count": len(saved)} + finally: + conn.close() + + +@router.delete("/{instrument}/overrides") +def clear_all_overrides(instrument: str) -> Dict[str, Any]: + """Supprime TOUTES les overrides d'un instrument (reset à zéro).""" + from services.database import get_conn + conn = get_conn() + try: + inst = instrument.upper() + conn.execute("DELETE FROM instrument_node_overrides WHERE instrument=?", (inst,)) + conn.commit() + return {"ok": True, "instrument": inst} + finally: + conn.close() + + @router.get("/{instrument}/regime") def get_instrument_regime( instrument: str, diff --git a/backend/services/gauge_sync.py b/backend/services/gauge_sync.py new file mode 100644 index 0000000..8495e43 --- /dev/null +++ b/backend/services/gauge_sync.py @@ -0,0 +1,287 @@ +""" +Gauge Sync — mappe les dernières données de marché (macro_regime_history) +vers les nœuds manuels des modèles instruments. + +Logique : + - Récupère les derniers gauges (VIX, DXY, US10Y, Brent, LQD, cuivre...) + - Pour chaque instrument, propose une valeur pour chaque nœud mappable + - Confidence : HIGH (direct), MEDIUM (dérivé simple), LOW (proxy) + - L'utilisateur review et confirme dans le frontend +""" +import json +import math +from typing import Optional + +# ── Structures de suggestion ─────────────────────────────────────────────────── + +def _conf(level: str, value: float, node_id: str, label: str, + unit: str, source: str, note: str) -> dict: + return { + "node_id": node_id, + "label": label, + "value": round(value, 2), + "unit": unit, + "source": source, + "confidence": level, # HIGH | MEDIUM | LOW + "note": note, + } + + +# ── Dérivations communes ─────────────────────────────────────────────────────── + +def _us_real_rate_bps(g: dict) -> float: + """Taux réel US approximé : 10Y nominal - breakeven estimé. + TIPS ETF 109.76 → taux TIPS ≈ 4.487% - 2.0% = ~2.0% → 200bps. + Approximation : 10Y - 2.2% (breakeven historique moyen). + """ + us10y = g.get("us10y", {}).get("value", 4.5) + breakeven_est = 2.2 # % + return (us10y - breakeven_est) * 100 # bps + + +def _risk_appetite_score(g: dict) -> float: + """Score appétit risque -5 à +5. + VIX < 15 = risk-on (+), VIX > 25 = risk-off (−). + """ + vix = g.get("vix", {}).get("value", 20.0) + spx_200d = g.get("spx_vs_200d", {}).get("value", 0.0) + # Base from VIX + score = 3.0 - vix / 8.0 + # Adjust from SPX vs 200d MA + score += spx_200d * 0.05 + return max(-5.0, min(5.0, round(score, 2))) + + +def _ig_spread_bps(g: dict) -> float: + """Proxy spread IG depuis prix LQD ETF. + LQD à 109+ = spreads très serrés (~80bps). Chaque point de LQD ≈ 5bps spread. + Baseline : LQD=109 → 80bps spreads. + """ + lqd = g.get("lqd", {}).get("value", 109.0) + return max(0, round((110.0 - lqd) * 8 + 80, 0)) + + +def _recession_prob_pct(g: dict) -> float: + """Probabilité récession % depuis pente 10Y-3M. + Pente positive (0.87%) → faible risque récession ~20%. + Pente inversée (<0) → risque élevé. + """ + slope = g.get("slope_10y3m", {}).get("value", 1.0) + prob = 50.0 - slope * 28.0 + return max(0.0, min(95.0, round(prob, 1))) + + +def _energy_delta(g: dict, baseline: float = 70.0) -> float: + """Delta prix énergie (Brent) vs baseline ($/bbl).""" + brent = g.get("brent", {}).get("value", baseline) + return round(brent - baseline, 2) + + +def _copper_score(g: dict) -> float: + """Score cycle cuivre -5 à +5. Baseline ~$4.5/lb.""" + copper = g.get("copper", {}).get("value", 4.5) + return round(max(-5, min(5, (copper - 4.5) * 2)), 2) + + +def _dxy_level(g: dict) -> float: + return g.get("dxy", {}).get("value", 100.0) + + +def _vix(g: dict) -> float: + return g.get("vix", {}).get("value", 18.0) + + +def _us10y_bps(g: dict) -> float: + return round(g.get("us10y", {}).get("value", 4.5) * 100, 0) + + +def _us30y_bps(g: dict) -> float: + """Approxime 30Y = 10Y + 25bps prime terme.""" + return round((_us10y_bps(g) / 100 + 0.25) * 100, 0) + + +def _yield_diff_usdjpy_bps(g: dict) -> float: + """Différentiel 10Y US-Japon (JGB ≈ 1.0% depuis politique BoJ).""" + us10y = g.get("us10y", {}).get("value", 4.5) + jgb10y_approx = 1.0 # Policy-controlled, approximate + return round((us10y - jgb10y_approx) * 100, 0) + + +def _slope_bps(g: dict) -> float: + return round(g.get("slope_10y3m", {}).get("value", 1.0) * 100, 0) + + +# ── Mappings par instrument ──────────────────────────────────────────────────── + +def _suggest_eurusd(g: dict) -> list[dict]: + vix = _vix(g) + real = _us_real_rate_bps(g) + risk = _risk_appetite_score(g) + energy = _energy_delta(g, baseline=70.0) + return [ + _conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX actuel = {vix:.1f}"), + _conf("HIGH", real, "m_us_real_rate", "Taux réel US 10Y", "bps", "gauge:us10y+tips", f"10Y {g.get('us10y',{}).get('value',4.5):.2f}% - 2.2% breakeven ≈ {real:.0f}bps"), + _conf("MEDIUM", risk, "m_risk_appetite","Appétit risque mondial", "score", "gauge:vix+spx", f"Score = 3 - VIX/8 + SPX200d×0.05 = {risk:.2f}"), + _conf("MEDIUM", energy, "m_energy_price", "Prix énergie delta", "$/bbl", "gauge:brent", f"Brent {g.get('brent',{}).get('value',70):.1f}$ - baseline 70$ = {energy:+.1f}"), + _conf("LOW", _dxy_level(g), "m_dollar_reserve", "Demande réserves USD", "score", "gauge:dxy", f"DXY {_dxy_level(g):.1f} → proxy demand USD (>100 = fort)"), + ] + + +def _suggest_usdjpy(g: dict) -> list[dict]: + vix = _vix(g) + risk = _risk_appetite_score(g) + yd = _yield_diff_usdjpy_bps(g) + slope = _slope_bps(g) + return [ + _conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX = {vix:.1f}"), + _conf("HIGH", yd, "m_yield_diff", "Diff 10Y US-JP", "bps", "gauge:us10y", f"US10Y {g.get('us10y',{}).get('value',4.5):.2f}% - JGB≈1.0% = {yd:.0f}bps"), + _conf("MEDIUM", _us_real_rate_bps(g), "m_us_real_rate", "Taux réel US 10Y", "bps", "gauge:us10y", f"≈ {_us_real_rate_bps(g):.0f}bps"), + _conf("MEDIUM", risk, "m_risk_appetite", "Appétit risque", "score", "gauge:vix", f"Score = {risk:.2f}"), + _conf("LOW", slope,"m_carry_momentum","Momentum carry", "score", "gauge:slope", f"Pente 10Y-3M = {slope:.0f}bps → carry actif"), + ] + + +def _suggest_xauusd(g: dict) -> list[dict]: + vix = _vix(g) + dxy = _dxy_level(g) + real = _us_real_rate_bps(g) + return [ + _conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX = {vix:.1f}"), + _conf("HIGH", dxy, "m_dxy", "DXY (indice dollar)", "pts", "gauge:dxy", f"DXY = {dxy:.1f}"), + _conf("HIGH", real, "m_us_real_rate", "Taux réel US 10Y", "bps", "gauge:us10y+tips", f"≈ {real:.0f}bps (10Y - 2.2% breakeven)"), + _conf("MEDIUM", _energy_delta(g, 70), "m_fiscal_risk", "Risque fiscal US", "score", "gauge:dxy", f"DXY < 100 = doutes USD → score {round(max(0, (100-dxy)/5), 1)}"), + _conf("LOW", _copper_score(g), "m_india_china", "Demande physique Asie", "score", "gauge:copper", f"Cuivre {g.get('copper',{}).get('value',4.5):.2f}$/lb → proxy Asie"), + ] + + +def _suggest_sp500(g: dict) -> list[dict]: + vix = _vix(g) + real = _us_real_rate_bps(g) + ig = _ig_spread_bps(g) + risk = _risk_appetite_score(g) + spx200d = g.get("spx_vs_200d", {}).get("value", 0) + rec_prob = _recession_prob_pct(g) + return [ + _conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX = {vix:.1f}"), + _conf("HIGH", real, "m_real_rate", "Taux réel US 10Y", "bps", "gauge:us10y+tips", f"≈ {real:.0f}bps"), + _conf("HIGH", ig, "m_ig_spread", "Spread IG (LQD proxy)", "bps", "gauge:lqd", f"LQD {g.get('lqd',{}).get('value',109):.2f} → spread ≈ {ig:.0f}bps"), + _conf("MEDIUM", risk, "m_fin_cond", "Conditions financières", "score", "gauge:vix+lqd", f"Score = {risk:.2f}"), + _conf("MEDIUM", rec_prob,"m_gdp", "Croissance PIB US", "%", "gauge:slope", f"Pente 10Y-3M = {_slope_bps(g):.0f}bps → récession {rec_prob:.0f}% (inversé → PIB)"), + ] + + +def _suggest_tlt(g: dict) -> list[dict]: + us10y = _us10y_bps(g) + us30y = _us30y_bps(g) + vix = _vix(g) + slope = _slope_bps(g) + rec = _recession_prob_pct(g) + tp = round(slope * 0.6, 0) # term premium proxy + return [ + _conf("HIGH", us10y, "m_us_10y", "Rendement UST 10Y", "bps", "gauge:us10y", f"UST 10Y = {us10y/100:.3f}%"), + _conf("HIGH", us30y, "m_us_30y", "Rendement UST 30Y", "bps", "gauge:us10y", f"≈ 10Y + 25bps = {us30y/100:.3f}%"), + _conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX = {vix:.1f}"), + _conf("MEDIUM", tp, "m_term_prem", "Prime de terme", "bps", "gauge:slope", f"Pente 10Y-3M {slope:.0f}bps × 0.6 ≈ {tp:.0f}bps"), + _conf("MEDIUM", rec, "m_recession_prob", "Prob. récession 12m", "%", "gauge:slope", f"Pente {slope:.0f}bps → prob ≈ {rec:.0f}%"), + _conf("LOW", round(_ig_spread_bps(g)/10, 1), "m_foreign_demand", + "Demande étrangère", "Mds$", "gauge:lqd", f"LQD sain → proxy demand bonds = {round(_ig_spread_bps(g)/10,1)}"), + ] + + +def _suggest_gbpusd(g: dict) -> list[dict]: + vix = _vix(g) + risk = _risk_appetite_score(g) + real = _us_real_rate_bps(g) + return [ + _conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX = {vix:.1f}"), + _conf("MEDIUM", risk, "m_risk_appetite", "Appétit risque", "score", "gauge:vix", f"GBP devise cyclique, risk-on = {risk:.2f}"), + _conf("LOW", real, "m_fed_path", "Anticipation Fed 12m", "bps", "gauge:us10y", f"Proxy Fed path depuis taux réels ≈ {real:.0f}bps"), + ] + + +def _suggest_eem(g: dict) -> list[dict]: + dxy = _dxy_level(g) + vix = _vix(g) + real = _us_real_rate_bps(g) + risk = _risk_appetite_score(g) + em_spread = round(_ig_spread_bps(g) * 1.8, 0) # EM spreads ≈ 1.8× IG + cop = _copper_score(g) + return [ + _conf("HIGH", dxy, "m_dxy_inv", "Dollar DXY", "pts", "gauge:dxy", f"DXY = {dxy:.1f}"), + _conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX = {vix:.1f}"), + _conf("HIGH", real, "m_us_real_rate", "Taux réel US 10Y", "bps", "gauge:us10y", f"≈ {real:.0f}bps"), + _conf("MEDIUM", risk, "m_risk_appetite", "Appétit risque", "score", "gauge:vix", f"Score = {risk:.2f}"), + _conf("MEDIUM", em_spread, "m_em_spread", "Spread souverain EM", "bps", "gauge:lqd", f"LQD proxy × 1.8 ≈ {em_spread:.0f}bps"), + _conf("MEDIUM", cop, "m_commodity_index","Indice commodités", "score", "gauge:copper",f"Cuivre {g.get('copper',{}).get('value',4.5):.2f} → score {cop:.2f}"), + _conf("LOW", round(cop * 0.5, 2), "m_china_pmi", "PMI manuf. Chine", "pts", "gauge:copper",f"Proxy cuivre → PMI-like = {round(50+cop*0.5,1)}"), + ] + + +def _suggest_qqq(g: dict) -> list[dict]: + vix = _vix(g) + real = _us_real_rate_bps(g) + risk = _risk_appetite_score(g) + spx_vs200 = g.get("spx_vs_200d", {}).get("value", 0) + return [ + _conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX = {vix:.1f} (beta élevé QQQ)"), + _conf("HIGH", real, "m_real_rate", "Taux réel US 10Y (duration)","bps","gauge:us10y", f"≈ {real:.0f}bps → taux d'actualisation tech"), + _conf("MEDIUM", risk, "m_retail_options", "Flux options retail", "score", "gauge:spx", f"Risk appetite {risk:.2f} → proxy momentum options"), + _conf("LOW", round(spx_vs200 * 0.8, 2), "m_tech_pe", + "Multiple PE tech (NTM)", "x", "gauge:spx_vs_200d", f"SPX vs 200d = {spx_vs200:.1f}% → PE trend"), + ] + + +# ── Registry ─────────────────────────────────────────────────────────────────── + +_SUGGEST_FN = { + "EURUSD": _suggest_eurusd, + "USDJPY": _suggest_usdjpy, + "XAUUSD": _suggest_xauusd, + "SP500": _suggest_sp500, + "TLT": _suggest_tlt, + "GBPUSD": _suggest_gbpusd, + "EEM": _suggest_eem, + "QQQ": _suggest_qqq, +} + + +# ── Public API ───────────────────────────────────────────────────────────────── + +def get_latest_gauges(conn) -> tuple[dict, str]: + """Retourne (gauges_dict, snapshot_date) depuis macro_regime_history.""" + row = conn.execute( + "SELECT gauges_summary_json, timestamp FROM macro_regime_history " + "ORDER BY timestamp DESC LIMIT 1" + ).fetchone() + if row: + r = dict(row) + gauges = json.loads(r.get("gauges_summary_json") or "{}") + date = str(r.get("timestamp", ""))[:10] + if gauges: + return gauges, date + + # Fallback : macro_gauge_snapshots + row2 = conn.execute( + "SELECT gauges_json, snapshot_date FROM macro_gauge_snapshots " + "ORDER BY snapshot_date DESC LIMIT 1" + ).fetchone() + if row2: + return json.loads(row2["gauges_json"] or "{}"), str(row2["snapshot_date"]) + + return {}, "" + + +def suggest_from_gauges(instrument: str, gauges: dict) -> list[dict]: + """ + Retourne les suggestions de valeurs pour les nœuds manuels d'un instrument + depuis les gauges de marché actuels. + """ + fn = _SUGGEST_FN.get(instrument.upper()) + if not fn or not gauges: + return [] + suggestions = fn(gauges) + # Filtre les NaN / Inf + return [ + s for s in suggestions + if math.isfinite(s.get("value", 0)) + ] diff --git a/frontend/src/pages/InstrumentModels.tsx b/frontend/src/pages/InstrumentModels.tsx index 9ff995c..7317f16 100644 --- a/frontend/src/pages/InstrumentModels.tsx +++ b/frontend/src/pages/InstrumentModels.tsx @@ -6,7 +6,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { RefreshCw, Edit3, X, Trash2, ChevronDown, ChevronUp, - TrendingUp, TrendingDown, Minus, LineChart, Table2, Network, + TrendingUp, TrendingDown, Minus, LineChart, Table2, Network, Activity, } from 'lucide-react' import clsx from 'clsx' import axios from 'axios' @@ -726,6 +726,202 @@ function TimelineView({ instrument }: { instrument: string }) { ) } +// ── Gauge Sync Panel ────────────────────────────────────────────────────────── + +interface GaugeSuggestion { + node_id: string + label: string + value: number + unit: string + source: string + confidence: 'HIGH' | 'MEDIUM' | 'LOW' + note: string +} + +interface GaugeSuggestionsResponse { + instrument: string + gauge_date: string + n_suggestions: number + suggestions: GaugeSuggestion[] + gauges_available: string[] +} + +const CONF_META: Record = { + HIGH: { color: 'text-emerald-400 bg-emerald-900/20 border-emerald-700/40', label: 'Direct' }, + MEDIUM: { color: 'text-amber-400 bg-amber-900/20 border-amber-700/40', label: 'Dérivé' }, + LOW: { color: 'text-slate-400 bg-slate-800/40 border-slate-700/30', label: 'Proxy' }, +} + +function SyncPanel({ instrument, onClose, onApplied }: { + instrument: string; onClose: () => void; onApplied: () => void +}) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [selected, setSelected] = useState>(new Set()) + const [applying, setApplying] = useState(false) + + useEffect(() => { + setLoading(true) + api.get(`/instrument-models/${instrument}/gauge-suggestions`) + .then(r => { + setData(r.data) + // Pre-select HIGH confidence items + const highs = new Set(r.data.suggestions.filter(s => s.confidence === 'HIGH').map(s => s.node_id)) + setSelected(highs) + }) + .catch(() => setData(null)) + .finally(() => setLoading(false)) + }, [instrument]) + + function toggleAll(conf: string) { + if (!data) return + const ids = data.suggestions.filter(s => s.confidence === conf).map(s => s.node_id) + const allSelected = ids.every(id => selected.has(id)) + setSelected(prev => { + const n = new Set(prev) + ids.forEach(id => allSelected ? n.delete(id) : n.add(id)) + return n + }) + } + + async function apply() { + if (!data) return + setApplying(true) + try { + const overrides = data.suggestions + .filter(s => selected.has(s.node_id)) + .map(s => ({ node_id: s.node_id, value: s.value, note: `[Auto] ${s.note}` })) + await api.post(`/instrument-models/${instrument}/apply-suggestions`, { overrides }) + onApplied() + onClose() + } finally { setApplying(false) } + } + + const selectedCount = selected.size + + return ( +
+
e.stopPropagation()}> + + {/* Header */} +
+
+
Sync Marché — {instrument}
+ {data && ( +
+ Gauges du {data.gauge_date} · {data.gauges_available.length} indicateurs disponibles +
+ )} +
+ +
+ + {/* Content */} +
+ {loading && ( +
Chargement des suggestions…
+ )} + {!loading && !data && ( +
+ Aucun snapshot de gauges disponible. Actualiser les données marché d'abord. +
+ )} + {data && ( +
+ {/* Confidence group filters */} +
+ {(['HIGH','MEDIUM','LOW'] as const).map(conf => { + const items = data.suggestions.filter(s => s.confidence === conf) + const meta = CONF_META[conf] + return items.length > 0 ? ( + + ) : null + })} + + {selectedCount} sélectionné{selectedCount > 1 ? 's' : ''} + +
+ + {/* Suggestion rows */} +
+ + + + + + + + + + + {data.suggestions.map(s => { + const isSel = selected.has(s.node_id) + const meta = CONF_META[s.confidence] + return ( + setSelected(prev => { const n = new Set(prev); isSel ? n.delete(s.node_id) : n.add(s.node_id); return n })} + className={clsx('border-b border-slate-700/20 cursor-pointer transition-colors', + isSel ? 'bg-blue-900/10' : 'hover:bg-dark-700/20')}> + + + + + + + ) + })} + +
+ VariableValeurConf.Détail
+
+ {isSel && } +
+
{s.label} + {s.value > 0 ? '+' : ''}{s.value} {s.unit} + + + {meta.label} + + + {s.note} +
+
+ + {/* Warning LOW confidence */} + {data.suggestions.some(s => s.confidence === 'LOW' && selected.has(s.node_id)) && ( +
+ + Certaines valeurs sélectionnées sont des proxies (Conf. Proxy) — revérifier avec les données réelles. +
+ )} +
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ) +} + // ── Main Page ────────────────────────────────────────────────────────────────── type ViewMode = 'dag' | 'table' | 'timeline' @@ -737,6 +933,7 @@ export default function InstrumentModels() { const [loading, setLoading] = useState(false) const [editNode, setEditNode] = useState(null) const [refreshKey, setRefreshKey] = useState(0) + const [showSync, setShowSync] = useState(false) const load = useCallback(() => { setLoading(true) @@ -779,10 +976,16 @@ export default function InstrumentModels() { Graphes causaux exhaustifs — propagation DAG 3 couches par instrument

- +
+ + +
{/* Instrument tabs */} @@ -832,6 +1035,17 @@ export default function InstrumentModels() {
{state.nodes.filter(n => n.source === 'manual').length} overrides manuels
{state.at_date}
+ {state.nodes.some(n => n.source === 'manual') && ( + + )} @@ -903,6 +1117,14 @@ export default function InstrumentModels() { onClose={() => setEditNode(null)} onSaved={onSaved} /> )} + + {showSync && ( + setShowSync(false)} + onApplied={() => { setShowSync(false); setRefreshKey(k => k + 1) }} + /> + )} ) }