feat: causal lab
This commit is contained in:
@@ -431,6 +431,8 @@ def patch_template(template_id: int, body: dict):
|
|||||||
sets.append("instruments=?"); params.append(json.dumps(body["instruments"]))
|
sets.append("instruments=?"); params.append(json.dumps(body["instruments"]))
|
||||||
if "graph_json" in body:
|
if "graph_json" in body:
|
||||||
sets.append("graph_json=?"); params.append(json.dumps(body["graph_json"]))
|
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:
|
if not sets:
|
||||||
conn.close(); return {"ok": True}
|
conn.close(); return {"ok": True}
|
||||||
@@ -1235,3 +1237,100 @@ def get_calibration():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[causal_lab] calibration: {e}")
|
logger.error(f"[causal_lab] calibration: {e}")
|
||||||
raise HTTPException(500, str(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))
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
Instrument Dashboard Router.
|
Instrument Dashboard Router.
|
||||||
Exposes per-instrument snapshot (price, indicators, regime, trend, events) and AI narrative.
|
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 fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List, Dict, Any, Optional
|
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))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
return {"ok": True, "instrument_id": instrument_id.upper(), "drivers_count": len(body.drivers)}
|
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
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ interface ChartEvent {
|
|||||||
description?: string
|
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 {
|
interface Props {
|
||||||
priceData: PriceCandle[]
|
priceData: PriceCandle[]
|
||||||
indicators: Record<string, LinePoint[]>
|
indicators: Record<string, LinePoint[]>
|
||||||
@@ -31,8 +37,11 @@ interface Props {
|
|||||||
height?: number
|
height?: number
|
||||||
chartType?: 'candles' | 'line'
|
chartType?: 'candles' | 'line'
|
||||||
onDateHover?: (date: string | null) => void
|
onDateHover?: (date: string | null) => void
|
||||||
|
theoryCurve?: TheoPoint[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { TheoPoint }
|
||||||
|
|
||||||
const MA_COLORS: Record<string, string> = {
|
const MA_COLORS: Record<string, string> = {
|
||||||
ma20: '#f59e0b',
|
ma20: '#f59e0b',
|
||||||
ma50: '#3b82f6',
|
ma50: '#3b82f6',
|
||||||
@@ -58,7 +67,7 @@ const CAT_LABELS: Record<string, string> = {
|
|||||||
technical: 'Tech.',
|
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 containerRef = useRef<HTMLDivElement>(null)
|
||||||
const cleanupRef = useRef<(() => void) | null>(null)
|
const cleanupRef = useRef<(() => void) | null>(null)
|
||||||
const onHoverRef = useRef(onDateHover)
|
const onHoverRef = useRef(onDateHover)
|
||||||
@@ -173,6 +182,30 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
|
|||||||
chart.addLineSeries(bbOpts).setData(indicators.bb_lower)
|
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()
|
chart.timeScale().fitContent()
|
||||||
|
|
||||||
// ── Star overlay — ★ icons positioned via chart coordinate API ────────
|
// ── Star overlay — ★ icons positioned via chart coordinate API ────────
|
||||||
@@ -293,7 +326,7 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
|
|||||||
cleanupRef.current?.()
|
cleanupRef.current?.()
|
||||||
cleanupRef.current = null
|
cleanupRef.current = null
|
||||||
}
|
}
|
||||||
}, [priceData, indicators, events, height, chartType])
|
}, [priceData, indicators, events, height, chartType, theoryCurve])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-dark-900/60 rounded-xl border border-slate-700/40 overflow-hidden">
|
<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="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 className="inline-block w-5 border-t border-dashed border-slate-400" />BB(20,2)
|
||||||
</span>
|
</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">
|
<span className="ml-auto flex items-center gap-2.5">
|
||||||
{Object.entries(CAT_COLORS).map(([cat, c]) => (
|
{Object.entries(CAT_COLORS).map(([cat, c]) => (
|
||||||
<span key={cat} className="flex items-center gap-0.5" style={{ color: c }}>
|
<span key={cat} className="flex items-center gap-0.5" style={{ color: c }}>
|
||||||
|
|||||||
@@ -380,6 +380,8 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [savingLag, setSavingLag] = 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 [editCoefs, setEditCoefs] = useState<Record<string, number>>({})
|
||||||
const [editEdgesLag, setEdgesLag] = useState<CausalEdge[]>([])
|
const [editEdgesLag, setEdgesLag] = useState<CausalEdge[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -440,6 +442,30 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
|
|||||||
} finally { setSavingLag(false) }
|
} 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) {
|
function updateEdgeLag(i: number, field: 'lag_min' | 'diffusion_min' | 'decay_days', val: string) {
|
||||||
setEdgesLag(prev => prev.map((e, idx) => idx !== i ? e : {
|
setEdgesLag(prev => prev.map((e, idx) => idx !== i ? e : {
|
||||||
...e,
|
...e,
|
||||||
@@ -609,6 +635,82 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null }
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-slate-600 text-sm">
|
<div className="flex-1 flex items-center justify-center text-slate-600 text-sm">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import InstrumentChart from '../components/InstrumentChart'
|
import InstrumentChart, { TheoPoint } from '../components/InstrumentChart'
|
||||||
|
|
||||||
const api = axios.create({ baseURL: '/api' })
|
const api = axios.create({ baseURL: '/api' })
|
||||||
|
|
||||||
@@ -665,110 +665,25 @@ function MacroGaugePanel({ snap, dateLabel }: { snap: MacroGaugeSnap; dateLabel:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── EventTimeline ─────────────────────────────────────────────────────────────
|
// ── Shared: trading-day-index x-coordinate ────────────────────────────────────
|
||||||
|
// Uses the same time scale as LightweightCharts (trading days, weekends skipped).
|
||||||
function EventTimeline({
|
// For a date D, snaps to the nearest available trading day index.
|
||||||
events, priceData, selectedDate, templates, causalInsts,
|
function makeTdToX(priceData: PriceCandle[], width: number): (d: string) => number {
|
||||||
}: {
|
const dates = priceData.map(c => c.time)
|
||||||
events: SnapshotEvent[]
|
const N = dates.length
|
||||||
priceData: PriceCandle[]
|
if (N < 2) return () => 0
|
||||||
selectedDate: string | null
|
function snap(d: string): number {
|
||||||
templates: CausalTemplate[]
|
if (d <= dates[0]) return 0
|
||||||
causalInsts: string[]
|
if (d >= dates[N - 1]) return N - 1
|
||||||
}) {
|
let lo = 0, hi = N - 1
|
||||||
const navigate = useNavigate()
|
while (lo < hi) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const mid = (lo + hi) >> 1
|
||||||
const [contWidth, setContWidth] = useState(800)
|
if (dates[mid] < d) lo = mid + 1
|
||||||
|
else hi = mid
|
||||||
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
|
|
||||||
}
|
}
|
||||||
rowEnds[row] = x + HALF_W
|
return lo
|
||||||
return { ev, x, row }
|
}
|
||||||
})
|
return (d: string) => (snap(d) / (N - 1)) * width
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CausalFrise ───────────────────────────────────────────────────────────────
|
// ── CausalFrise ───────────────────────────────────────────────────────────────
|
||||||
@@ -829,12 +744,8 @@ function CausalFrise({
|
|||||||
|
|
||||||
const minDate = priceData[0].time
|
const minDate = priceData[0].time
|
||||||
const maxDate = priceData[priceData.length - 1].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 usable = Math.max(contWidth, 1)
|
||||||
|
const tdToX = makeTdToX(priceData, usable)
|
||||||
const clampTs = (d: string) => Math.min(Math.max(new Date(d).getTime(), minTs), maxTs)
|
|
||||||
const dateToX = (d: string) => ((clampTs(d) - minTs) / (maxTs - minTs)) * usable
|
|
||||||
|
|
||||||
// Build chips (one per event × template)
|
// Build chips (one per event × template)
|
||||||
type Chip = {
|
type Chip = {
|
||||||
@@ -850,8 +761,8 @@ function CausalFrise({
|
|||||||
const d = new Date(ev.date); d.setDate(d.getDate() + 30)
|
const d = new Date(ev.date); d.setDate(d.getDate() + 30)
|
||||||
return d.toISOString().slice(0, 10)
|
return d.toISOString().slice(0, 10)
|
||||||
})()
|
})()
|
||||||
const x1 = dateToX(ev.date)
|
const x1 = tdToX(ev.date)
|
||||||
const raw = dateToX(endDate) - x1
|
const raw = tdToX(endDate) - x1
|
||||||
const w = Math.max(raw, FRISE_MIN_W)
|
const w = Math.max(raw, FRISE_MIN_W)
|
||||||
return { ev, tmpl, x1, x2: x1 + w, w, active: isActiveAt(ev, selectedDate) }
|
return { ev, tmpl, x1, x2: x1 + w, w, active: isActiveAt(ev, selectedDate) }
|
||||||
})
|
})
|
||||||
@@ -882,7 +793,7 @@ function CausalFrise({
|
|||||||
month: 'short',
|
month: 'short',
|
||||||
...(cur.getFullYear() !== start.getFullYear() ? { year: '2-digit' } : {}),
|
...(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)
|
cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1)
|
||||||
}
|
}
|
||||||
@@ -892,7 +803,7 @@ function CausalFrise({
|
|||||||
const visTicks = ticks.filter((_, i) => i % tickStep === 0)
|
const visTicks = ticks.filter((_, i) => i % tickStep === 0)
|
||||||
|
|
||||||
const crossX = selectedDate && selectedDate >= minDate && selectedDate <= maxDate
|
const crossX = selectedDate && selectedDate >= minDate && selectedDate <= maxDate
|
||||||
? dateToX(selectedDate) : null
|
? tdToX(selectedDate) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1090,6 +1001,9 @@ export default function InstrumentDashboard() {
|
|||||||
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse'>('counters')
|
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse'>('counters')
|
||||||
const [templates, setTemplates] = useState<CausalTemplate[]>([])
|
const [templates, setTemplates] = useState<CausalTemplate[]>([])
|
||||||
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
|
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()
|
const instrumentId = id.toUpperCase()
|
||||||
|
|
||||||
@@ -1104,6 +1018,8 @@ export default function InstrumentDashboard() {
|
|||||||
setNarrative('')
|
setNarrative('')
|
||||||
setSelectedDate(null)
|
setSelectedDate(null)
|
||||||
setMacroAtDate(null)
|
setMacroAtDate(null)
|
||||||
|
setTheoryCurve(null)
|
||||||
|
setShowTheory(false)
|
||||||
api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
|
api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
|
||||||
.then(r => {
|
.then(r => {
|
||||||
setSnapshot(r.data)
|
setSnapshot(r.data)
|
||||||
@@ -1126,6 +1042,20 @@ export default function InstrumentDashboard() {
|
|||||||
if (date) setSelectedDate(date)
|
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(() => {
|
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> }
|
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> = {}
|
const priceMap: Record<string, PriceCandle> = {}
|
||||||
@@ -1322,6 +1252,7 @@ export default function InstrumentDashboard() {
|
|||||||
height={420}
|
height={420}
|
||||||
chartType={chartStyle}
|
chartType={chartStyle}
|
||||||
onDateHover={handleDateHover}
|
onDateHover={handleDateHover}
|
||||||
|
theoryCurve={showTheory && theoryCurve ? theoryCurve : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Date badge */}
|
{/* Date badge */}
|
||||||
@@ -1383,30 +1314,27 @@ export default function InstrumentDashboard() {
|
|||||||
|
|
||||||
{tabUnder === 'analyse' && (() => {
|
{tabUnder === 'analyse' && (() => {
|
||||||
const causalInsts = CAT_TO_CAUSAL_INST[selected?.category ?? ''] ?? []
|
const causalInsts = CAT_TO_CAUSAL_INST[selected?.category ?? ''] ?? []
|
||||||
|
const theoPt = effectiveDate && theoryCurve
|
||||||
|
? theoryCurve.find(p => p.date === effectiveDate) ?? null
|
||||||
|
: null
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Zone haute — frise des événements (seulement ceux avec graphe causal) */}
|
{/* 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">
|
|
||||||
<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 */}
|
|
||||||
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
|
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<BarChart2 className="w-4 h-4 text-violet-400" />
|
<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 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>
|
</div>
|
||||||
<CausalFrise
|
<CausalFrise
|
||||||
events={snapshot.events}
|
events={snapshot.events}
|
||||||
@@ -1417,6 +1345,44 @@ export default function InstrumentDashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Note globale */}
|
||||||
<ExplanationScore
|
<ExplanationScore
|
||||||
events={snapshot.events}
|
events={snapshot.events}
|
||||||
|
|||||||
Reference in New Issue
Block a user