1929 lines
84 KiB
TypeScript
1929 lines
84 KiB
TypeScript
/**
|
||
* InstrumentModels — graphes causaux exhaustifs par instrument.
|
||
* Architecture 3 couches : inputs (events + manuels) → intermédiaires → output
|
||
* Vues : DAG visuel | Tableau éditable | Timeline évolution
|
||
*/
|
||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||
import {
|
||
RefreshCw, Edit3, X, Trash2, ChevronDown, ChevronUp,
|
||
TrendingUp, TrendingDown, Minus, LineChart, Table2, Network, Activity,
|
||
Plus, Zap,
|
||
} from 'lucide-react'
|
||
import clsx from 'clsx'
|
||
import axios from 'axios'
|
||
|
||
const api = axios.create({ baseURL: '/api' })
|
||
|
||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||
|
||
type NodeType = 'input_event' | 'input_manual' | 'intermediate' | 'output'
|
||
|
||
interface ModelNode {
|
||
id: string
|
||
label: string
|
||
node_type: NodeType
|
||
category: string
|
||
unit: string
|
||
description: string
|
||
coefficient_to_pips?: number
|
||
event_category?: string
|
||
formula?: string
|
||
display_col: number
|
||
// computed
|
||
computed_value: number
|
||
pip_contribution: number
|
||
raw_value?: number
|
||
source: 'manual' | 'events' | 'neutral' | 'computed'
|
||
override_note?: string
|
||
override_set_at?: string
|
||
// Phase 2
|
||
pip_linear?: number
|
||
pip_saturated?: number
|
||
saturation_pct?: number
|
||
regime_weight?: number
|
||
// Phase 3 — event nodes: baseline (structurel) + surprise (événement)
|
||
baseline_value?: number
|
||
event_surprise?: number
|
||
}
|
||
|
||
interface RegimeInfo {
|
||
regime: string
|
||
label: string
|
||
dominant_cat: string | null
|
||
scores: Record<string, number>
|
||
weights: Record<string, number>
|
||
}
|
||
|
||
interface ModelState {
|
||
instrument: string
|
||
name: string
|
||
description: string
|
||
at_date: string
|
||
net_pips: number
|
||
structural_pips: number
|
||
event_pips: number
|
||
price_intercept: number
|
||
pip_to_price: number
|
||
yf_ticker: string
|
||
fundamental_level: number
|
||
synthetic_price: number
|
||
direction: 'bullish' | 'bearish' | 'neutral'
|
||
nodes: ModelNode[]
|
||
output_node: string
|
||
regime: RegimeInfo
|
||
event_details: Record<string, EventDetail[]>
|
||
}
|
||
|
||
interface TimelinePoint {
|
||
date: string
|
||
net_pips: number
|
||
structural_pips: number
|
||
event_pips: number
|
||
fundamental_level: number
|
||
synthetic_price: number
|
||
regime?: string
|
||
nodes: Record<string, number>
|
||
}
|
||
|
||
interface PricePoint {
|
||
date: string
|
||
close: number
|
||
}
|
||
|
||
interface VirtualEventForm {
|
||
id: string
|
||
date: string
|
||
category: string
|
||
pips: number
|
||
label: string
|
||
absorption_days: number
|
||
rise_days: number
|
||
plateau_days: number
|
||
decay_type: string
|
||
}
|
||
|
||
interface EventDetail {
|
||
analysis_id: number
|
||
event_id: number
|
||
title: string
|
||
start_date: string
|
||
days_since: number
|
||
pip_prediction: number
|
||
lifecycle_factor: number
|
||
remaining_pips: number
|
||
calibration: {
|
||
absorption_days: number
|
||
rise_days: number
|
||
plateau_days: number
|
||
decay_type: string
|
||
}
|
||
}
|
||
|
||
interface CalendarEvent {
|
||
analysis_id: number
|
||
event_id: number
|
||
title: string
|
||
start_date: string
|
||
category: string
|
||
is_future: boolean
|
||
days_offset: number
|
||
pip_prediction: number
|
||
lifecycle_factor: number
|
||
remaining_pips: number
|
||
calibration: {
|
||
absorption_days: number
|
||
rise_days: number
|
||
plateau_days: number
|
||
decay_type: string
|
||
}
|
||
}
|
||
|
||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||
|
||
const INSTRUMENTS = ['EURUSD','USDJPY','XAUUSD','SP500','TLT','GBPUSD','EEM','QQQ']
|
||
|
||
const NODE_TYPE_META: Record<NodeType, { label: string; color: string; bg: string }> = {
|
||
input_event: { label: 'Events', color: 'text-sky-400', bg: 'bg-sky-900/30 border-sky-700/40' },
|
||
input_manual: { label: 'Manuel', color: 'text-violet-400', bg: 'bg-violet-900/30 border-violet-700/40' },
|
||
intermediate: { label: 'Intermédiaire', color: 'text-amber-400', bg: 'bg-amber-900/20 border-amber-700/40' },
|
||
output: { label: 'Résultat', color: 'text-emerald-400', bg: 'bg-emerald-900/20 border-emerald-700/40' },
|
||
}
|
||
|
||
const SOURCE_META: Record<string, { dot: string; label: string }> = {
|
||
manual: { dot: 'bg-violet-400', label: 'Override' },
|
||
events: { dot: 'bg-sky-400', label: 'Events' },
|
||
neutral: { dot: 'bg-slate-600', label: 'Neutre' },
|
||
computed: { dot: 'bg-amber-400', label: 'Calculé' },
|
||
}
|
||
|
||
const PERIODS = ['5d','1mo','3mo','6mo','1y','2y'] as const
|
||
type Period = typeof PERIODS[number]
|
||
|
||
const LAYER_COLORS: Record<string, string> = {
|
||
'net_pips': 'bg-emerald-500',
|
||
'layer_monetary': 'bg-blue-400',
|
||
'layer_rates': 'bg-blue-400',
|
||
'layer_growth': 'bg-violet-400',
|
||
'layer_uk_macro': 'bg-violet-400',
|
||
'layer_risk': 'bg-red-400',
|
||
'layer_risk_credit': 'bg-red-400',
|
||
'layer_positioning': 'bg-orange-400',
|
||
'layer_policy': 'bg-orange-400',
|
||
'layer_flows': 'bg-orange-400',
|
||
'layer_demand': 'bg-teal-400',
|
||
'layer_refuge': 'bg-pink-400',
|
||
'layer_tech_fundamental': 'bg-purple-400',
|
||
'layer_supply': 'bg-slate-400',
|
||
'layer_china': 'bg-yellow-400',
|
||
'layer_dollar': 'bg-sky-400',
|
||
'layer_global': 'bg-gray-400',
|
||
'layer_macro': 'bg-lime-400',
|
||
}
|
||
|
||
const CANVAS_COLORS: Record<string, string> = {
|
||
'net_pips': '#10b981',
|
||
'layer_monetary': '#60a5fa',
|
||
'layer_rates': '#60a5fa',
|
||
'layer_growth': '#a78bfa',
|
||
'layer_uk_macro': '#a78bfa',
|
||
'layer_risk': '#f87171',
|
||
'layer_risk_credit': '#f87171',
|
||
'layer_positioning': '#fb923c',
|
||
'layer_policy': '#fb923c',
|
||
'layer_flows': '#fb923c',
|
||
'layer_demand': '#2dd4bf',
|
||
'layer_refuge': '#f472b6',
|
||
'layer_tech_fundamental': '#c084fc',
|
||
'layer_supply': '#94a3b8',
|
||
'layer_china': '#fbbf24',
|
||
'layer_dollar': '#38bdf8',
|
||
'layer_global': '#cbd5e1',
|
||
'layer_macro': '#a3e635',
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
function pipColor(v: number) {
|
||
if (v > 0.5) return 'text-emerald-400'
|
||
if (v < -0.5) return 'text-red-400'
|
||
return 'text-slate-400'
|
||
}
|
||
|
||
function pipBg(v: number) {
|
||
if (v > 0.5) return 'bg-emerald-500'
|
||
if (v < -0.5) return 'bg-red-500'
|
||
return 'bg-slate-600'
|
||
}
|
||
|
||
function fmt(v: number) {
|
||
const s = v > 0 ? '+' : ''
|
||
return `${s}${v.toFixed(1)}`
|
||
}
|
||
|
||
function fmtPrice(v: number, pipToPrice: number): string {
|
||
if (pipToPrice <= 0.0001) return v.toFixed(4)
|
||
if (pipToPrice <= 0.01) return v.toFixed(2)
|
||
if (pipToPrice <= 0.1) return v.toFixed(2)
|
||
return v.toFixed(0)
|
||
}
|
||
|
||
const EVENT_CATEGORIES = [
|
||
{ value: 'central_bank', label: 'Banque Centrale' },
|
||
{ value: 'monetary_shock', label: 'Surprise Macro' },
|
||
{ value: 'geopolitical', label: 'Géopolitique' },
|
||
{ value: 'trade_policy', label: 'Commerce / Tarifs' },
|
||
{ value: 'growth_shock', label: 'Choc Croissance' },
|
||
{ value: 'credit_stress', label: 'Stress Crédit' },
|
||
{ value: 'commodity', label: 'Commodités' },
|
||
{ value: 'sentiment', label: 'Sentiment' },
|
||
{ value: 'technical', label: 'Technique' },
|
||
{ value: 'unclassified', label: 'Non classifié' },
|
||
]
|
||
|
||
function DirectionBadge({ direction, pips }: { direction: string; pips: number }) {
|
||
const icon =
|
||
direction === 'bullish' ? <TrendingUp className="w-3.5 h-3.5"/> :
|
||
direction === 'bearish' ? <TrendingDown className="w-3.5 h-3.5"/> :
|
||
<Minus className="w-3.5 h-3.5"/>
|
||
const cls =
|
||
direction === 'bullish' ? 'text-emerald-400 border-emerald-700/50 bg-emerald-900/30' :
|
||
direction === 'bearish' ? 'text-red-400 border-red-700/50 bg-red-900/30' :
|
||
'text-slate-400 border-slate-700/40 bg-slate-800/50'
|
||
return (
|
||
<span className={clsx('inline-flex items-center gap-1.5 px-2.5 py-1 rounded border text-sm font-semibold', cls)}>
|
||
{icon} {fmt(pips)} pips · {direction.toUpperCase()}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// ── Regime Badge ──────────────────────────────────────────────────────────────
|
||
|
||
const REGIME_META: Record<string, { color: string; bg: string; icon: string }> = {
|
||
MONETARY_DOMINANCE: { color: 'text-blue-400', bg: 'bg-blue-900/30 border-blue-700/40', icon: '🏦' },
|
||
GEOPOLITICAL_RISK: { color: 'text-orange-400', bg: 'bg-orange-900/30 border-orange-700/40', icon: '⚠️' },
|
||
CREDIT_STRESS: { color: 'text-red-400', bg: 'bg-red-900/30 border-red-700/40', icon: '🔴' },
|
||
GROWTH_SCARE: { color: 'text-amber-400', bg: 'bg-amber-900/30 border-amber-700/40', icon: '📉' },
|
||
COMMODITY_SHOCK: { color: 'text-yellow-400', bg: 'bg-yellow-900/30 border-yellow-700/40','icon': '🛢️' },
|
||
BALANCED: { color: 'text-slate-400', bg: 'bg-slate-800/50 border-slate-700/40', icon: '⚖️' },
|
||
}
|
||
|
||
function RegimeBadge({ regime }: { regime: RegimeInfo }) {
|
||
const meta = REGIME_META[regime.regime] || REGIME_META.BALANCED
|
||
const topWeights = Object.entries(regime.weights)
|
||
.filter(([, w]) => w > 1.0)
|
||
.sort(([, a], [, b]) => b - a)
|
||
.slice(0, 3)
|
||
|
||
return (
|
||
<div className={clsx('inline-flex flex-col gap-1 px-3 py-2 rounded-lg border text-xs', meta.bg)}>
|
||
<div className="flex items-center gap-1.5">
|
||
<span>{meta.icon}</span>
|
||
<span className={clsx('font-semibold', meta.color)}>{regime.label}</span>
|
||
{regime.dominant_cat && (
|
||
<span className="text-slate-500">· {regime.dominant_cat}</span>
|
||
)}
|
||
</div>
|
||
{topWeights.length > 0 && (
|
||
<div className="flex gap-2 flex-wrap">
|
||
{topWeights.map(([layer, w]) => (
|
||
<span key={layer} className={clsx('font-mono', meta.color)}>
|
||
{layer.replace('layer_', '')} ×{w.toFixed(2)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Node Edit Modal ───────────────────────────────────────────────────────────
|
||
|
||
function NodeEditModal({ node, instrument, onClose, onSaved }: {
|
||
node: ModelNode; instrument: string; onClose: () => void; onSaved: () => void
|
||
}) {
|
||
const [val, setVal] = useState<string>(node.raw_value !== undefined ? String(node.raw_value) : '')
|
||
const [note, setNote] = useState(node.override_note || '')
|
||
const [saving, setSaving] = useState(false)
|
||
const [clrg, setClrg] = useState(false)
|
||
|
||
const coeff = node.coefficient_to_pips ?? 1.0
|
||
const preview = node.node_type === 'input_manual'
|
||
? (parseFloat(val) || 0) * coeff
|
||
: (parseFloat(val) || 0)
|
||
|
||
async function save() {
|
||
setSaving(true)
|
||
try {
|
||
await api.put(`/instrument-models/${instrument}/nodes/${node.id}/override`, {
|
||
value: parseFloat(val) || 0, note
|
||
})
|
||
onSaved(); onClose()
|
||
} finally { setSaving(false) }
|
||
}
|
||
|
||
async function clear() {
|
||
setClrg(true)
|
||
try {
|
||
await api.delete(`/instrument-models/${instrument}/nodes/${node.id}/override`)
|
||
onSaved(); onClose()
|
||
} finally { setClrg(false) }
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||
onClick={onClose}>
|
||
<div className="bg-dark-800 border border-slate-700/60 rounded-xl shadow-2xl w-full max-w-md mx-4 p-6"
|
||
onClick={e => e.stopPropagation()}>
|
||
<div className="flex items-start justify-between mb-4">
|
||
<div>
|
||
<div className="text-white font-semibold text-sm">{node.label}</div>
|
||
<div className="text-slate-500 text-xs mt-0.5 leading-relaxed max-w-xs">{node.description}</div>
|
||
</div>
|
||
<button onClick={onClose} className="text-slate-500 hover:text-white ml-2">
|
||
<X className="w-4 h-4"/>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="text-xs text-slate-400 mb-1 block">
|
||
Valeur ({node.unit})
|
||
{node.node_type === 'input_manual' && coeff !== 0 &&
|
||
<span className="ml-1.5 text-slate-500">× {coeff} = <span className={pipColor(preview)}>{fmt(preview)} pips</span></span>}
|
||
{node.node_type === 'input_event' && <span className="ml-1.5 text-slate-500">niveau structurel (les events s'y ajoutent)</span>}
|
||
</label>
|
||
<input
|
||
type="number" step="any" value={val} onChange={e => setVal(e.target.value)}
|
||
placeholder="0"
|
||
className="w-full bg-dark-700 border border-slate-700/50 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500/60"
|
||
/>
|
||
</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: NFP très fort, marché hawkish…"
|
||
className="w-full bg-dark-700 border border-slate-700/50 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500/60"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{val !== '' && (
|
||
<div className={clsx('mt-3 rounded px-3 py-2 text-sm font-semibold',
|
||
preview > 0 ? 'bg-emerald-900/30 text-emerald-400'
|
||
: preview < 0 ? 'bg-red-900/30 text-red-400'
|
||
: 'bg-slate-800 text-slate-400')}>
|
||
Impact estimé : {fmt(preview)} pips
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-2 mt-4">
|
||
<button onClick={save} disabled={saving || val === ''}
|
||
className="flex-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-sm rounded px-3 py-2 font-medium transition-colors">
|
||
{saving ? 'Enregistrement…' : 'Enregistrer'}
|
||
</button>
|
||
{node.source === 'manual' && (
|
||
<button onClick={clear} disabled={clrg}
|
||
className="flex items-center gap-1.5 bg-red-900/40 hover:bg-red-800/50 disabled:opacity-40 text-red-400 text-sm rounded px-3 py-2 transition-colors">
|
||
<Trash2 className="w-3.5 h-3.5"/> {clrg ? '…' : 'Effacer'}
|
||
</button>
|
||
)}
|
||
<button onClick={onClose} className="bg-dark-700 hover:bg-dark-600 text-slate-400 text-sm rounded px-3 py-2 transition-colors">
|
||
Annuler
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── DAG View ──────────────────────────────────────────────────────────────────
|
||
|
||
const COL_LABELS = ['Events actifs', 'Variables manuelles', 'Couches intermédiaires', 'Résultat net']
|
||
|
||
function NodeCard({ node, onEdit, eventDetails }: {
|
||
node: ModelNode; onEdit: (n: ModelNode) => void
|
||
eventDetails?: Record<string, EventDetail[]>
|
||
}) {
|
||
const meta = NODE_TYPE_META[node.node_type]
|
||
const v = node.pip_contribution
|
||
const canEdit = node.node_type === 'input_event' || node.node_type === 'input_manual'
|
||
const isManual = node.node_type === 'input_manual'
|
||
const hasValue = node.source === 'manual' && node.raw_value !== undefined && node.raw_value !== 0
|
||
const coeff = node.coefficient_to_pips ?? 1
|
||
|
||
return (
|
||
<div
|
||
className={clsx(
|
||
'relative rounded-lg border px-3 py-2 mb-1.5 transition-all',
|
||
meta.bg,
|
||
canEdit && 'cursor-pointer hover:border-blue-500/60 hover:shadow-md hover:shadow-blue-900/20',
|
||
node.source === 'manual' && 'ring-1 ring-violet-500/30',
|
||
)}
|
||
onClick={() => canEdit && onEdit(node)}
|
||
title={node.description}
|
||
>
|
||
{node.source === 'manual' && <span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-violet-400"/>}
|
||
{node.source === 'events' && <span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-sky-400"/>}
|
||
|
||
<div className="text-xs text-slate-300 font-medium leading-tight line-clamp-2 pr-3">{node.label}</div>
|
||
|
||
{isManual ? (
|
||
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
|
||
{hasValue ? (
|
||
<span className={clsx('text-xs font-bold font-mono', pipColor(v))}>
|
||
{node.raw_value! > 0 ? '+' : ''}{node.raw_value} {node.unit}
|
||
</span>
|
||
) : (
|
||
<span className="text-xs text-slate-600">— {node.unit}</span>
|
||
)}
|
||
<span className="text-xs text-slate-600 bg-slate-800/60 px-1.5 py-0.5 rounded font-mono">
|
||
w:{coeff}
|
||
</span>
|
||
{hasValue && (
|
||
<span className={clsx('text-xs font-mono', pipColor(v))}>
|
||
={fmt(v)}p
|
||
</span>
|
||
)}
|
||
</div>
|
||
) : node.node_type === 'input_event' ? (() => {
|
||
const cat = node.event_category || ''
|
||
const evList = eventDetails?.[cat] || []
|
||
const maxLf = evList.length > 0 ? Math.max(...evList.map(e => e.lifecycle_factor)) : 0
|
||
const barW = Math.round(maxLf * 100)
|
||
const baseline = node.baseline_value ?? 0
|
||
const surprise = node.event_surprise ?? 0
|
||
const hasBaseline = baseline !== 0
|
||
return (
|
||
<div className="mt-1">
|
||
{hasBaseline ? (
|
||
<div className="space-y-0.5">
|
||
<div className="flex flex-wrap gap-x-2 gap-y-0">
|
||
<span className="text-xs text-violet-300/80 font-mono">base:{fmt(baseline)}p</span>
|
||
{surprise !== 0 && (
|
||
<span className={clsx('text-xs font-mono', pipColor(surprise))}>surp:{fmt(surprise)}p</span>
|
||
)}
|
||
</div>
|
||
<span className={clsx('text-xs font-bold font-mono', pipColor(v))}>={fmt(v)}p</span>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2">
|
||
<span className={clsx('text-xs font-bold font-mono', pipColor(v))}>{fmt(v)} pips</span>
|
||
{evList.length > 0 && <span className="text-xs text-slate-600">{evList.length} ev</span>}
|
||
</div>
|
||
)}
|
||
{evList.length > 0 && (
|
||
<div className="mt-0.5 flex items-center gap-1.5">
|
||
<div className="flex-1 h-0.5 rounded-full bg-slate-700/60 overflow-hidden">
|
||
<div className={clsx('h-full rounded-full', pipBg(surprise !== 0 ? surprise : v))} style={{width:`${barW}%`}}/>
|
||
</div>
|
||
<span className="text-slate-600 text-xs font-mono">{barW}%</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})() : (
|
||
<div className={clsx('text-xs font-bold mt-1', node.node_type === 'output' ? 'text-base' : '', pipColor(v))}>
|
||
{fmt(v)} pips
|
||
</div>
|
||
)}
|
||
|
||
{Math.abs(v) > 0.05 && !isManual && (
|
||
<div className="mt-1 h-1 rounded-full bg-slate-700/40 overflow-hidden">
|
||
<div className={clsx('h-full rounded-full', pipBg(v))} style={{width: `${Math.min(100, Math.abs(v / 50) * 100)}%`}}/>
|
||
</div>
|
||
)}
|
||
{isManual && Math.abs(v) > 0.05 && (
|
||
<div className="mt-1 h-0.5 rounded-full bg-slate-700/40 overflow-hidden">
|
||
<div className={clsx('h-full rounded-full', pipBg(v))} style={{width: `${Math.min(100, Math.abs(v / 50) * 100)}%`}}/>
|
||
</div>
|
||
)}
|
||
|
||
{node.node_type === 'intermediate' && (
|
||
<div className="mt-1 flex items-center gap-2">
|
||
{node.formula && <span className="text-slate-600 text-xs">{node.formula.split('+').length} sources</span>}
|
||
{node.regime_weight !== undefined && node.regime_weight !== 1.0 && (
|
||
<span className={clsx('text-xs font-mono font-bold',
|
||
node.regime_weight > 1.0 ? 'text-amber-400' : 'text-slate-500')}>
|
||
×{node.regime_weight.toFixed(2)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
interface EdgeDraw {
|
||
x1: number; y1: number; x2: number; y2: number
|
||
coeff: number; fromId: string; toId: string; key: string
|
||
fromType: NodeType
|
||
}
|
||
|
||
function DagView({ nodes, instrument, onEdit, eventDetails }: {
|
||
nodes: ModelNode[]; instrument: string; onEdit: (n: ModelNode) => void
|
||
eventDetails: Record<string, EventDetail[]>
|
||
}) {
|
||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||
const [svgEdges, setSvgEdges] = useState<EdgeDraw[]>([])
|
||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||
const [svgH, setSvgH] = useState(600)
|
||
|
||
const cols: ModelNode[][] = [[], [], [], []]
|
||
for (const n of nodes) cols[Math.min(n.display_col ?? 0, 3)].push(n)
|
||
|
||
const nodeTypeMap = useMemo(() => {
|
||
const m = new Map<string, NodeType>()
|
||
for (const n of nodes) m.set(n.id, n.node_type)
|
||
return m
|
||
}, [nodes])
|
||
|
||
// Parse directed connections from intermediate/output formula strings
|
||
const connections = useMemo(() => {
|
||
const result: { from: string; to: string; coeff: number }[] = []
|
||
for (const node of nodes) {
|
||
if (!node.formula) continue
|
||
for (const term of node.formula.split('+')) {
|
||
const t = term.trim()
|
||
const m1 = t.match(/^([\d.]+)\s*\*\s*(\w+)$/)
|
||
if (m1) {
|
||
result.push({ from: m1[2], to: node.id, coeff: parseFloat(m1[1]) })
|
||
} else if (/^\w+$/.test(t)) {
|
||
result.push({ from: t, to: node.id, coeff: 1.0 })
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
}, [nodes])
|
||
|
||
const recompute = useCallback(() => {
|
||
const wrapper = wrapperRef.current
|
||
if (!wrapper) return
|
||
const rect0 = wrapper.getBoundingClientRect()
|
||
const sL = wrapper.scrollLeft
|
||
const sT = wrapper.scrollTop
|
||
const edges: EdgeDraw[] = []
|
||
for (const conn of connections) {
|
||
const fromEl = nodeRefs.current.get(conn.from)
|
||
const toEl = nodeRefs.current.get(conn.to)
|
||
if (!fromEl || !toEl) continue
|
||
const fr = fromEl.getBoundingClientRect()
|
||
const tr = toEl.getBoundingClientRect()
|
||
edges.push({
|
||
x1: fr.right - rect0.left + sL,
|
||
y1: fr.top + fr.height / 2 - rect0.top + sT,
|
||
x2: tr.left - rect0.left + sL,
|
||
y2: tr.top + tr.height / 2 - rect0.top + sT,
|
||
coeff: conn.coeff,
|
||
fromId: conn.from,
|
||
toId: conn.to,
|
||
key: `${conn.from}->${conn.to}`,
|
||
fromType: nodeTypeMap.get(conn.from) ?? 'input_event',
|
||
})
|
||
}
|
||
setSvgEdges(edges)
|
||
setSvgH(wrapper.scrollHeight)
|
||
}, [connections, nodeTypeMap])
|
||
|
||
useEffect(() => {
|
||
const t = setTimeout(recompute, 100)
|
||
const ro = new ResizeObserver(() => setTimeout(recompute, 80))
|
||
if (wrapperRef.current) ro.observe(wrapperRef.current)
|
||
return () => { clearTimeout(t); ro.disconnect() }
|
||
}, [recompute])
|
||
|
||
function edgeStroke(e: EdgeDraw): string {
|
||
if (e.fromType === 'input_event') return '#38bdf8' // sky — event source
|
||
if (e.fromType === 'input_manual') return '#a78bfa' // violet — manual source
|
||
return '#f59e0b' // amber — intermediate→output
|
||
}
|
||
|
||
return (
|
||
<div ref={wrapperRef} className="relative overflow-x-auto" onScroll={recompute}>
|
||
{/* SVG arêtes superposées */}
|
||
<svg
|
||
className="absolute top-0 left-0 pointer-events-none"
|
||
style={{ width: '100%', height: svgH, zIndex: 5, overflow: 'visible' }}
|
||
aria-hidden="true"
|
||
>
|
||
{svgEdges.map(e => {
|
||
const isHov = hoveredId === e.fromId || hoveredId === e.toId
|
||
const opacity = isHov ? 0.80 : 0.13
|
||
const stroke = edgeStroke(e)
|
||
const dx = Math.min(50, (e.x2 - e.x1) * 0.38)
|
||
return (
|
||
<g key={e.key} opacity={opacity}>
|
||
<path
|
||
d={`M ${e.x1} ${e.y1} C ${e.x1 + dx} ${e.y1}, ${e.x2 - dx} ${e.y2}, ${e.x2} ${e.y2}`}
|
||
fill="none" stroke={stroke}
|
||
strokeWidth={e.coeff > 1.05 ? 2 : 1}
|
||
/>
|
||
{/* Arrowhead */}
|
||
<polygon
|
||
points={`${e.x2},${e.y2} ${e.x2 - 5},${e.y2 - 3} ${e.x2 - 5},${e.y2 + 3}`}
|
||
fill={stroke} fillOpacity={0.7}
|
||
/>
|
||
{/* Coefficient label on hover */}
|
||
{isHov && e.coeff !== 1.0 && (
|
||
<text
|
||
x={(e.x1 + e.x2) / 2} y={(e.y1 + e.y2) / 2 - 4}
|
||
fill={stroke} fontSize="9" textAnchor="middle"
|
||
style={{ userSelect: 'none', fontFamily: 'monospace' }}
|
||
>
|
||
×{e.coeff.toFixed(2)}
|
||
</text>
|
||
)}
|
||
</g>
|
||
)
|
||
})}
|
||
</svg>
|
||
|
||
{/* Grille 4 colonnes */}
|
||
<div className="grid grid-cols-4 gap-3 min-w-[780px]" style={{ position: 'relative', zIndex: 10 }}>
|
||
{COL_LABELS.map((lbl, ci) => (
|
||
<div key={ci}>
|
||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 pb-1 border-b border-slate-700/30">
|
||
{lbl}
|
||
<span className="ml-1.5 text-slate-600 font-normal normal-case">({cols[ci].length})</span>
|
||
</div>
|
||
{cols[ci].map(n => (
|
||
<div
|
||
key={n.id}
|
||
ref={el => { if (el) nodeRefs.current.set(n.id, el); else nodeRefs.current.delete(n.id) }}
|
||
onMouseEnter={() => { setHoveredId(n.id); recompute() }}
|
||
onMouseLeave={() => setHoveredId(null)}
|
||
>
|
||
<NodeCard node={n} onEdit={onEdit} eventDetails={eventDetails}/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Légende */}
|
||
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-slate-700/20 flex-wrap">
|
||
<div className="flex items-center gap-1.5 text-xs text-sky-400">
|
||
<span className="w-3 h-px bg-sky-400 inline-block rounded"/> Events → couches
|
||
</div>
|
||
<div className="flex items-center gap-1.5 text-xs text-violet-400">
|
||
<span className="w-3 h-px bg-violet-400 inline-block rounded"/> Manuel → couches
|
||
</div>
|
||
<div className="flex items-center gap-1.5 text-xs text-amber-400">
|
||
<span className="w-3 h-px bg-amber-400 inline-block rounded"/> Couches → output
|
||
</div>
|
||
<span className="text-xs text-slate-600 ml-1">· survol = highlight</span>
|
||
<div className="ml-2 flex gap-3">
|
||
{(Object.entries(SOURCE_META) as [string, { dot: string; label: string }][]).map(([src, m]) => (
|
||
<div key={src} className="flex items-center gap-1.5 text-xs text-slate-500">
|
||
<span className={clsx('w-2 h-2 rounded-full', m.dot)}/>{m.label}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center gap-1.5 text-xs text-slate-500 ml-auto">
|
||
<Edit3 className="w-3 h-3"/> Cliquer pour modifier
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Table View ────────────────────────────────────────────────────────────────
|
||
|
||
type SortKey = 'label' | 'category' | 'pip_contribution' | 'source'
|
||
|
||
function TableView({ nodes, onEdit }: { nodes: ModelNode[]; onEdit: (n: ModelNode) => void }) {
|
||
const [sort, setSort] = useState<SortKey>('pip_contribution')
|
||
const [sortDir, setSortDir] = useState<1 | -1>(-1)
|
||
const [filterType, setFilterType] = useState<string>('all')
|
||
const [filterCat, setFilterCat] = useState<string>('all')
|
||
const [expanded, setExpanded] = useState<string | null>(null)
|
||
|
||
const categories = useMemo(() => {
|
||
const s = new Set(nodes.map(n => n.category))
|
||
return ['all', ...Array.from(s).sort()]
|
||
}, [nodes])
|
||
|
||
const maxAbs = useMemo(() =>
|
||
Math.max(...nodes.map(n => Math.abs(n.pip_contribution)), 1), [nodes])
|
||
|
||
const sorted = useMemo(() => {
|
||
const filtered = nodes.filter(n =>
|
||
(filterType === 'all' || n.node_type === filterType) &&
|
||
(filterCat === 'all' || n.category === filterCat)
|
||
)
|
||
return [...filtered].sort((a, b) => {
|
||
if (sort === 'pip_contribution') return (a.pip_contribution - b.pip_contribution) * sortDir
|
||
const av = sort === 'label' ? a.label : sort === 'category' ? a.category : a.source
|
||
const bv = sort === 'label' ? b.label : sort === 'category' ? b.category : b.source
|
||
return String(av).localeCompare(String(bv)) * sortDir
|
||
})
|
||
}, [nodes, filterType, filterCat, sort, sortDir])
|
||
|
||
function toggle(key: SortKey) {
|
||
if (sort === key) setSortDir(d => d === 1 ? -1 : 1)
|
||
else { setSort(key); setSortDir(-1) }
|
||
}
|
||
|
||
function SortBtn({ k, label }: { k: SortKey; label: string }) {
|
||
return (
|
||
<button onClick={() => toggle(k)}
|
||
className={clsx('flex items-center gap-1', sort === k ? 'text-blue-400' : 'text-slate-500 hover:text-slate-300')}>
|
||
{label}
|
||
{sort === k && (sortDir === -1 ? <ChevronDown className="w-3 h-3"/> : <ChevronUp className="w-3 h-3"/>)}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
const TYPE_OPTS = [
|
||
{ value:'all', label:'Tous' },
|
||
{ value:'input_event', label:'Events' },
|
||
{ value:'input_manual', label:'Manuels' },
|
||
{ value:'intermediate', label:'Interméd.' },
|
||
{ value:'output', label:'Output' },
|
||
]
|
||
|
||
return (
|
||
<div>
|
||
<div className="flex flex-wrap gap-2 mb-3">
|
||
{TYPE_OPTS.map(o => (
|
||
<button key={o.value} onClick={() => setFilterType(o.value)}
|
||
className={clsx('px-2.5 py-1 rounded text-xs font-medium transition-colors',
|
||
filterType === o.value ? 'bg-blue-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
<select value={filterCat} onChange={e => setFilterCat(e.target.value)}
|
||
className="bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-slate-400 focus:outline-none ml-2">
|
||
{categories.map(c => <option key={c} value={c}>{c === 'all' ? 'Toutes catégories' : c}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
<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 w-8">T</th>
|
||
<th className="px-3 py-2 text-left"><SortBtn k="label" label="Nœud"/></th>
|
||
<th className="px-3 py-2 text-left hidden sm:table-cell"><SortBtn k="category" label="Catégorie"/></th>
|
||
<th className="px-3 py-2 text-right"><SortBtn k="pip_contribution" label="Pips"/></th>
|
||
<th className="px-3 py-2 text-center"><SortBtn k="source" label="Source"/></th>
|
||
<th className="px-3 py-2 text-center w-24">Bar</th>
|
||
<th className="px-3 py-2 text-center w-8"/>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sorted.map(node => {
|
||
const meta = NODE_TYPE_META[node.node_type]
|
||
const v = node.pip_contribution
|
||
const isExp = expanded === node.id
|
||
const canEdit = node.node_type === 'input_event' || node.node_type === 'input_manual'
|
||
return [
|
||
<tr key={node.id}
|
||
className={clsx('border-b border-slate-700/20 hover:bg-dark-700/20 transition-colors cursor-pointer', isExp && 'bg-dark-700/10')}>
|
||
<td className="px-3 py-2">
|
||
<span className={clsx('font-bold text-xs', meta.color)}>
|
||
{node.node_type === 'input_event' ? 'EV' : node.node_type === 'input_manual' ? 'M' : node.node_type === 'intermediate' ? 'INT' : 'OUT'}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-2" onClick={() => setExpanded(isExp ? null : node.id)}>
|
||
<span className="text-slate-300 font-medium hover:text-white">
|
||
{node.label}
|
||
{node.source === 'manual' && <span className="ml-1 text-violet-400">●</span>}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-2 text-slate-500 hidden sm:table-cell">{node.category}</td>
|
||
<td className={clsx('px-3 py-2 text-right font-mono font-bold', pipColor(v))}>{fmt(v)}</td>
|
||
<td className="px-3 py-2 text-center">
|
||
<span className="flex items-center justify-center gap-1">
|
||
<span className={clsx('w-1.5 h-1.5 rounded-full', SOURCE_META[node.source]?.dot || 'bg-slate-600')}/>
|
||
<span className="text-slate-500">{SOURCE_META[node.source]?.label}</span>
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<div className="h-1.5 rounded-full bg-slate-700/40 overflow-hidden w-full">
|
||
<div className={clsx('h-full rounded-full', pipBg(v))}
|
||
style={{width: `${Math.min(100, Math.abs(v / maxAbs) * 100)}%`}}/>
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-2 text-center">
|
||
{canEdit && (
|
||
<button onClick={() => onEdit(node)} className="text-slate-600 hover:text-blue-400 transition-colors">
|
||
<Edit3 className="w-3.5 h-3.5"/>
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>,
|
||
isExp && (
|
||
<tr key={`${node.id}-exp`} className="bg-dark-800/40 border-b border-slate-700/10">
|
||
<td/>
|
||
<td colSpan={6} className="px-3 py-2 text-xs text-slate-400 space-y-1">
|
||
<div>{node.description}</div>
|
||
{node.node_type === 'input_manual' && node.coefficient_to_pips !== undefined && (
|
||
<div className="text-slate-500">
|
||
Coeff : {node.coefficient_to_pips} pips/{node.unit}
|
||
{node.raw_value !== undefined ? ` · ${node.raw_value} ${node.unit}` : ''}
|
||
{node.pip_linear !== undefined && node.pip_saturated !== undefined && (
|
||
<span>
|
||
{' '}→ linéaire <span className="text-slate-400">{fmt(node.pip_linear)}</span>
|
||
{' '}/ saturé <span className={pipColor(node.pip_saturated)}>{fmt(node.pip_saturated)}</span>
|
||
{node.saturation_pct !== undefined && node.saturation_pct > 1 && (
|
||
<span className="text-amber-500/70"> (sat. {node.saturation_pct.toFixed(0)}%)</span>
|
||
)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{node.node_type === 'intermediate' && node.formula && (
|
||
<div className="text-slate-500 font-mono text-xs">{node.formula}</div>
|
||
)}
|
||
{node.override_note && <div className="text-violet-400 italic">Note : {node.override_note}</div>}
|
||
{node.override_set_at && <div className="text-slate-600">Modifié : {node.override_set_at.slice(0,10)}</div>}
|
||
</td>
|
||
</tr>
|
||
),
|
||
].filter(Boolean)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
{sorted.length === 0 && (
|
||
<div className="text-center py-6 text-slate-500 text-sm">Aucun nœud pour ce filtre</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Timeline Chart ─────────────────────────────────────────────────────────────
|
||
|
||
const REGIME_CANVAS_COLORS: Record<string, string> = {
|
||
MONETARY_DOMINANCE: 'rgba(59,130,246,0.07)',
|
||
GEOPOLITICAL_RISK: 'rgba(249,115,22,0.07)',
|
||
CREDIT_STRESS: 'rgba(239,68,68,0.07)',
|
||
GROWTH_SCARE: 'rgba(245,158,11,0.07)',
|
||
COMMODITY_SHOCK: 'rgba(234,179,8,0.07)',
|
||
BALANCED: 'rgba(0,0,0,0)',
|
||
}
|
||
|
||
function newVirtualEvent(): VirtualEventForm {
|
||
return {
|
||
id: Math.random().toString(36).slice(2),
|
||
date: new Date().toISOString().slice(0, 10),
|
||
category: 'central_bank',
|
||
pips: 50,
|
||
label: 'Event virtuel',
|
||
absorption_days: 14,
|
||
rise_days: 1,
|
||
plateau_days: 0,
|
||
decay_type: 'exp',
|
||
}
|
||
}
|
||
|
||
function TimelineView({ instrument }: { instrument: string }) {
|
||
const [period, setPeriod] = useState<Period>('3mo')
|
||
const [data, setData] = useState<TimelinePoint[]>([])
|
||
const [realPrices, setRealPrices] = useState<PricePoint[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [showReal, setShowReal] = useState(true)
|
||
const [showFund, setShowFund] = useState(true)
|
||
const [showPips, setShowPips] = useState(false) // toggle: price vs pips
|
||
const [layerKeys, setLayerKeys] = useState<string[]>([])
|
||
const [shownLayers, setShownLayers] = useState<Set<string>>(new Set())
|
||
const [showVirtual, setShowVirtual] = useState(false)
|
||
const [virtuals, setVirtuals] = useState<VirtualEventForm[]>([])
|
||
const [whatifLoading, setWhatifLoading] = useState(false)
|
||
const [whatifData, setWhatifData] = useState<TimelinePoint[] | null>(null)
|
||
const [importingCal, setImportingCal] = useState(false)
|
||
const [calMsg, setCalMsg] = useState<string | null>(null)
|
||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||
|
||
const activeData = whatifData ?? data
|
||
|
||
// Load timeline
|
||
useEffect(() => {
|
||
setLoading(true)
|
||
setWhatifData(null)
|
||
api.get<TimelinePoint[]>(`/instrument-models/${instrument}/timeline?period=${period}`)
|
||
.then(r => {
|
||
const pts = r.data
|
||
setData(pts)
|
||
if (pts.length > 0) {
|
||
const keys = Object.keys(pts[0].nodes).filter(k => k.startsWith('layer_'))
|
||
setLayerKeys(keys)
|
||
}
|
||
})
|
||
.catch(() => setData([]))
|
||
.finally(() => setLoading(false))
|
||
}, [instrument, period])
|
||
|
||
// Load real price history
|
||
useEffect(() => {
|
||
api.get<{ prices: PricePoint[] }>(`/instrument-models/${instrument}/price-history?period=${period}`)
|
||
.then(r => setRealPrices(r.data.prices || []))
|
||
.catch(() => setRealPrices([]))
|
||
}, [instrument, period])
|
||
|
||
// Draw canvas
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current
|
||
if (!canvas || activeData.length === 0) return
|
||
const ctx = canvas.getContext('2d')
|
||
if (!ctx) return
|
||
|
||
const W = canvas.width = canvas.offsetWidth
|
||
const H = canvas.height = 300
|
||
ctx.clearRect(0, 0, W, H)
|
||
|
||
// Build date→index map for real prices alignment
|
||
const dateToIdx = new Map<string, number>()
|
||
activeData.forEach((pt, i) => dateToIdx.set(pt.date, i))
|
||
|
||
// Value extractor depending on mode
|
||
const getV = (pt: TimelinePoint) => showPips ? pt.net_pips : pt.synthetic_price
|
||
const getFundV = (pt: TimelinePoint) => showPips ? pt.structural_pips : pt.fundamental_level
|
||
|
||
let minV = Infinity, maxV = -Infinity
|
||
for (const pt of activeData) {
|
||
const v = getV(pt)
|
||
if (v < minV) minV = v
|
||
if (v > maxV) maxV = v
|
||
if (showFund) {
|
||
const f = getFundV(pt)
|
||
if (f < minV) minV = f
|
||
if (f > maxV) maxV = f
|
||
}
|
||
}
|
||
if (showReal && realPrices.length > 0 && !showPips) {
|
||
for (const p of realPrices) {
|
||
if (p.close < minV) minV = p.close
|
||
if (p.close > maxV) maxV = p.close
|
||
}
|
||
}
|
||
for (const k of shownLayers) {
|
||
for (const pt of activeData) {
|
||
const v = pt.nodes[k] ?? 0
|
||
if (v < minV) minV = v
|
||
if (v > maxV) maxV = v
|
||
}
|
||
}
|
||
|
||
const pad = Math.max(showPips ? 5 : 0.001, 0.08 * (maxV - minV))
|
||
minV -= pad; maxV += pad
|
||
|
||
const mL = 64, mR = 16, mT = 12, mB = 28
|
||
const cW = W - mL - mR, cH = H - mT - mB
|
||
const toX = (i: number) => mL + (i / Math.max(activeData.length - 1, 1)) * cW
|
||
const toY = (v: number) => mT + (1 - (v - minV) / (maxV - minV)) * cH
|
||
|
||
// Regime bands
|
||
let bandStart = 0, bandRegime = activeData[0]?.regime || 'BALANCED'
|
||
for (let i = 1; i <= activeData.length; i++) {
|
||
const r = i < activeData.length ? (activeData[i]?.regime || 'BALANCED') : null
|
||
if (r !== bandRegime || i === activeData.length) {
|
||
const color = REGIME_CANVAS_COLORS[bandRegime]
|
||
if (color && color !== 'rgba(0,0,0,0)') {
|
||
ctx.fillStyle = color
|
||
ctx.fillRect(toX(bandStart), mT, toX(Math.max(i - 1, bandStart)) - toX(bandStart) + 1, cH)
|
||
}
|
||
bandStart = i; bandRegime = r || 'BALANCED'
|
||
}
|
||
}
|
||
|
||
// Grid
|
||
const nG = 5
|
||
for (let i = 0; i <= nG; i++) {
|
||
const v = minV + (i / nG) * (maxV - minV)
|
||
const y = toY(v)
|
||
ctx.strokeStyle = '#1e293b'; ctx.lineWidth = 1
|
||
ctx.beginPath(); ctx.moveTo(mL, y); ctx.lineTo(W - mR, y); ctx.stroke()
|
||
ctx.fillStyle = '#64748b'; ctx.font = '10px sans-serif'; ctx.textAlign = 'right'
|
||
const lbl = showPips ? fmt(v) : v.toFixed(v > 100 ? 1 : 4)
|
||
ctx.fillText(lbl, mL - 4, y + 3.5)
|
||
}
|
||
|
||
if (showPips && minV < 0 && maxV > 0) {
|
||
const y0 = toY(0)
|
||
ctx.strokeStyle = '#334155'; ctx.setLineDash([3,3]); ctx.lineWidth = 1
|
||
ctx.beginPath(); ctx.moveTo(mL, y0); ctx.lineTo(W - mR, y0); ctx.stroke()
|
||
ctx.setLineDash([])
|
||
}
|
||
|
||
// X labels
|
||
const step = Math.max(1, Math.floor(activeData.length / Math.floor(cW / 60)))
|
||
ctx.fillStyle = '#64748b'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'
|
||
for (let i = 0; i < activeData.length; i += step) {
|
||
ctx.fillText(activeData[i].date.slice(5), toX(i), H - 4)
|
||
}
|
||
|
||
// Virtual event markers
|
||
if (virtuals.length > 0) {
|
||
ctx.fillStyle = '#f59e0b'
|
||
for (const ve of virtuals) {
|
||
const idx = dateToIdx.get(ve.date)
|
||
if (idx !== undefined) {
|
||
const x = toX(idx)
|
||
ctx.beginPath()
|
||
ctx.moveTo(x, mT + cH)
|
||
ctx.lineTo(x - 4, mT + cH + 6)
|
||
ctx.lineTo(x + 4, mT + cH + 6)
|
||
ctx.closePath()
|
||
ctx.fill()
|
||
}
|
||
}
|
||
}
|
||
|
||
// Real price line (light grey)
|
||
if (showReal && !showPips && realPrices.length > 0) {
|
||
ctx.strokeStyle = 'rgba(148,163,184,0.5)'; ctx.lineWidth = 1.5; ctx.setLineDash([])
|
||
ctx.beginPath()
|
||
let started = false
|
||
for (const p of realPrices) {
|
||
const idx = dateToIdx.get(p.date)
|
||
if (idx !== undefined) {
|
||
const x = toX(idx); const y = toY(p.close)
|
||
if (!started) { ctx.moveTo(x, y); started = true } else { ctx.lineTo(x, y) }
|
||
}
|
||
}
|
||
ctx.stroke()
|
||
}
|
||
|
||
// Fundamental level (dashed amber)
|
||
if (showFund) {
|
||
ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 1; ctx.setLineDash([5, 4])
|
||
ctx.beginPath()
|
||
activeData.forEach((pt, i) => {
|
||
const y = toY(getFundV(pt))
|
||
i === 0 ? ctx.moveTo(toX(i), y) : ctx.lineTo(toX(i), y)
|
||
})
|
||
ctx.stroke()
|
||
ctx.setLineDash([])
|
||
}
|
||
|
||
// Layer lines (dashed, thin)
|
||
for (const k of shownLayers) {
|
||
const color = CANVAS_COLORS[k] || '#94a3b8'
|
||
ctx.strokeStyle = color; ctx.lineWidth = 1.2; ctx.setLineDash([4, 3])
|
||
ctx.beginPath()
|
||
activeData.forEach((pt, i) => {
|
||
const v = showPips ? (pt.nodes[k] ?? 0) : (pt.synthetic_price + (pt.nodes[k] ?? 0) * (1 / Math.max(activeData.length, 1)))
|
||
i === 0 ? ctx.moveTo(toX(i), toY(showPips ? v : (pt.nodes[k] ?? 0))) : ctx.lineTo(toX(i), toY(showPips ? v : (pt.nodes[k] ?? 0)))
|
||
})
|
||
ctx.stroke()
|
||
ctx.setLineDash([])
|
||
}
|
||
|
||
// Synthetic price (main bold line — green/red)
|
||
const lastPt = activeData[activeData.length - 1]
|
||
const lastFirst = activeData[0]
|
||
const trending = getV(lastPt) > getV(lastFirst)
|
||
ctx.strokeStyle = trending ? '#10b981' : '#f87171'; ctx.lineWidth = 2.5; ctx.setLineDash([])
|
||
ctx.beginPath()
|
||
activeData.forEach((pt, i) => {
|
||
i === 0 ? ctx.moveTo(toX(i), toY(getV(pt))) : ctx.lineTo(toX(i), toY(getV(pt)))
|
||
})
|
||
ctx.stroke()
|
||
|
||
// What-if overlay (if exists, draw original in grey too)
|
||
if (whatifData) {
|
||
ctx.strokeStyle = '#6366f1'; ctx.lineWidth = 2; ctx.setLineDash([6, 3])
|
||
ctx.beginPath()
|
||
whatifData.forEach((pt, i) => {
|
||
i === 0 ? ctx.moveTo(toX(i), toY(getV(pt))) : ctx.lineTo(toX(i), toY(getV(pt)))
|
||
})
|
||
ctx.stroke()
|
||
ctx.setLineDash([])
|
||
}
|
||
|
||
}, [activeData, realPrices, showReal, showFund, showPips, shownLayers, virtuals, whatifData])
|
||
|
||
async function runWhatIf() {
|
||
if (virtuals.length === 0) return
|
||
setWhatifLoading(true)
|
||
try {
|
||
const r = await api.post<TimelinePoint[]>(`/instrument-models/${instrument}/timeline-whatif`, {
|
||
period,
|
||
virtual_events: virtuals.map(ve => ({
|
||
date: ve.date,
|
||
category: ve.category,
|
||
pips: ve.pips,
|
||
label: ve.label,
|
||
absorption_days: ve.absorption_days,
|
||
rise_days: ve.rise_days,
|
||
plateau_days: ve.plateau_days,
|
||
decay_type: ve.decay_type,
|
||
})),
|
||
})
|
||
setWhatifData(r.data)
|
||
} finally { setWhatifLoading(false) }
|
||
}
|
||
|
||
function toggleLayer(k: string) {
|
||
setShownLayers(prev => { const n = new Set(prev); n.has(k) ? n.delete(k) : n.add(k); return n })
|
||
}
|
||
|
||
if (loading) return <div className="flex items-center justify-center h-64 text-slate-500 text-sm">Calcul timeline…</div>
|
||
if (!loading && data.length === 0) return <div className="text-center py-8 text-slate-500 text-sm">Aucune donnée pour {instrument}</div>
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{/* Controls */}
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{PERIODS.map(p => (
|
||
<button key={p} onClick={() => setPeriod(p)}
|
||
className={clsx('px-2 py-1 rounded text-xs font-medium transition-colors',
|
||
period === p ? 'bg-blue-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}>
|
||
{p}
|
||
</button>
|
||
))}
|
||
<div className="ml-2 flex gap-1.5">
|
||
<button onClick={() => setShowPips(v => !v)}
|
||
className={clsx('px-2 py-1 rounded text-xs border transition-all',
|
||
showPips ? 'border-blue-600/50 text-blue-400 bg-blue-900/20' : 'border-slate-700/40 text-slate-400')}>
|
||
{showPips ? 'Mode pips' : 'Mode prix'}
|
||
</button>
|
||
<button onClick={() => setShowReal(v => !v)}
|
||
className={clsx('flex items-center gap-1 px-2 py-1 rounded text-xs border transition-all',
|
||
showReal ? 'border-slate-600/50 text-slate-300' : 'border-slate-700/30 text-slate-600')}>
|
||
<span className="w-2 h-0.5 bg-slate-400 inline-block"/> Réel
|
||
</button>
|
||
<button onClick={() => setShowFund(v => !v)}
|
||
className={clsx('flex items-center gap-1 px-2 py-1 rounded text-xs border transition-all',
|
||
showFund ? 'border-amber-600/50 text-amber-400' : 'border-slate-700/30 text-slate-600')}>
|
||
<span className="w-2 h-0.5 bg-amber-400 inline-block" style={{borderTop:'1px dashed #f59e0b'}}/> Fondamental
|
||
</button>
|
||
<button onClick={() => setShowVirtual(v => !v)}
|
||
className={clsx('flex items-center gap-1.5 px-2 py-1 rounded text-xs border transition-all',
|
||
showVirtual ? 'border-violet-600/50 text-violet-400 bg-violet-900/20' : 'border-slate-700/30 text-slate-500')}>
|
||
<Zap className="w-3 h-3"/> What-if {virtuals.length > 0 && `(${virtuals.length})`}
|
||
</button>
|
||
</div>
|
||
<span className="ml-auto text-xs text-slate-500">{activeData.length} jours</span>
|
||
</div>
|
||
|
||
{/* Layer toggles */}
|
||
{layerKeys.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{layerKeys.map(k => (
|
||
<button key={k} onClick={() => toggleLayer(k)}
|
||
className={clsx('flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium border transition-all',
|
||
shownLayers.has(k) ? 'border-slate-600/50 text-slate-300 bg-slate-800/40' : 'border-slate-700/30 text-slate-600')}>
|
||
<span className={clsx('w-2 h-2 rounded-full', LAYER_COLORS[k] || 'bg-slate-500')}/>
|
||
{k.replace('layer_', '')}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Canvas */}
|
||
<div className="rounded-lg bg-dark-900/50 border border-slate-700/30 p-2">
|
||
<canvas ref={canvasRef} className="w-full" style={{height: 300, display: 'block'}}/>
|
||
{/* Legend */}
|
||
<div className="flex flex-wrap gap-3 mt-2 px-1">
|
||
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
||
<span className="w-4 h-0.5 bg-emerald-500 inline-block"/> Prix synthétique
|
||
</div>
|
||
{showReal && (
|
||
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
||
<span className="w-4 h-0.5 bg-slate-400 inline-block opacity-50"/> Prix réel
|
||
</div>
|
||
)}
|
||
{showFund && (
|
||
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
||
<span className="w-4 h-0.5 border-t border-dashed border-amber-500 inline-block"/> Fondamental
|
||
</div>
|
||
)}
|
||
{whatifData && (
|
||
<div className="flex items-center gap-1.5 text-xs text-violet-400">
|
||
<span className="w-4 h-0.5 border-t border-dashed border-violet-500 inline-block"/> What-if
|
||
<button onClick={() => setWhatifData(null)} className="ml-1 text-slate-600 hover:text-slate-400">✕</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Virtual Events Panel */}
|
||
{showVirtual && (
|
||
<div className="rounded-lg border border-violet-700/30 bg-violet-900/10 p-3 space-y-3">
|
||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||
<div className="text-xs font-semibold text-violet-400">Events virtuels (What-if)</div>
|
||
<div className="flex gap-1.5">
|
||
<button
|
||
disabled={importingCal}
|
||
onClick={async () => {
|
||
setImportingCal(true)
|
||
try {
|
||
const r = await api.get<CalendarEvent[]>(
|
||
`/instrument-models/${instrument}/calendar-events?days_back=365&days_forward=90`
|
||
)
|
||
const imported: VirtualEventForm[] = r.data.map(ev => ({
|
||
id: ev.analysis_id.toString(),
|
||
date: ev.start_date,
|
||
category: ev.category,
|
||
pips: ev.pip_prediction,
|
||
label: ev.title || ev.category,
|
||
absorption_days: ev.calibration.absorption_days,
|
||
rise_days: ev.calibration.rise_days,
|
||
plateau_days: ev.calibration.plateau_days,
|
||
decay_type: ev.calibration.decay_type,
|
||
}))
|
||
if (imported.length === 0) {
|
||
setCalMsg('Aucun event analysé pour cet instrument — utilisez CausalLab d\'abord')
|
||
} else {
|
||
setVirtuals(prev => {
|
||
const existingIds = new Set(prev.map(v => v.id))
|
||
const added = imported.filter(x => !existingIds.has(x.id))
|
||
setCalMsg(`${added.length} event${added.length > 1 ? 's' : ''} importé${added.length > 1 ? 's' : ''}`)
|
||
return [...prev, ...added]
|
||
})
|
||
}
|
||
setTimeout(() => setCalMsg(null), 3500)
|
||
} finally { setImportingCal(false) }
|
||
}}
|
||
className="flex items-center gap-1 text-xs text-sky-400 hover:text-sky-300 border border-sky-700/40 rounded px-2 py-0.5 transition-colors disabled:opacity-40">
|
||
<Activity className="w-3 h-3"/> {importingCal ? '…' : 'Import calendrier'}
|
||
</button>
|
||
<button onClick={() => setVirtuals(v => [...v, newVirtualEvent()])}
|
||
className="flex items-center gap-1 text-xs text-violet-400 hover:text-violet-300 border border-violet-700/40 rounded px-2 py-0.5 transition-colors">
|
||
<Plus className="w-3 h-3"/> Ajouter
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{calMsg && (
|
||
<div className={clsx('text-xs px-2 py-1 rounded', calMsg.startsWith('Aucun') ? 'text-amber-400 bg-amber-900/20' : 'text-emerald-400 bg-emerald-900/20')}>
|
||
{calMsg}
|
||
</div>
|
||
)}
|
||
{virtuals.length === 0 && !calMsg && (
|
||
<div className="text-xs text-slate-500 text-center py-2">
|
||
Ajoute des events hypothétiques pour simuler leur impact sur la courbe synthétique.
|
||
</div>
|
||
)}
|
||
{virtuals.map((ve, idx) => (
|
||
<div key={ve.id} className="grid grid-cols-2 md:grid-cols-4 gap-2 items-end bg-dark-800/40 rounded p-2">
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-0.5">Date</label>
|
||
<input type="date" value={ve.date}
|
||
onChange={e => setVirtuals(v => v.map((x, i) => i === idx ? {...x, date: e.target.value} : x))}
|
||
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-0.5">Catégorie</label>
|
||
<select value={ve.category}
|
||
onChange={e => setVirtuals(v => v.map((x, i) => i === idx ? {...x, category: e.target.value} : x))}
|
||
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none">
|
||
{EVENT_CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-0.5">Magnitude (pips)</label>
|
||
<input type="number" step="any" value={ve.pips}
|
||
onChange={e => setVirtuals(v => v.map((x, i) => i === idx ? {...x, pips: parseFloat(e.target.value) || 0} : x))}
|
||
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-0.5">Absorption (j)</label>
|
||
<div className="flex gap-1">
|
||
<input type="number" value={ve.absorption_days}
|
||
onChange={e => setVirtuals(v => v.map((x, i) => i === idx ? {...x, absorption_days: parseInt(e.target.value)||14} : x))}
|
||
className="flex-1 bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/>
|
||
<button onClick={() => setVirtuals(v => v.filter((_, i) => i !== idx))}
|
||
className="text-red-500/60 hover:text-red-400 px-1.5 transition-colors">
|
||
<X className="w-3 h-3"/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="col-span-2 md:col-span-4">
|
||
<input type="text" value={ve.label} placeholder="Label de l'event"
|
||
onChange={e => setVirtuals(v => v.map((x, i) => i === idx ? {...x, label: e.target.value} : x))}
|
||
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{virtuals.length > 0 && (
|
||
<div className="flex gap-2 justify-end">
|
||
<button onClick={() => { setVirtuals([]); setWhatifData(null) }}
|
||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors">
|
||
Effacer tout
|
||
</button>
|
||
<button onClick={runWhatIf} disabled={whatifLoading}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 bg-violet-600 hover:bg-violet-500 disabled:opacity-40 text-white text-xs rounded font-medium transition-colors">
|
||
<Zap className="w-3 h-3"/>
|
||
{whatifLoading ? 'Simulation…' : 'Simuler'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Gauge Sync Panel ──────────────────────────────────────────────────────────
|
||
|
||
interface GaugeSuggestion {
|
||
node_id: string
|
||
label: string
|
||
value: number
|
||
unit: string
|
||
source: string
|
||
confidence: 'HIGH' | 'MEDIUM' | 'LOW'
|
||
note: string
|
||
}
|
||
|
||
interface GaugeSuggestionsResponse {
|
||
instrument: string
|
||
gauge_date: string
|
||
n_suggestions: number
|
||
suggestions: GaugeSuggestion[]
|
||
gauges_available: string[]
|
||
}
|
||
|
||
const CONF_META: Record<string, { color: string; label: string }> = {
|
||
HIGH: { color: 'text-emerald-400 bg-emerald-900/20 border-emerald-700/40', label: 'Direct' },
|
||
MEDIUM: { color: 'text-amber-400 bg-amber-900/20 border-amber-700/40', label: 'Dérivé' },
|
||
LOW: { color: 'text-slate-400 bg-slate-800/40 border-slate-700/30', label: 'Proxy' },
|
||
}
|
||
|
||
function SyncPanel({ instrument, onClose, onApplied }: {
|
||
instrument: string; onClose: () => void; onApplied: (atDate?: string) => void
|
||
}) {
|
||
const [data, setData] = useState<GaugeSuggestionsResponse | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||
const [applying, setApplying] = useState(false)
|
||
const [atDate, setAtDate] = useState<string>('')
|
||
|
||
function fetchSuggestions(date?: string) {
|
||
setLoading(true)
|
||
const qs = date ? `?at_date=${date}` : ''
|
||
api.get<GaugeSuggestionsResponse>(`/instrument-models/${instrument}/gauge-suggestions${qs}`)
|
||
.then(r => {
|
||
setData(r.data)
|
||
const highs = new Set(r.data.suggestions.filter(s => s.confidence === 'HIGH').map(s => s.node_id))
|
||
setSelected(highs)
|
||
})
|
||
.catch(() => setData(null))
|
||
.finally(() => setLoading(false))
|
||
}
|
||
|
||
useEffect(() => { fetchSuggestions() }, [instrument])
|
||
|
||
function toggleAll(conf: string) {
|
||
if (!data) return
|
||
const ids = data.suggestions.filter(s => s.confidence === conf).map(s => s.node_id)
|
||
const allSelected = ids.every(id => selected.has(id))
|
||
setSelected(prev => {
|
||
const n = new Set(prev)
|
||
ids.forEach(id => allSelected ? n.delete(id) : n.add(id))
|
||
return n
|
||
})
|
||
}
|
||
|
||
async function apply() {
|
||
if (!data) return
|
||
setApplying(true)
|
||
try {
|
||
const overrides = data.suggestions
|
||
.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)
|
||
onClose()
|
||
} finally { setApplying(false) }
|
||
}
|
||
|
||
const selectedCount = selected.size
|
||
|
||
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-2xl 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 className="flex-1">
|
||
<div className="text-white font-semibold">Sync Marché — {instrument}</div>
|
||
{data && (
|
||
<div className="text-xs text-slate-500 mt-0.5">
|
||
Snapshot: {data.gauge_date}
|
||
{(data as any).requested_date && (data as any).requested_date !== data.gauge_date && (
|
||
<span className="text-amber-500/70 ml-1">(demandé: {(data as any).requested_date})</span>
|
||
)}
|
||
· {data.gauges_available.length} indicateurs
|
||
</div>
|
||
)}
|
||
{/* Date picker to initialize at a historical date */}
|
||
<div className="flex items-center gap-2 mt-2">
|
||
<label className="text-xs text-slate-500">Date d'init :</label>
|
||
<input
|
||
type="date"
|
||
value={atDate}
|
||
onChange={e => setAtDate(e.target.value)}
|
||
className="bg-dark-700 border border-slate-700/40 rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-blue-500/60"
|
||
/>
|
||
<button
|
||
onClick={() => fetchSuggestions(atDate || undefined)}
|
||
disabled={loading}
|
||
className="px-2 py-0.5 bg-blue-600/20 border border-blue-700/40 rounded text-xs text-blue-400 hover:bg-blue-600/30 transition-colors disabled:opacity-40">
|
||
{loading ? '…' : 'Charger'}
|
||
</button>
|
||
{atDate && (
|
||
<button onClick={() => { setAtDate(''); fetchSuggestions() }}
|
||
className="text-xs text-slate-600 hover:text-slate-400">✕ Réinitialiser</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button onClick={onClose} className="text-slate-500 hover:text-white ml-4">
|
||
<X className="w-4 h-4"/>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto p-4">
|
||
{loading && (
|
||
<div className="text-center py-8 text-slate-500 text-sm">Chargement des suggestions…</div>
|
||
)}
|
||
{!loading && !data && (
|
||
<div className="text-center py-8 text-red-400 text-sm">
|
||
Aucun snapshot de gauges disponible. Actualiser les données marché d'abord.
|
||
</div>
|
||
)}
|
||
{data && (
|
||
<div className="space-y-3">
|
||
{/* Confidence group filters */}
|
||
<div className="flex gap-2 flex-wrap">
|
||
{(['HIGH','MEDIUM','LOW'] as const).map(conf => {
|
||
const items = data.suggestions.filter(s => s.confidence === conf)
|
||
const meta = CONF_META[conf]
|
||
return items.length > 0 ? (
|
||
<button key={conf} onClick={() => toggleAll(conf)}
|
||
className={clsx('flex items-center gap-1.5 px-2.5 py-1 rounded border text-xs font-medium transition-colors', meta.color)}>
|
||
<span>{items.every(s => selected.has(s.node_id)) ? '☑' : '☐'}</span>
|
||
{meta.label} ({items.length})
|
||
</button>
|
||
) : null
|
||
})}
|
||
<span className="ml-auto text-xs text-slate-500 self-center">
|
||
{selectedCount} sélectionné{selectedCount > 1 ? 's' : ''}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Suggestion 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 w-8"/>
|
||
<th className="px-3 py-2 text-left">Variable</th>
|
||
<th className="px-3 py-2 text-right">Valeur</th>
|
||
<th className="px-3 py-2 text-center w-16">Conf.</th>
|
||
<th className="px-3 py-2 text-left text-slate-600">Détail</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.suggestions.map(s => {
|
||
const isSel = selected.has(s.node_id)
|
||
const meta = CONF_META[s.confidence]
|
||
return (
|
||
<tr key={s.node_id}
|
||
onClick={() => setSelected(prev => { const n = new Set(prev); isSel ? n.delete(s.node_id) : n.add(s.node_id); return n })}
|
||
className={clsx('border-b border-slate-700/20 cursor-pointer transition-colors',
|
||
isSel ? 'bg-blue-900/10' : 'hover:bg-dark-700/20')}>
|
||
<td className="px-3 py-2 text-center">
|
||
<div className={clsx('w-3.5 h-3.5 rounded border mx-auto transition-colors',
|
||
isSel ? 'bg-blue-500 border-blue-400' : 'border-slate-600')}>
|
||
{isSel && <span className="text-white text-xs flex items-center justify-center h-full leading-none">✓</span>}
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-2 text-slate-300 font-medium">{s.label}</td>
|
||
<td className="px-3 py-2 text-right font-mono text-white">
|
||
{s.value > 0 ? '+' : ''}{s.value} <span className="text-slate-500">{s.unit}</span>
|
||
</td>
|
||
<td className="px-3 py-2 text-center">
|
||
<span className={clsx('px-1.5 py-0.5 rounded border text-xs', meta.color)}>
|
||
{meta.label}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-2 text-slate-500 truncate max-w-[200px]" title={s.note}>
|
||
{s.note}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Warning LOW confidence */}
|
||
{data.suggestions.some(s => s.confidence === 'LOW' && selected.has(s.node_id)) && (
|
||
<div className="flex items-start gap-2 px-3 py-2 rounded bg-amber-900/20 border border-amber-700/30 text-xs text-amber-400">
|
||
<span className="shrink-0">⚠</span>
|
||
Certaines valeurs sélectionnées sont des proxies (Conf. Proxy) — revérifier avec les données réelles.
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="flex gap-2 p-4 border-t border-slate-700/40">
|
||
<button onClick={apply} disabled={applying || selectedCount === 0 || !data}
|
||
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…' : `Importer ${selectedCount} valeur${selectedCount > 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
|
||
|
||
function CalibrationView({ instrument, eventDetails }: {
|
||
instrument: string
|
||
eventDetails: Record<string, EventDetail[]>
|
||
}) {
|
||
const [localCalib, setLocalCalib] = useState<Record<number, EventDetail['calibration']>>({})
|
||
const [saving, setSaving] = useState<Record<number, boolean>>({})
|
||
const [saved, setSaved] = useState<Record<number, boolean>>({})
|
||
|
||
const allEvents = Object.values(eventDetails).flat()
|
||
.sort((a, b) => b.lifecycle_factor - a.lifecycle_factor)
|
||
|
||
if (allEvents.length === 0) {
|
||
return (
|
||
<div className="text-center py-12 text-slate-500 text-sm">
|
||
Aucun event actif actuellement — les paramètres d'arêtes s'affichent ici quand des events sont en cours d'absorption.
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function getCalib(ev: EventDetail) {
|
||
return localCalib[ev.analysis_id] ?? ev.calibration
|
||
}
|
||
|
||
function setField(id: number, field: keyof EventDetail['calibration'], val: string | number) {
|
||
setLocalCalib(prev => ({
|
||
...prev,
|
||
[id]: { ...(prev[id] ?? allEvents.find(e => e.analysis_id === id)!.calibration), [field]: val },
|
||
}))
|
||
setSaved(prev => ({ ...prev, [id]: false }))
|
||
}
|
||
|
||
async function saveCalib(ev: EventDetail) {
|
||
const calib = getCalib(ev)
|
||
setSaving(prev => ({ ...prev, [ev.analysis_id]: true }))
|
||
try {
|
||
await api.put(`/instrument-models/${instrument}/events/${ev.analysis_id}/calibration`, calib)
|
||
setSaved(prev => ({ ...prev, [ev.analysis_id]: true }))
|
||
} finally {
|
||
setSaving(prev => ({ ...prev, [ev.analysis_id]: false }))
|
||
}
|
||
}
|
||
|
||
async function resetCalib(ev: EventDetail) {
|
||
await api.delete(`/instrument-models/${instrument}/events/${ev.analysis_id}/calibration`)
|
||
setLocalCalib(prev => { const n = {...prev}; delete n[ev.analysis_id]; return n })
|
||
setSaved(prev => ({ ...prev, [ev.analysis_id]: false }))
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
<div className="text-xs text-slate-500 mb-3">
|
||
Paramètres de decay (arêtes) des events actifs — modifiez le lifecycle par event indépendamment du template.
|
||
</div>
|
||
{allEvents.map(ev => {
|
||
const cal = getCalib(ev)
|
||
const lf = ev.lifecycle_factor
|
||
const isSaving = saving[ev.analysis_id]
|
||
const isDirty = !!localCalib[ev.analysis_id]
|
||
const wasSaved = saved[ev.analysis_id]
|
||
return (
|
||
<div key={ev.analysis_id}
|
||
className="rounded-lg border border-slate-700/30 bg-dark-800/40 p-3 space-y-2">
|
||
{/* Event header */}
|
||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<span className="text-xs text-slate-500 shrink-0">{ev.start_date}</span>
|
||
<span className="text-xs font-medium text-slate-300 truncate">{ev.title || ev.analysis_id}</span>
|
||
<span className="text-xs text-slate-600 bg-slate-800 rounded px-1.5 py-0.5 shrink-0">{ev.days_since}j</span>
|
||
</div>
|
||
<div className="flex items-center gap-3 shrink-0">
|
||
<span className={clsx('text-xs font-bold font-mono', pipColor(ev.pip_prediction))}>
|
||
{fmt(ev.pip_prediction)}p
|
||
</span>
|
||
<span className={clsx('text-xs font-mono', pipColor(ev.remaining_pips))}>
|
||
→{fmt(ev.remaining_pips)}p
|
||
</span>
|
||
{/* Lifecycle bar */}
|
||
<div className="flex items-center gap-1">
|
||
<div className="w-20 h-1.5 rounded-full bg-slate-700/60 overflow-hidden">
|
||
<div className={clsx('h-full rounded-full', lf > 0.5 ? 'bg-emerald-500' : lf > 0.2 ? 'bg-amber-500' : 'bg-red-500')}
|
||
style={{width:`${Math.round(lf*100)}%`}}/>
|
||
</div>
|
||
<span className="text-xs text-slate-500 font-mono">{Math.round(lf*100)}%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Calibration inputs */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-0.5">Absorption (j)</label>
|
||
<input type="number" min={1} max={365} value={cal.absorption_days}
|
||
onChange={e => setField(ev.analysis_id, 'absorption_days', parseInt(e.target.value)||1)}
|
||
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-blue-500/60"/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-0.5">Rise (j)</label>
|
||
<input type="number" min={0} max={30} value={cal.rise_days}
|
||
onChange={e => setField(ev.analysis_id, 'rise_days', parseInt(e.target.value)||0)}
|
||
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-blue-500/60"/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-0.5">Plateau (j)</label>
|
||
<input type="number" min={0} max={30} value={cal.plateau_days}
|
||
onChange={e => setField(ev.analysis_id, 'plateau_days', parseInt(e.target.value)||0)}
|
||
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-blue-500/60"/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-0.5">Decay type</label>
|
||
<select value={cal.decay_type}
|
||
onChange={e => setField(ev.analysis_id, 'decay_type', e.target.value)}
|
||
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none">
|
||
{DECAY_OPTIONS.map(d => <option key={d} value={d}>{d}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="flex items-center gap-2 justify-end">
|
||
{isDirty && !wasSaved && (
|
||
<button onClick={() => resetCalib(ev)}
|
||
className="text-xs text-slate-600 hover:text-slate-400 transition-colors">
|
||
↩ Réinitialiser template
|
||
</button>
|
||
)}
|
||
<button onClick={() => saveCalib(ev)} disabled={isSaving}
|
||
className={clsx('flex items-center gap-1 px-3 py-1 rounded text-xs font-medium transition-colors',
|
||
wasSaved ? 'bg-emerald-700/40 text-emerald-400 border border-emerald-700/40'
|
||
: isDirty ? 'bg-blue-600 hover:bg-blue-500 text-white'
|
||
: 'bg-dark-700 text-slate-500 border border-slate-700/30')}>
|
||
{isSaving ? 'Sauvegarde…' : wasSaved ? '✓ Sauvegardé' : 'Sauvegarder'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Main Page ──────────────────────────────────────────────────────────────────
|
||
|
||
type ViewMode = 'dag' | 'table' | 'timeline' | 'calibration'
|
||
|
||
export default function InstrumentModels() {
|
||
const [instrument, setInstrument] = useState('EURUSD')
|
||
const [view, setView] = useState<ViewMode>('dag')
|
||
const [state, setState] = useState<ModelState | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [editNode, setEditNode] = useState<ModelNode | null>(null)
|
||
const [refreshKey, setRefreshKey] = useState(0)
|
||
const [showSync, setShowSync] = useState(false)
|
||
|
||
const load = useCallback(() => {
|
||
setLoading(true)
|
||
api.get<ModelState>(`/instrument-models/${instrument}`)
|
||
.then(r => setState(r.data))
|
||
.catch(() => setState(null))
|
||
.finally(() => setLoading(false))
|
||
}, [instrument])
|
||
|
||
useEffect(() => { load() }, [load, refreshKey])
|
||
|
||
function onSaved() { setRefreshKey(k => k + 1) }
|
||
|
||
const layerSummary = useMemo(() => {
|
||
if (!state) return []
|
||
return state.nodes
|
||
.filter(n => n.node_type === 'intermediate')
|
||
.map(n => ({
|
||
...n,
|
||
pct: state.net_pips !== 0
|
||
? Math.round((n.pip_contribution / Math.abs(state.net_pips)) * 100)
|
||
: 0,
|
||
}))
|
||
.sort((a, b) => Math.abs(b.pip_contribution) - Math.abs(a.pip_contribution))
|
||
}, [state])
|
||
|
||
const VIEW_BTNS: { key: ViewMode; Icon: typeof Network; label: string }[] = [
|
||
{ key: 'dag', Icon: Network, label: 'DAG' },
|
||
{ key: 'table', Icon: Table2, label: 'Tableau' },
|
||
{ key: 'timeline', Icon: LineChart, label: 'Timeline' },
|
||
{ key: 'calibration', Icon: Activity, label: 'Calibration' },
|
||
]
|
||
|
||
return (
|
||
<div className="p-4 space-y-4">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div>
|
||
<h1 className="text-xl font-bold text-white">Modèles Instruments</h1>
|
||
<p className="text-sm text-slate-500 mt-0.5">
|
||
Graphes causaux exhaustifs — propagation DAG 3 couches par instrument
|
||
</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => setShowSync(true)}
|
||
className="flex items-center gap-2 px-3 py-2 bg-blue-600/20 border border-blue-700/40 rounded-lg text-blue-400 hover:bg-blue-600/30 text-sm transition-colors font-medium">
|
||
<Activity className="w-4 h-4"/> Sync Marché
|
||
</button>
|
||
<button onClick={() => setRefreshKey(k => k+1)} disabled={loading}
|
||
className="flex items-center gap-2 px-3 py-2 bg-dark-700 border border-slate-700/40 rounded-lg text-slate-400 hover:text-white text-sm transition-colors disabled:opacity-40">
|
||
<RefreshCw className={clsx('w-4 h-4', loading && 'animate-spin')}/> Actualiser
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Instrument tabs */}
|
||
<div className="flex gap-1.5 flex-wrap">
|
||
{INSTRUMENTS.map(inst => (
|
||
<button key={inst} onClick={() => setInstrument(inst)}
|
||
className={clsx('px-3 py-1.5 rounded-lg text-sm font-medium transition-all border',
|
||
instrument === inst
|
||
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/30'
|
||
: 'bg-dark-700 border-slate-700/40 text-slate-400 hover:text-white hover:border-slate-600')}>
|
||
{inst}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Synthetic Price Banner */}
|
||
{state && (
|
||
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3">
|
||
{/* Row 1 — synthetic price + direction + regime */}
|
||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||
<div>
|
||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">
|
||
{state.name} — Prix Synthétique
|
||
</div>
|
||
<div className="flex items-baseline gap-3">
|
||
<span className={clsx('text-3xl font-bold font-mono tracking-tight',
|
||
state.direction === 'bullish' ? 'text-emerald-400'
|
||
: state.direction === 'bearish' ? 'text-red-400'
|
||
: 'text-slate-300')}>
|
||
{fmtPrice(state.synthetic_price, state.pip_to_price)}
|
||
</span>
|
||
<DirectionBadge direction={state.direction} pips={state.net_pips}/>
|
||
</div>
|
||
{/* Structural vs event breakdown */}
|
||
<div className="flex gap-4 mt-2 text-xs">
|
||
<div>
|
||
<span className="text-slate-500">Fondamental </span>
|
||
<span className="text-amber-400 font-mono font-semibold">
|
||
{fmtPrice(state.fundamental_level, state.pip_to_price)}
|
||
</span>
|
||
<span className="text-slate-600 ml-1">({fmt(state.structural_pips)}p)</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-slate-500">Surprises </span>
|
||
<span className={clsx('font-mono font-semibold', pipColor(state.event_pips))}>
|
||
{fmt(state.event_pips)}p
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-slate-600">Intercept </span>
|
||
<span className="text-slate-500 font-mono">
|
||
{fmtPrice(state.price_intercept, state.pip_to_price)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Layer contributions */}
|
||
{layerSummary.length > 0 && (
|
||
<div className="flex gap-4 flex-wrap">
|
||
{layerSummary.map(l => (
|
||
<div key={l.id} className="text-center min-w-[72px]">
|
||
<div className="text-xs text-slate-500 leading-tight mb-0.5">
|
||
{l.label.replace('▶ ', '').split(' ').slice(0, 2).join(' ')}
|
||
</div>
|
||
<div className={clsx('text-sm font-bold font-mono', pipColor(l.pip_contribution))}>{fmt(l.pip_contribution)}</div>
|
||
<div className="text-xs text-slate-600">{l.pct > 0 ? '+' : ''}{l.pct}%</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-col gap-2 self-start">
|
||
{state.regime && <RegimeBadge regime={state.regime}/>}
|
||
<div className="text-xs text-slate-600 space-y-0.5">
|
||
<div>{state.nodes.filter(n => n.source === 'events').length} events actifs</div>
|
||
<div>{state.nodes.filter(n => n.source === 'manual').length} overrides manuels</div>
|
||
<div className="text-slate-700">{state.at_date}</div>
|
||
</div>
|
||
{state.nodes.some(n => n.source === 'manual') && (
|
||
<button
|
||
onClick={async () => {
|
||
if (!confirm('Effacer tous les overrides manuels ?')) return
|
||
await api.delete(`/instrument-models/${instrument}/overrides`)
|
||
setRefreshKey(k => k + 1)
|
||
}}
|
||
className="text-xs text-red-500/60 hover:text-red-400 transition-colors text-left">
|
||
✕ Reset toutes valeurs
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* View selector */}
|
||
{state && (
|
||
<div className="flex items-center gap-1.5 border-b border-slate-700/30 pb-3">
|
||
{VIEW_BTNS.map(({ key, Icon, label }) => (
|
||
<button key={key} onClick={() => setView(key)}
|
||
className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors',
|
||
view === key
|
||
? 'bg-blue-600/20 text-blue-400 border border-blue-700/40'
|
||
: 'text-slate-500 hover:text-white border border-transparent')}>
|
||
<Icon className="w-4 h-4"/> {label}
|
||
</button>
|
||
))}
|
||
<span className="ml-auto text-xs text-slate-600">
|
||
{state.nodes.filter(n=>n.node_type==='input_event').length} EV
|
||
· {state.nodes.filter(n=>n.node_type==='input_manual').length} M
|
||
· {state.nodes.filter(n=>n.node_type==='intermediate').length} INT
|
||
· {state.nodes.length} nœuds total
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Main content */}
|
||
{state && (
|
||
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
|
||
{view === 'dag' && <DagView nodes={state.nodes} instrument={instrument} onEdit={setEditNode} eventDetails={state.event_details || {}}/>}
|
||
{view === 'table' && <TableView nodes={state.nodes} onEdit={setEditNode}/>}
|
||
{view === 'timeline' && <TimelineView instrument={instrument}/>}
|
||
{view === 'calibration' && <CalibrationView instrument={instrument} eventDetails={state.event_details || {}}/>}
|
||
</div>
|
||
)}
|
||
|
||
{/* Quick stats */}
|
||
{state && view !== 'timeline' && (
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
{[
|
||
{ label: 'Prix synthétique', value: fmtPrice(state.synthetic_price, state.pip_to_price), sub: state.direction, color: state.direction === 'bullish' ? 'text-emerald-400' : state.direction === 'bearish' ? 'text-red-400' : 'text-white' },
|
||
{ label: 'Niveau fondamental',value: fmtPrice(state.fundamental_level, state.pip_to_price), sub: `struct: ${fmt(state.structural_pips)}p`, color: 'text-amber-400' },
|
||
{ label: 'Surprises events', value: `${fmt(state.event_pips)} pips`, sub: `sur ${state.nodes.filter(n=>n.source==='events').length} events actifs`, color: pipColor(state.event_pips) },
|
||
{ label: 'Variables manuelles',value: `${state.nodes.filter(n=>n.source==='manual').length} actives`, sub: `sur ${state.nodes.filter(n=>n.node_type==='input_manual').length} disponibles`, color: 'text-violet-400' },
|
||
].map(card => (
|
||
<div key={card.label} className="rounded-lg border border-slate-700/30 bg-dark-800/50 p-3">
|
||
<div className="text-xs text-slate-500 mb-1">{card.label}</div>
|
||
<div className={clsx('text-lg font-bold font-mono', card.color)}>{card.value}</div>
|
||
<div className="text-xs text-slate-600">{card.sub}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{!state && !loading && (
|
||
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-8 text-center text-slate-500">
|
||
Modèle introuvable pour {instrument}. Vérifier que le backend est démarré.
|
||
</div>
|
||
)}
|
||
|
||
{loading && !state && (
|
||
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-8 text-center text-slate-500">
|
||
Chargement du modèle…
|
||
</div>
|
||
)}
|
||
|
||
{editNode && (
|
||
<NodeEditModal
|
||
node={editNode} instrument={instrument}
|
||
onClose={() => setEditNode(null)} onSaved={onSaved}
|
||
/>
|
||
)}
|
||
|
||
{showSync && (
|
||
<SyncPanel
|
||
instrument={instrument}
|
||
onClose={() => setShowSync(false)}
|
||
onApplied={(atDate) => {
|
||
setShowSync(false)
|
||
setRefreshKey(k => k + 1)
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|