feat: instrument model
This commit is contained in:
@@ -35,6 +35,7 @@ import AIDesks from './pages/AIDesks'
|
||||
import MacroSeriesPage from './pages/MacroSeriesPage'
|
||||
import EuroSimulator from './pages/EuroSimulator'
|
||||
import CausalLab from './pages/CausalLab'
|
||||
import InstrumentModels from './pages/InstrumentModels'
|
||||
import { useCycleWatcher } from './hooks/useApi'
|
||||
|
||||
// ── Keep-alive pages ──────────────────────────────────────────────────────────
|
||||
@@ -62,6 +63,7 @@ const KEEP_ALIVE_DEFS: { path: string; component: ComponentType }[] = [
|
||||
{ path: '/calendar', component: CalendarPage },
|
||||
{ path: '/simulator', component: EuroSimulator },
|
||||
{ path: '/causal-lab', component: CausalLab },
|
||||
{ path: '/instrument-models', component: InstrumentModels },
|
||||
{ path: '/macro-series', component: MacroSeriesPage },
|
||||
{ path: '/institutional', component: InstitutionalReports },
|
||||
{ path: '/specialist-desks', component: SpecialistDesks },
|
||||
|
||||
@@ -27,6 +27,7 @@ const nav = [
|
||||
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
|
||||
{ to: '/simulator', icon: Sliders, label: 'EUR/USD Simulator' },
|
||||
{ to: '/causal-lab', icon: FlaskConical, label: 'Lab Causal' },
|
||||
{ to: '/instrument-models', icon: Microscope, label: 'Modèles Instruments' },
|
||||
{ to: '/macro-series', icon: TrendingUp, label: 'Macro Series' },
|
||||
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
|
||||
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
|
||||
|
||||
@@ -33,6 +33,7 @@ export const KEEP_ALIVE_META: KeepAliveMeta[] = [
|
||||
{ path: '/calendar', label: 'Calendar', icon: Calendar },
|
||||
{ path: '/simulator', label: 'Simulator', icon: Sliders },
|
||||
{ path: '/causal-lab', label: 'Lab Causal', icon: FlaskConical },
|
||||
{ path: '/instrument-models', label: 'Modèles Instr.', icon: Microscope },
|
||||
{ path: '/macro-series', label: 'Macro Series', icon: MacroSeriesIcon },
|
||||
{ path: '/institutional', label: 'Inst. Reports', icon: Building2 },
|
||||
{ path: '/specialist-desks', label: 'Specialist Desks', icon: Users },
|
||||
|
||||
637
frontend/src/pages/InstrumentModels.tsx
Normal file
637
frontend/src/pages/InstrumentModels.tsx
Normal file
@@ -0,0 +1,637 @@
|
||||
/**
|
||||
* InstrumentModels — graphes causaux exhaustifs par instrument.
|
||||
* Vue tableau + graphe, nœuds éditables, filtres catégorie/type.
|
||||
*/
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import axios from 'axios'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const api = axios.create({ baseURL: '/api' })
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ModelNode {
|
||||
id: string; label: string; type: 'structural' | 'event_driven' | 'output'
|
||||
category: string; unit: string; description: string
|
||||
coefficient?: number
|
||||
current_value: number | null; pip_contribution: number | null
|
||||
source: 'manual' | 'events' | 'neutral' | 'computed'
|
||||
override_note?: string; override_set_at?: string
|
||||
}
|
||||
interface ModelState {
|
||||
instrument: string; name: string; description: string
|
||||
at_date: string; net_pips: number; direction: 'bullish' | 'bearish' | 'neutral'
|
||||
nodes: ModelNode[]; output_node: string
|
||||
}
|
||||
interface ModelMeta {
|
||||
instrument: string; name: string; description: string
|
||||
n_structural: number; n_event_driven: number
|
||||
}
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const INSTRUMENTS = ['EURUSD','USDJPY','XAUUSD','SP500','TLT','GBPUSD','EEM','QQQ']
|
||||
|
||||
const CAT_COLOR: Record<string, string> = {
|
||||
monetary: 'bg-blue-900/40 text-blue-300 border-blue-700/40',
|
||||
inflation: 'bg-orange-900/40 text-orange-300 border-orange-700/40',
|
||||
macro: 'bg-teal-900/40 text-teal-300 border-teal-700/40',
|
||||
political: 'bg-purple-900/40 text-purple-300 border-purple-700/40',
|
||||
flows: 'bg-cyan-900/40 text-cyan-300 border-cyan-700/40',
|
||||
positioning: 'bg-indigo-900/40 text-indigo-300 border-indigo-700/40',
|
||||
sentiment: 'bg-pink-900/40 text-pink-300 border-pink-700/40',
|
||||
credit: 'bg-red-900/40 text-red-300 border-red-700/40',
|
||||
earnings: 'bg-emerald-900/40 text-emerald-300 border-emerald-700/40',
|
||||
valuation: 'bg-lime-900/40 text-lime-300 border-lime-700/40',
|
||||
tech: 'bg-violet-900/40 text-violet-300 border-violet-700/40',
|
||||
commodity: 'bg-amber-900/40 text-amber-300 border-amber-700/40',
|
||||
supply: 'bg-stone-900/40 text-stone-300 border-stone-700/40',
|
||||
// event-driven categories
|
||||
central_bank: 'bg-blue-900/40 text-blue-300 border-blue-700/40',
|
||||
monetary_shock: 'bg-teal-900/40 text-teal-300 border-teal-700/40',
|
||||
geopolitical: 'bg-red-900/40 text-red-300 border-red-700/40',
|
||||
trade_policy: 'bg-orange-900/40 text-orange-300 border-orange-700/40',
|
||||
growth_shock: 'bg-emerald-900/40 text-emerald-300 border-emerald-700/40',
|
||||
credit_stress: 'bg-rose-900/40 text-rose-300 border-rose-700/40',
|
||||
technical: 'bg-slate-700/40 text-slate-300 border-slate-600/40',
|
||||
output: 'bg-violet-900/40 text-violet-300 border-violet-700/40',
|
||||
}
|
||||
|
||||
const CAT_LABELS: Record<string, string> = {
|
||||
monetary:'Monétaire', inflation:'Inflation', macro:'Macro/Croissance',
|
||||
political:'Politique', flows:'Flux & Réserves', positioning:'Positionnement',
|
||||
sentiment:'Sentiment', credit:'Crédit', earnings:'Bénéfices',
|
||||
valuation:'Valorisation', tech:'Technologie', commodity:'Commodités',
|
||||
supply:'Offre', output:'Résultat',
|
||||
central_bank:'Banques Centrales', monetary_shock:'Surprise Macro',
|
||||
geopolitical:'Géopolitique', trade_policy:'Commerce', growth_shock:'Croissance',
|
||||
credit_stress:'Stress Crédit', technical:'Technique',
|
||||
}
|
||||
|
||||
function catBadge(cat: string) {
|
||||
const cls = CAT_COLOR[cat] ?? 'bg-slate-700/40 text-slate-400 border-slate-600/40'
|
||||
return (
|
||||
<span className={clsx('text-[10px] px-1.5 py-0.5 rounded border font-medium', cls)}>
|
||||
{CAT_LABELS[cat] ?? cat}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function pipColor(v: number | null) {
|
||||
if (v === null) return 'text-slate-500'
|
||||
if (v > 0) return 'text-emerald-400'
|
||||
if (v < 0) return 'text-red-400'
|
||||
return 'text-slate-500'
|
||||
}
|
||||
|
||||
function fmt(v: number | null, unit?: string) {
|
||||
if (v === null) return '—'
|
||||
const s = (v > 0 ? '+' : '') + v
|
||||
return unit ? `${s} ${unit}` : s
|
||||
}
|
||||
|
||||
// ── Node Edit Modal ────────────────────────────────────────────────────────────
|
||||
|
||||
function NodeEditModal({
|
||||
node, instrument, onClose, onSaved
|
||||
}: {
|
||||
node: ModelNode; instrument: string
|
||||
onClose: () => void; onSaved: () => void
|
||||
}) {
|
||||
const [val, setVal] = useState(String(node.current_value ?? 0))
|
||||
const [note, setNote] = useState(node.override_note ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const hasOverride = node.source === 'manual'
|
||||
|
||||
async function save() {
|
||||
const num = parseFloat(val)
|
||||
if (isNaN(num)) { setError('Valeur invalide'); return }
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.put(`/instrument-models/${instrument}/nodes/${node.id}/override`, { value: num, note })
|
||||
onSaved()
|
||||
} catch { setError('Erreur lors de la sauvegarde') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.delete(`/instrument-models/${instrument}/nodes/${node.id}/override`)
|
||||
onSaved()
|
||||
} catch { setError('Erreur') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const coeff = node.coefficient ?? 1
|
||||
const preview = node.type === 'structural' ? parseFloat(val || '0') * coeff : parseFloat(val || '0')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="bg-dark-800 border border-slate-700/60 rounded-2xl w-full max-w-md p-6 shadow-2xl">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-200">{node.label}</div>
|
||||
<div className="flex items-center gap-2 mt-1">{catBadge(node.category)}
|
||||
<span className="text-[10px] text-slate-500">{node.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-500 hover:text-slate-300 text-lg leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 leading-relaxed mb-4">{node.description}</p>
|
||||
|
||||
{node.type === 'structural' && (
|
||||
<div className="text-xs text-slate-500 mb-3 flex gap-3">
|
||||
<span>Coefficient: <strong className="text-slate-300">{coeff}</strong> {node.unit}/pips</span>
|
||||
<span>→ Impact: <strong className={clsx(pipColor(preview))}>{preview >= 0 ? '+' : ''}{preview.toFixed(1)} pips</strong></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.type === 'event_driven' && (
|
||||
<div className="text-xs text-slate-500 mb-3">
|
||||
{node.source === 'events'
|
||||
? `Valeur auto depuis events actifs: ${fmt(node.current_value, 'pips')}`
|
||||
: 'Aucun event actif (0 pips auto). Override possible.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 mb-1 block">Valeur ({node.unit})</label>
|
||||
<input
|
||||
type="number" step="any"
|
||||
value={val}
|
||||
onChange={e => setVal(e.target.value)}
|
||||
className="w-full bg-dark-900 border border-slate-600/60 rounded-lg px-3 py-2 text-sm text-slate-200 focus:border-violet-500/60 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 mb-1 block">Note (optionnel)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
placeholder="Ex: Anticipation Fed -50bps d'ici fin 2026"
|
||||
className="w-full bg-dark-900 border border-slate-600/60 rounded-lg px-3 py-2 text-sm text-slate-200 focus:border-violet-500/60 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-400 mt-2">{error}</p>}
|
||||
|
||||
<div className="flex gap-2 mt-5">
|
||||
<button
|
||||
onClick={save} disabled={saving}
|
||||
className="flex-1 py-2 rounded-lg bg-violet-700/60 hover:bg-violet-700/80 text-violet-100 text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? '…' : '💾 Enregistrer'}
|
||||
</button>
|
||||
{hasOverride && (
|
||||
<button
|
||||
onClick={clear} disabled={saving}
|
||||
className="px-4 py-2 rounded-lg bg-red-900/40 hover:bg-red-900/60 text-red-300 text-sm border border-red-700/40 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} className="px-4 py-2 rounded-lg border border-slate-600/40 text-slate-400 hover:text-slate-200 text-sm transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasOverride && node.override_set_at && (
|
||||
<p className="text-[10px] text-slate-600 mt-3 text-center">
|
||||
Override défini le {node.override_set_at?.slice(0,10)}
|
||||
{node.override_note ? ` — "${node.override_note}"` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Graph View (SVG DAG) ───────────────────────────────────────────────────────
|
||||
|
||||
function GraphView({ model, onNodeClick }: { model: ModelState; onNodeClick: (n: ModelNode) => void }) {
|
||||
const structural = model.nodes.filter(n => n.type === 'structural')
|
||||
const eventDriven = model.nodes.filter(n => n.type === 'event_driven')
|
||||
const outputNode = model.nodes.find(n => n.type === 'output')
|
||||
|
||||
const NODE_W = 190, NODE_H = 46, GAP_Y = 10
|
||||
const COL_X = { structural: 20, event: 240, output: 460 }
|
||||
const SVG_W = 660
|
||||
const leftH = structural.length * (NODE_H + GAP_Y)
|
||||
const rightH = eventDriven.length * (NODE_H + GAP_Y)
|
||||
const SVG_H = Math.max(leftH, rightH, 120) + 40
|
||||
|
||||
function nodeY(arr: ModelNode[], i: number, totalH: number) {
|
||||
const groupH = arr.length * (NODE_H + GAP_Y)
|
||||
const startY = (totalH - groupH) / 2 + 20
|
||||
return startY + i * (NODE_H + GAP_Y)
|
||||
}
|
||||
|
||||
const outputY = SVG_H / 2 - NODE_H / 2
|
||||
const outX = COL_X.output
|
||||
const outCX = outX + NODE_W / 2
|
||||
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<svg width={SVG_W} height={SVG_H} className="block mx-auto">
|
||||
{/* Structural → output edges */}
|
||||
{structural.map((n, i) => {
|
||||
const y = nodeY(structural, i, SVG_H)
|
||||
const cx = COL_X.structural + NODE_W
|
||||
const cy = y + NODE_H / 2
|
||||
const pips = n.pip_contribution ?? 0
|
||||
const color = pips > 0 ? '#34d399' : pips < 0 ? '#f87171' : '#475569'
|
||||
return (
|
||||
<path key={n.id}
|
||||
d={`M${cx},${cy} C${(cx + outX) / 2},${cy} ${(cx + outX) / 2},${outputY + NODE_H / 2} ${outX},${outputY + NODE_H / 2}`}
|
||||
fill="none" stroke={color} strokeWidth={Math.max(1, Math.min(3, Math.abs(pips) / 10))} strokeOpacity={0.5}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{/* Event → output edges */}
|
||||
{eventDriven.map((n, i) => {
|
||||
const y = nodeY(eventDriven, i, SVG_H)
|
||||
const cx = COL_X.event + NODE_W
|
||||
const cy = y + NODE_H / 2
|
||||
const pips = n.pip_contribution ?? 0
|
||||
const color = pips > 0 ? '#34d399' : pips < 0 ? '#f87171' : '#475569'
|
||||
return (
|
||||
<path key={n.id}
|
||||
d={`M${cx},${cy} C${(cx + outX) / 2},${cy} ${(cx + outX) / 2},${outputY + NODE_H / 2} ${outX},${outputY + NODE_H / 2}`}
|
||||
fill="none" stroke={color} strokeWidth={Math.max(1, Math.min(3, Math.abs(pips) / 10))} strokeOpacity={0.5}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Structural nodes */}
|
||||
{structural.map((n, i) => {
|
||||
const y = nodeY(structural, i, SVG_H)
|
||||
const pips = n.pip_contribution ?? 0
|
||||
const border = pips > 0 ? '#059669' : pips < 0 ? '#dc2626' : '#334155'
|
||||
const bg = n.source === 'manual' ? '#1e1b4b' : '#0f172a'
|
||||
return (
|
||||
<g key={n.id} className="cursor-pointer" onClick={() => onNodeClick(n)}>
|
||||
<rect x={COL_X.structural} y={y} width={NODE_W} height={NODE_H} rx={8}
|
||||
fill={bg} stroke={border} strokeWidth={1.5} />
|
||||
{n.source === 'manual' && (
|
||||
<rect x={COL_X.structural} y={y} width={4} height={NODE_H} rx={2} fill="#7c3aed" />
|
||||
)}
|
||||
<text x={COL_X.structural + 12} y={y + 16} fontSize={10} fill="#94a3b8">{n.label}</text>
|
||||
<text x={COL_X.structural + 12} y={y + 32} fontSize={11} fill={pips > 0 ? '#34d399' : pips < 0 ? '#f87171' : '#64748b'}
|
||||
fontWeight="600">
|
||||
{n.source === 'neutral' ? `0 ${n.unit}` : `${pips >= 0 ? '+' : ''}${pips} pips`}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Event-driven nodes */}
|
||||
{eventDriven.map((n, i) => {
|
||||
const y = nodeY(eventDriven, i, SVG_H)
|
||||
const pips = n.pip_contribution ?? 0
|
||||
const border = pips > 0 ? '#059669' : pips < 0 ? '#dc2626' : '#334155'
|
||||
return (
|
||||
<g key={n.id} className="cursor-pointer" onClick={() => onNodeClick(n)}>
|
||||
<rect x={COL_X.event} y={y} width={NODE_W} height={NODE_H} rx={8}
|
||||
fill="#0f172a" stroke={border} strokeWidth={1.5} strokeDasharray="4 2" />
|
||||
<text x={COL_X.event + 10} y={y + 16} fontSize={10} fill="#94a3b8">{n.label}</text>
|
||||
<text x={COL_X.event + 10} y={y + 32} fontSize={11} fill={pips > 0 ? '#34d399' : pips < 0 ? '#f87171' : '#64748b'}
|
||||
fontWeight="600">
|
||||
{pips === 0 ? '0 pips' : `${pips >= 0 ? '+' : ''}${pips} pips`}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Output node */}
|
||||
{outputNode && (
|
||||
<g>
|
||||
<rect x={outX} y={outputY} width={NODE_W} height={NODE_H + 10} rx={10}
|
||||
fill={model.net_pips > 0 ? '#064e3b' : model.net_pips < 0 ? '#450a0a' : '#1e293b'}
|
||||
stroke={model.net_pips > 0 ? '#059669' : model.net_pips < 0 ? '#dc2626' : '#475569'}
|
||||
strokeWidth={2} />
|
||||
<text x={outX + NODE_W / 2} y={outputY + 18} fontSize={11} fill="#94a3b8" textAnchor="middle">
|
||||
Impact Net
|
||||
</text>
|
||||
<text x={outX + NODE_W / 2} y={outputY + 38} fontSize={16} fontWeight="700"
|
||||
fill={model.net_pips > 0 ? '#34d399' : model.net_pips < 0 ? '#f87171' : '#94a3b8'}
|
||||
textAnchor="middle">
|
||||
{model.net_pips >= 0 ? '+' : ''}{model.net_pips} pips
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Column labels */}
|
||||
<text x={COL_X.structural + NODE_W / 2} y={14} fontSize={9} fill="#475569" textAnchor="middle" textDecoration="uppercase">
|
||||
FACTEURS STRUCTURELS
|
||||
</text>
|
||||
<text x={COL_X.event + NODE_W / 2} y={14} fontSize={9} fill="#475569" textAnchor="middle">
|
||||
PRESSIONS ÉVÉNEMENTIELLES
|
||||
</text>
|
||||
<text x={outX + NODE_W / 2} y={14} fontSize={9} fill="#475569" textAnchor="middle">
|
||||
RÉSULTAT
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Table View ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type SortKey = 'label' | 'category' | 'pip_contribution' | 'source'
|
||||
type FilterType = 'all' | 'structural' | 'event_driven'
|
||||
|
||||
function TableView({ model, onNodeClick }: { model: ModelState; onNodeClick: (n: ModelNode) => void }) {
|
||||
const [sortKey, setSortKey] = useState<SortKey>('pip_contribution')
|
||||
const [sortDir, setSortDir] = useState<1 | -1>(-1)
|
||||
const [filterType, setFilterType] = useState<FilterType>('all')
|
||||
const [filterCat, setFilterCat] = useState<string>('all')
|
||||
|
||||
const cats = Array.from(new Set(model.nodes.map(n => n.category))).filter(c => c !== 'output')
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir(d => d === 1 ? -1 : 1)
|
||||
else { setSortKey(key); setSortDir(-1) }
|
||||
}
|
||||
|
||||
const rows = model.nodes
|
||||
.filter(n => n.type !== 'output')
|
||||
.filter(n => filterType === 'all' || n.type === filterType)
|
||||
.filter(n => filterCat === 'all' || n.category === filterCat)
|
||||
.sort((a, b) => {
|
||||
let va: any = a[sortKey]; let vb: any = b[sortKey]
|
||||
if (sortKey === 'pip_contribution') {
|
||||
va = Math.abs(a.pip_contribution ?? 0)
|
||||
vb = Math.abs(b.pip_contribution ?? 0)
|
||||
}
|
||||
if (typeof va === 'string') return sortDir * va.localeCompare(vb)
|
||||
return sortDir * ((va ?? 0) - (vb ?? 0))
|
||||
})
|
||||
|
||||
function SortTh({ k, label }: { k: SortKey; label: string }) {
|
||||
const active = sortKey === k
|
||||
return (
|
||||
<th className={clsx('text-left text-[11px] font-medium uppercase tracking-wide cursor-pointer select-none py-2 px-3',
|
||||
active ? 'text-violet-300' : 'text-slate-500 hover:text-slate-300'
|
||||
)} onClick={() => toggleSort(k)}>
|
||||
{label} {active ? (sortDir === -1 ? '↓' : '↑') : ''}
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<div className="flex rounded-lg overflow-hidden border border-slate-700/40 text-xs">
|
||||
{(['all','structural','event_driven'] as FilterType[]).map(t => (
|
||||
<button key={t} onClick={() => setFilterType(t)}
|
||||
className={clsx('px-3 py-1.5 transition-colors',
|
||||
filterType === t ? 'bg-violet-700/60 text-violet-100' : 'bg-dark-800 text-slate-400 hover:text-slate-200')}>
|
||||
{t === 'all' ? 'Tous' : t === 'structural' ? 'Structurels' : 'Événementiels'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<select value={filterCat} onChange={e => setFilterCat(e.target.value)}
|
||||
className="bg-dark-800 border border-slate-700/40 rounded-lg px-3 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-violet-500/60">
|
||||
<option value="all">Toutes catégories</option>
|
||||
{cats.map(c => <option key={c} value={c}>{CAT_LABELS[c] ?? c}</option>)}
|
||||
</select>
|
||||
|
||||
<span className="text-xs text-slate-600 ml-auto">{rows.length} facteurs</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-700/30">
|
||||
<table className="w-full min-w-[640px]">
|
||||
<thead className="bg-dark-900/60 border-b border-slate-700/30">
|
||||
<tr>
|
||||
<SortTh k="label" label="Facteur" />
|
||||
<SortTh k="category" label="Catégorie" />
|
||||
<th className="text-left text-[11px] font-medium uppercase tracking-wide text-slate-500 py-2 px-3">Type</th>
|
||||
<th className="text-right text-[11px] font-medium uppercase tracking-wide text-slate-500 py-2 px-3">Valeur</th>
|
||||
<SortTh k="pip_contribution" label="Impact (pips)" />
|
||||
<SortTh k="source" label="Source" />
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/20">
|
||||
{rows.map(node => {
|
||||
const pips = node.pip_contribution ?? 0
|
||||
const barW = model.net_pips !== 0
|
||||
? Math.min(100, Math.abs(pips) / Math.max(...model.nodes.map(n => Math.abs(n.pip_contribution ?? 0))) * 100)
|
||||
: 0
|
||||
return (
|
||||
<tr key={node.id}
|
||||
onClick={() => node.type !== 'output' && onNodeClick(node)}
|
||||
className="hover:bg-slate-800/30 cursor-pointer transition-colors group">
|
||||
<td className="py-2.5 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{node.source === 'manual' && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-violet-500 shrink-0" title="Override manuel" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-xs text-slate-200 font-medium">{node.label}</div>
|
||||
<div className="text-[10px] text-slate-600 truncate max-w-[220px]">{node.description.slice(0, 60)}…</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">{catBadge(node.category)}</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<span className={clsx('text-[10px] px-1.5 py-0.5 rounded border',
|
||||
node.type === 'structural'
|
||||
? 'bg-slate-800/60 text-slate-400 border-slate-600/40'
|
||||
: 'bg-slate-800/40 text-slate-500 border-slate-700/30 italic')}>
|
||||
{node.type === 'structural' ? 'Structurel' : 'Événementiel'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right">
|
||||
{node.type === 'structural' && node.source === 'manual' ? (
|
||||
<span className="text-xs font-mono text-violet-300">
|
||||
{fmt(node.current_value, node.unit)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 h-2 bg-slate-800/60 rounded-full overflow-hidden shrink-0">
|
||||
<div className={clsx('h-full rounded-full', pips > 0 ? 'bg-emerald-600/70' : pips < 0 ? 'bg-red-600/70' : 'bg-slate-700')}
|
||||
style={{ width: `${barW}%` }} />
|
||||
</div>
|
||||
<span className={clsx('text-xs font-mono font-semibold w-16 text-right shrink-0', pipColor(pips))}>
|
||||
{pips === 0 ? '0' : `${pips >= 0 ? '+' : ''}${pips}`}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<span className={clsx('text-[10px]',
|
||||
node.source === 'manual' ? 'text-violet-400' :
|
||||
node.source === 'events' ? 'text-blue-400' :
|
||||
node.source === 'computed'? 'text-violet-400' : 'text-slate-600')}>
|
||||
{node.source === 'manual' ? '✎ Manuel' :
|
||||
node.source === 'events' ? '⚡ Events' :
|
||||
node.source === 'computed'? '∑ Calculé' : '— Neutre'}
|
||||
</span>
|
||||
{node.override_note && (
|
||||
<div className="text-[10px] text-slate-600 italic truncate max-w-[100px]" title={node.override_note}>
|
||||
{node.override_note}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-2 text-slate-600 group-hover:text-slate-400 text-sm">›</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function InstrumentModels() {
|
||||
const [selected, setSelected] = useState('EURUSD')
|
||||
const [model, setModel] = useState<ModelState | null>(null)
|
||||
const [metas, setMetas] = useState<ModelMeta[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [view, setView] = useState<'table' | 'graph'>('table')
|
||||
const [editNode, setEditNode] = useState<ModelNode | null>(null)
|
||||
const refreshKey = useRef(0)
|
||||
|
||||
const load = useCallback((inst = selected) => {
|
||||
setLoading(true)
|
||||
api.get(`/instrument-models/${inst}`)
|
||||
.then(r => setModel(r.data))
|
||||
.catch(() => setModel(null))
|
||||
.finally(() => setLoading(false))
|
||||
}, [selected])
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/instrument-models').then(r => setMetas(r.data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load(selected) }, [selected])
|
||||
|
||||
function onNodeClick(node: ModelNode) {
|
||||
if (node.type === 'output') return
|
||||
setEditNode(node)
|
||||
}
|
||||
|
||||
function onSaved() {
|
||||
setEditNode(null)
|
||||
load(selected)
|
||||
}
|
||||
|
||||
const dir = model?.direction
|
||||
const dirCls = dir === 'bullish' ? 'text-emerald-400' : dir === 'bearish' ? 'text-red-400' : 'text-slate-400'
|
||||
const dirLabel = dir === 'bullish' ? '▲ HAUSSIER' : dir === 'bearish' ? '▼ BAISSIER' : '◼ NEUTRE'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-900 text-slate-200 p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-100">Modèles d'Instruments</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">Graphes causaux exhaustifs — facteurs structurels + pressions événementielles</p>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button onClick={() => setView('table')}
|
||||
className={clsx('px-3 py-1.5 rounded-lg text-xs border transition-colors',
|
||||
view === 'table' ? 'bg-violet-700/50 border-violet-600/50 text-violet-200' : 'bg-dark-800 border-slate-700/40 text-slate-400 hover:text-slate-200')}>
|
||||
☰ Tableau
|
||||
</button>
|
||||
<button onClick={() => setView('graph')}
|
||||
className={clsx('px-3 py-1.5 rounded-lg text-xs border transition-colors',
|
||||
view === 'graph' ? 'bg-violet-700/50 border-violet-600/50 text-violet-200' : 'bg-dark-800 border-slate-700/40 text-slate-400 hover:text-slate-200')}>
|
||||
◈ Graphe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instrument tabs */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{INSTRUMENTS.map(inst => {
|
||||
const meta = metas.find(m => m.instrument === inst)
|
||||
return (
|
||||
<button key={inst} onClick={() => setSelected(inst)}
|
||||
className={clsx('px-4 py-2 rounded-xl text-sm font-medium border transition-colors',
|
||||
selected === inst
|
||||
? 'bg-violet-700/50 border-violet-600/60 text-violet-100'
|
||||
: 'bg-dark-800/60 border-slate-700/40 text-slate-400 hover:border-violet-700/40 hover:text-slate-200')}>
|
||||
{meta?.name ?? inst}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Net pressure summary */}
|
||||
{model && (
|
||||
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 px-5 py-4 flex items-center gap-6 flex-wrap">
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wide">Pression nette</div>
|
||||
<div className={clsx('text-2xl font-bold font-mono mt-0.5', dirCls)}>
|
||||
{model.net_pips >= 0 ? '+' : ''}{model.net_pips} <span className="text-base">pips</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={clsx('px-3 py-1.5 rounded-lg border text-sm font-semibold', dirCls,
|
||||
dir === 'bullish' ? 'border-emerald-700/40 bg-emerald-900/20'
|
||||
: dir === 'bearish' ? 'border-red-700/40 bg-red-900/20'
|
||||
: 'border-slate-700/40 bg-slate-800/20')}>
|
||||
{dirLabel}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
<span className="text-slate-300 font-mono">{model.nodes.filter(n => n.type === 'structural').length}</span> facteurs structurels ·{' '}
|
||||
<span className="text-slate-300 font-mono">{model.nodes.filter(n => n.type === 'event_driven').length}</span> pressions événementielles
|
||||
</div>
|
||||
<div className="ml-auto text-[10px] text-slate-600">
|
||||
Au {model.at_date} · {model.name}
|
||||
</div>
|
||||
<button onClick={() => load(selected)} className="text-xs text-slate-500 hover:text-slate-300 border border-slate-700/40 px-2 py-1 rounded transition-colors">
|
||||
↻ Actualiser
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
{loading && (
|
||||
<div className="text-center py-20 text-slate-600 text-sm animate-pulse">Chargement du modèle…</div>
|
||||
)}
|
||||
|
||||
{!loading && model && (
|
||||
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-5">
|
||||
{view === 'table'
|
||||
? <TableView model={model} onNodeClick={onNodeClick} />
|
||||
: <GraphView model={model} onNodeClick={onNodeClick} />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !model && (
|
||||
<div className="text-center py-20 text-slate-600">Modèle introuvable pour {selected}</div>
|
||||
)}
|
||||
|
||||
{/* Edit modal */}
|
||||
{editNode && model && (
|
||||
<NodeEditModal
|
||||
node={editNode}
|
||||
instrument={selected}
|
||||
onClose={() => setEditNode(null)}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user