feat: temporal params (lag/diffusion/decay) sur aretes causales
- CausalEdge: lag_min, diffusion_min, decay_days - SVG: labels +Xm (jaune, source) et decay (gris, cible) sur les aretes - Editeur: champs lag/diffusion/decay dans add-edge et edit-edge - Bibliotheque: section Parametres temporels avec grille editable + sauvegarde PATCH - Backend: _drift_metrics utilise lag_min pour decaler fenetre intraday 5min - analyze: calcule effective_lag depuis aretes → market_asset, retourne dans resultat Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -134,24 +134,25 @@ def _fetch_prices(event_date_str: str, instruments: list[str]) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def _drift_metrics(prices: dict, event_date_str: str, inst: str) -> dict:
|
||||
def _drift_metrics(prices: dict, event_date_str: str, inst: str, lag_min: int = 0) -> dict:
|
||||
series = prices.get(inst, [])
|
||||
mode = prices.get("mode", "none")
|
||||
edate = event_date_str[:10]
|
||||
|
||||
empty = {"pre_pips": None, "post_pips": None, "drift_ratio": None, "leak": "unknown"}
|
||||
empty = {"pre_pips": None, "post_pips": None, "drift_ratio": None, "leak": "unknown", "lag_min": lag_min}
|
||||
if not series:
|
||||
return empty
|
||||
|
||||
mult = 10000 if inst in ("EURUSD",) else 10
|
||||
if mode == "intraday_5m":
|
||||
n = len(series)
|
||||
if n < 8:
|
||||
return empty
|
||||
mid = n // 2
|
||||
# Convertir en pips (×10000 pour FX, ×10 pour or/pétrole/SP500)
|
||||
mult = 10000 if inst in ("EURUSD",) else 10
|
||||
pre_pips = round((series[mid - 1]["c"] - series[0]["c"]) * mult)
|
||||
post_pips = round((series[-1]["c"] - series[mid]["c"]) * mult)
|
||||
# Décale le mid en avant selon le lag (chaque barre = 5 min)
|
||||
lag_bars = max(0, round(lag_min / 5))
|
||||
mid = min(n // 2 + lag_bars, n - 2)
|
||||
pre_pips = round((series[mid - 1]["c"] - series[0]["c"]) * mult)
|
||||
post_pips = round((series[-1]["c"] - series[mid]["c"]) * mult)
|
||||
else:
|
||||
pre = [b for b in series if b["t"] < edate]
|
||||
same = [b for b in series if b["t"] == edate]
|
||||
@@ -760,6 +761,15 @@ def analyze_event(body: AnalyzeRequest):
|
||||
# Évaluation du graphe
|
||||
node_values = evaluate_graph(graph, inputs, body.coef_overrides or {})
|
||||
|
||||
# Lag effectif : max des lag_min sur toutes les arêtes menant à un market_asset
|
||||
edges = graph.get("edges", [])
|
||||
nodes_map = {n["id"]: n for n in graph.get("nodes", [])}
|
||||
output_ids = {n["id"] for n in graph.get("nodes", []) if n.get("type") in ("market_asset", "output")}
|
||||
effective_lag = max(
|
||||
(e.get("lag_min", 0) or 0 for e in edges if e.get("to") in output_ids),
|
||||
default=0,
|
||||
)
|
||||
|
||||
# Prix réels
|
||||
instruments = list({body.instrument} | set(tmpl.get("instruments", [body.instrument])))
|
||||
prices = _fetch_prices(event["start_date"], instruments)
|
||||
@@ -768,7 +778,7 @@ def analyze_event(body: AnalyzeRequest):
|
||||
actual_moves: dict = {}
|
||||
drift_by_inst: dict = {}
|
||||
for inst in instruments:
|
||||
drift = _drift_metrics(prices, event["start_date"], inst)
|
||||
drift = _drift_metrics(prices, event["start_date"], inst, lag_min=effective_lag)
|
||||
drift_by_inst[inst] = drift
|
||||
if drift.get("post_pips") is not None:
|
||||
actual_moves[inst] = drift["post_pips"]
|
||||
@@ -786,18 +796,19 @@ def analyze_event(body: AnalyzeRequest):
|
||||
analyzed_at = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
result = {
|
||||
"event": event,
|
||||
"template_id": body.template_id,
|
||||
"template_name": tmpl["name"],
|
||||
"instrument": body.instrument,
|
||||
"inputs": inputs,
|
||||
"node_values": node_values,
|
||||
"actual_moves": actual_moves,
|
||||
"yields": yields,
|
||||
"activation": activation,
|
||||
"drift": drift_by_inst.get(body.instrument, {}),
|
||||
"prices_mode": prices.get("mode", "none"),
|
||||
"analyzed_at": analyzed_at,
|
||||
"event": event,
|
||||
"template_id": body.template_id,
|
||||
"template_name": tmpl["name"],
|
||||
"instrument": body.instrument,
|
||||
"inputs": inputs,
|
||||
"node_values": node_values,
|
||||
"actual_moves": actual_moves,
|
||||
"yields": yields,
|
||||
"activation": activation,
|
||||
"drift": drift_by_inst.get(body.instrument, {}),
|
||||
"prices_mode": prices.get("mode", "none"),
|
||||
"effective_lag_min": effective_lag,
|
||||
"analyzed_at": analyzed_at,
|
||||
}
|
||||
|
||||
# Persistance
|
||||
|
||||
@@ -18,9 +18,12 @@ export interface CausalEdge {
|
||||
from: string; to: string
|
||||
style: 'solid' | 'dashed'
|
||||
type?: string
|
||||
strength?: 1 | 2 | 3 // 1=fin, 2=normal, 3=épais
|
||||
strength?: 1 | 2 | 3
|
||||
sign?: 'positive' | 'negative' | 'neutral'
|
||||
label?: string
|
||||
lag_min?: number // délai avant onset (minutes, 0 = immédiat)
|
||||
diffusion_min?: number // durée jusqu'à absorption complète (minutes)
|
||||
decay_days?: number | null // demi-vie (jours), null = shift permanent
|
||||
}
|
||||
interface Coefficient { value: number; calibrated: number | null; description: string }
|
||||
interface InputMapping {
|
||||
@@ -61,7 +64,7 @@ interface AnalysisResult {
|
||||
actual_moves: Record<string, number>; yields: Record<string, number | null>
|
||||
activation: { score: number | null; nodes: Record<string, NodeResult>; correct: number; total: number }
|
||||
drift: { pre_pips: number | null; post_pips: number | null; drift_ratio: number | null; leak: string }
|
||||
prices_mode: string; analyzed_at: string
|
||||
prices_mode: string; analyzed_at: string; effective_lag_min?: number
|
||||
}
|
||||
interface Recommendation {
|
||||
template_id: number | null; template_name: string; confidence: number
|
||||
@@ -106,6 +109,15 @@ const edgeColor = (sign?: string): string => ({
|
||||
/** Épaisseur selon strength */
|
||||
const edgeWidth = (s?: number) => s === 1 ? 1 : s === 3 ? 3.5 : 2
|
||||
|
||||
/** Format lag minutes → "+5m" / "+2h" / "+3j" */
|
||||
function fmtLag(min: number): string {
|
||||
if (min < 60) return `+${min}m`
|
||||
if (min < 1440) return `+${(min / 60).toFixed(0)}h`
|
||||
return `+${(min / 1440).toFixed(0)}j`
|
||||
}
|
||||
/** Format decay days → "↩3j" */
|
||||
const fmtDecay = (d: number) => `↩${d}j`
|
||||
|
||||
const CAT_LABELS: Record<string, string> = {
|
||||
macro_us: 'Macro US', macro_eu: 'Macro EU', geopolitical: 'Géopolitique',
|
||||
commodity: 'Matières premières', report: 'Report', sentiment: 'Sentiment',
|
||||
@@ -166,6 +178,18 @@ export function GraphSVG({
|
||||
return `M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2}`
|
||||
}
|
||||
|
||||
function bezierPt(e: CausalEdge, t0: number): { x: number; y: number } | null {
|
||||
const s = nodeMap[e.from]; const t = nodeMap[e.to]
|
||||
if (!s || !t) return null
|
||||
const x1 = s.x; const y1 = s.y + NH / 2
|
||||
const x2 = t.x; const y2 = t.y - NH / 2
|
||||
const my = (y1 + y2) / 2
|
||||
const dx = x2 - x1; const dy = y2 - y1; const len = Math.sqrt(dx*dx + dy*dy) || 1
|
||||
const bx = (1-t0)**3*x1 + 3*(1-t0)**2*t0*x1 + 3*(1-t0)*t0**2*x2 + t0**3*x2
|
||||
const by = (1-t0)**3*y1 + 3*(1-t0)**2*t0*my + 3*(1-t0)*t0**2*my + t0**3*y2
|
||||
return { x: bx + (-dy/len)*10, y: by + (dx/len)*10 }
|
||||
}
|
||||
|
||||
// Label position: 1/3 of the way from source (avoids node centers)
|
||||
function edgeLabelPos(e: CausalEdge): { x: number; y: number } | null {
|
||||
const s = nodeMap[e.from]; const t = nodeMap[e.to]
|
||||
@@ -220,9 +244,13 @@ export function GraphSVG({
|
||||
const sign = e.sign ?? 'neutral'
|
||||
const color = edgeColor(sign)
|
||||
const sw = edgeWidth(e.strength)
|
||||
const lpos = e.label ? edgeLabelPos(e) : null
|
||||
const lpos = e.label ? edgeLabelPos(e) : null
|
||||
const labelText = e.label ?? ''
|
||||
const labelW = labelText.length * 5
|
||||
const labelW = labelText.length * 5
|
||||
const lagTxt = (!compact && e.lag_min) ? fmtLag(e.lag_min) : null
|
||||
const decayTxt = (!compact && e.decay_days != null) ? fmtDecay(e.decay_days) : null
|
||||
const lagPos = lagTxt ? bezierPt(e, 0.1) : null
|
||||
const decayPos = decayTxt ? bezierPt(e, 0.88) : null
|
||||
return (
|
||||
<g key={i}>
|
||||
<path d={edgePath(e)} fill="none"
|
||||
@@ -231,13 +259,26 @@ export function GraphSVG({
|
||||
markerEnd={`url(#arrow-${sign})`} opacity={0.85} />
|
||||
{lpos && (
|
||||
<g>
|
||||
{/* Dark bg pill behind label */}
|
||||
<rect x={lpos.x - 2} y={lpos.y - 9} width={labelW + 4} height={11}
|
||||
rx={3} fill="#0c1220" opacity={0.82} />
|
||||
<text x={lpos.x} y={lpos.y} fill={color} fontSize={8.5} fontWeight="500"
|
||||
letterSpacing="0.2">{labelText}</text>
|
||||
</g>
|
||||
)}
|
||||
{lagPos && lagTxt && (
|
||||
<g>
|
||||
<rect x={lagPos.x - 2} y={lagPos.y - 9} width={lagTxt.length * 5 + 4} height={11}
|
||||
rx={3} fill="#0c1220" opacity={0.85} />
|
||||
<text x={lagPos.x} y={lagPos.y} fill="#fbbf24" fontSize={8} fontWeight="600">{lagTxt}</text>
|
||||
</g>
|
||||
)}
|
||||
{decayPos && decayTxt && (
|
||||
<g>
|
||||
<rect x={decayPos.x - 2} y={decayPos.y - 9} width={decayTxt.length * 5 + 4} height={11}
|
||||
rx={3} fill="#0c1220" opacity={0.85} />
|
||||
<text x={decayPos.x} y={decayPos.y} fill="#94a3b8" fontSize={8} fontWeight="600">{decayTxt}</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
@@ -335,9 +376,11 @@ function TabLibrary() {
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [selected, setSelected] = useState<Template | null>(null)
|
||||
const [catFilter, setCatFilter] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [editCoefs, setEditCoefs] = useState<Record<string, number>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [savingLag, setSavingLag] = useState(false)
|
||||
const [editCoefs, setEditCoefs] = useState<Record<string, number>>({})
|
||||
const [editEdgesLag, setEdgesLag] = useState<CausalEdge[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [apiError, setApiError] = useState<string | null>(null)
|
||||
const [debugInfo, setDebugInfo] = useState<Record<string, unknown> | null>(null)
|
||||
@@ -358,6 +401,7 @@ function TabLibrary() {
|
||||
setEditCoefs(Object.fromEntries(
|
||||
Object.entries(t.graph_json?.coefficients || {}).map(([k, v]) => [k, v.value])
|
||||
))
|
||||
setEdgesLag(JSON.parse(JSON.stringify(t.graph_json?.edges || [])))
|
||||
}
|
||||
|
||||
async function saveCoefs() {
|
||||
@@ -373,6 +417,29 @@ function TabLibrary() {
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
async function saveTemporalParams() {
|
||||
if (!selected) return
|
||||
setSavingLag(true)
|
||||
try {
|
||||
const updatedGraph = { ...selected.graph_json, edges: editEdgesLag }
|
||||
await api(`/api/causal-lab/template/${selected.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ graph_json: updatedGraph }),
|
||||
})
|
||||
const fresh = await api(`/api/causal-lab/template/${selected.id}`)
|
||||
setSelected(fresh)
|
||||
setEdgesLag(JSON.parse(JSON.stringify(fresh.graph_json?.edges || [])))
|
||||
} finally { setSavingLag(false) }
|
||||
}
|
||||
|
||||
function updateEdgeLag(i: number, field: 'lag_min' | 'diffusion_min' | 'decay_days', val: string) {
|
||||
setEdgesLag(prev => prev.map((e, idx) => idx !== i ? e : {
|
||||
...e,
|
||||
[field]: val === '' ? (field === 'decay_days' ? null : undefined)
|
||||
: (field === 'decay_days' ? parseFloat(val) : parseInt(val)),
|
||||
}))
|
||||
}
|
||||
|
||||
async function deleteTemplate() {
|
||||
if (!selected) return
|
||||
if (!window.confirm(`Supprimer "${selected.name}" ? Cette action est irréversible.`)) return
|
||||
@@ -498,6 +565,45 @@ function TabLibrary() {
|
||||
{saving ? 'Sauvegarde…' : 'Sauvegarder les coefficients'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Paramètres temporels */}
|
||||
{editEdgesLag.some(e => e.from && e.to) && (
|
||||
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
|
||||
<h4 className="text-slate-300 text-xs font-semibold mb-3 uppercase tracking-wider flex items-center gap-2">
|
||||
<Zap className="w-3.5 h-3.5 text-yellow-400" /> Paramètres temporels
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[1fr_64px_64px_64px] gap-2 text-xs text-slate-500 pb-1 border-b border-slate-700/30">
|
||||
<span>Arête</span><span className="text-center">Lag (m)</span>
|
||||
<span className="text-center">Diff. (m)</span><span className="text-center">Decay (j)</span>
|
||||
</div>
|
||||
{editEdgesLag.map((e, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_64px_64px_64px] gap-2 items-center">
|
||||
<span className="text-xs font-mono text-slate-400 truncate">{e.from} → {e.to}</span>
|
||||
<input type="number" min={0} step={5}
|
||||
value={e.lag_min ?? ''}
|
||||
onChange={ev => updateEdgeLag(i, 'lag_min', ev.target.value)}
|
||||
placeholder="0"
|
||||
className="w-full bg-dark-800 border border-slate-600 rounded px-1.5 py-1 text-xs text-slate-200 text-center" />
|
||||
<input type="number" min={0} step={15}
|
||||
value={e.diffusion_min ?? ''}
|
||||
onChange={ev => updateEdgeLag(i, 'diffusion_min', ev.target.value)}
|
||||
placeholder="60"
|
||||
className="w-full bg-dark-800 border border-slate-600 rounded px-1.5 py-1 text-xs text-slate-200 text-center" />
|
||||
<input type="number" min={0} step={1}
|
||||
value={e.decay_days ?? ''}
|
||||
onChange={ev => updateEdgeLag(i, 'decay_days', ev.target.value)}
|
||||
placeholder="∞"
|
||||
className="w-full bg-dark-800 border border-slate-600 rounded px-1.5 py-1 text-xs text-slate-200 text-center" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={saveTemporalParams} disabled={savingLag}
|
||||
className="mt-4 px-4 py-2 bg-yellow-700 hover:bg-yellow-600 disabled:opacity-50 rounded text-xs font-medium text-white">
|
||||
{savingLag ? 'Sauvegarde…' : 'Sauvegarder les paramètres temporels'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-slate-600 text-sm">
|
||||
@@ -1003,6 +1109,29 @@ function TabEditor() {
|
||||
value={newEdge.label ?? ''} onChange={e => setNewEdge(p => ({ ...p, label: e.target.value }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<div>
|
||||
<label className="text-slate-500">Lag (min)</label>
|
||||
<input type="number" min={0} step={5} placeholder="0"
|
||||
value={newEdge.lag_min ?? ''}
|
||||
onChange={e => setNewEdge(p => ({ ...p, lag_min: e.target.value === '' ? undefined : parseInt(e.target.value) }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-500">Diffusion (min)</label>
|
||||
<input type="number" min={0} step={15} placeholder="60"
|
||||
value={newEdge.diffusion_min ?? ''}
|
||||
onChange={e => setNewEdge(p => ({ ...p, diffusion_min: e.target.value === '' ? undefined : parseInt(e.target.value) }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-500">Decay (jours)</label>
|
||||
<input type="number" min={0} step={1} placeholder="∞"
|
||||
value={newEdge.decay_days ?? ''}
|
||||
onChange={e => setNewEdge(p => ({ ...p, decay_days: e.target.value === '' ? null : parseFloat(e.target.value) }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={addEdge}
|
||||
className="w-full py-1 bg-purple-600 hover:bg-purple-500 rounded text-xs text-white">
|
||||
+ Ajouter l'arête
|
||||
@@ -1068,6 +1197,29 @@ function TabEditor() {
|
||||
onChange={ev => setEditEdgeVal(p => ({ ...p, label: ev.target.value }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<div>
|
||||
<label className="text-slate-500">Lag (min)</label>
|
||||
<input type="number" min={0} step={5}
|
||||
value={editEdge.lag_min ?? e.lag_min ?? ''}
|
||||
onChange={ev => setEditEdgeVal(p => ({ ...p, lag_min: ev.target.value === '' ? undefined : parseInt(ev.target.value) }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-500">Diffusion (min)</label>
|
||||
<input type="number" min={0} step={15}
|
||||
value={editEdge.diffusion_min ?? e.diffusion_min ?? ''}
|
||||
onChange={ev => setEditEdgeVal(p => ({ ...p, diffusion_min: ev.target.value === '' ? undefined : parseInt(ev.target.value) }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-500">Decay (j)</label>
|
||||
<input type="number" min={0} step={1}
|
||||
value={editEdge.decay_days ?? e.decay_days ?? ''}
|
||||
onChange={ev => setEditEdgeVal(p => ({ ...p, decay_days: ev.target.value === '' ? null : parseFloat(ev.target.value) }))}
|
||||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={applyEdgeEdit}
|
||||
className="w-full py-1 bg-yellow-700 hover:bg-yellow-600 rounded text-xs text-white">
|
||||
Appliquer
|
||||
@@ -1388,6 +1540,7 @@ function TabAnalyze() {
|
||||
{ label: 'Réel post', val: fmtPips(result.drift?.post_pips) },
|
||||
{ label: 'Mode prix', val: result.prices_mode },
|
||||
{ label: 'Fuite info', val: result.drift?.leak ?? '—' },
|
||||
{ label: 'Lag mesuré', val: result.effective_lag_min != null ? `${result.effective_lag_min}min` : '0min' },
|
||||
].map(m => (
|
||||
<div key={m.label} className="bg-dark-800 rounded p-3 text-center">
|
||||
<div className="text-xs text-slate-500">{m.label}</div>
|
||||
|
||||
Reference in New Issue
Block a user