feat: instrument model

This commit is contained in:
OpenSquared
2026-07-03 16:03:59 +02:00
parent 2d8cccec07
commit 2bb8109eb4
3 changed files with 525 additions and 159 deletions

View File

@@ -168,6 +168,21 @@ interface MacroGuidanceItem {
} | null
}
interface NodeScenario {
id: number
instrument: string
node_id: string
label: string
horizon: 'ct' | 'mt' | 'lt'
target_date: string
target_value: number
confidence: number
trajectory: 'step' | 'linear' | 'exp'
absorption_days: number
notes?: string
created_at: string
}
// ── Constants ─────────────────────────────────────────────────────────────────
const INSTRUMENTS = ['EURUSD','USDJPY','XAUUSD','SP500','TLT','GBPUSD','EEM','QQQ']
@@ -1990,134 +2005,254 @@ function CalibrationView({ instrument, eventDetails }: {
// ── MacroConfigView ────────────────────────────────────────────────────────────
function MacroConfigView({ instrument, nodes }: { instrument: string; nodes: ModelNode[] }) {
const [guidance, setGuidance] = useState<MacroGuidanceItem[]>([])
const [localKeys, setLocalKeys] = useState<Record<string, string>>({})
const [saved, setSaved] = useState<Record<string, boolean>>({})
const [saving, setSaving] = useState<string | null>(null)
const HORIZON_META = {
ct: { label: 'CT', color: 'text-sky-400', bg: 'bg-sky-900/30 border-sky-700/40', desc: 'Court terme (< 3 mois)' },
mt: { label: 'MT', color: 'text-amber-400', bg: 'bg-amber-900/30 border-amber-700/40', desc: 'Moyen terme (3-12 mois)' },
lt: { label: 'LT', color: 'text-violet-400', bg: 'bg-violet-900/30 border-violet-700/40',desc: 'Long terme (> 12 mois)' },
}
const TRAJ_OPTIONS = [
{ value: 'step', label: 'Choc immédiat (step)' },
{ value: 'linear', label: 'Dérive linéaire' },
{ value: 'exp', label: 'Convergence exp.' },
]
const manualNodes = nodes.filter(n => n.node_type === 'input_manual')
interface AddScenarioForm {
label: string; horizon: 'ct'|'mt'|'lt'; target_date: string; target_value: string
confidence: string; trajectory: string; absorption_days: string; notes: string
}
const EMPTY_FORM: AddScenarioForm = {
label: '', horizon: 'mt', target_date: '', target_value: '', confidence: '0.7',
trajectory: 'linear', absorption_days: '30', notes: '',
}
function NodeScenarioSection({
instrument, node, guidance,
}: { instrument: string; node: ModelNode; guidance: MacroGuidanceItem | undefined }) {
const [scenarios, setScenarios] = useState<NodeScenario[]>([])
const [expanded, setExpanded] = useState(false)
const [addForm, setAddForm] = useState<AddScenarioForm | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
const init: Record<string, string> = {}
for (const n of manualNodes) init[n.id] = n.macro_key ?? ''
setLocalKeys(init)
}, [nodes])
if (!expanded) return
api.get(`/instrument-models/${instrument}/scenarios`, { params: { node_id: node.id } })
.then(r => setScenarios(r.data))
.catch(() => {})
}, [expanded, instrument, node.id])
const refreshGuidance = useCallback(() => {
async function submitScenario() {
if (!addForm) return
setSaving(true)
try {
await api.post(`/instrument-models/${instrument}/scenarios`, {
node_id: node.id,
label: addForm.label || `${node.label} ${addForm.horizon.toUpperCase()}`,
horizon: addForm.horizon,
target_date: addForm.target_date,
target_value: parseFloat(addForm.target_value),
confidence: parseFloat(addForm.confidence),
trajectory: addForm.trajectory,
absorption_days: parseInt(addForm.absorption_days, 10),
notes: addForm.notes,
})
const r = await api.get(`/instrument-models/${instrument}/scenarios`, { params: { node_id: node.id } })
setScenarios(r.data)
setAddForm(null)
} catch {}
setSaving(false)
}
async function deleteScenario(id: number) {
await api.delete(`/instrument-models/${instrument}/scenarios/${id}`)
setScenarios(prev => prev.filter(s => s.id !== id))
}
const hm = HORIZON_META
const mk = node.macro_key
const curV = guidance?.current_value
const nxt = guidance?.next_event
return (
<div className={clsx('rounded-lg border transition-colors',
(mk || scenarios.length > 0)
? 'border-violet-700/30 bg-violet-900/10'
: 'border-slate-700/20 bg-dark-800/30')}>
{/* Row header */}
<div className="flex items-center gap-3 px-3 py-2.5">
<div className="flex-1 min-w-0">
<div className="text-sm text-white font-medium truncate">{node.label}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-slate-600">{node.unit} · ×{node.coefficient_to_pips}</span>
{mk && <span className="text-xs text-violet-400 font-mono">{mk}</span>}
</div>
</div>
{/* Current value from ff_calendar */}
{curV != null && (
<div className="text-right shrink-0">
<div className="text-sm font-bold font-mono text-white">{curV.toFixed(2)}<span className="text-xs text-slate-500 ml-1">{node.unit}</span></div>
{nxt && (
<div className="text-xs text-amber-400">
{nxt.forecast != null ? nxt.forecast.toFixed(2) : '?'} <span className="text-slate-600">dans {nxt.days_until}j</span>
</div>
)}
</div>
)}
{/* Scenario count + expand */}
<button onClick={() => setExpanded(e => !e)}
className="flex items-center gap-1.5 shrink-0 text-xs text-slate-500 hover:text-white transition-colors">
{scenarios.length > 0 && (
<span className="px-1.5 py-0.5 rounded bg-violet-800/50 text-violet-300 font-mono">
{scenarios.length}
</span>
)}
{expanded ? <ChevronUp className="w-3.5 h-3.5"/> : <ChevronDown className="w-3.5 h-3.5"/>}
</button>
</div>
{/* Expanded section */}
{expanded && (
<div className="border-t border-slate-700/30 px-3 pt-2 pb-3 space-y-2">
{/* Existing scenarios */}
{scenarios.map(s => {
const hMeta = hm[s.horizon] ?? hm.mt
return (
<div key={s.id} className={clsx('flex items-center gap-2 px-2 py-1.5 rounded border text-xs', hMeta.bg)}>
<span className={clsx('font-semibold w-6 shrink-0', hMeta.color)}>{hMeta.label}</span>
<span className="text-white flex-1 truncate font-medium">{s.label}</span>
<span className="text-slate-400 shrink-0">{s.target_date}</span>
<span className="font-mono font-bold text-white shrink-0">{s.target_value.toFixed(2)}</span>
<span className="text-slate-500 shrink-0">{Math.round(s.confidence*100)}%</span>
<span className="text-slate-600 shrink-0">{s.trajectory}</span>
<button onClick={() => deleteScenario(s.id)}
className="text-slate-600 hover:text-red-400 transition-colors shrink-0">
<Trash2 className="w-3 h-3"/>
</button>
</div>
)
})}
{/* Add form */}
{addForm ? (
<div className="space-y-2 pt-1">
<div className="grid grid-cols-3 gap-2">
{(['ct','mt','lt'] as const).map(h => (
<button key={h} onClick={() => setAddForm(f => f && ({...f, horizon: h}))}
className={clsx('py-1 rounded border text-xs font-semibold transition-colors', hm[h].bg,
addForm.horizon === h ? hm[h].color : 'text-slate-500')}>
{hm[h].label} <span className="font-normal opacity-60">{h === 'ct' ? '< 3m' : h === 'mt' ? '3-12m' : '> 1an'}</span>
</button>
))}
</div>
<div className="grid grid-cols-2 gap-2">
<input type="text" placeholder="Label (ex: Fed surprise cut)"
value={addForm.label}
onChange={e => setAddForm(f => f && ({...f, label: e.target.value}))}
className="col-span-2 text-xs bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-white placeholder-slate-600 focus:outline-none focus:border-violet-500/50"/>
<div>
<div className="text-xs text-slate-600 mb-1">Date cible</div>
<input type="date"
value={addForm.target_date}
onChange={e => setAddForm(f => f && ({...f, target_date: e.target.value}))}
className="w-full text-xs bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-white focus:outline-none"/>
</div>
<div>
<div className="text-xs text-slate-600 mb-1">Valeur cible ({node.unit})</div>
<input type="number" step="any"
placeholder={curV != null ? curV.toFixed(2) : '0.00'}
value={addForm.target_value}
onChange={e => setAddForm(f => f && ({...f, target_value: e.target.value}))}
className="w-full text-xs bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-white focus:outline-none"/>
</div>
<div>
<div className="text-xs text-slate-600 mb-1">Confidence</div>
<input type="range" min="0" max="1" step="0.05"
value={addForm.confidence}
onChange={e => setAddForm(f => f && ({...f, confidence: e.target.value}))}
className="w-full accent-violet-500"/>
<div className="text-xs text-violet-400 text-center">{Math.round(parseFloat(addForm.confidence)*100)}%</div>
</div>
<div>
<div className="text-xs text-slate-600 mb-1">Trajectoire</div>
<select value={addForm.trajectory}
onChange={e => setAddForm(f => f && ({...f, trajectory: e.target.value}))}
className="w-full text-xs bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-white focus:outline-none">
{TRAJ_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
<div className="flex gap-2 justify-end">
<button onClick={() => setAddForm(null)}
className="text-xs text-slate-600 hover:text-slate-400 px-3 py-1.5 transition-colors">
Annuler
</button>
<button onClick={submitScenario} disabled={saving || !addForm.target_date || !addForm.target_value}
className="text-xs bg-violet-600 hover:bg-violet-500 disabled:opacity-40 text-white px-3 py-1.5 rounded transition-colors">
{saving ? 'Saving…' : '+ Ajouter'}
</button>
</div>
</div>
) : (
<button onClick={() => setAddForm({...EMPTY_FORM})}
className="flex items-center gap-1 text-xs text-slate-600 hover:text-violet-400 transition-colors">
<Plus className="w-3 h-3"/> Ajouter un scénario CT/MT/LT
</button>
)}
</div>
)}
</div>
)
}
function MacroConfigView({ instrument, nodes }: { instrument: string; nodes: ModelNode[] }) {
const [guidance, setGuidance] = useState<MacroGuidanceItem[]>([])
useEffect(() => {
api.get(`/instrument-models/${instrument}/macro-guidance`)
.then(r => setGuidance(r.data))
.catch(() => {})
}, [instrument])
useEffect(() => { refreshGuidance() }, [refreshGuidance])
async function saveMacroKey(nodeId: string, mk: string) {
setSaving(nodeId)
try {
await api.patch(`/instrument-models/${instrument}/nodes/${nodeId}`, { macro_key: mk })
setSaved(prev => ({ ...prev, [nodeId]: true }))
setTimeout(() => setSaved(prev => ({ ...prev, [nodeId]: false })), 2000)
refreshGuidance()
} catch {}
setSaving(null)
}
const linkedCount = Object.values(localKeys).filter(Boolean).length
const manualNodes = nodes.filter(n => n.node_type === 'input_manual')
const linkedCount = manualNodes.filter(n => n.macro_key).length
const scenarioCount = guidance.length // approximate
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-medium text-white">Paramètres macro des nœuds</div>
<div className="text-sm font-medium text-white">Paramètres macro & scénarios</div>
<div className="text-xs text-slate-500 mt-0.5">
Liez chaque nœud manuel à une variable macro-économique. La machine interpolera
entre les publications FF Calendar pour créer une guidance temporelle des fondamentaux.
Chaque nœud macro-lié reçoit ses valeurs depuis FF Calendar.
Ajoutez des scénarios CT/MT/LT pour projeter la guidance fondamentale dans le temps.
</div>
</div>
<span className="text-xs text-violet-400 font-mono">
{linkedCount}/{manualNodes.length} liés
<span className="text-xs text-violet-400 font-mono shrink-0">
{linkedCount}/{manualNodes.length} liés FF
</span>
</div>
{/* Guidance summary cards — nodes that already have macro keys */}
{guidance.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{guidance.map(g => (
<div key={g.node_id} className="rounded-lg border border-violet-700/30 bg-violet-900/10 p-3">
<div className="text-xs text-violet-400 font-medium truncate">{g.node_label}</div>
<div className="text-xs text-slate-500 mb-1.5">{g.macro_key}</div>
<div className="flex items-baseline gap-1">
<span className="text-base font-bold font-mono text-white">
{g.current_value != null ? g.current_value.toFixed(2) : '—'}
</span>
<span className="text-xs text-slate-500">{g.unit}</span>
</div>
{g.next_event && (
<div className="text-xs text-slate-500 mt-1">
<span className="text-amber-400"> {g.next_event.forecast != null ? g.next_event.forecast.toFixed(2) : '?'}</span>
<span className="ml-1">dans {g.next_event.days_until}j</span>
</div>
)}
{g.next_event && (
<div className="text-xs text-slate-600 mt-0.5 truncate" title={g.next_event.name}>
{g.next_event.name}
</div>
)}
</div>
))}
</div>
)}
{/* Node mapping table */}
<div className="space-y-1.5">
<div className="text-xs text-slate-600 uppercase tracking-wider mb-2">Mapping nœud clé macro</div>
{manualNodes.map(node => {
const mk = localKeys[node.id] ?? ''
const isSaving = saving === node.id
const isSaved = saved[node.id]
return (
<div key={node.id}
className={clsx('flex items-center gap-3 px-3 py-2 rounded-lg border transition-colors',
mk ? 'border-violet-700/30 bg-violet-900/10' : 'border-slate-700/20 bg-dark-800/30')}>
<div className="flex-1 min-w-0">
<div className="text-sm text-white truncate">{node.label}</div>
<div className="text-xs text-slate-600">{node.unit} · ×{node.coefficient_to_pips}</div>
</div>
<select
value={mk}
onChange={e => {
const v = e.target.value
setLocalKeys(prev => ({ ...prev, [node.id]: v }))
saveMacroKey(node.id, v)
}}
disabled={isSaving}
className="text-xs bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-white w-44 shrink-0 focus:outline-none focus:border-violet-500/50">
{MACRO_KEY_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<div className="w-5 shrink-0 text-center">
{isSaving && <span className="text-xs text-blue-400 animate-pulse"></span>}
{isSaved && <Check className="w-3.5 h-3.5 text-emerald-400"/>}
</div>
</div>
)
})}
{manualNodes.map(node => (
<NodeScenarioSection
key={node.id}
instrument={instrument}
node={node}
guidance={guidance.find(g => g.node_id === node.id)}
/>
))}
</div>
{linkedCount > 0 && (
<div className="rounded-lg border border-slate-700/30 bg-dark-800/40 p-3 text-xs text-slate-500">
<span className="text-white font-medium">{linkedCount} nœud{linkedCount > 1 ? 's' : ''} macro-lié{linkedCount > 1 ? 's' : ''}</span>
{' '} la simulation Timeline utilisera des valeurs time-varying pour ces nœuds,
interpolant entre les publications passées et les forecasts des prochains events.
Le niveau fondamental ne sera plus statique mais guidé par les données FF Calendar.
</div>
)}
<div className="rounded-lg border border-slate-700/30 bg-dark-800/40 p-3 text-xs text-slate-500 space-y-1">
<div className="text-white font-medium">Comment fonctionne la guidance ?</div>
<div> <span className="text-sky-400">CT</span> choc/event imminent (ex: réunion Fed dans 3 sem surprise cut à 3.75%)</div>
<div> <span className="text-amber-400">MT</span> régime macro 3-12 mois (ex: regain inflation stagnation à 3.80%)</div>
<div> <span className="text-violet-400">LT</span> vision structurelle (ex: cycle de baisse 2.00% fin 2027)</div>
<div className="text-slate-600 pt-1">La Timeline interpolera chaque nœud vers sa cible avec la confidence pondérée. Relancez la simulation pour voir la courbe fondamentale évoluer.</div>
</div>
</div>
)
}