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:
OpenSquared
2026-06-28 11:47:04 +02:00
parent cb33fa0dd7
commit 98d6be8212
2 changed files with 192 additions and 28 deletions

View File

@@ -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

View File

@@ -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>