feat: causal lab

This commit is contained in:
OpenSquared
2026-06-28 18:08:09 +02:00
parent c286c7c000
commit 6ebbf4326e
5 changed files with 481 additions and 133 deletions

View File

@@ -24,6 +24,12 @@ interface ChartEvent {
description?: string
}
interface TheoPoint {
date: string
cumulative_pips: number
contributions: { template_name: string; event_name: string; event_date: string; pips: number; decay_factor: number }[]
}
interface Props {
priceData: PriceCandle[]
indicators: Record<string, LinePoint[]>
@@ -31,8 +37,11 @@ interface Props {
height?: number
chartType?: 'candles' | 'line'
onDateHover?: (date: string | null) => void
theoryCurve?: TheoPoint[]
}
export type { TheoPoint }
const MA_COLORS: Record<string, string> = {
ma20: '#f59e0b',
ma50: '#3b82f6',
@@ -58,7 +67,7 @@ const CAT_LABELS: Record<string, string> = {
technical: 'Tech.',
}
export default function InstrumentChart({ priceData, indicators, events = [], height = 420, chartType = 'candles', onDateHover }: Props) {
export default function InstrumentChart({ priceData, indicators, events = [], height = 420, chartType = 'candles', onDateHover, theoryCurve }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const cleanupRef = useRef<(() => void) | null>(null)
const onHoverRef = useRef(onDateHover)
@@ -173,6 +182,30 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
chart.addLineSeries(bbOpts).setData(indicators.bb_lower)
}
// ── Theoretical curve — left scale (pips) ─────────────────────────────
if (theoryCurve?.length) {
chart.priceScale('left').applyOptions({
visible: true,
borderColor: 'rgba(167,139,250,0.25)',
textColor: '#a78bfa',
scaleMargins: { top: 0.1, bottom: 0.1 },
})
const theorySeries = chart.addLineSeries({
color: 'rgba(167,139,250,0.75)',
lineWidth: 1.5,
priceScaleId: 'left',
title: 'Δ pips théorique',
priceLineVisible: false,
lastValueVisible: true,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 3,
})
const theoryData = theoryCurve
.filter(p => p.contributions.length > 0)
.map(p => ({ time: p.date as any, value: p.cumulative_pips }))
if (theoryData.length) theorySeries.setData(theoryData)
}
chart.timeScale().fitContent()
// ── Star overlay — ★ icons positioned via chart coordinate API ────────
@@ -293,7 +326,7 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
cleanupRef.current?.()
cleanupRef.current = null
}
}, [priceData, indicators, events, height, chartType])
}, [priceData, indicators, events, height, chartType, theoryCurve])
return (
<div className="bg-dark-900/60 rounded-xl border border-slate-700/40 overflow-hidden">
@@ -307,6 +340,12 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
<span className="flex items-center gap-1.5 opacity-40">
<span className="inline-block w-5 border-t border-dashed border-slate-400" />BB(20,2)
</span>
{theoryCurve?.some(p => p.contributions.length > 0) && (
<span className="flex items-center gap-1.5" style={{ color: '#a78bfa' }}>
<span className="inline-block w-5 h-0.5 rounded" style={{ background: '#a78bfa' }} />
Théorie (pips)
</span>
)}
<span className="ml-auto flex items-center gap-2.5">
{Object.entries(CAT_COLORS).map(([cat, c]) => (
<span key={cat} className="flex items-center gap-0.5" style={{ color: c }}>

View File

@@ -380,6 +380,8 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [savingLag, setSavingLag] = useState(false)
const [savingTheory, setSavingTheory] = useState(false)
const [genTheory, setGenTheory] = useState(false)
const [editCoefs, setEditCoefs] = useState<Record<string, number>>({})
const [editEdgesLag, setEdgesLag] = useState<CausalEdge[]>([])
const [loading, setLoading] = useState(true)
@@ -440,6 +442,30 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
} finally { setSavingLag(false) }
}
async function generateTheory() {
if (!selected) return
setGenTheory(true)
try {
await api(`/api/causal-lab/template/${selected.id}/generate-theory`, { method: 'POST' })
const fresh = await api(`/api/causal-lab/template/${selected.id}`)
setSelected(fresh)
} finally { setGenTheory(false) }
}
async function saveTheoryParams(updates: Record<string, unknown>) {
if (!selected) return
setSavingTheory(true)
try {
const newCalib = { ...(selected.calibration_json || {}), ...updates }
await api(`/api/causal-lab/template/${selected.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calibration_json: newCalib }),
})
const fresh = await api(`/api/causal-lab/template/${selected.id}`)
setSelected(fresh)
} finally { setSavingTheory(false) }
}
function updateEdgeLag(i: number, field: 'lag_min' | 'diffusion_min' | 'decay_days', val: string) {
setEdgesLag(prev => prev.map((e, idx) => idx !== i ? e : {
...e,
@@ -609,6 +635,82 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
</button>
</div>
)}
{/* Paramètres théoriques */}
<div className="bg-dark-700 rounded-lg p-4 border border-violet-800/30">
<div className="flex items-center gap-2 mb-3">
<h4 className="text-slate-300 text-xs font-semibold uppercase tracking-wider flex items-center gap-2 flex-1">
<span className="text-violet-400"></span> Paramètres théoriques
</h4>
<button
onClick={generateTheory}
disabled={genTheory}
className="px-3 py-1 bg-violet-700 hover:bg-violet-600 disabled:opacity-50 rounded text-xs font-medium text-white flex items-center gap-1.5"
>
{genTheory ? (
<><span className="animate-spin inline-block"></span> Génération</>
) : (
<><span></span> Générer IA</>
)}
</button>
</div>
{(() => {
const calib = selected.calibration_json || {}
const absorption = calib.absorption_days as number | undefined
const decay = calib.decay_type as string | undefined
const conf = calib.confidence as number | undefined
const rationale = calib.theory_rationale as string | undefined
const genAt = calib.theory_generated_at as string | undefined
return (
<div className="space-y-3">
{rationale && (
<p className="text-xs text-violet-300 italic border-l-2 border-violet-700/60 pl-2">{rationale}</p>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-slate-500 mb-1">Durée absorption (j)</div>
<input
type="number" min={1} max={60} step={1}
value={absorption ?? ''}
placeholder="—"
onChange={e => saveTheoryParams({ absorption_days: parseInt(e.target.value) || 7 })}
className="w-full bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 text-center"
/>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Type de décroissance</div>
<select
value={decay ?? 'exp'}
onChange={e => saveTheoryParams({ decay_type: e.target.value })}
className="w-full bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200"
>
<option value="step">step tout ou rien</option>
<option value="linear">linear déclin linéaire</option>
<option value="exp">exp déclin exponentiel</option>
</select>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
{conf !== undefined && (
<span className="flex items-center gap-1">
Confiance IA :
<span className={`font-mono font-semibold ${conf >= 0.7 ? 'text-emerald-400' : conf >= 0.4 ? 'text-yellow-400' : 'text-red-400'}`}>
{Math.round(conf * 100)}%
</span>
</span>
)}
{genAt && (
<span className="ml-auto opacity-60">
{new Date(genAt).toLocaleDateString('fr-FR')}
</span>
)}
{savingTheory && <span className="text-violet-400 animate-pulse">Sauvegarde</span>}
</div>
</div>
)
})()}
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-slate-600 text-sm">

View File

@@ -6,7 +6,7 @@ import {
} from 'lucide-react'
import axios from 'axios'
import clsx from 'clsx'
import InstrumentChart from '../components/InstrumentChart'
import InstrumentChart, { TheoPoint } from '../components/InstrumentChart'
const api = axios.create({ baseURL: '/api' })
@@ -665,110 +665,25 @@ function MacroGaugePanel({ snap, dateLabel }: { snap: MacroGaugeSnap; dateLabel:
)
}
// ── EventTimeline ─────────────────────────────────────────────────────────────
function EventTimeline({
events, priceData, selectedDate, templates, causalInsts,
}: {
events: SnapshotEvent[]
priceData: PriceCandle[]
selectedDate: string | null
templates: CausalTemplate[]
causalInsts: string[]
}) {
const navigate = useNavigate()
const containerRef = useRef<HTMLDivElement>(null)
const [contWidth, setContWidth] = useState(800)
useEffect(() => {
const el = containerRef.current; if (!el) return
const obs = new ResizeObserver(entries => setContWidth(entries[0].contentRect.width))
obs.observe(el)
return () => obs.disconnect()
}, [])
// Only events that have an instantiated analysis for this instrument
// Uses analyzed_instruments (actual DB analyses) — not template.instruments (theoretical)
const linked = events.filter(ev => {
if (!ev.analyzed_instruments) return false
const insts = ev.analyzed_instruments.split(',')
return causalInsts.some(ci => insts.includes(ci))
})
if (!priceData.length || !linked.length) {
return (
<div ref={containerRef} className="h-16 flex items-center justify-center text-xs text-slate-600 italic">
Aucun événement avec graphe causal pour cet instrument
</div>
)
}
const minDate = priceData[0].time
const maxDate = priceData[priceData.length - 1].time
const minTs = new Date(minDate).getTime()
const maxTs = new Date(maxDate).getTime()
const PAD = 24
const usable = Math.max(contWidth - PAD * 2, 1)
function tsToX(d: string): number {
return PAD + ((new Date(d).getTime() - minTs) / (maxTs - minTs)) * usable
}
const visible = linked.filter(ev => ev.date >= minDate && ev.date <= maxDate)
const HALF_W = 48
const ROW_H = 28
const MAX_ROWS = 5
const rowEnds: number[] = new Array(MAX_ROWS).fill(-Infinity)
const placed = visible.map(ev => {
const x = tsToX(ev.date)
let row = 0
for (let r = 0; r < MAX_ROWS; r++) {
if (rowEnds[r] <= x - HALF_W) { row = r; break }
row = r
// ── Shared: trading-day-index x-coordinate ────────────────────────────────────
// Uses the same time scale as LightweightCharts (trading days, weekends skipped).
// For a date D, snaps to the nearest available trading day index.
function makeTdToX(priceData: PriceCandle[], width: number): (d: string) => number {
const dates = priceData.map(c => c.time)
const N = dates.length
if (N < 2) return () => 0
function snap(d: string): number {
if (d <= dates[0]) return 0
if (d >= dates[N - 1]) return N - 1
let lo = 0, hi = N - 1
while (lo < hi) {
const mid = (lo + hi) >> 1
if (dates[mid] < d) lo = mid + 1
else hi = mid
}
rowEnds[row] = x + HALF_W
return { ev, x, row }
})
const maxRow = placed.length ? Math.max(...placed.map(p => p.row)) : 0
const containerH = (maxRow + 1) * ROW_H + 28
const crossX = selectedDate && selectedDate >= minDate && selectedDate <= maxDate
? tsToX(selectedDate) : null
return (
<div ref={containerRef} className="relative w-full overflow-hidden select-none" style={{ height: containerH }}>
<div className="absolute bottom-7 left-0 right-0 h-px bg-slate-700/40" />
{crossX !== null && (
<div className="absolute top-0 bottom-0 w-px bg-blue-400/40 pointer-events-none" style={{ left: crossX }} />
)}
{placed.map(({ ev, x, row }) => {
const active = isActiveAt(ev, selectedDate)
return (
<div
key={`${ev.date}-${ev.title}`}
onClick={() => ev.id && navigate(`/market-events?event=${ev.id}`)}
title={`${ev.title}\n${fmtDateFR(ev.date)}`}
className="absolute flex flex-col items-center cursor-pointer group"
style={{ left: x, top: row * ROW_H, transform: 'translateX(-50%)' }}
>
<span className={clsx('text-sm leading-none', active ? 'text-amber-400' : 'text-slate-600 group-hover:text-slate-400')}></span>
<span className={clsx(
'absolute top-full mt-0.5 text-[9px] whitespace-nowrap px-1 rounded bg-dark-800/90 border border-slate-700/40 opacity-0 group-hover:opacity-100 transition-opacity z-10 pointer-events-none',
active ? 'text-amber-300 border-amber-800/40' : 'text-slate-400'
)}>
{ev.title.length > 24 ? ev.title.slice(0, 24) + '…' : ev.title}
</span>
</div>
)
})}
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-6">
<span className="text-[10px] text-slate-700">{fmtDateFR(minDate)}</span>
<span className="text-[10px] text-slate-700">{fmtDateFR(maxDate)}</span>
</div>
</div>
)
return lo
}
return (d: string) => (snap(d) / (N - 1)) * width
}
// ── CausalFrise ───────────────────────────────────────────────────────────────
@@ -829,12 +744,8 @@ function CausalFrise({
const minDate = priceData[0].time
const maxDate = priceData[priceData.length - 1].time
const minTs = new Date(minDate).getTime()
const maxTs = new Date(maxDate).getTime()
const usable = Math.max(contWidth, 1)
const clampTs = (d: string) => Math.min(Math.max(new Date(d).getTime(), minTs), maxTs)
const dateToX = (d: string) => ((clampTs(d) - minTs) / (maxTs - minTs)) * usable
const tdToX = makeTdToX(priceData, usable)
// Build chips (one per event × template)
type Chip = {
@@ -850,8 +761,8 @@ function CausalFrise({
const d = new Date(ev.date); d.setDate(d.getDate() + 30)
return d.toISOString().slice(0, 10)
})()
const x1 = dateToX(ev.date)
const raw = dateToX(endDate) - x1
const x1 = tdToX(ev.date)
const raw = tdToX(endDate) - x1
const w = Math.max(raw, FRISE_MIN_W)
return { ev, tmpl, x1, x2: x1 + w, w, active: isActiveAt(ev, selectedDate) }
})
@@ -882,7 +793,7 @@ function CausalFrise({
month: 'short',
...(cur.getFullYear() !== start.getFullYear() ? { year: '2-digit' } : {}),
}),
x: dateToX(cur.toISOString().slice(0, 10)),
x: tdToX(cur.toISOString().slice(0, 10)),
})
cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1)
}
@@ -892,7 +803,7 @@ function CausalFrise({
const visTicks = ticks.filter((_, i) => i % tickStep === 0)
const crossX = selectedDate && selectedDate >= minDate && selectedDate <= maxDate
? dateToX(selectedDate) : null
? tdToX(selectedDate) : null
return (
<div
@@ -1090,6 +1001,9 @@ export default function InstrumentDashboard() {
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse'>('counters')
const [templates, setTemplates] = useState<CausalTemplate[]>([])
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
const [theoryCurve, setTheoryCurve] = useState<TheoPoint[] | null>(null)
const [loadingTheory, setLoadingTheory] = useState(false)
const [showTheory, setShowTheory] = useState(false)
const instrumentId = id.toUpperCase()
@@ -1104,6 +1018,8 @@ export default function InstrumentDashboard() {
setNarrative('')
setSelectedDate(null)
setMacroAtDate(null)
setTheoryCurve(null)
setShowTheory(false)
api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
.then(r => {
setSnapshot(r.data)
@@ -1126,6 +1042,20 @@ export default function InstrumentDashboard() {
if (date) setSelectedDate(date)
}, [])
const toggleTheory = useCallback(() => {
if (showTheory) {
setShowTheory(false)
setTheoryCurve(null)
return
}
if (theoryCurve) { setShowTheory(true); return }
setLoadingTheory(true)
api.get(`/instruments/${instrumentId}/theoretical-curve?period=${period}`)
.then(r => { setTheoryCurve(r.data); setShowTheory(true) })
.catch(() => {})
.finally(() => setLoadingTheory(false))
}, [showTheory, theoryCurve, instrumentId, period])
const { priceMap, indMap, sortedDates, dateIndex } = useMemo(() => {
if (!snapshot) return { priceMap: {} as Record<string, PriceCandle>, indMap: {} as Record<string, Record<string, number>>, sortedDates: [] as string[], dateIndex: {} as Record<string, number> }
const priceMap: Record<string, PriceCandle> = {}
@@ -1322,6 +1252,7 @@ export default function InstrumentDashboard() {
height={420}
chartType={chartStyle}
onDateHover={handleDateHover}
theoryCurve={showTheory && theoryCurve ? theoryCurve : undefined}
/>
{/* Date badge */}
@@ -1383,30 +1314,27 @@ export default function InstrumentDashboard() {
{tabUnder === 'analyse' && (() => {
const causalInsts = CAT_TO_CAUSAL_INST[selected?.category ?? ''] ?? []
const theoPt = effectiveDate && theoryCurve
? theoryCurve.find(p => p.date === effectiveDate) ?? null
: null
return (
<div className="space-y-3">
{/* Zone haute — frise des événements (seulement ceux avec graphe causal) */}
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
<div className="flex items-center gap-2 mb-3">
<Calendar className="w-4 h-4 text-amber-400" />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Frise des événements</span>
<span className="text-xs text-slate-600 ml-auto">survol = détail · clic = market event</span>
</div>
<EventTimeline
events={snapshot.events}
priceData={snapshot.price_data}
selectedDate={effectiveDate}
templates={templates}
causalInsts={causalInsts}
/>
</div>
{/* Zone basse — frise des graphes causaux */}
{/* Frise des graphes causaux (inclut l'event) */}
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
<div className="flex items-center gap-2 mb-3">
<BarChart2 className="w-4 h-4 text-violet-400" />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Frise des graphes</span>
<span className="text-xs text-slate-600 ml-auto">largeur durée · clic = détail</span>
<button
onClick={toggleTheory}
disabled={loadingTheory}
className={`ml-auto px-2.5 py-1 rounded text-xs font-medium border transition-colors ${
showTheory
? 'bg-violet-700/60 border-violet-600/60 text-violet-200'
: 'bg-dark-900/40 border-slate-600/40 text-slate-400 hover:border-violet-600/40 hover:text-violet-300'
}`}
>
{loadingTheory ? '↻ Chargement…' : showTheory ? '⟁ Théorie ON' : '⟁ Courbe théorique'}
</button>
</div>
<CausalFrise
events={snapshot.events}
@@ -1417,6 +1345,44 @@ export default function InstrumentDashboard() {
/>
</div>
{/* Décomposition théorique au curseur */}
{showTheory && theoPt && (
<div className="rounded-xl border border-violet-800/40 bg-violet-950/20 p-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-violet-400 text-xs"></span>
<span className="text-xs font-semibold text-violet-300 uppercase tracking-wide">
Contributions théoriques {dateLabel}
</span>
<span className={`ml-auto text-sm font-mono font-bold ${theoPt.cumulative_pips >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{theoPt.cumulative_pips >= 0 ? '+' : ''}{theoPt.cumulative_pips} pips
</span>
</div>
{theoPt.contributions.length === 0 ? (
<p className="text-xs text-slate-500 italic">Aucun événement actif à cette date.</p>
) : (
<div className="space-y-2">
{theoPt.contributions.map((c, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<div className="flex-1 min-w-0">
<span className="text-slate-300 font-medium truncate block">{c.event_name}</span>
<span className="text-slate-500">{c.template_name} · depuis {c.event_date} · {Math.round(c.decay_factor * 100)}% actif</span>
</div>
<span className={`font-mono font-semibold shrink-0 ${c.pips >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{c.pips >= 0 ? '+' : ''}{c.pips} pip
</span>
</div>
))}
</div>
)}
</div>
)}
{showTheory && !theoPt && theoryCurve && (
<div className="rounded-xl border border-violet-800/30 bg-violet-950/10 p-3 text-xs text-slate-500 text-center">
Aucune contribution théorique pour cette date
</div>
)}
{/* Note globale */}
<ExplanationScore
events={snapshot.events}