feat: instrument analysis

This commit is contained in:
OpenSquared
2026-06-28 16:36:28 +02:00
parent 0693d41d91
commit e4e17330b4
6 changed files with 715 additions and 649 deletions

View File

@@ -185,15 +185,27 @@ def create_desk(body: AIDeskUpsert) -> Dict[str, Any]:
@router.put("/{desk_id}")
def update_desk(desk_id: int, body: AIDeskUpsert) -> Dict[str, Any]:
from services.database import get_all_ai_desks, upsert_ai_desk
from services.database import get_all_ai_desks, update_ai_desk_by_id
desks = get_all_ai_desks()
desk = next((d for d in desks if d["id"] == desk_id), None)
if not desk:
raise HTTPException(404, f"Desk {desk_id} not found")
upsert_ai_desk({**body.dict(), "name": desk["name"]})
update_ai_desk_by_id(desk_id, body.dict())
return {"status": "updated"}
@router.patch("/{desk_id}/toggle")
def toggle_desk_active(desk_id: int) -> Dict[str, Any]:
from services.database import get_all_ai_desks, update_ai_desk_by_id
desks = get_all_ai_desks()
desk = next((d for d in desks if d["id"] == desk_id), None)
if not desk:
raise HTTPException(404, f"Desk {desk_id} not found")
desk["active"] = not desk["active"]
update_ai_desk_by_id(desk_id, desk)
return {"status": "toggled", "active": desk["active"]}
@router.delete("/{desk_id}")
def delete_desk(desk_id: int) -> Dict[str, Any]:
from services.database import get_all_ai_desks, delete_ai_desk

View File

@@ -329,11 +329,15 @@ class InstantiateRequest(BaseModel):
class AnalyzeRequest(BaseModel):
market_event_id: int
template_id: int
instrument: str = "EURUSD"
instrument: str = "" # vide = analyser tous les instruments du template
inputs: dict = {}
coef_overrides: dict = {}
class CreateFromEventRequest(BaseModel):
market_event_id: int
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/api/causal-lab/debug")
@@ -697,7 +701,12 @@ Règles :
Retourne UNIQUEMENT un JSON : {{ "node_id": valeur_numerique, ... }}
Inclure uniquement les nœuds listés ci-dessus."""
client = _openai_client()
from services.database import get_config as _get_cfg
import openai as _openai
_key = _get_cfg("openai_api_key") or ""
if not _key:
raise HTTPException(400, "Clé OpenAI manquante dans la configuration")
client = _openai.OpenAI(api_key=_key)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
@@ -864,8 +873,13 @@ def analyze_event(body: AnalyzeRequest):
default=0,
)
# Prix réels
instruments = list({body.instrument} | set(tmpl.get("instruments", [body.instrument])))
# Instruments : utiliser tous ceux du template si aucun spécifié
if body.instrument:
instruments = list({body.instrument} | set(tmpl.get("instruments", [body.instrument])))
else:
instruments = list(set(tmpl.get("instruments", []))) or ["EURUSD"]
primary_inst = body.instrument or (instruments[0] if instruments else "EURUSD")
prices = _fetch_prices(event["start_date"], instruments)
edate = event["start_date"][:10]
@@ -893,16 +907,18 @@ def analyze_event(body: AnalyzeRequest):
"event": event,
"template_id": body.template_id,
"template_name": tmpl["name"],
"instrument": body.instrument,
"instrument": primary_inst,
"instruments": instruments,
"inputs": inputs,
"node_values": node_values,
"actual_moves": actual_moves,
"yields": yields,
"activation": activation,
"drift": drift_by_inst.get(body.instrument, {}),
"drift": drift_by_inst.get(primary_inst, {}),
"prices_mode": prices.get("mode", "none"),
"effective_lag_min": effective_lag,
"analyzed_at": analyzed_at,
"graph_json": graph, # structure complète pour visualisation frontend
}
# Persistance
@@ -915,13 +931,13 @@ def analyze_event(body: AnalyzeRequest):
""", (
body.market_event_id,
body.template_id,
body.instrument,
primary_inst,
json.dumps(inputs),
json.dumps(body.coef_overrides),
json.dumps(node_values),
json.dumps(actual_moves),
activation.get("score"),
json.dumps(drift_by_inst.get(body.instrument, {})),
json.dumps(drift_by_inst.get(primary_inst, {})),
analyzed_at,
))
conn.commit()
@@ -929,11 +945,11 @@ def analyze_event(body: AnalyzeRequest):
# Calibration
update_calibration(conn, body.template_id, {
"activation_score": activation.get("score"),
"instrument": body.instrument,
"pred_pips": node_values.get(body.instrument.lower(), 0) or
"instrument": primary_inst,
"pred_pips": node_values.get(primary_inst.lower(), 0) or
next((v for k, v in node_values.items()
if body.instrument.lower() in k.lower()), 0),
"actual_pips": actual_moves.get(body.instrument),
if primary_inst.lower() in k.lower()), 0),
"actual_pips": actual_moves.get(primary_inst),
"analyzed_at": analyzed_at,
})
conn.close()
@@ -947,6 +963,194 @@ def analyze_event(body: AnalyzeRequest):
raise HTTPException(500, str(e))
def _build_graph_json_from_spec(spec: dict) -> dict:
"""Construit un graph_json complet depuis le spec simplifié généré par GPT-4o."""
input_node = spec.get("input_node", {})
intermediates = spec.get("intermediate_nodes", [])
outputs = spec.get("output_nodes", [])
nodes: list = []
edges: list = []
coefficients: dict = {}
input_mapping: dict = {}
# Nœud d'entrée — centré en haut
inp_id = input_node.get("id", "surprise")
nodes.append({
"id": inp_id, "label": input_node.get("label", "Surprise"),
"type": "input", "x": 200, "y": 50,
"unit": input_node.get("unit", "% vs consensus"),
})
input_mapping[inp_id] = {
"source": "surprise",
"unit": input_node.get("unit", "% vs consensus"),
"description": input_node.get("label", "Surprise"),
}
# Nœuds intermédiaires — étalés horizontalement au milieu
n_inter = max(len(intermediates), 1)
x_step_i = max(160, 340 // n_inter)
for i, inter in enumerate(intermediates):
x = 200 - (len(intermediates) - 1) * x_step_i // 2 + i * x_step_i
inter_id = inter["id"]
coef_name = inter.get("coef_name", f"coef_{inter_id}")
coef_val = float(inter.get("coef_value", 2.0))
coefficients[coef_name] = {
"value": coef_val, "calibrated": False,
"description": inter.get("coef_desc", coef_name),
}
nodes.append({
"id": inter_id, "label": inter.get("label", inter_id),
"type": "intermediate", "x": x, "y": 175,
"formula": f"{inp_id} * {coef_name}",
})
edges.append({"from": inp_id, "to": inter_id,
"sign": "positive" if coef_val >= 0 else "negative", "style": "solid"})
# Nœuds de sortie — étalés horizontalement en bas
inter_ids = [n["id"] for n in intermediates]
n_out = max(len(outputs), 1)
x_step_o = max(140, 320 // n_out)
for i, out in enumerate(outputs):
x = 200 - (len(outputs) - 1) * x_step_o // 2 + i * x_step_o
out_id = out["id"]
coef_name = out.get("coef_name", f"coef_{out_id}")
coef_val = float(out.get("coef_value", -100.0))
coefficients[coef_name] = {
"value": coef_val, "calibrated": False,
"description": out.get("coef_desc", coef_name),
}
src = out.get("source_intermediate", "")
from_id = src if src in inter_ids else (intermediates[0]["id"] if intermediates else inp_id)
nodes.append({
"id": out_id, "label": out.get("label", out_id),
"type": "market_asset", "x": x, "y": 310,
"unit": "pips", "instrument": out.get("instrument", "EURUSD"),
"formula": f"{from_id} * {coef_name}",
})
edges.append({"from": from_id, "to": out_id,
"sign": "positive" if coef_val >= 0 else "negative", "style": "solid"})
instruments = list({out.get("instrument", "EURUSD") for out in outputs})
return {
"nodes": nodes, "edges": edges,
"coefficients": coefficients, "input_mapping": input_mapping,
"instruments": instruments,
}
@router.post("/api/causal-lab/create-from-event")
def create_template_from_event(body: CreateFromEventRequest):
"""GPT-4o génère et enregistre un template causal adapté à l'événement."""
try:
from services.database import get_conn, get_config
from services.causal_graphs import init_tables
import openai
key = get_config("openai_api_key") or ""
if not key:
raise HTTPException(400, "Clé OpenAI manquante dans la configuration")
conn = get_conn()
init_tables(conn)
ev_row = conn.execute(
"SELECT * FROM market_events WHERE id = ?", (body.market_event_id,)
).fetchone()
if not ev_row:
conn.close()
raise HTTPException(404, "Événement introuvable")
event = dict(ev_row)
prompt = f"""Tu es un analyste financier spécialisé en graphes causaux macro.
Crée un graphe causal pour l'événement suivant.
Événement :
- Nom : {event.get('name')}
- Catégorie : {event.get('category')} / {event.get('sub_type', '')}
- Description : {(event.get('description') or '')[:400]}
- Réel : {event.get('actual_value')} | Attendu : {event.get('expected_value')} | Surprise % : {event.get('surprise_pct')}
Retourne UNIQUEMENT ce JSON (sans commentaire ni markdown) :
{{
"name": "<nom court, ex: 'CPI US — Taux/USD'>",
"category": "macro_us",
"description": "<1 phrase décrivant le mécanisme>",
"input_node": {{
"id": "<snake_case>",
"label": "<label court>",
"unit": "<unité>"
}},
"intermediate_nodes": [
{{
"id": "<snake_case>",
"label": "<label>",
"coef_name": "<coef_snake_case>",
"coef_value": <float [-5,5]>,
"coef_desc": "<description>"
}}
],
"output_nodes": [
{{
"id": "<instr_lower>_pip",
"label": "<INSTRUMENT>",
"instrument": "<EURUSD|XAUUSD|SP500|BRENT|US10Y>",
"source_intermediate": "<id_nœud_intermédiaire>",
"coef_name": "<coef_snake_case>",
"coef_value": <pips/unité, valeur absolue 30-200, signe selon impact>,
"coef_desc": "<description>"
}}
]
}}
Règles : coef intermédiaire ∈ [-5,5] ; coef output en pips, |val| ∈ [30,200].
Surprise CPI US supérieur → taux ↑ → USD ↑ → EURUSD ↓ (coef négatif), XAU ↓, SP500 ↓."""
client = openai.OpenAI(api_key=key)
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0.3,
max_tokens=900,
)
spec = json.loads(resp.choices[0].message.content or "{}")
graph_json = _build_graph_json_from_spec(spec)
category = spec.get("category", "macro_us")
instruments = graph_json.get("instruments", ["EURUSD"])
cur = conn.execute("""
INSERT INTO causal_graph_templates
(name, category, sub_type, description, instruments, graph_json, heuristic_ver)
VALUES (?, ?, ?, ?, ?, ?, 1)
""", (
spec.get("name", event.get("name", "Template IA")),
category,
event.get("sub_type", ""),
spec.get("description", ""),
json.dumps(instruments),
json.dumps(graph_json),
))
new_id = cur.lastrowid
conn.commit()
conn.close()
return {
"id": new_id,
"name": spec.get("name", "Template IA"),
"category": category,
"instruments": instruments,
"graph_json": graph_json,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"[causal_lab] create-from-event: {e}")
raise HTTPException(500, str(e))
@router.get("/api/causal-lab/analyses")
def list_analyses(
template_id: int = Query(0),

View File

@@ -5176,6 +5176,28 @@ def upsert_ai_desk(desk: Dict[str, Any]) -> int:
conn.close()
def update_ai_desk_by_id(desk_id: int, desk: Dict[str, Any]) -> bool:
conn = get_conn()
try:
instr = desk.get("instruments", [])
cfg = desk.get("config", {})
if isinstance(instr, list):
instr = json.dumps(instr)
if isinstance(cfg, dict):
cfg = json.dumps(cfg)
conn.execute("""
UPDATE ai_desks SET
name=?, type=?, active=?, system_prompt=?,
instruments=?, config=?, updated_at=datetime('now')
WHERE id=?
""", (desk["name"], desk["type"], int(desk.get("active", 1)),
desk.get("system_prompt", ""), instr, cfg, desk_id))
conn.commit()
return True
finally:
conn.close()
def delete_ai_desk(name: str) -> bool:
conn = get_conn()
try:

View File

@@ -41,6 +41,30 @@ const SOURCE_LABELS: Record<string, string> = {
reports: 'Reports',
}
interface DeskRef {
id: number
name: string
type: string
active: boolean
}
const DESK_TYPE_TO_SOURCE: Record<string, string> = {
news: 'news',
eco: 'eco',
fundamental: 'news',
technical: 'technical',
report: 'reports',
}
const DESK_TYPE_COLORS: Record<string, string> = {
news: 'bg-blue-900/30 border-blue-600/50 text-blue-300',
fundamental: 'bg-amber-900/30 border-amber-600/50 text-amber-300',
technical: 'bg-cyan-900/30 border-cyan-600/50 text-cyan-300',
eco: 'bg-emerald-900/30 border-emerald-600/50 text-emerald-300',
report: 'bg-purple-900/30 border-purple-600/50 text-purple-300',
sentiment: 'bg-rose-900/30 border-rose-600/50 text-rose-300',
}
const CAT_COLORS: Record<string, string> = {
event_calendar: 'text-amber-400',
geopolitical: 'text-red-400',
@@ -120,7 +144,6 @@ function BootstrapAction({ action }: { action: ActionDef }) {
// ── Check Market Events config panel ────────────────────────────────────────
interface CheckEventsConfig {
sources: string[]
news_impact_min: number
eco_z_threshold: number
technical_lookback_days: number
@@ -148,7 +171,6 @@ function presetDates(hours: number): { date_from: string; date_to: string } {
function makeDefaultConfig(): CheckEventsConfig {
return {
sources: ['news', 'eco', 'technical', 'reports'],
news_impact_min: 0.55,
eco_z_threshold: 1.5,
technical_lookback_days: 7,
@@ -160,30 +182,40 @@ function makeDefaultConfig(): CheckEventsConfig {
function CheckEventsPanel() {
const [config, setConfig] = useState<CheckEventsConfig>(makeDefaultConfig)
const [desks, setDesks] = useState<DeskRef[]>([])
const [running, setRunning] = useState(false)
const [result, setResult] = useState<ActionResult | null>(null)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState(false)
const [showConfig, setShowConfig] = useState(false)
const toggleSource = (src: string) => {
setConfig(c => ({
...c,
sources: c.sources.includes(src)
? c.sources.filter(s => s !== src)
: [...c.sources, src],
}))
useEffect(() => {
fetch(`${API}/api/ai-desks`)
.then(r => r.json())
.then(d => setDesks(d))
.catch(() => {})
}, [])
const toggleDesk = async (desk: DeskRef) => {
const next = !desk.active
setDesks(prev => prev.map(d => d.id === desk.id ? { ...d, active: next } : d))
await fetch(`${API}/api/ai-desks/${desk.id}/toggle`, { method: 'PATCH' }).catch(() => {})
}
const run = async () => {
setRunning(true)
setResult(null)
setError(null)
const sources = [...new Set(
desks
.filter(d => d.active && DESK_TYPE_TO_SOURCE[d.type])
.map(d => DESK_TYPE_TO_SOURCE[d.type])
)]
try {
const res = await fetch(`${API}/api/actions/check-market-events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
body: JSON.stringify({ ...config, sources }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
@@ -211,7 +243,7 @@ function CheckEventsPanel() {
<span className="w-2 h-2 rounded-full bg-emerald-400" />
<span className="text-slate-100 font-semibold text-sm">Check New Market Events</span>
<span className="text-xs text-slate-500 ml-1">
Scans news · FRED · MA crossovers · rapports institutionnels
Scans news · éco. calendar · MA crossovers · rapports institutionnels
</span>
</div>
<button
@@ -221,7 +253,7 @@ function CheckEventsPanel() {
{showConfig ? 'Masquer config' : 'Config'}
</button>
<button
disabled={running || config.sources.length === 0}
disabled={running}
onClick={run}
className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-700
disabled:text-slate-500 text-white text-sm font-medium rounded-lg px-4 py-2 transition-colors"
@@ -266,21 +298,25 @@ function CheckEventsPanel() {
</div>
</div>
{/* Sources */}
{/* Desks IA */}
<div>
<div className="text-slate-400 mb-2 text-xs uppercase tracking-wide">Sources actives</div>
<div className="text-slate-400 mb-2 text-xs uppercase tracking-wide">Desks IA</div>
<div className="flex flex-wrap gap-2">
{['news', 'eco', 'technical', 'reports'].map(s => (
<button key={s} onClick={() => toggleSource(s)}
className={`px-3 py-1 rounded-full text-xs border transition-colors ${
config.sources.includes(s)
? 'bg-emerald-600/30 border-emerald-500/50 text-emerald-300'
: 'border-slate-700 text-slate-500 hover:border-slate-500'
{desks.map(desk => (
<button key={desk.id} onClick={() => toggleDesk(desk)}
className={`flex items-center gap-1.5 px-3 py-1 rounded border text-xs transition-colors ${
desk.active
? (DESK_TYPE_COLORS[desk.type] ?? 'bg-slate-700/40 border-slate-600 text-slate-300')
: 'border-slate-700 text-slate-600 hover:border-slate-500 hover:text-slate-400'
}`}
>
{SOURCE_LABELS[s]}
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${desk.active ? 'bg-current' : 'bg-slate-700'}`} />
{desk.name}
</button>
))}
{desks.length === 0 && (
<span className="text-xs text-slate-600 italic">Aucun desk configuré</span>
)}
</div>
</div>

View File

@@ -771,99 +771,246 @@ function EventTimeline({
)
}
// ── EventGraphCards ───────────────────────────────────────────────────────────
// ── CausalFrise ───────────────────────────────────────────────────────────────
function EventGraphCards({
events, templates, selectedDate, causalInsts,
const FRISE_LANE_H = 24 // px par lane
const FRISE_CHIP_H = 18 // hauteur d'un chip
const FRISE_CHIP_PAD = (FRISE_LANE_H - FRISE_CHIP_H) / 2
const FRISE_AXIS_H = 20 // axe temporel en bas
const FRISE_MIN_W = 44 // largeur minimale d'un chip (en px)
const FRISE_POPUP_W = 220 // largeur du popup
function CausalFrise({
events, templates, priceData, selectedDate, causalInsts,
}: {
events: SnapshotEvent[]
templates: CausalTemplate[]
events: SnapshotEvent[]
templates: CausalTemplate[]
priceData: PriceCandle[]
selectedDate: string | null
causalInsts: string[]
causalInsts: string[]
}) {
const navigate = useNavigate()
const navigate = useNavigate()
const containerRef = useRef<HTMLDivElement>(null)
const [contWidth, setContWidth] = useState(400)
const [activeChip, setActiveChip] = useState<{
tmpl: CausalTemplate; ev: SnapshotEvent; chipX: number; chipY: number
} | null>(null)
// Events that have an instantiated causal analysis for this instrument
const linkedEvents = events.filter(ev => {
if (!ev.analyzed_instruments) return false
useEffect(() => {
const el = containerRef.current; if (!el) return
setContWidth(el.getBoundingClientRect().width)
const obs = new ResizeObserver(e => setContWidth(e[0].contentRect.width))
obs.observe(el)
return () => obs.disconnect()
}, [])
// Close popup on outside click (tiny delay avoids self-close)
useEffect(() => {
if (!activeChip) return
let tid: ReturnType<typeof setTimeout>
const close = () => setActiveChip(null)
tid = setTimeout(() => document.addEventListener('mousedown', close), 60)
return () => { clearTimeout(tid); document.removeEventListener('mousedown', close) }
}, [activeChip])
const linked = events.filter(ev => {
if (!ev.analyzed_instruments || ev.template_id == null) return false
const insts = ev.analyzed_instruments.split(',')
return causalInsts.some(ci => insts.includes(ci))
})
// Unique templates from those linked events
const seenIds = new Set<number>()
const relevant: { tmpl: CausalTemplate; eventsForTmpl: SnapshotEvent[] }[] = []
for (const ev of linkedEvents) {
if (!ev.template_id || seenIds.has(ev.template_id)) continue
const tmpl = templates.find(t => t.id === ev.template_id)
if (!tmpl) continue
seenIds.add(ev.template_id)
relevant.push({
tmpl,
eventsForTmpl: linkedEvents.filter(e => e.template_id === ev.template_id),
})
}
if (!relevant.length) {
if (!priceData.length || !linked.length) {
return (
<div className="text-xs text-slate-600 italic py-3">
Aucun graphe causal lié aux événements de cette période pour cet instrument
<div ref={containerRef} className="h-12 flex items-center justify-center text-xs text-slate-600 italic">
Aucun graphe causal lié 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 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
// Build chips (one per event × template)
type Chip = {
ev: SnapshotEvent; tmpl: CausalTemplate
x1: number; x2: number; w: number; active: boolean
}
const chips: Chip[] = linked
.filter(ev => ev.date <= maxDate)
.map(ev => {
const tmpl = templates.find(t => t.id === ev.template_id)
if (!tmpl) return null
const endDate = ev.end_date ?? (() => {
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 w = Math.max(raw, FRISE_MIN_W)
return { ev, tmpl, x1, x2: x1 + w, w, active: isActiveAt(ev, selectedDate) }
})
.filter((c): c is Chip => c !== null)
.sort((a, b) => a.x1 - b.x1)
// Greedy lane assignment — no overlap
const laneEnds: number[] = []
const placed = chips.map(chip => {
const GAP = 3
let lane = laneEnds.findIndex(end => end + GAP <= chip.x1)
if (lane === -1) { lane = laneEnds.length; laneEnds.push(0) }
laneEnds[lane] = chip.x2
return { ...chip, lane }
})
const numLanes = Math.max(laneEnds.length, 1)
const containerH = numLanes * FRISE_LANE_H + FRISE_AXIS_H + 4
// Month tick marks — skip some if too crowded
const ticks: { label: string; x: number }[] = []
{
const start = new Date(minDate)
let cur = new Date(start.getFullYear(), start.getMonth() + 1, 1)
while (cur.toISOString().slice(0, 10) <= maxDate) {
ticks.push({
label: cur.toLocaleDateString('fr-FR', {
month: 'short',
...(cur.getFullYear() !== start.getFullYear() ? { year: '2-digit' } : {}),
}),
x: dateToX(cur.toISOString().slice(0, 10)),
})
cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1)
}
}
// Keep 1 label every ~70px to avoid crowding
const tickStep = Math.max(1, Math.ceil(ticks.length / (usable / 70)))
const visTicks = ticks.filter((_, i) => i % tickStep === 0)
const crossX = selectedDate && selectedDate >= minDate && selectedDate <= maxDate
? dateToX(selectedDate) : null
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{relevant.map(({ tmpl, eventsForTmpl }) => {
const active = eventsForTmpl.some(ev => isActiveAt(ev, selectedDate))
<div
ref={containerRef}
className="relative w-full select-none"
style={{ height: containerH }}
onClick={() => setActiveChip(null)}
>
{/* Month grid lines */}
{visTicks.map((t, i) => (
<div key={i} className="absolute top-0 pointer-events-none"
style={{ left: t.x, bottom: FRISE_AXIS_H, width: 1, background: 'rgba(100,116,139,0.12)' }} />
))}
{/* Crosshair */}
{crossX !== null && (
<div className="absolute top-0 bottom-0 pointer-events-none"
style={{ left: crossX, width: 1, background: 'rgba(96,165,250,0.3)' }} />
)}
{/* Chips */}
{placed.map(({ ev, tmpl, x1, w, lane, active }) => {
const catTw = CAUSAL_CAT_TW[tmpl.category] ?? 'text-slate-400 border-slate-700/30 bg-slate-800/40'
const chipY = lane * FRISE_LANE_H + FRISE_CHIP_PAD
const isOpen = activeChip?.ev.id === ev.id && activeChip?.tmpl.id === tmpl.id
const charsFit = Math.floor((w - 18) / 5.5)
const label = charsFit < 3 ? '' : tmpl.name.length > charsFit
? tmpl.name.slice(0, charsFit - 1) + '…' : tmpl.name
return (
<div
key={tmpl.id}
onClick={() => navigate(`/causal-lab?template=${tmpl.id}`)}
key={`${tmpl.id}-${ev.id ?? ev.date}`}
className={clsx(
'rounded-lg border p-2.5 cursor-pointer transition-colors group',
'absolute rounded border cursor-pointer transition-all flex items-center gap-1 overflow-hidden px-1.5',
'text-[9px] font-medium leading-none',
active
? 'border-emerald-600/50 bg-emerald-900/20 hover:bg-emerald-900/30'
: 'border-slate-700/30 bg-dark-700/40 hover:bg-dark-700/70'
? catTw + (isOpen ? ' ring-1 ring-current/40 brightness-125' : ' hover:brightness-110')
: clsx('text-slate-500 border-slate-700/40 bg-slate-800/25',
'hover:text-slate-300 hover:border-slate-600/60 hover:bg-slate-800/50'),
)}
style={{ left: x1, top: chipY, width: w, height: FRISE_CHIP_H }}
onMouseDown={e => e.stopPropagation()}
onClick={e => {
e.stopPropagation()
setActiveChip(isOpen ? null : { tmpl, ev, chipX: x1, chipY })
}}
title={`${tmpl.name} · ${ev.title}\n${fmtDateFR(ev.date)}${ev.end_date ? fmtDateFR(ev.end_date) : '+30j'}`}
>
<div className="flex items-start justify-between gap-1 mb-1.5">
<span className={clsx(
'text-xs font-semibold leading-tight group-hover:underline',
active ? 'text-emerald-300' : 'text-slate-500'
)}>
{tmpl.name}
</span>
<span className={clsx(
'shrink-0 text-[9px] px-1 py-0.5 rounded border',
CAUSAL_CAT_TW[tmpl.category] ?? 'text-slate-500 border-slate-700/30'
)}>
{tmpl.category}
</span>
</div>
{/* Event titles linked to this template */}
<div className="space-y-0.5 mb-1.5">
{eventsForTmpl.slice(0, 2).map(ev => (
<div key={ev.id} className={clsx(
'text-[9px] truncate',
isActiveAt(ev, selectedDate) ? 'text-amber-400' : 'text-slate-600'
)}>
{fmtDateFR(ev.date)} · {ev.title.length > 28 ? ev.title.slice(0, 28) + '…' : ev.title}
</div>
))}
{eventsForTmpl.length > 2 && (
<div className="text-[9px] text-slate-700">+{eventsForTmpl.length - 2} événements</div>
)}
</div>
<div className="flex items-center justify-between">
<span className={clsx('text-[9px]', active ? 'text-emerald-500' : 'text-slate-700')}>
{active ? '● actif' : '○ période'}
</span>
<span className={clsx('w-2 h-2 rounded-full', active ? 'bg-emerald-400' : 'bg-slate-700')} />
</div>
<span className={clsx('w-1.5 h-1.5 rounded-full bg-current shrink-0', !active && 'opacity-40')} />
{label && <span className="truncate">{label}</span>}
</div>
)
})}
{/* Popup */}
{activeChip && (() => {
const { tmpl, ev, chipX, chipY } = activeChip
const popH = 108
const popTop = chipY - popH - 6 >= 0 ? chipY - popH - 6 : chipY + FRISE_CHIP_H + 4
const popLeft = Math.max(0, Math.min(chipX, contWidth - FRISE_POPUP_W - 4))
return (
<div
className="absolute z-30 bg-dark-800 border border-slate-600/40 rounded-lg shadow-2xl p-3"
style={{ left: popLeft, top: popTop, width: FRISE_POPUP_W }}
onMouseDown={e => e.stopPropagation()}
onClick={e => e.stopPropagation()}
>
<button
onClick={() => setActiveChip(null)}
className="absolute top-1.5 right-1.5 w-4 h-4 flex items-center justify-center text-slate-600 hover:text-slate-300 text-xs leading-none"
>×</button>
<div className="flex items-start gap-2 mb-1.5 pr-4">
<span className="flex-1 text-xs font-semibold text-slate-200 leading-tight line-clamp-2">{tmpl.name}</span>
<span className={clsx('text-[9px] px-1 py-0.5 rounded border shrink-0',
CAUSAL_CAT_TW[tmpl.category] ?? 'text-slate-500 border-slate-700/30'
)}>{tmpl.category}</span>
</div>
<div className="text-[10px] text-slate-400 truncate mb-1.5">{ev.title}</div>
<div className="flex items-center gap-3 mb-2.5 text-[9px] text-slate-600">
<span className="flex items-center gap-1">
<Clock className="w-2.5 h-2.5" />
{fmtDateFR(ev.date)} {ev.end_date ? fmtDateFR(ev.end_date) : '+30j'}
</span>
{ev.impact_score != null && (
<span className="text-amber-600"> {ev.impact_score.toFixed(1)}</span>
)}
{ev.surprise_pct != null && (
<span className={ev.surprise_pct > 0 ? 'text-emerald-600' : 'text-red-600'}>
{ev.surprise_pct > 0 ? '+' : ''}{ev.surprise_pct.toFixed(1)}%
</span>
)}
</div>
<div className="flex gap-1.5">
<button
onClick={() => { if (ev.id) navigate(`/market-events?event=${ev.id}`); setActiveChip(null) }}
className="flex-1 text-[10px] text-slate-400 hover:text-slate-200 bg-slate-700/40 hover:bg-slate-700/60 rounded px-2 py-1 transition-colors"
>Événement </button>
<button
onClick={() => { navigate(`/causal-lab?template=${tmpl.id}`); setActiveChip(null) }}
className="flex-1 text-[10px] text-violet-400 hover:text-violet-200 bg-violet-900/20 hover:bg-violet-900/40 rounded px-2 py-1 transition-colors"
>CausalLab </button>
</div>
</div>
)
})()}
{/* X-axis */}
<div className="absolute left-0 right-0 border-t border-slate-700/30" style={{ bottom: FRISE_AXIS_H }}>
{visTicks.map((t, i) => (
<span key={i} className="absolute text-[9px] text-slate-600 -translate-x-1/2 pt-0.5" style={{ left: t.x }}>
{t.label}
</span>
))}
</div>
</div>
)
}
@@ -1233,16 +1380,17 @@ export default function InstrumentDashboard() {
/>
</div>
{/* Zone basse — graphes causaux liés aux événements */}
{/* Zone basse — frise des graphes causaux */}
<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">Graphes causaux</span>
<span className="text-xs text-slate-600 ml-auto">vert = actif · clic = bibliothèque</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>
</div>
<EventGraphCards
<CausalFrise
events={snapshot.events}
templates={templates}
priceData={snapshot.price_data}
selectedDate={effectiveDate}
causalInsts={causalInsts}
/>

View File

@@ -69,24 +69,12 @@ interface TemplateRef {
instruments: string[]
}
interface GraphNode { id: string; label: string; type: string; x: number; y: number; unit?: string; instrument?: string }
interface GraphEdge { from: string; to: string; style?: string; sign?: string }
interface GraphData { nodes: GraphNode[]; edges: GraphEdge[] }
// ── Constants ─────────────────────────────────────────────────────────────────
const CAT_COLORS: Record<string, string> = {
event_calendar: 'bg-amber-500/20 text-amber-300 border-amber-500/30',
geopolitical: 'bg-red-500/20 text-red-300 border-red-500/30',
fundamental: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30',
report: 'bg-blue-500/20 text-blue-300 border-blue-500/30',
sentiment: 'bg-purple-500/20 text-purple-300 border-purple-500/30',
technical: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30',
}
const CAT_DOT: Record<string, string> = {
event_calendar: 'bg-amber-400',
geopolitical: 'bg-red-400',
fundamental: 'bg-emerald-400',
report: 'bg-blue-400',
sentiment: 'bg-purple-400',
technical: 'bg-cyan-400',
}
const LEVEL_COLORS: Record<string, string> = {
long: 'text-purple-400',
medium: 'text-amber-400',
@@ -115,8 +103,7 @@ const DIR_ICONS: Record<string, string> = {
bullish: '▲', bearish: '▼', neutral: '●', depends_on_outcome: '◆',
}
const CATEGORIES = ['event_calendar', 'geopolitical', 'fundamental', 'report', 'sentiment', 'technical']
const LEVELS = ['long', 'medium', 'short']
const LEVELS = ['long', 'medium', 'short']
const ALL_INSTRUMENTS = [
'SPY','QQQ','IWM','EEM','EFA','GLD','SLV','USO','UNG','TLT',
'HYG','EURUSD=X','USDJPY=X','GBPUSD=X','VXX','AAPL','NVDA','GS','XOM','BTC-USD',
@@ -137,15 +124,6 @@ function ScoreBar({ score, max = 1 }: { score: number; max?: number }) {
)
}
function CatBadge({ cat }: { cat: string }) {
return (
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded border text-xs font-medium', CAT_COLORS[cat] ?? 'bg-slate-700/50 text-slate-400 border-slate-700')}>
<span className={clsx('w-1.5 h-1.5 rounded-full', CAT_DOT[cat] ?? 'bg-slate-400')} />
{cat.replace('_', ' ')}
</span>
)
}
// ── Instrument impact row (inline editable) ───────────────────────────────────
function ImpactRow({
@@ -312,6 +290,79 @@ function AddInstrumentPanel({ eventId, onAdded }: { eventId: number; onAdded: ()
)
}
// ── CausalGraphMini — SVG inline du graphe instancié ─────────────────────────
function CausalGraphMini({ nodes, edges, nodeValues }: {
nodes: GraphNode[]
edges: GraphEdge[]
nodeValues: Record<string, number>
}) {
if (!nodes.length) return null
const xs = nodes.map(n => n.x)
const ys = nodes.map(n => n.y)
const minX = Math.min(...xs) - 70
const maxX = Math.max(...xs) + 70
const minY = Math.min(...ys) - 30
const maxY = Math.max(...ys) + 30
const vw = Math.max(maxX - minX, 200)
const vh = Math.max(maxY - minY, 100)
const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n]))
const BOX_W = 90, BOX_H = 36, SHORTEN = 21
return (
<svg viewBox={`${minX} ${minY} ${vw} ${vh}`} className="w-full" style={{ maxHeight: 250 }}>
<defs>
{(['positive', 'negative', 'neutral'] as const).map(s => (
<marker key={s} id={`cgm-arr-${s}`} markerWidth={7} markerHeight={7} refX={6} refY={3} orient="auto">
<path d="M0,0 L0,6 L7,3 z" fill={s === 'positive' ? '#14b8a6' : s === 'negative' ? '#f97316' : '#64748b'} />
</marker>
))}
</defs>
{edges.map((e, i) => {
const from = nodeMap[e.from], to = nodeMap[e.to]
if (!from || !to) return null
const dx = to.x - from.x, dy = to.y - from.y
const len = Math.sqrt(dx * dx + dy * dy) || 1
const sign = e.sign || 'neutral'
const color = sign === 'positive' ? '#14b8a6' : sign === 'negative' ? '#f97316' : '#64748b'
return (
<line key={i}
x1={from.x + (dx / len) * SHORTEN} y1={from.y + (dy / len) * SHORTEN}
x2={to.x - (dx / len) * (SHORTEN + 5)} y2={to.y - (dy / len) * (SHORTEN + 5)}
stroke={color} strokeWidth={1.5}
strokeDasharray={e.style === 'dashed' ? '5,3' : undefined}
markerEnd={`url(#cgm-arr-${sign})`} />
)
})}
{nodes.map(n => {
const val = nodeValues[n.id]
const isOut = n.type === 'market_asset' || n.type === 'output'
const isIn = n.type === 'input'
const fill = isOut ? (val > 0 ? '#052e16' : val < 0 ? '#450a0a' : '#1e293b') : '#0f172a'
const stroke = isIn ? '#3b82f6'
: isOut ? (val > 0 ? '#16a34a' : val < 0 ? '#dc2626' : '#64748b') : '#475569'
const valColor = val > 0 ? '#4ade80' : val < 0 ? '#f87171' : '#94a3b8'
const lbl = n.label.length > 14 ? n.label.slice(0, 13) + '…' : n.label
const valStr = val !== undefined
? `${val > 0 ? '+' : ''}${val.toFixed(n.unit === 'pips' ? 0 : 1)}${n.unit === 'pips' ? 'p' : n.unit === '%' ? '%' : ''}`
: '—'
return (
<g key={n.id} transform={`translate(${n.x},${n.y})`}>
<rect x={-BOX_W/2} y={-BOX_H/2} width={BOX_W} height={BOX_H} rx={4}
fill={fill} stroke={stroke} strokeWidth={isOut ? 2 : 1} />
<text y={-7} textAnchor="middle" fill="#94a3b8" fontSize={8} fontFamily="ui-monospace,monospace">{lbl}</text>
<text y={9} textAnchor="middle" fill={val !== undefined ? valColor : '#475569'}
fontSize={10} fontWeight="bold" fontFamily="ui-monospace,monospace">{valStr}</text>
</g>
)
})}
</svg>
)
}
// ── Event detail panel (right side) ──────────────────────────────────────────
function EventDetail({
@@ -327,20 +378,24 @@ function EventDetail({
const [editing, setEditing] = useState(false)
const [editData, setEditData] = useState<Partial<MarketEvent>>({})
const [saving, setSaving] = useState(false)
const [allCats, setAllCats] = useState<EventCategory[]>([])
const [catSaving, setCatSaving] = useState(false)
const [analyses, setAnalyses] = useState<CausalAnalysis[]>([])
// Inline analysis
const [templates, setTemplates] = useState<TemplateRef[]>([])
const [selTmpl, setSelTmpl] = useState(0)
const [instrument, setInstrument] = useState('EURUSD')
const [loadingRec, setLoadingRec] = useState(false)
const [templates, setTemplates] = useState<TemplateRef[]>([])
const [selTmpl, setSelTmpl] = useState(0)
const [loadingRec, setLoadingRec] = useState(false)
const [loadingInst, setLoadingInst] = useState(false)
const [loadingAn, setLoadingAn] = useState(false)
const [recMsg, setRecMsg] = useState<string | null>(null)
const [instInputs, setInstInputs] = useState<Record<string, number>>({})
const [instMsg, setInstMsg] = useState<string | null>(null)
const [anResult, setAnResult] = useState<{ score: number | null; preds: Record<string, number> } | null>(null)
const [loadingAn, setLoadingAn] = useState(false)
const [loadingCreate, setLoadingCreate] = useState(false)
const [recMsg, setRecMsg] = useState<string | null>(null)
const [noTemplateFound, setNoTemplateFound] = useState(false)
const [instInputs, setInstInputs] = useState<Record<string, number>>({})
const [instMsg, setInstMsg] = useState<string | null>(null)
const [anResult, setAnResult] = useState<{
score: number | null
preds: Record<string, number>
actuals: Record<string, number>
} | null>(null)
const [graphData, setGraphData] = useState<GraphData | null>(null)
const loadDetail = useCallback(async () => {
const r = await fetch(`/api/market-events/${event.id}`)
@@ -360,34 +415,6 @@ function EventDetail({
loadAnalyses()
fetch('/api/causal-lab/templates').then(r => r.json()).then(d => setTemplates(Array.isArray(d) ? d : [])).catch(() => {})
}, [loadDetail, loadAnalyses])
useEffect(() => {
fetch('/api/impact/categories')
.then(r => r.json())
.then(d => setAllCats(Array.isArray(d) ? d : []))
.catch(() => {})
}, [])
// Quick-save sub_type without entering full edit mode
const assignCategory = async (catName: string) => {
const cat = allCats.find(c => c.name === catName)
setCatSaving(true)
const payload = {
...detail,
sub_type: catName,
category: cat?.type ?? detail.category,
source_refs: detail.source_refs ?? [],
}
const r = await fetch(`/api/market-events/${event.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
setCatSaving(false)
if (r.ok) {
setDetail(d => ({ ...d, sub_type: catName, category: cat?.type ?? d.category }))
onUpdated({ ...detail, sub_type: catName, category: cat?.type ?? detail.category })
}
}
const saveEdit = async () => {
setSaving(true)
@@ -429,7 +456,8 @@ function EventDetail({
}
const recommendTemplate = async () => {
setLoadingRec(true); setRecMsg(null); setAnResult(null); setInstInputs({}); setInstMsg(null)
setLoadingRec(true); setRecMsg(null); setAnResult(null); setGraphData(null)
setInstInputs({}); setInstMsg(null); setNoTemplateFound(false)
try {
const r = await fetch('/api/causal-lab/recommend', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -443,25 +471,48 @@ function EventDetail({
setRecMsg(`${rec.template_name}${rec.confidence ? ` (${Math.round(rec.confidence * 100)}%)` : ''}`)
await instantiate(rec.template_id)
} else {
setRecMsg('Aucun template — créez-en un dans l\'Editeur')
setRecMsg('Aucun template adapté trouvé')
setNoTemplateFound(true)
}
} catch (e: any) { setRecMsg(`${(e as any).message}`) }
finally { setLoadingRec(false) }
}
const runAnalysis = async () => {
if (!selTmpl) return
setLoadingAn(true); setAnResult(null)
const createTemplateFromEvent = async () => {
setLoadingCreate(true); setRecMsg(null); setNoTemplateFound(false)
try {
const r = await fetch('/api/causal-lab/analyze', {
const r = await fetch('/api/causal-lab/create-from-event', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ market_event_id: detail.id, template_id: selTmpl, instrument, inputs: instInputs, coef_overrides: {} }),
body: JSON.stringify({ market_event_id: detail.id }),
})
const d = await r.json()
if (!r.ok) throw new Error(d.detail || r.statusText)
setAnResult({ score: d.activation?.score ?? null, preds: d.node_values ?? {} })
const tmplRes = await fetch('/api/causal-lab/templates')
const tmplData = await tmplRes.json()
setTemplates(Array.isArray(tmplData) ? tmplData : [])
if (d.id) {
setSelTmpl(d.id)
setRecMsg(`✓ Template créé : ${d.name}`)
await instantiate(d.id)
}
} catch (e: any) { setRecMsg(`${(e as any).message}`) }
finally { setLoadingCreate(false) }
}
const runAnalysis = async () => {
if (!selTmpl) return
setLoadingAn(true); setAnResult(null); setGraphData(null)
try {
const r = await fetch('/api/causal-lab/analyze', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ market_event_id: detail.id, template_id: selTmpl, inputs: instInputs, coef_overrides: {} }),
})
const d = await r.json()
if (!r.ok) throw new Error(d.detail || r.statusText)
setAnResult({ score: d.activation?.score ?? null, preds: d.node_values ?? {}, actuals: d.actual_moves ?? {} })
if (d.graph_json?.nodes?.length) setGraphData(d.graph_json)
await loadAnalyses()
} catch (e: any) { setAnResult({ score: null, preds: {} }) }
} catch (e: any) { setAnResult({ score: null, preds: {}, actuals: {} }) }
finally { setLoadingAn(false) }
}
@@ -482,7 +533,6 @@ function EventDetail({
<h2 className="text-slate-100 font-bold text-sm leading-snug">{detail.name}</h2>
)}
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
<CatBadge cat={detail.category} />
<span className={clsx('text-xs font-semibold', LEVEL_COLORS[detail.level])}>{detail.level}</span>
<span className="text-xs text-slate-500">{detail.start_date}</span>
{detail.end_date && <span className="text-xs text-slate-600"> {detail.end_date}</span>}
@@ -524,63 +574,6 @@ function EventDetail({
)}
</div>
{/* ── Catégorie IA ─────────────────────────────────────────────── */}
{(() => {
const currentSub = editing ? (editData.sub_type ?? detail.sub_type ?? '') : (detail.sub_type ?? '')
const matchedCat = allCats.find(c => c.name === currentSub)
const nDefaults = matchedCat?.default_impacts?.length ?? 0
return (
<div className="bg-slate-800/40 rounded-lg p-3 border border-slate-700/30">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-500 uppercase tracking-wide">Catégorie IA</span>
{matchedCat && (
<span className="text-xs text-slate-600">
{nDefaults} impact{nDefaults !== 1 ? 's' : ''} par défaut
</span>
)}
</div>
{editing ? (
<select
value={editData.sub_type ?? detail.sub_type ?? ''}
onChange={e => {
const cat = allCats.find(c => c.name === e.target.value)
setEditData(d => ({
...d,
sub_type: e.target.value,
...(cat ? { category: cat.type } : {}),
}))
}}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1.5 text-slate-200 text-sm"
>
<option value=""> aucune catégorie </option>
{allCats.map(c => (
<option key={c.name} value={c.name}>{c.name} ({c.type})</option>
))}
</select>
) : (
<div className="flex items-center gap-2">
<select
value={currentSub}
onChange={e => assignCategory(e.target.value)}
disabled={catSaving}
className="flex-1 bg-dark-900 border border-slate-700 rounded px-2 py-1.5 text-slate-200 text-sm"
>
<option value=""> aucune catégorie </option>
{allCats.map(c => (
<option key={c.name} value={c.name}>{c.name} ({c.type})</option>
))}
</select>
{catSaving && <Loader2 size={13} className="animate-spin text-slate-500 shrink-0" />}
</div>
)}
{matchedCat?.description && (
<div className="text-xs text-slate-600 mt-1.5 italic">{matchedCat.description}</div>
)}
</div>
)
})()}
{/* Description */}
{editing ? (
<textarea
@@ -595,14 +588,6 @@ function EventDetail({
{editing ? (
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs text-slate-500 mb-1">Type</div>
<select defaultValue={detail.category}
onChange={e => setEditData(d => ({ ...d, category: e.target.value }))}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Niveau</div>
<select defaultValue={detail.level}
@@ -758,6 +743,17 @@ function EventDetail({
</div>
)}
{noTemplateFound && (
<button
onClick={createTemplateFromEvent}
disabled={loadingCreate}
className="flex items-center gap-1.5 text-xs bg-purple-800/30 hover:bg-purple-700/50 border border-purple-700/40 text-purple-300 rounded px-2 py-1.5 w-full justify-center disabled:opacity-50"
>
{loadingCreate ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} />}
{loadingCreate ? 'Création IA en cours' : "L'IA peut créer un template"}
</button>
)}
{/* Instantiation status + inputs preview */}
{selTmpl > 0 && (
<div className="space-y-1">
@@ -789,24 +785,15 @@ function EventDetail({
</div>
)}
{/* Instrument + analyze */}
{/* Analyze + open in CausalLab */}
<div className="flex gap-1.5">
<select
value={instrument}
onChange={e => setInstrument(e.target.value)}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-300"
>
{['EURUSD', 'XAUUSD', 'SP500', 'BRENT', 'US10Y', 'EU10Y'].map(i => (
<option key={i} value={i}>{i}</option>
))}
</select>
<button
onClick={runAnalysis}
disabled={!selTmpl || loadingAn}
className="flex-1 flex items-center justify-center gap-1.5 text-xs bg-blue-700/50 hover:bg-blue-700/70 border border-blue-700/40 text-blue-200 rounded px-3 py-1.5 disabled:opacity-40"
>
{loadingAn ? <Loader2 size={10} className="animate-spin" /> : null}
{loadingAn ? 'Analyse' : 'Lancer'}
{loadingAn ? 'Analyse' : 'Lancer l\'analyse'}
</button>
<button
onClick={() => navigate(`/causal-lab?template=${selTmpl || ''}`)}
@@ -819,7 +806,8 @@ function EventDetail({
{/* Inline result */}
{anResult && (
<div className="bg-slate-900/60 rounded p-2 space-y-1">
<div className="bg-slate-900/60 rounded p-2 space-y-2">
{/* Activation score */}
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">Activation</span>
{anResult.score != null ? (
@@ -828,24 +816,46 @@ function EventDetail({
anResult.score >= 0.7 ? 'text-emerald-400' :
anResult.score >= 0.4 ? 'text-amber-400' : 'text-red-400'
)}>{Math.round(anResult.score * 100)}%</span>
) : <span className="text-xs text-red-400">Erreur</span>}
) : <span className="text-xs text-slate-500">données manquantes</span>}
</div>
{Object.entries(anResult.preds)
.filter(([k]) => !k.includes('error'))
.sort(([, a], [, b]) => Math.abs(b) - Math.abs(a))
.slice(0, 4)
.map(([k, v]) => (
<div key={k} className="flex justify-between items-center">
<span className="text-xs text-slate-500 truncate">{k}</span>
<span className={clsx('text-xs font-mono', v > 0 ? 'text-emerald-400' : 'text-red-400')}>
{v > 0 ? '+' : ''}{Math.round(v)} pip
</span>
</div>
))}
{/* Per-instrument actual moves */}
{Object.keys(anResult.actuals).length > 0 && (
<div className="space-y-1">
{Object.entries(anResult.actuals).map(([inst, move]) => {
const pred = Object.entries(anResult.preds)
.find(([k]) => k.toLowerCase().includes(inst.toLowerCase()))?.[1]
return (
<div key={inst} className="flex items-center justify-between gap-2">
<span className="text-xs font-mono text-slate-400 w-16 shrink-0">{inst}</span>
{pred !== undefined && (
<span className={clsx('text-xs font-mono', pred > 0 ? 'text-blue-400' : 'text-orange-400')}>
pred {pred > 0 ? '+' : ''}{Math.round(pred)}p
</span>
)}
<span className={clsx('text-xs font-mono font-bold ml-auto', move > 0 ? 'text-emerald-400' : 'text-red-400')}>
{move > 0 ? '+' : ''}{Math.round(move)}p
</span>
</div>
)
})}
</div>
)}
{/* Causal graph visualization */}
{graphData?.nodes?.length && (
<div className="border-t border-slate-700/50 pt-2">
<CausalGraphMini
nodes={graphData.nodes}
edges={graphData.edges}
nodeValues={anResult.preds}
/>
</div>
)}
</div>
)}
{templates.length === 0 && (
{templates.length === 0 && !noTemplateFound && (
<p className="text-xs text-slate-600 italic">
Aucun template disponible —{' '}
<button onClick={() => navigate('/causal-lab')} className="text-blue-500 hover:text-blue-400 underline">
@@ -865,8 +875,7 @@ function EventDetail({
function CreateModal({ onCreated, onClose }: { onCreated: () => void; onClose: () => void }) {
const [form, setForm] = useState({
name: '', start_date: new Date().toISOString().slice(0, 10),
category: 'fundamental', level: 'short', sub_type: '',
description: '', impact_score: 0.5,
level: 'short', description: '', impact_score: 0.5,
})
const [saving, setSaving] = useState(false)
@@ -902,13 +911,6 @@ function CreateModal({ onCreated, onClose }: { onCreated: () => void; onClose: (
<input type="date" value={form.start_date} onChange={f('start_date')}
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm" />
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Catégorie</div>
<select value={form.category} onChange={f('category')}
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm">
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Niveau</div>
<select value={form.level} onChange={f('level')}
@@ -916,11 +918,6 @@ function CreateModal({ onCreated, onClose }: { onCreated: () => void; onClose: (
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Sous-type</div>
<input value={form.sub_type} onChange={f('sub_type')} placeholder="ex: CPI, Conflit..."
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm" />
</div>
</div>
<textarea value={form.description} onChange={f('description')} placeholder="Description..." rows={3}
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm resize-none" />
@@ -956,7 +953,7 @@ function EventRow({
selected && 'bg-slate-800/60 border-l-2 border-l-blue-500',
)}
>
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', CAT_DOT[ev.category] ?? 'bg-slate-500')} />
<span className="w-1.5 h-1.5 rounded-full shrink-0 bg-slate-500" />
<div className="flex-1 min-w-0">
<div className="text-sm text-slate-200 truncate font-medium">{ev.name}</div>
<div className="flex items-center gap-2 mt-0.5">
@@ -984,327 +981,12 @@ function EventRow({
)
}
// ── Types catégorie ──────────────────────────────────────────────────────────
interface DefaultImpact {
instrument_id: string
sensitivity: number // 0-1
typical_direction: string
notes: string
}
interface EventCategory {
id?: number
name: string
type: string
sub_type: string
description: string
default_impacts: DefaultImpact[]
is_ai_generated?: number
updated_at?: string
}
// ── Category editor panel ─────────────────────────────────────────────────────
function CategoryEditor({
cat, onSaved, onDeleted, onClose,
}: {
cat: EventCategory | null
onSaved: () => void
onDeleted?: () => void
onClose: () => void
}) {
const isNew = !cat?.id
const [form, setForm] = useState<EventCategory>(cat ?? {
name: '', type: 'event_calendar', sub_type: '', description: '', default_impacts: [],
})
const [saving, setSaving] = useState(false)
const [deleting, setDel] = useState(false)
const save = async () => {
if (!form.name) return
setSaving(true)
await fetch(`/api/impact/categories/${encodeURIComponent(form.name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: form.type, sub_type: form.sub_type,
description: form.description,
default_impacts: form.default_impacts,
}),
})
setSaving(false)
onSaved()
onClose()
}
const del = async () => {
if (!cat?.name || !confirm(`Supprimer la catégorie "${cat.name}" ?`)) return
setDel(true)
await fetch(`/api/impact/categories/${encodeURIComponent(cat.name)}`, { method: 'DELETE' })
setDel(false)
onDeleted?.()
onClose()
}
const setImpact = (idx: number, field: keyof DefaultImpact, value: any) => {
setForm(f => {
const impacts = [...f.default_impacts]
impacts[idx] = { ...impacts[idx], [field]: value }
return { ...f, default_impacts: impacts }
})
}
const addImpact = () => {
setForm(f => ({
...f,
default_impacts: [...f.default_impacts, { instrument_id: 'SPY', sensitivity: 0.5, typical_direction: 'depends_on_outcome', notes: '' }],
}))
}
const removeImpact = (idx: number) => {
setForm(f => ({ ...f, default_impacts: f.default_impacts.filter((_, i) => i !== idx) }))
}
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex items-center gap-3 px-5 py-4 border-b border-slate-700/40 shrink-0">
<h3 className="text-slate-100 font-semibold text-sm flex-1">
{isNew ? 'Nouvelle catégorie' : `Éditer : ${cat!.name}`}
</h3>
{!isNew && (
<button onClick={del} disabled={deleting}
className="flex items-center gap-1 text-xs text-red-400 hover:text-red-300 border border-red-800/50 rounded px-2 py-1">
{deleting ? <Loader2 size={11} className="animate-spin" /> : <Trash2 size={11} />} Supprimer
</button>
)}
<button onClick={onClose} className="text-slate-500 hover:text-slate-300"><X size={14} /></button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
{/* Name + type */}
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-slate-500 mb-1">Nom *</div>
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
disabled={!isNew}
placeholder="ex: BOE Décision de taux"
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-1.5 text-slate-200 text-sm disabled:opacity-50" />
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Type</div>
<select value={form.type} onChange={e => setForm(f => ({ ...f, type: e.target.value }))}
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-1.5 text-slate-200 text-sm">
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Sous-type</div>
<input value={form.sub_type} onChange={e => setForm(f => ({ ...f, sub_type: e.target.value }))}
placeholder="ex: BOE"
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-1.5 text-slate-200 text-sm" />
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Description</div>
<input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
placeholder="Résumé court..."
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-1.5 text-slate-200 text-sm" />
</div>
</div>
{/* Default impacts table */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-slate-500 uppercase tracking-wide">
Impacts par défaut
<span className="text-slate-600 ml-1.5 normal-case">— l'IA s'appuie sur ces sensibilités comme base</span>
</div>
<button onClick={addImpact}
className="flex items-center gap-1 text-xs text-emerald-400 hover:text-emerald-300 border border-emerald-800/50 rounded px-2 py-1">
<Plus size={11} /> Ajouter
</button>
</div>
{form.default_impacts.length === 0 ? (
<div className="text-xs text-slate-600 italic px-3 py-3 bg-slate-800/20 rounded-lg border border-slate-800/40">
Aucun impact par défaut — cliquez sur "Ajouter" pour en définir.
</div>
) : (
<div className="bg-slate-800/30 rounded-lg overflow-hidden">
{/* Header */}
<div className="grid text-xs text-slate-600 px-3 py-1.5 border-b border-slate-800 bg-slate-800/50"
style={{ gridTemplateColumns: '7rem 8rem 2rem 10rem 1fr 1.5rem' }}>
<span>Instrument</span>
<span>Sensibilité</span>
<span></span>
<span>Direction type</span>
<span>Notes</span>
<span></span>
</div>
{form.default_impacts.map((imp, idx) => (
<div key={idx} className="grid items-center gap-2 px-3 py-2 border-b border-slate-800/60 last:border-0"
style={{ gridTemplateColumns: '7rem 8rem 2rem 10rem 1fr 1.5rem' }}>
<select value={imp.instrument_id}
onChange={e => setImpact(idx, 'instrument_id', e.target.value)}
className="bg-dark-900 border border-slate-700 rounded px-1.5 py-1 text-slate-200 text-xs w-full">
{ALL_INSTRUMENTS.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<div className="flex flex-col gap-0.5">
<input type="range" min="0" max="1" step="0.05" value={imp.sensitivity}
onChange={e => setImpact(idx, 'sensitivity', +e.target.value)}
className="accent-amber-400 w-full" />
<span className="text-xs text-center text-amber-300">{imp.sensitivity.toFixed(2)}</span>
</div>
<span className={clsx('text-sm font-bold text-center', DIR_COLORS[imp.typical_direction] ?? 'text-slate-400')}>
{DIR_ICONS[imp.typical_direction] ?? '●'}
</span>
<select value={imp.typical_direction}
onChange={e => setImpact(idx, 'typical_direction', e.target.value)}
className="bg-dark-900 border border-slate-700 rounded px-1.5 py-1 text-slate-200 text-xs w-full">
{['bullish','bearish','neutral','depends_on_outcome'].map(d => <option key={d} value={d}>{d}</option>)}
</select>
<input value={imp.notes} onChange={e => setImpact(idx, 'notes', e.target.value)}
placeholder="Note..."
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs w-full" />
<button onClick={() => removeImpact(idx)}
className="text-slate-600 hover:text-red-400"><Trash2 size={12} /></button>
</div>
))}
</div>
)}
</div>
</div>
<div className="px-5 pb-5 pt-3 border-t border-slate-700/30 flex gap-2 shrink-0">
<button onClick={save} disabled={saving || !form.name}
className="flex items-center gap-2 bg-emerald-700 hover:bg-emerald-600 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded-lg px-5 py-2 text-sm font-medium">
{saving ? <Loader2 size={14} className="animate-spin" /> : <CheckCircle size={14} />}
{isNew ? 'Créer' : 'Sauvegarder'}
</button>
<button onClick={onClose} className="text-sm text-slate-500 hover:text-slate-300 px-4">Annuler</button>
</div>
</div>
)
}
// ── Categories panel ──────────────────────────────────────────────────────────
function CategoriesPanel() {
const [cats, setCats] = useState<EventCategory[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<EventCategory | null>(null)
const [showNew, setShowNew] = useState(false)
const [search, setSearch] = useState('')
const load = useCallback(async () => {
setLoading(true)
const r = await fetch('/api/impact/categories')
if (r.ok) {
const data = await r.json()
setCats(data.map((c: any) => ({
...c,
default_impacts: typeof c.default_impacts === 'string'
? JSON.parse(c.default_impacts || '[]')
: (c.default_impacts || []),
})))
}
setLoading(false)
}, [])
useEffect(() => { load() }, [load])
const filtered = cats.filter(c =>
!search || c.name.toLowerCase().includes(search.toLowerCase()) ||
c.type.includes(search.toLowerCase()) || c.sub_type.toLowerCase().includes(search.toLowerCase())
)
// Group by type
const grouped = CATEGORIES.reduce((acc, t) => {
acc[t] = filtered.filter(c => c.type === t)
return acc
}, {} as Record<string, EventCategory[]>)
return (
<div className="flex h-full overflow-hidden">
{/* Left: list */}
<div className="w-72 shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
<div className="px-4 py-3 border-b border-slate-700/30 space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-200 flex-1">Catégories ({cats.length})</span>
<button onClick={() => { setSelected(null); setShowNew(true) }}
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs rounded px-2 py-1">
<Plus size={11} /> Nouvelle
</button>
<button onClick={load} disabled={loading}
className="p-1 text-slate-500 hover:text-slate-300">
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
</button>
</div>
<div className="relative">
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Filtrer..."
className="w-full bg-slate-800/60 border border-slate-700/50 rounded pl-7 pr-3 py-1.5 text-xs text-slate-200 placeholder:text-slate-600" />
</div>
</div>
<div className="flex-1 overflow-y-auto">
{CATEGORIES.map(type => {
const group = grouped[type] ?? []
if (group.length === 0) return null
return (
<div key={type}>
<div className={clsx('px-3 py-1.5 text-xs font-semibold uppercase tracking-wide sticky top-0 bg-dark-900/90',
CAT_COLORS[type]?.split(' ')[1] ?? 'text-slate-500')}>
{type.replace('_', ' ')} ({group.length})
</div>
{group.map(c => (
<div key={c.name}
onClick={() => { setSelected(c); setShowNew(false) }}
className={clsx(
'flex items-center gap-2 px-4 py-2.5 cursor-pointer border-b border-slate-800/40 hover:bg-slate-800/40',
selected?.name === c.name && 'bg-slate-800/60 border-l-2 border-l-blue-500',
)}>
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', CAT_DOT[c.type] ?? 'bg-slate-500')} />
<div className="flex-1 min-w-0">
<div className="text-xs text-slate-200 font-medium truncate">{c.name}</div>
<div className="text-xs text-slate-600">{c.default_impacts.length} instruments</div>
</div>
</div>
))}
</div>
)
})}
</div>
</div>
{/* Right: editor */}
<div className="flex-1 overflow-hidden">
{(showNew || selected) ? (
<CategoryEditor
key={showNew ? 'new' : selected!.name}
cat={showNew ? null : selected}
onSaved={() => { load(); setShowNew(false) }}
onDeleted={() => { load(); setSelected(null) }}
onClose={() => { setShowNew(false); setSelected(null) }}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-slate-600 gap-3">
<SlidersHorizontal size={36} className="opacity-30" />
<div className="text-sm">Sélectionne une catégorie pour l'éditer</div>
<div className="text-xs opacity-60">Les impacts par défaut sont utilisés par l'IA comme base de référence</div>
</div>
)}
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
// ── Main page ─────────────────────────────────────────────────────────────────
export default function MarketEvents() {
const [searchParams] = useSearchParams()
const [activeTab, setActiveTab] = useState<'events' | 'categories'>('events')
const [events, setEvents] = useState<MarketEvent[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
@@ -1316,7 +998,6 @@ export default function MarketEvents() {
// ── Filter state ─────────────────────────────────────────────────────────
const [search, setSearch] = useState('')
const [category, setCategory] = useState('')
const [level, setLevel] = useState('')
const [minScore, setMinScore] = useState(0)
const [evaluated, setEvaluated] = useState<'' | 'true' | 'false'>('')
@@ -1368,7 +1049,6 @@ export default function MarketEvents() {
setLoading(true)
const params = new URLSearchParams()
if (search) params.set('search', search)
if (category) params.set('category', category)
if (level) params.set('level', level)
if (minScore) params.set('min_score', String(minScore))
if (dateFrom) params.set('date_from', dateFrom)
@@ -1395,7 +1075,7 @@ export default function MarketEvents() {
if (e.name === 'AbortError') return // superseded by a newer load
}
setLoading(false)
}, [search, category, level, minScore, dateFrom, dateTo, genFrom, genTo, evaluated,
}, [search, level, minScore, dateFrom, dateTo, genFrom, genTo, evaluated,
instTicker, instMinScore, instDir, sortBy, sortDir])
useEffect(() => {
@@ -1431,31 +1111,7 @@ export default function MarketEvents() {
return (
<div className="flex flex-col h-screen overflow-hidden">
{/* ── Tab bar ── */}
<div className="flex items-center gap-0 border-b border-slate-700/40 bg-dark-800/80 px-4 shrink-0">
<h1 className="text-sm font-bold text-slate-300 mr-6 py-3">Market Events</h1>
{(['events', 'categories'] as const).map(tab => (
<button key={tab} onClick={() => setActiveTab(tab)}
className={clsx(
'px-4 py-3 text-sm border-b-2 transition-colors',
activeTab === tab
? 'border-blue-500 text-blue-300 font-medium'
: 'border-transparent text-slate-500 hover:text-slate-300',
)}>
{tab === 'events' ? '📋 Événements' : '🏷️ Catégories & Defaults'}
</button>
))}
</div>
{/* ── Categories tab ── */}
{activeTab === 'categories' && (
<div className="flex-1 overflow-hidden">
<CategoriesPanel />
</div>
)}
{/* ── Events tab ── */}
{activeTab === 'events' && <div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 overflow-hidden">
{/* ── Left panel: filters + list ── */}
<div className="w-96 shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
{/* ── Search + actions ── */}
@@ -1505,18 +1161,6 @@ export default function MarketEvents() {
</div>
)}
{/* ── Category chips ── */}
<div className="flex flex-wrap gap-1">
{CATEGORIES.map(c => (
<button key={c} onClick={() => setCategory(category === c ? '' : c)}
className={clsx('px-2 py-0.5 rounded-full text-xs border transition-colors',
category === c
? (CAT_COLORS[c] ?? 'bg-slate-700 text-slate-200 border-slate-600')
: 'border-slate-800 text-slate-600 hover:border-slate-600 hover:text-slate-400')}>
{c.replace('_', ' ')}
</button>
))}
</div>
</div>
{/* ── Sort + more filters toggle ── */}
@@ -1673,9 +1317,9 @@ export default function MarketEvents() {
<div className="px-3 py-1 text-xs text-slate-600 border-b border-slate-800/30 flex items-center gap-2">
<span>{loading ? '...' : total} événement{total !== 1 ? 's' : ''}</span>
{instTicker && <span className="text-amber-400">· impact {instTicker}</span>}
{(category || level || search || minScore > 0 || evaluated || dateFrom || dateTo || genFrom || genTo || instTicker) && (
{(level || search || minScore > 0 || evaluated || dateFrom || dateTo || genFrom || genTo || instTicker) && (
<button onClick={() => {
setSearch(''); setCategory(''); setLevel(''); setMinScore(0)
setSearch(''); setLevel(''); setMinScore(0)
setEvaluated(''); setDateFrom(''); setDateTo(''); setDatePreset('all')
setGenFrom(''); setGenTo('')
setInstTicker(''); setInstDir(''); setInstMin(0.3); setSortBy('date')
@@ -1729,7 +1373,7 @@ export default function MarketEvents() {
</div>
{showCreate && <CreateModal onCreated={load} onClose={() => setShowCreate(false)} />}
</div>}
</div>
</div>
)
}