feat: instrument model
This commit is contained in:
@@ -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,
|
||||
|
||||
287
backend/services/gauge_sync.py
Normal file
287
backend/services/gauge_sync.py
Normal file
@@ -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))
|
||||
]
|
||||
@@ -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<string, { color: string; label: string }> = {
|
||||
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<GaugeSuggestionsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [applying, setApplying] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
api.get<GaugeSuggestionsResponse>(`/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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||
onClick={onClose}>
|
||||
<div className="bg-dark-800 border border-slate-700/60 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[85vh] flex flex-col"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700/40">
|
||||
<div>
|
||||
<div className="text-white font-semibold">Sync Marché — {instrument}</div>
|
||||
{data && (
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
Gauges du {data.gauge_date} · {data.gauges_available.length} indicateurs disponibles
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-500 hover:text-white">
|
||||
<X className="w-4 h-4"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading && (
|
||||
<div className="text-center py-8 text-slate-500 text-sm">Chargement des suggestions…</div>
|
||||
)}
|
||||
{!loading && !data && (
|
||||
<div className="text-center py-8 text-red-400 text-sm">
|
||||
Aucun snapshot de gauges disponible. Actualiser les données marché d'abord.
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
<div className="space-y-3">
|
||||
{/* Confidence group filters */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(['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 ? (
|
||||
<button key={conf} onClick={() => toggleAll(conf)}
|
||||
className={clsx('flex items-center gap-1.5 px-2.5 py-1 rounded border text-xs font-medium transition-colors', meta.color)}>
|
||||
<span>{items.every(s => selected.has(s.node_id)) ? '☑' : '☐'}</span>
|
||||
{meta.label} ({items.length})
|
||||
</button>
|
||||
) : null
|
||||
})}
|
||||
<span className="ml-auto text-xs text-slate-500 self-center">
|
||||
{selectedCount} sélectionné{selectedCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Suggestion rows */}
|
||||
<div className="rounded-lg border border-slate-700/40 overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-dark-700/60 border-b border-slate-700/40 text-slate-500">
|
||||
<th className="px-3 py-2 w-8"/>
|
||||
<th className="px-3 py-2 text-left">Variable</th>
|
||||
<th className="px-3 py-2 text-right">Valeur</th>
|
||||
<th className="px-3 py-2 text-center w-16">Conf.</th>
|
||||
<th className="px-3 py-2 text-left text-slate-600">Détail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.suggestions.map(s => {
|
||||
const isSel = selected.has(s.node_id)
|
||||
const meta = CONF_META[s.confidence]
|
||||
return (
|
||||
<tr key={s.node_id}
|
||||
onClick={() => 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')}>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<div className={clsx('w-3.5 h-3.5 rounded border mx-auto transition-colors',
|
||||
isSel ? 'bg-blue-500 border-blue-400' : 'border-slate-600')}>
|
||||
{isSel && <span className="text-white text-xs flex items-center justify-center h-full leading-none">✓</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-300 font-medium">{s.label}</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-white">
|
||||
{s.value > 0 ? '+' : ''}{s.value} <span className="text-slate-500">{s.unit}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={clsx('px-1.5 py-0.5 rounded border text-xs', meta.color)}>
|
||||
{meta.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-500 truncate max-w-[200px]" title={s.note}>
|
||||
{s.note}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Warning LOW confidence */}
|
||||
{data.suggestions.some(s => s.confidence === 'LOW' && selected.has(s.node_id)) && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded bg-amber-900/20 border border-amber-700/30 text-xs text-amber-400">
|
||||
<span className="shrink-0">⚠</span>
|
||||
Certaines valeurs sélectionnées sont des proxies (Conf. Proxy) — revérifier avec les données réelles.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-2 p-4 border-t border-slate-700/40">
|
||||
<button onClick={apply} disabled={applying || selectedCount === 0 || !data}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-sm rounded-lg px-4 py-2 font-medium transition-colors">
|
||||
{applying ? 'Import…' : `Importer ${selectedCount} valeur${selectedCount > 1 ? 's' : ''}`}
|
||||
</button>
|
||||
<button onClick={onClose} className="bg-dark-700 hover:bg-dark-600 text-slate-400 text-sm rounded-lg px-4 py-2 transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type ViewMode = 'dag' | 'table' | 'timeline'
|
||||
@@ -737,6 +933,7 @@ export default function InstrumentModels() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [editNode, setEditNode] = useState<ModelNode | null>(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
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setRefreshKey(k => k+1)} disabled={loading}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-dark-700 border border-slate-700/40 rounded-lg text-slate-400 hover:text-white text-sm transition-colors disabled:opacity-40">
|
||||
<RefreshCw className={clsx('w-4 h-4', loading && 'animate-spin')}/> Actualiser
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setShowSync(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-blue-600/20 border border-blue-700/40 rounded-lg text-blue-400 hover:bg-blue-600/30 text-sm transition-colors font-medium">
|
||||
<Activity className="w-4 h-4"/> Sync Marché
|
||||
</button>
|
||||
<button onClick={() => setRefreshKey(k => k+1)} disabled={loading}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-dark-700 border border-slate-700/40 rounded-lg text-slate-400 hover:text-white text-sm transition-colors disabled:opacity-40">
|
||||
<RefreshCw className={clsx('w-4 h-4', loading && 'animate-spin')}/> Actualiser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instrument tabs */}
|
||||
@@ -832,6 +1035,17 @@ export default function InstrumentModels() {
|
||||
<div>{state.nodes.filter(n => n.source === 'manual').length} overrides manuels</div>
|
||||
<div className="text-slate-700">{state.at_date}</div>
|
||||
</div>
|
||||
{state.nodes.some(n => n.source === 'manual') && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm('Effacer tous les overrides manuels ?')) return
|
||||
await api.delete(`/instrument-models/${instrument}/overrides`)
|
||||
setRefreshKey(k => k + 1)
|
||||
}}
|
||||
className="text-xs text-red-500/60 hover:text-red-400 transition-colors text-left">
|
||||
✕ Reset toutes valeurs
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -903,6 +1117,14 @@ export default function InstrumentModels() {
|
||||
onClose={() => setEditNode(null)} onSaved={onSaved}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSync && (
|
||||
<SyncPanel
|
||||
instrument={instrument}
|
||||
onClose={() => setShowSync(false)}
|
||||
onApplied={() => { setShowSync(false); setRefreshKey(k => k + 1) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user