diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py index 5781c8c..64992e1 100644 --- a/backend/routers/causal_lab.py +++ b/backend/routers/causal_lab.py @@ -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)) diff --git a/backend/routers/instruments.py b/backend/routers/instruments.py index f1c3b9e..a3c26e8 100644 --- a/backend/routers/instruments.py +++ b/backend/routers/instruments.py @@ -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 diff --git a/frontend/src/components/InstrumentChart.tsx b/frontend/src/components/InstrumentChart.tsx index 41340ad..52228d1 100644 --- a/frontend/src/components/InstrumentChart.tsx +++ b/frontend/src/components/InstrumentChart.tsx @@ -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 @@ -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 = { ma20: '#f59e0b', ma50: '#3b82f6', @@ -58,7 +67,7 @@ const CAT_LABELS: Record = { 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(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 (
@@ -307,6 +340,12 @@ export default function InstrumentChart({ priceData, indicators, events = [], he BB(20,2) + {theoryCurve?.some(p => p.contributions.length > 0) && ( + + + Théorie (pips) + + )} {Object.entries(CAT_COLORS).map(([cat, c]) => ( diff --git a/frontend/src/pages/CausalLab.tsx b/frontend/src/pages/CausalLab.tsx index 9ed71c6..06c9c5e 100644 --- a/frontend/src/pages/CausalLab.tsx +++ b/frontend/src/pages/CausalLab.tsx @@ -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>({}) const [editEdgesLag, setEdgesLag] = useState([]) 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) { + 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 }
)} + + {/* Paramètres théoriques */} +
+
+

+ Paramètres théoriques +

+ +
+ + {(() => { + 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 ( +
+ {rationale && ( +

{rationale}

+ )} +
+
+
Durée absorption (j)
+ 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" + /> +
+
+
Type de décroissance
+ +
+
+
+ {conf !== undefined && ( + + Confiance IA : + = 0.7 ? 'text-emerald-400' : conf >= 0.4 ? 'text-yellow-400' : 'text-red-400'}`}> + {Math.round(conf * 100)}% + + + )} + {genAt && ( + + {new Date(genAt).toLocaleDateString('fr-FR')} + + )} + {savingTheory && Sauvegarde…} +
+
+ ) + })()} +
) : (
diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index 49c9e13..2643ba3 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -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(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 ( -
- Aucun événement avec graphe causal pour cet instrument -
- ) - } - - 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 ( -
-
- {crossX !== null && ( -
- )} - {placed.map(({ ev, x, row }) => { - const active = isActiveAt(ev, selectedDate) - return ( -
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%)' }} - > - - - {ev.title.length > 24 ? ev.title.slice(0, 24) + '…' : ev.title} - -
- ) - })} -
- {fmtDateFR(minDate)} - {fmtDateFR(maxDate)} -
-
- ) + 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 (
('counters') const [templates, setTemplates] = useState([]) const [macroAtDate, setMacroAtDate] = useState(null) + const [theoryCurve, setTheoryCurve] = useState(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, indMap: {} as Record>, sortedDates: [] as string[], dateIndex: {} as Record } const priceMap: Record = {} @@ -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 (
- {/* Zone haute — frise des événements (seulement ceux avec graphe causal) */} -
-
- - Frise des événements - survol = détail · clic = market event -
- -
- - {/* Zone basse — frise des graphes causaux */} + {/* Frise des graphes causaux (inclut l'event) */}
Frise des graphes - largeur ∝ durée · clic = détail +
+ {/* Décomposition théorique au curseur */} + {showTheory && theoPt && ( +
+
+ + + Contributions théoriques — {dateLabel} + + = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {theoPt.cumulative_pips >= 0 ? '+' : ''}{theoPt.cumulative_pips} pips + +
+ {theoPt.contributions.length === 0 ? ( +

Aucun événement actif à cette date.

+ ) : ( +
+ {theoPt.contributions.map((c, i) => ( +
+
+ {c.event_name} + {c.template_name} · depuis {c.event_date} · {Math.round(c.decay_factor * 100)}% actif +
+ = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {c.pips >= 0 ? '+' : ''}{c.pips} pip + +
+ ))} +
+ )} +
+ )} + + {showTheory && !theoPt && theoryCurve && ( +
+ ⟁ Aucune contribution théorique pour cette date +
+ )} + {/* Note globale */}