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

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