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

@@ -431,6 +431,8 @@ def patch_template(template_id: int, body: dict):
sets.append("instruments=?"); params.append(json.dumps(body["instruments"]))
if "graph_json" in body:
sets.append("graph_json=?"); params.append(json.dumps(body["graph_json"]))
if "calibration_json" in body:
sets.append("calibration_json=?"); params.append(json.dumps(body["calibration_json"]))
if not sets:
conn.close(); return {"ok": True}
@@ -1235,3 +1237,100 @@ def get_calibration():
except Exception as e:
logger.error(f"[causal_lab] calibration: {e}")
raise HTTPException(500, str(e))
@router.post("/api/causal-lab/template/{template_id}/generate-theory")
def generate_theory(template_id: int):
"""GPT-4o-mini génère absorption_days, decay_type et confidence pour le template parent."""
try:
from services.database import get_conn, get_config
from services.causal_graphs import get_template
conn = get_conn()
_init(conn)
tmpl = get_template(conn, template_id)
if not tmpl:
conn.close(); raise HTTPException(404, "Template introuvable")
stats = conn.execute("""
SELECT COUNT(*) as n, AVG(activation_score) as avg_act
FROM causal_event_analyses WHERE template_id = ?
""", (template_id,)).fetchone()
conn.close()
n_analyses = stats["n"] if stats else 0
avg_act = round((stats["avg_act"] or 0.0), 2) if stats else 0.0
graph = tmpl.get("graph_json", {})
coefs = {k: v.get("value") for k, v in graph.get("coefficients", {}).items()}
key = get_config("openai_api_key") or ""
if not key:
raise HTTPException(400, "Clé OpenAI manquante dans la configuration")
import openai
prompt = (
f'Tu es un expert en microstructure de marché et dynamique d\'absorption des chocs de prix.\n\n'
f'Template causal : "{tmpl["name"]}"\n'
f'Catégorie : {tmpl["category"]} / {tmpl.get("sub_type", "")}\n'
f'Description : {tmpl.get("description", "")}\n'
f'Instruments : {tmpl.get("instruments", [])}\n'
f'Coefficients : {json.dumps(coefs, ensure_ascii=False)}\n'
f'Analyses historiques : {n_analyses} (activation directionnelle moy. : {avg_act:.0%})\n\n'
f'Propose les paramètres d\'absorption de l\'impact de marché :\n'
f'- absorption_days : jours calendaires avant absorption à >90% (entier 1-60)\n'
f'- decay_type : "step" (tout-ou-rien), "linear" (déclin linéaire), "exp" (exponentiel)\n'
f'- confidence : confiance 0.0-1.0\n'
f'- rationale : justification courte (max 120 chars)\n\n'
f'Références : décisions taux→3-7j/exp ; CPI/NFP→2-5j/exp ; '
f'géopolitique→5-21j/linear ; PMI secondaire→1-2j/step\n\n'
f'JSON uniquement : {{"absorption_days": N, "decay_type": "...", "confidence": 0.X, "rationale": "..."}}'
)
client = openai.OpenAI(api_key=key)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0.2,
max_tokens=200,
)
raw = resp.choices[0].message.content or "{}"
params = json.loads(raw)
absorption_days = max(1, min(60, int(params.get("absorption_days", 7))))
decay_type = params.get("decay_type", "exp")
if decay_type not in ("step", "linear", "exp"):
decay_type = "exp"
confidence = round(max(0.0, min(1.0, float(params.get("confidence", 0.5)))), 2)
rationale = str(params.get("rationale", ""))[:150]
conn2 = get_conn()
existing_calib = dict(tmpl.get("calibration_json") or {})
existing_calib.update({
"absorption_days": absorption_days,
"decay_type": decay_type,
"confidence": confidence,
"theory_rationale": rationale,
"theory_generated_at": datetime.utcnow().isoformat() + "Z",
})
conn2.execute(
"UPDATE causal_graph_templates SET calibration_json=?, updated_at=datetime('now') WHERE id=?",
(json.dumps(existing_calib), template_id),
)
conn2.commit()
conn2.close()
return {
"template_id": template_id,
"absorption_days": absorption_days,
"decay_type": decay_type,
"confidence": confidence,
"rationale": rationale,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"[causal_lab] generate_theory {template_id}: {e}")
raise HTTPException(500, str(e))

View File

@@ -2,6 +2,9 @@
Instrument Dashboard Router.
Exposes per-instrument snapshot (price, indicators, regime, trend, events) and AI narrative.
"""
import json
import math
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from typing import List, Dict, Any, Optional
@@ -85,3 +88,142 @@ def update_drivers(instrument_id: str, body: DriverUpdate) -> Dict[str, Any]:
raise HTTPException(status_code=500, detail=str(e))
return {"ok": True, "instrument_id": instrument_id.upper(), "drivers_count": len(body.drivers)}
# ── Instrument mult (pips → price conversion) ─────────────────────────────────
_INST_MULT: Dict[str, int] = {"EURUSD": 10000, "GBPUSD": 10000, "USDJPY": 100, "AUDUSD": 10000}
def _get_mult(inst: str) -> int:
return _INST_MULT.get(inst.upper(), 10)
def _decay(days_after: int, absorption_days: int, decay_type: str) -> float:
"""Decay factor ∈ [0,1] for a given number of days after the event."""
if days_after < 0:
return 0.0
if decay_type == "step":
return 1.0 if days_after <= absorption_days else 0.0
elif decay_type == "linear":
return max(0.0, 1.0 - days_after / max(absorption_days, 1))
else: # exp — 3 time-constants reach ~5% at absorption_days
lam = 3.0 / max(absorption_days, 1)
return math.exp(-lam * days_after)
@router.get("/{instrument_id}/theoretical-curve")
def get_theoretical_curve(
instrument_id: str,
period: str = Query("1y"),
) -> List[Dict[str, Any]]:
"""
Courbe théorique composite : pour chaque jour calendaire de la période,
somme des impacts décroissants des analyses causales stockées.
Retourne [{date, cumulative_pips, contributions: [{template_name, event_name, event_date, pips, decay_factor}]}]
"""
from services.database import get_conn
period_lookback: Dict[str, int] = {
"5d": 7, "1mo": 35, "3mo": 95, "6mo": 190,
"1y": 370, "2y": 740, "5y": 1830,
}
lookback = period_lookback.get(period, 370)
date_to = datetime.utcnow().date()
date_from = date_to - timedelta(days=lookback)
# Fetch events that started before date_from too — they may still be decaying into the window
extended_from = date_from - timedelta(days=90)
inst_upper = instrument_id.upper()
conn = get_conn()
try:
rows = conn.execute("""
SELECT a.id,
a.prediction_json,
a.activation_score,
e.start_date AS event_date,
e.name AS event_name,
t.name AS template_name,
t.calibration_json
FROM causal_event_analyses a
JOIN market_events e ON e.id = a.market_event_id
JOIN causal_graph_templates t ON t.id = a.template_id
WHERE a.instrument = ?
AND e.start_date >= ?
AND e.start_date <= ?
ORDER BY e.start_date
""", (inst_upper, str(extended_from), str(date_to))).fetchall()
finally:
conn.close()
# ── Build calendar-day series ─────────────────────────────────────────────
all_dates: List[str] = []
cur = date_from
while cur <= date_to:
all_dates.append(str(cur))
cur += timedelta(days=1)
curve: Dict[str, Dict] = {
d: {"cumulative_pips": 0.0, "contributions": []} for d in all_dates
}
for row in rows:
r = dict(row)
try:
predictions = json.loads(r["prediction_json"] or "{}")
calib = json.loads(r["calibration_json"] or "{}")
except Exception:
continue
# Extract predicted pips for this instrument from node_values dict
inst_lower = inst_upper.lower()
predicted_pips: Optional[float] = None
if inst_lower in predictions:
predicted_pips = float(predictions[inst_lower])
else:
for k, v in predictions.items():
if inst_lower in k.lower():
try:
predicted_pips = float(v)
break
except (TypeError, ValueError):
pass
if predicted_pips is None or predicted_pips == 0:
continue
absorption_days: int = max(1, int(calib.get("absorption_days", 7)))
dtype: str = str(calib.get("decay_type", "exp"))
event_date_str: str = r["event_date"][:10]
try:
event_date = datetime.strptime(event_date_str, "%Y-%m-%d").date()
except ValueError:
continue
for d in all_dates:
cal_date = datetime.strptime(d, "%Y-%m-%d").date()
days_after = (cal_date - event_date).days
df = _decay(days_after, absorption_days, dtype)
if df < 0.01:
continue
contribution = round(predicted_pips * df, 2)
curve[d]["cumulative_pips"] += contribution
curve[d]["contributions"].append({
"template_name": r["template_name"],
"event_name": r["event_name"],
"event_date": event_date_str,
"pips": contribution,
"decay_factor": round(df, 3),
})
# Round totals and strip empty-contribution days at the edges
result = []
for d in all_dates:
entry = curve[d]
entry["cumulative_pips"] = round(entry["cumulative_pips"], 1)
result.append({"date": d, **entry})
return result

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}