feat: instrument model
This commit is contained in:
@@ -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.
|
||||
<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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user