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"]))
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user