feat: instrument model

This commit is contained in:
OpenSquared
2026-07-03 17:39:44 +02:00
parent ff551deecd
commit 716d8fa56c
3 changed files with 405 additions and 10 deletions

View File

@@ -148,6 +148,43 @@ def apply_gauge_suggestions(
conn.close()
@router.get("/{instrument}/macro-sync")
def get_macro_sync(instrument: str) -> Dict[str, Any]:
"""Retourne les valeurs actuelles + forecasts FF Calendar pour tous les nœuds macro-liés."""
from services.database import get_conn
from services.instrument_models import get_macro_sync_items
conn = get_conn()
try:
items = get_macro_sync_items(conn, instrument)
return {"instrument": instrument.upper(), "items": items, "count": len(items)}
finally:
conn.close()
class MacroSyncApplyBody(BaseModel):
items: list[dict] # [{node_id, value, note}]
@router.post("/{instrument}/macro-sync/apply")
def apply_macro_sync(instrument: str, body: MacroSyncApplyBody) -> Dict[str, Any]:
"""Applique les valeurs macro (actuelles ou forecasts) comme node overrides."""
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.items:
nid = item.get("node_id", "")
value = item.get("value")
note = item.get("note", "[Sync Marché FF Calendar]")
if nid and value is not None:
set_node_override(conn, inst, nid, float(value), note)
saved.append(nid)
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)."""

View File

@@ -315,6 +315,132 @@ def build_node_combined_timeline(
return result
# ── Macro sync — lecture directe ff_calendar → overrides ──────────────────────
def get_macro_sync_items(conn, instrument: str) -> list[dict]:
"""
Pour chaque nœud avec macro_key dans le graphe, retourne :
- actual_value : dernière valeur publiée (passé)
- actual_date : date de cette publication
- forecast_value: forecast du prochain événement (futur)
- forecast_date / next_event_name / days_until
- current_override : valeur déjà settée en DB (ou None)
Utilisé par le bouton "Sync Marché" pour pré-remplir le graphe.
"""
inst_upper = instrument.upper()
row = conn.execute(
"SELECT graph_json FROM instrument_models WHERE instrument=?", (inst_upper,)
).fetchone()
if not row:
return []
graph_def = json.loads(row["graph_json"])
today = date_type.today()
today_str = str(today)
q_from = str(today - timedelta(days=730))
q_to = str(today + timedelta(days=180))
overrides = {r["node_id"]: float(r["value"]) for r in conn.execute(
"SELECT node_id, value FROM instrument_node_overrides WHERE instrument=?", (inst_upper,)
).fetchall()}
items = []
for node in graph_def.get("nodes", []):
mk = node.get("macro_key")
if not mk or node.get("node_type") != "input_manual":
continue
mk_info = FF_MACRO_KEYS.get(mk)
if not mk_info:
continue
currency = mk_info["currency"]
patterns = mk_info["names"]
try:
rows = conn.execute(
"""SELECT event_date, event_name, actual_value, forecast_value, previous_value
FROM ff_calendar
WHERE currency=? AND event_date>=? AND event_date<=?
ORDER BY event_date ASC""",
(currency, q_from, q_to)
).fetchall()
except Exception:
rows = []
matched = [
dict(r) for r in rows
if any(p in str(r["event_name"]).lower() for p in patterns)
]
# Deduplicate by date
seen: set[str] = set()
deduped = []
for ev in matched:
if ev["event_date"] not in seen:
seen.add(ev["event_date"])
deduped.append(ev)
# Latest actual (most recent past event with actual_value)
past = [e for e in deduped if e["event_date"] <= today_str]
actual_val: Optional[float] = None
actual_date: Optional[str] = None
for ev in reversed(past):
v = _parse_ff_num(ev.get("actual_value"))
if v is not None:
actual_val = v
actual_date = ev["event_date"]
break
# Next forecast (first future event)
future = [e for e in deduped if e["event_date"] > today_str]
forecast_val: Optional[float] = None
forecast_date: Optional[str] = None
next_event_name: Optional[str] = None
days_until: Optional[int] = None
if future:
nxt = future[0]
forecast_val = (
_parse_ff_num(nxt.get("forecast_value"))
or _parse_ff_num(nxt.get("previous_value"))
)
forecast_date = nxt["event_date"]
next_event_name = nxt["event_name"]
try:
days_until = (date_type.fromisoformat(forecast_date) - today).days
except Exception:
days_until = None
# Unit-aware post-conversion
# The FF Calendar stores NFP in absolute (e.g. 228000), node expects K (228)
# PMI stored as absolute value (54.7), node expects deviation from 50 (4.7)
unit = node.get("unit", "")
def _convert(v: Optional[float]) -> Optional[float]:
if v is None:
return None
if unit == "K" and abs(v) >= 1_000:
return round(v / 1_000, 1)
if unit == "pts" and v > 10: # PMI > 10 → is absolute, subtract 50
return round(v - 50.0, 1)
return round(v, 4)
items.append({
"node_id": node["id"],
"node_label": node["label"],
"macro_key": mk,
"unit": unit,
"coefficient_to_pips": node.get("coefficient_to_pips", 1),
"actual_value": _convert(actual_val),
"actual_date": actual_date,
"forecast_value": _convert(forecast_val),
"forecast_date": forecast_date,
"next_event_name": next_event_name,
"days_until_forecast": days_until,
"current_override": overrides.get(node["id"]),
})
return items
# ── Scenario CRUD ──────────────────────────────────────────────────────────────
def get_node_scenarios(conn, instrument: str, node_id: Optional[str] = None) -> list[dict]:

View File

@@ -1705,7 +1705,7 @@ const CONF_META: Record<string, { color: string; label: string }> = {
}
function SyncPanel({ instrument, onClose, onApplied }: {
instrument: string; onClose: () => void; onApplied: (atDate?: string) => void
instrument: string; onClose: () => void; onApplied: () => void
}) {
const [data, setData] = useState<GaugeSuggestionsResponse | null>(null)
const [loading, setLoading] = useState(true)
@@ -1747,7 +1747,7 @@ function SyncPanel({ instrument, onClose, onApplied }: {
.filter(s => selected.has(s.node_id))
.map(s => ({ node_id: s.node_id, value: s.value, note: `[Auto${atDate ? ' @'+atDate : ''}] ${s.note}` }))
await api.post(`/instrument-models/${instrument}/apply-suggestions`, { overrides })
onApplied(atDate || undefined)
onApplied()
onClose()
} finally { setApplying(false) }
}
@@ -1901,6 +1901,235 @@ function SyncPanel({ instrument, onClose, onApplied }: {
)
}
// ── Macro Sync Panel (FF Calendar → node overrides) ───────────────────────────
interface MacroSyncItem {
node_id: string; node_label: string; macro_key: string; unit: string
coefficient_to_pips: number
actual_value: number | null; actual_date: string | null
forecast_value: number | null; forecast_date: string | null
next_event_name: string | null; days_until_forecast: number | null
current_override: number | null
}
function MacroSyncPanel({ instrument, onClose, onApplied }: {
instrument: string; onClose: () => void; onApplied: () => void
}) {
const [items, setItems] = useState<MacroSyncItem[]>([])
const [loading, setLoading] = useState(true)
// per-node choice: 'actual' | 'forecast' | null (skip)
const [choices, setChoices] = useState<Record<string, 'actual' | 'forecast' | null>>({})
const [applying, setApplying] = useState(false)
useEffect(() => {
setLoading(true)
api.get(`/instrument-models/${instrument}/macro-sync`)
.then(r => {
const its: MacroSyncItem[] = r.data.items
setItems(its)
// Default: select 'actual' when available
const init: Record<string, 'actual' | 'forecast' | null> = {}
for (const it of its) {
init[it.node_id] = it.actual_value != null ? 'actual' : it.forecast_value != null ? 'forecast' : null
}
setChoices(init)
})
.catch(() => setItems([]))
.finally(() => setLoading(false))
}, [instrument])
async function apply() {
setApplying(true)
try {
const payload = items.flatMap(it => {
const choice = choices[it.node_id]
if (!choice) return []
const value = choice === 'actual' ? it.actual_value : it.forecast_value
if (value == null) return []
const source = choice === 'actual'
? `Actual ${it.actual_date ?? ''}`
: `Forecast ${it.forecast_date ?? ''} — ${it.next_event_name ?? ''}`
return [{ node_id: it.node_id, value, note: `[FF Calendar] ${source}` }]
})
await api.post(`/instrument-models/${instrument}/macro-sync/apply`, { items: payload })
onApplied()
onClose()
} finally { setApplying(false) }
}
const applyCount = Object.values(choices).filter(v => v != null).length
function setAllActual() {
const n = { ...choices }
for (const it of items) {
if (it.actual_value != null) n[it.node_id] = 'actual'
}
setChoices(n)
}
function setAllForecast() {
const n = { ...choices }
for (const it of items) {
if (it.forecast_value != null) n[it.node_id] = 'forecast'
}
setChoices(n)
}
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-3xl 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} (FF Calendar)</div>
<div className="text-xs text-slate-500 mt-0.5">
Synchronise les nœuds macro avec les dernières valeurs publiées + forecasts prochains événements.
</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-10 text-slate-500 text-sm">Lecture FF Calendar…</div>
)}
{!loading && items.length === 0 && (
<div className="text-center py-10 text-slate-500 text-sm">
Aucun nœud macro-lié trouvé, ou aucune donnée FF Calendar disponible.
</div>
)}
{!loading && items.length > 0 && (
<div className="space-y-3">
{/* Quick actions */}
<div className="flex gap-2 flex-wrap items-center">
<span className="text-xs text-slate-500">Sélectionner tout :</span>
<button onClick={setAllActual}
className="px-2.5 py-1 rounded border border-violet-700/40 bg-violet-900/20 text-violet-300 text-xs hover:bg-violet-800/30 transition-colors">
Valeurs actuelles
</button>
<button onClick={setAllForecast}
className="px-2.5 py-1 rounded border border-amber-700/40 bg-amber-900/20 text-amber-300 text-xs hover:bg-amber-800/30 transition-colors">
Forecasts
</button>
<button onClick={() => setChoices(Object.fromEntries(items.map(i => [i.node_id, null])))}
className="px-2.5 py-1 rounded border border-slate-700/30 text-slate-500 text-xs hover:text-slate-300 transition-colors">
Désélectionner
</button>
<span className="ml-auto text-xs text-slate-500">{applyCount} sélectionné{applyCount > 1 ? 's' : ''}</span>
</div>
{/* Node 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 text-left">Variable</th>
<th className="px-3 py-2 text-center w-28">
<span className="text-violet-400">Actuel</span>
</th>
<th className="px-3 py-2 text-center w-40">
<span className="text-amber-400">Prochain forecast</span>
</th>
<th className="px-3 py-2 text-center w-28">Override actuel</th>
</tr>
</thead>
<tbody>
{items.map(it => {
const ch = choices[it.node_id]
return (
<tr key={it.node_id}
className="border-b border-slate-700/20 hover:bg-dark-700/30 transition-colors">
<td className="px-3 py-2.5">
<div className="font-medium text-white">{it.node_label}</div>
<div className="text-slate-600">{it.macro_key}</div>
</td>
{/* Actual value cell */}
<td className="px-2 py-2.5 text-center">
{it.actual_value != null ? (
<button
onClick={() => setChoices(p => ({ ...p, [it.node_id]: ch === 'actual' ? null : 'actual' }))}
className={clsx(
'px-2 py-1.5 rounded border text-xs font-mono w-full transition-colors',
ch === 'actual'
? 'border-violet-500 bg-violet-900/40 text-violet-200 font-bold'
: 'border-slate-700/40 text-slate-300 hover:border-violet-700/50',
)}>
{it.actual_value.toFixed(2)} {it.unit}
<div className="text-slate-600 font-normal text-xs">{it.actual_date}</div>
</button>
) : (
<span className="text-slate-700">—</span>
)}
</td>
{/* Forecast cell */}
<td className="px-2 py-2.5 text-center">
{it.forecast_value != null ? (
<button
onClick={() => setChoices(p => ({ ...p, [it.node_id]: ch === 'forecast' ? null : 'forecast' }))}
className={clsx(
'px-2 py-1.5 rounded border text-xs font-mono w-full transition-colors',
ch === 'forecast'
? 'border-amber-500 bg-amber-900/30 text-amber-200 font-bold'
: 'border-slate-700/40 text-slate-300 hover:border-amber-700/50',
)}>
{it.forecast_value.toFixed(2)} {it.unit}
<div className="text-slate-600 font-normal text-xs">
{it.forecast_date} ({it.days_until_forecast}j)
</div>
</button>
) : (
<span className="text-slate-700">—</span>
)}
</td>
{/* Current override */}
<td className="px-3 py-2.5 text-center">
{it.current_override != null ? (
<span className="font-mono text-violet-400">
{it.current_override.toFixed(2)} {it.unit}
</span>
) : (
<span className="text-slate-700">—</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="text-xs text-slate-600">
<span className="text-violet-400">Actuel</span> = dernière valeur publiée dans FF Calendar.&nbsp;
<span className="text-amber-400">Forecast</span> = estimation du prochain événement.
Cliquer sur une cellule pour la sélectionner / désélectionner.
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex gap-2 p-4 border-t border-slate-700/40">
<button onClick={apply} disabled={applying || applyCount === 0}
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' : `Appliquer ${applyCount} valeur${applyCount > 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>
)
}
// ── Calibration View ──────────────────────────────────────────────────────────
const DECAY_OPTIONS = ['exp', 'linear', 'step'] as const
@@ -2530,14 +2759,17 @@ export default function InstrumentModels() {
)}
{showSync && (
<SyncPanel
instrument={instrument}
onClose={() => setShowSync(false)}
onApplied={(atDate) => {
setShowSync(false)
setRefreshKey(k => k + 1)
}}
/>
state?.nodes.some(n => n.macro_key)
? <MacroSyncPanel
instrument={instrument}
onClose={() => setShowSync(false)}
onApplied={() => { setShowSync(false); setRefreshKey(k => k + 1) }}
/>
: <SyncPanel
instrument={instrument}
onClose={() => setShowSync(false)}
onApplied={() => { setShowSync(false); setRefreshKey(k => k + 1) }}
/>
)}
</div>
)