feat: instrument model

This commit is contained in:
OpenSquared
2026-07-02 20:31:30 +02:00
parent ae5865a156
commit aecbdc9929
7 changed files with 1423 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ from routers import institutional as institutional_router
from routers import eco as eco_router
from routers import simulator as simulator_router
from routers import causal_lab as causal_lab_router
from routers import instrument_models as instrument_models_router
from services.database import init_db, get_config, cleanup_stale_running_cycles
import os
import logging
@@ -101,6 +102,15 @@ def startup():
_log.info(f"[Startup] Guidance sync done: {_gr}")
except Exception as _e:
_log.warning(f"[Startup] Guidance sync failed: {_e}")
# Seed instrument models (graphes causaux exhaustifs par instrument)
try:
from services.database import get_conn as _get_conn
from services.instrument_models import seed_instrument_models as _sim
_im_conn = _get_conn()
_sim(_im_conn); _im_conn.close()
_log.info("[Startup] Instrument models seeded")
except Exception as _e:
_log.warning(f"[Startup] Instrument models seed failed: {_e}")
# Auto-bootstrap désactivé — utiliser les boutons dans Cycle Actions / Timeline
# Start auto-cycle scheduler if enabled
from services.auto_cycle import start_scheduler
@@ -222,6 +232,7 @@ app.include_router(ai_desks_router.router)
app.include_router(eco_router.router)
app.include_router(simulator_router.router)
app.include_router(causal_lab_router.router)
app.include_router(instrument_models_router.router)
@app.get("/")

View File

@@ -0,0 +1,102 @@
"""
Instrument Models Router — graphes causaux exhaustifs par instrument.
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
router = APIRouter(prefix="/api/instrument-models", tags=["instrument-models"])
class OverrideBody(BaseModel):
value: float
note: Optional[str] = ""
@router.get("", response_model=List[Dict[str, Any]])
def list_instrument_models():
"""Liste tous les modèles (métadonnées, sans valeurs calculées)."""
from services.database import get_conn
from services.instrument_models import INSTRUMENT_MODELS
conn = get_conn()
try:
rows = conn.execute(
"SELECT instrument, updated_at FROM instrument_models ORDER BY instrument"
).fetchall()
result = []
for r in rows:
inst = r["instrument"]
meta = INSTRUMENT_MODELS.get(inst, {})
node_counts = {"structural": 0, "event_driven": 0, "output": 0}
for n in meta.get("nodes", []):
node_counts[n.get("type", "structural")] = node_counts.get(n.get("type", "structural"), 0) + 1
result.append({
"instrument": inst,
"name": meta.get("name", inst),
"description": meta.get("description", ""),
"n_structural": node_counts["structural"],
"n_event_driven": node_counts["event_driven"],
"updated_at": r["updated_at"],
})
return result
finally:
conn.close()
@router.get("/{instrument}")
def get_instrument_model(
instrument: str,
at_date: Optional[str] = Query(None, description="YYYY-MM-DD (défaut: aujourd'hui)"),
) -> Dict[str, Any]:
"""Graphe complet avec valeurs courantes des nœuds."""
from services.database import get_conn
from services.instrument_models import get_model_state
conn = get_conn()
try:
state = get_model_state(conn, instrument.upper(), at_date)
if not state:
raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}")
return state
finally:
conn.close()
@router.put("/{instrument}/nodes/{node_id}/override")
def set_override(instrument: str, node_id: str, body: OverrideBody) -> Dict[str, Any]:
"""Définit ou met à jour la valeur manuelle d'un nœud structurel."""
from services.database import get_conn
from services.instrument_models import set_node_override
conn = get_conn()
try:
set_node_override(conn, instrument.upper(), node_id, body.value, body.note or "")
return {"ok": True, "instrument": instrument.upper(), "node_id": node_id, "value": body.value}
finally:
conn.close()
@router.delete("/{instrument}/nodes/{node_id}/override")
def clear_override(instrument: str, node_id: str) -> Dict[str, Any]:
"""Supprime l'override manuel d'un nœud (retour à valeur neutre / events)."""
from services.database import get_conn
from services.instrument_models import clear_node_override
conn = get_conn()
try:
clear_node_override(conn, instrument.upper(), node_id)
return {"ok": True, "instrument": instrument.upper(), "node_id": node_id}
finally:
conn.close()
@router.get("/{instrument}/nodes/{node_id}/overrides")
def get_node_overrides(instrument: str) -> List[Dict[str, Any]]:
"""Toutes les overrides manuelles pour un instrument."""
from services.database import get_conn
conn = get_conn()
try:
rows = conn.execute(
"SELECT node_id, value, note, set_at FROM instrument_node_overrides WHERE instrument=? ORDER BY set_at DESC",
(instrument.upper(),)
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()

View File

@@ -0,0 +1,669 @@
"""
Instrument Models — graphe causal exhaustif par instrument.
Deux types de nœuds :
- structural : facteurs éditables manuellement (carry, taux réels, croissance…)
- event_driven: pressions auto depuis causal_event_analyses, groupées par catégorie template
- output : nœud résultat (somme en pips)
Valeur d'un nœud structural = override utilisateur × coefficient → pips
Valeur d'un nœud event_driven = sum(pips × decay) depuis les analyses actives
Output = sum(tous les nœuds en pips)
"""
import json
import math
from datetime import datetime, timedelta, date as date_type
from typing import Optional
# ── Helpers ────────────────────────────────────────────────────────────────────
def _decay(days: int, absorption: int, dtype: str) -> float:
if days < 0: return 0.0
if dtype == "step": return 1.0 if days <= absorption else 0.0
if dtype == "linear": return max(0.0, 1.0 - days / max(absorption, 1))
lam = 3.0 / max(absorption, 1)
return math.exp(-lam * days)
# ── Comprehensive node definitions ─────────────────────────────────────────────
# coefficient (structural only) : native_unit × coefficient = pips impact
INSTRUMENT_MODELS: dict[str, dict] = {
# ═══════════════════════════════════════════════════════════════════════════════
"EURUSD": {
"name": "EUR/USD", "output_node": "eurusd",
"description": "Taux de change Euro / Dollar — pair G10 la plus liquide au monde",
"nodes": [
# ── Monétaire ──────────────────────────────────────────────────────────────
{"id":"rate_diff_ois_2y","label":"Spread OIS 2Y USD-EUR","type":"structural","category":"monetary","unit":"bps","coefficient":1.5,
"description":"Différentiel taux swap OIS 2 ans USD vs EUR. Principal déterminant court terme. Positif = USD plus rémunérateur → EUR/USD ↓"},
{"id":"fed_path_12m","label":"Anticipation Fed 12m","type":"structural","category":"monetary","unit":"bps","coefficient":0.8,
"description":"Variation cumulée taux Fed attendue sur 12m (négatif = cuts → USD ↓ → EUR/USD ↑)"},
{"id":"ecb_path_12m","label":"Anticipation BCE 12m","type":"structural","category":"monetary","unit":"bps","coefficient":-0.8,
"description":"Variation cumulée taux BCE attendue sur 12m (négatif = cuts → EUR ↓ → EUR/USD ↓)"},
{"id":"us_real_rate","label":"Taux réel US 10Y (TIPS)","type":"structural","category":"monetary","unit":"bps","coefficient":-0.5,
"description":"Rendement TIPS 10Y. Hausse = USD attractif pour capitaux → EUR/USD ↓"},
{"id":"eu_real_rate","label":"Taux réel EU 10Y","type":"structural","category":"monetary","unit":"bps","coefficient":0.5,
"description":"Taux réel zone euro 10Y. Hausse = EUR attractif → EUR/USD ↑"},
{"id":"carry_attractiveness","label":"Attractivité carry EUR/USD","type":"structural","category":"monetary","unit":"score","coefficient":0.6,
"description":"Score synthétique carry trade EUR vs USD (+= EUR avantageux, -=USD avantageux)"},
# ── Macro ──────────────────────────────────────────────────────────────────
{"id":"us_growth_advantage","label":"Avantage croissance US/EU","type":"structural","category":"macro","unit":"pts","coefficient":-15.0,
"description":"Différentiel PIB US - EU (annualisé). US surperformance → USD fort → pair ↓"},
{"id":"us_labor_market","label":"Marché emploi US","type":"structural","category":"macro","unit":"score","coefficient":-0.4,
"description":"Score santé marché emploi US (NFP, chômage, salaires). Fort = USD ↑"},
{"id":"eu_pmi_composite","label":"PMI composite Eurozone","type":"structural","category":"macro","unit":"pts","coefficient":0.3,
"description":"PMI composite zone euro (>50 = expansion). Hausse = EUR ↑"},
# ── Inflation ──────────────────────────────────────────────────────────────
{"id":"us_cpi_yoy","label":"CPI US YoY","type":"structural","category":"inflation","unit":"%","coefficient":-0.8,
"description":"Inflation américaine. Hausse surprise → Fed plus hawkish → USD ↑ → pair ↓"},
{"id":"eu_cpi_yoy","label":"HICP Eurozone YoY","type":"structural","category":"inflation","unit":"%","coefficient":0.8,
"description":"Inflation zone euro. Hausse surprise → BCE plus hawkish → EUR ↑"},
# ── Géopolitique & Politique ────────────────────────────────────────────────
{"id":"eu_fragmentation_risk","label":"Risque fragmentation UE","type":"structural","category":"political","unit":"score","coefficient":-0.5,
"description":"Risque politique/fragmentation zone euro (0=stable, 100=crise). Hausse → EUR ↓"},
{"id":"us_political_risk","label":"Incertitude politique US","type":"structural","category":"political","unit":"score","coefficient":0.3,
"description":"Incertitude politique américaine (debt ceiling, shutdown, élections). Hausse → USD ↓"},
{"id":"energy_price_impact","label":"Prix énergie (termes échanges EU)","type":"structural","category":"macro","unit":"$/bbl","coefficient":-0.25,
"description":"Prix pétrole/gaz. Hausse élargit déficit commercial EU → EUR ↓ structurellement"},
# ── Sentiment & Risque ─────────────────────────────────────────────────────
{"id":"risk_appetite","label":"Appétit risque mondial","type":"structural","category":"sentiment","unit":"score","coefficient":0.5,
"description":"Score risk-on/off global (+100=risk-on max). Risk-on → sorties USD → EUR/USD ↑"},
{"id":"vix_level","label":"Niveau VIX","type":"structural","category":"sentiment","unit":"pts","coefficient":-0.4,
"description":"VIX. Spike → flight to USD safe haven → EUR/USD ↓"},
# ── Positionnement & Flux ─────────────────────────────────────────────────
{"id":"cftc_eur_net","label":"Positions nettes EUR (CoT)","type":"structural","category":"positioning","unit":"k contrats","coefficient":0.1,
"description":"Positions spéculatives nettes EUR sur CME (CoT). Long extrême → risque retournement."},
{"id":"dollar_reserve_demand","label":"Demande réserves USD","type":"structural","category":"flows","unit":"score","coefficient":-0.4,
"description":"Demande globale de réserves en USD (score). Fort = USD structurellement demandé → pair ↓"},
# ── Event-driven (auto depuis causal_event_analyses) ────────────────────────
{"id":"ev_central_bank","label":"Pression Banques Centrales","type":"event_driven","category":"central_bank","unit":"pips",
"description":"Contributions cumulées des événements banques centrales actifs (décisions, minutes, guidance). Décroissance exp."},
{"id":"ev_macro_surprise","label":"Surprise Données Macro","type":"event_driven","category":"monetary_shock","unit":"pips",
"description":"Surprises publications économiques (CPI, NFP, PIB, PMI, ventes détail). Décroissance rapide ~12j."},
{"id":"ev_geopolitical","label":"Risque Géopolitique","type":"event_driven","category":"geopolitical","unit":"pips",
"description":"Chocs géopolitiques actifs (conflits, sanctions, tensions). Décroissance linéaire ~21j."},
{"id":"ev_trade_policy","label":"Choc Commercial / Tarifs","type":"event_driven","category":"trade_policy","unit":"pips",
"description":"Annonces commerciales (tarifs, accords, menaces). Décroissance lente."},
{"id":"ev_growth_shock","label":"Choc de Croissance","type":"event_driven","category":"growth_shock","unit":"pips",
"description":"Chocs perspectives croissance (récession, rebond inattendu)."},
{"id":"ev_commodity","label":"Choc Commodités","type":"event_driven","category":"commodity","unit":"pips",
"description":"Chocs matières premières (pétrole, gaz, métaux) impactant USD ou EUR."},
{"id":"ev_credit_stress","label":"Stress Crédit / Liquidité","type":"event_driven","category":"credit_stress","unit":"pips",
"description":"Événements stress crédit/liquidité (banking stress, spreads IG/HY)."},
{"id":"ev_sentiment","label":"Sentiment & Positionnement","type":"event_driven","category":"sentiment","unit":"pips",
"description":"Changements sentiment et flux positionnement institutionnel."},
{"id":"ev_technical","label":"Momentum Technique","type":"event_driven","category":"technical","unit":"pips",
"description":"Signaux techniques (cassures, croisements MA, niveaux clés)."},
# ── Output ─────────────────────────────────────────────────────────────────
{"id":"eurusd","label":"EUR/USD Impact Net","type":"output","category":"output","unit":"pips",
"description":"Pression nette = Σ(structurels × coefficient) + Σ(event-driven en pips)"},
]
},
# ═══════════════════════════════════════════════════════════════════════════════
"USDJPY": {
"name": "USD/JPY", "output_node": "usdjpy",
"description": "Taux de change Dollar / Yen — pair carry & safe haven par excellence",
"nodes": [
{"id":"yield_diff_10y","label":"Différentiel rendement 10Y US-JP","type":"structural","category":"monetary","unit":"bps","coefficient":1.2,
"description":"Spread rendement UST10Y - JGB10Y. Principal moteur USD/JPY. Hausse = USD/JPY ↑"},
{"id":"fed_path_12m","label":"Anticipation Fed 12m","type":"structural","category":"monetary","unit":"bps","coefficient":0.6,
"description":"Variation cumulée taux Fed 12m. Hausse = USD ↑ → USD/JPY ↑"},
{"id":"boj_policy_stance","label":"Biais BoJ (hawkish/dovish)","type":"structural","category":"monetary","unit":"score","coefficient":-8.0,
"description":"Score posture BoJ (+= hawkish). Hawkish BoJ → JPY apprécie → USD/JPY ↓"},
{"id":"jgb_yield_10y","label":"Rendement JGB 10Y","type":"structural","category":"monetary","unit":"bps","coefficient":-0.8,
"description":"Rendement JGB 10Y. Hausse = JPY attractif → USD/JPY ↓"},
{"id":"us_real_rate","label":"Taux réel US 10Y","type":"structural","category":"monetary","unit":"bps","coefficient":0.6,
"description":"TIPS 10Y. Hausse = USD attractif vs actifs risqués → USD/JPY ↑"},
{"id":"risk_appetite","label":"Appétit risque mondial","type":"structural","category":"sentiment","unit":"score","coefficient":-1.2,
"description":"Score risk-on. Risk-off → fuite vers JPY safe haven → USD/JPY ↓"},
{"id":"vix_level","label":"Niveau VIX","type":"structural","category":"sentiment","unit":"pts","coefficient":-0.8,
"description":"VIX. Spike → demande JPY refuge → USD/JPY ↓"},
{"id":"carry_trade_momentum","label":"Momentum carry USD/JPY","type":"structural","category":"positioning","unit":"score","coefficient":0.4,
"description":"Force du carry trade. Positif = flux acheteurs USD/JPY (emprunter JPY, investir USD)."},
{"id":"cftc_jpy_net_short","label":"Positions nettes JPY short (CoT)","type":"structural","category":"positioning","unit":"k contrats","coefficient":0.15,
"description":"Positions short JPY spéculatifs CoT CFTC. Extrême = risque short squeeze → USD/JPY ↓ brutal."},
{"id":"mof_intervention_risk","label":"Risque intervention MoF","type":"structural","category":"political","unit":"score","coefficient":-0.8,
"description":"Probabilité intervention verbale/physique du Trésor japonais (0=aucune, 100=imminente). Hausse → USD/JPY ↓ préventif."},
{"id":"japan_current_account","label":"Balance courante Japon","type":"structural","category":"flows","unit":"Mds¥","coefficient":-0.05,
"description":"Excédent courant japonais. Large surplus = rapatriements YEN → USD/JPY ↓ structurel."},
{"id":"us_japan_trade_tension","label":"Tensions commerciales US-Japon","type":"structural","category":"political","unit":"score","coefficient":0.3,
"description":"Tensions bilatérales US-Japon (tarifs, pressions). Hausse → USD/JPY incertain."},
{"id":"ev_central_bank","label":"Pression Banques Centrales","type":"event_driven","category":"central_bank","unit":"pips","description":"Fed, BoJ decisions/minutes actifs."},
{"id":"ev_macro_surprise","label":"Surprise Macro","type":"event_driven","category":"monetary_shock","unit":"pips","description":"NFP, CPI US, Tankan, CPI Japon surprises."},
{"id":"ev_geopolitical","label":"Risque Géopolitique","type":"event_driven","category":"geopolitical","unit":"pips","description":"Tensions NK, conflits régionaux → JPY safe haven."},
{"id":"ev_trade_policy","label":"Choc Commercial","type":"event_driven","category":"trade_policy","unit":"pips","description":"Tarifs US-Japon, accords commerciaux."},
{"id":"ev_growth_shock","label":"Choc Croissance","type":"event_driven","category":"growth_shock","unit":"pips","description":"Chocs PIB/récession → risk-off → JPY."},
{"id":"ev_credit_stress","label":"Stress Crédit","type":"event_driven","category":"credit_stress","unit":"pips","description":"Stress bancaire → flight to JPY."},
{"id":"ev_sentiment","label":"Sentiment & Flux","type":"event_driven","category":"sentiment","unit":"pips","description":"Positionnement spéculatif, flux risk-on/off."},
{"id":"ev_technical","label":"Momentum Technique","type":"event_driven","category":"technical","unit":"pips","description":"Niveaux clés, tendances USD/JPY."},
{"id":"usdjpy","label":"USD/JPY Impact Net","type":"output","category":"output","unit":"pips","description":"Pression nette cumulée USD/JPY"},
]
},
# ═══════════════════════════════════════════════════════════════════════════════
"XAUUSD": {
"name": "XAU/USD (Or)", "output_node": "xauusd",
"description": "Or vs Dollar — actif refuge, couverture inflation et géopolitique",
"nodes": [
{"id":"us_real_rate_10y","label":"Taux réel US 10Y (TIPS)","type":"structural","category":"monetary","unit":"bps","coefficient":-2.5,
"description":"PLUS IMPORTANT pour l'or. Taux réel US négatif/en baisse → or ↑. Chaque -10bps ≈ +25 pips or."},
{"id":"dxy_level","label":"Indice Dollar (DXY)","type":"structural","category":"monetary","unit":"pts","coefficient":-3.0,
"description":"Niveau DXY. Or libellé en USD → DXY ↑ = or ↓ mécaniquement (corrélation ~-0.75)."},
{"id":"inflation_breakeven_10y","label":"Breakeven inflation 10Y US","type":"structural","category":"inflation","unit":"bps","coefficient":1.5,
"description":"Anticipations inflation 10Y US (TIPS). Hausse = valeur réelle USD ↓ → or demandé comme couverture."},
{"id":"fed_path_12m","label":"Anticipation Fed 12m","type":"structural","category":"monetary","unit":"bps","coefficient":-1.0,
"description":"Variation taux Fed attendue. Cuts attendus → taux réels ↓ → or ↑."},
{"id":"global_uncertainty_score","label":"Indice incertitude mondiale","type":"structural","category":"sentiment","unit":"score","coefficient":0.3,
"description":"Score incertitude géopolitique/économique globale. Hausse → demande refuge or."},
{"id":"vix_level","label":"Niveau VIX","type":"structural","category":"sentiment","unit":"pts","coefficient":0.4,
"description":"VIX. Spike → or comme couverture risque extrême."},
{"id":"central_bank_gold_buying","label":"Achats CB (or)","type":"structural","category":"flows","unit":"tonnes/mois","coefficient":2.0,
"description":"Achats nets d'or par les banques centrales mondiales. Driver structurel majeur depuis 2022."},
{"id":"etf_gold_flows","label":"Flux ETF or (GLD, IAU)","type":"structural","category":"flows","unit":"tonnes","coefficient":1.5,
"description":"Flux net dans les ETF adossés à l'or. Entrées → demande physique → or ↑."},
{"id":"cftc_gold_net_long","label":"Positions nettes or (CoT)","type":"structural","category":"positioning","unit":"k contrats","coefficient":0.15,
"description":"Positions spéculatives nettes or COMEX. Extrême long → risque liquidation → or ↓."},
{"id":"us_fiscal_deficit","label":"Déficit fiscal US (%PIB)","type":"structural","category":"macro","unit":"%","coefficient":0.5,
"description":"Inquiétudes sur la trajectoire fiscale US → doutes sur USD → or refuge."},
{"id":"gold_mine_cost","label":"Coût d'extraction (AISC)","type":"structural","category":"supply","unit":"$/oz","coefficient":0.05,
"description":"All-in sustaining cost mines d'or. Support fondamental pour le prix."},
{"id":"india_china_demand","label":"Demande physique Inde/Chine","type":"structural","category":"flows","unit":"score","coefficient":0.4,
"description":"Demande physique joaillerie/investment Inde et Chine. Saisonnalité (Diwali, Nouvel An chinois)."},
{"id":"ev_central_bank","label":"Pression Banques Centrales","type":"event_driven","category":"central_bank","unit":"pips","description":"Décisions Fed/BoE/BCE → impact taux réels → or."},
{"id":"ev_macro_surprise","label":"Surprise Macro","type":"event_driven","category":"monetary_shock","unit":"pips","description":"CPI/PCE surprises, NFP → réévaluation taux réels → or."},
{"id":"ev_geopolitical","label":"Risque Géopolitique","type":"event_driven","category":"geopolitical","unit":"pips","description":"Conflits, tensions → demande refuge or maximale."},
{"id":"ev_trade_policy","label":"Choc Commercial","type":"event_driven","category":"trade_policy","unit":"pips","description":"Tarifs/guerres commerciales → incertitude → or."},
{"id":"ev_growth_shock","label":"Choc Croissance","type":"event_driven","category":"growth_shock","unit":"pips","description":"Récession → easing monétaire attendu → or ↑."},
{"id":"ev_credit_stress","label":"Stress Crédit / Systémique","type":"event_driven","category":"credit_stress","unit":"pips","description":"Crises bancaires, stress liquidité → or refuge absolu."},
{"id":"ev_commodity","label":"Choc Commodités","type":"event_driven","category":"commodity","unit":"pips","description":"Chocs matières premières (inflation des inputs)."},
{"id":"ev_sentiment","label":"Sentiment & Flux","type":"event_driven","category":"sentiment","unit":"pips","description":"Sentiment risk-off, flux vers or."},
{"id":"ev_technical","label":"Momentum Technique","type":"event_driven","category":"technical","unit":"pips","description":"Cassures ATH, niveaux clés or."},
{"id":"xauusd","label":"XAU/USD Impact Net","type":"output","category":"output","unit":"pips","description":"Pression nette cumulée sur or/USD"},
]
},
# ═══════════════════════════════════════════════════════════════════════════════
"SP500": {
"name": "S&P 500", "output_node": "sp500",
"description": "Indice actions US large cap — baromètre risque global",
"nodes": [
{"id":"fed_path_12m","label":"Anticipation Fed 12m","type":"structural","category":"monetary","unit":"bps","coefficient":-0.6,
"description":"Cuts attendus → taux discount ↓ → PE expansion → SP500 ↑. Chaque -25bps ≈ +15 pts SP500."},
{"id":"us_real_rate_10y","label":"Taux réel US 10Y","type":"structural","category":"monetary","unit":"bps","coefficient":-1.5,
"description":"Taux réel = taux d'actualisation des bénéfices futurs. Hausse → PE compression → SP500 ↓."},
{"id":"eps_growth_12m","label":"Croissance BPA attendue 12m","type":"structural","category":"earnings","unit":"%","coefficient":8.0,
"description":"Révisions croissance bénéfices S&P 500. +1% révision haussière ≈ +8 pts SP500."},
{"id":"eps_revision_ratio","label":"Ratio révisions BPA","type":"structural","category":"earnings","unit":"ratio","coefficient":2.0,
"description":"Ratio révisions haussières/baissières (>1 = majorité haussière). Fort driver momentum."},
{"id":"pe_expansion","label":"Expansion multiple PE","type":"structural","category":"valuation","unit":"x","coefficient":12.0,
"description":"Variation multiple PE du S&P 500. +1x PE ≈ +12 pts SP500 (base ~4500)."},
{"id":"financial_conditions","label":"Indice conditions financières","type":"structural","category":"monetary","unit":"score","coefficient":1.5,
"description":"Chicago Fed NFCI ou Goldman GSFCI. Desserrement = SP500 ↑."},
{"id":"credit_spread_ig","label":"Spread crédit IG (bps)","type":"structural","category":"credit","unit":"bps","coefficient":-0.8,
"description":"Spread obligations IG. Hausse = conditions crédit durcissent → SP500 ↓."},
{"id":"credit_spread_hy","label":"Spread crédit HY (bps)","type":"structural","category":"credit","unit":"bps","coefficient":-0.5,
"description":"Spread high yield. Baromètre stress financier. Spike → SP500 ↓."},
{"id":"buyback_volume","label":"Volume rachats actions","type":"structural","category":"flows","unit":"Mds$/sem","coefficient":0.5,
"description":"Flux de rachats d'actions S&P 500 en cours. Support technique majeur."},
{"id":"retail_flow","label":"Flux retail (FOMO)","type":"structural","category":"flows","unit":"score","coefficient":0.3,
"description":"Score flux acheteurs retail (ETF, options call). Momentum-driven."},
{"id":"consumer_confidence","label":"Confiance consommateur US","type":"structural","category":"macro","unit":"pts","coefficient":0.3,
"description":"Conference Board ou Michigan. Signal dépenses → bénéfices → SP500."},
{"id":"us_gdp_growth","label":"Croissance PIB US","type":"structural","category":"macro","unit":"%","coefficient":5.0,
"description":"Croissance réelle US annualisée. Surprise haussière → SP500 ↑."},
{"id":"vix_level","label":"Niveau VIX","type":"structural","category":"sentiment","unit":"pts","coefficient":-0.8,
"description":"VIX. Spike → risk-off → SP500 ↓. Normalisation VIX → SP500 ↑."},
{"id":"geopolitical_risk_premium","label":"Prime risque géopolitique","type":"structural","category":"political","unit":"score","coefficient":-0.5,
"description":"Tensions géopolitiques majeures → incertitude → prime de risque → SP500 ↓."},
{"id":"ev_central_bank","label":"Pression Banques Centrales","type":"event_driven","category":"central_bank","unit":"pips","description":"Fed pivot, pause, hawkish surprise → SP500 directement."},
{"id":"ev_macro_surprise","label":"Surprise Macro","type":"event_driven","category":"monetary_shock","unit":"pips","description":"NFP, CPI, PIB, ISM surprises."},
{"id":"ev_geopolitical","label":"Risque Géopolitique","type":"event_driven","category":"geopolitical","unit":"pips","description":"Conflits, sanctions → risk-off SP500."},
{"id":"ev_trade_policy","label":"Choc Commercial","type":"event_driven","category":"trade_policy","unit":"pips","description":"Tarifs → chaînes supply, marges bénéficiaires SP500."},
{"id":"ev_growth_shock","label":"Choc Croissance","type":"event_driven","category":"growth_shock","unit":"pips","description":"Récession/reprise choc."},
{"id":"ev_credit_stress","label":"Stress Crédit","type":"event_driven","category":"credit_stress","unit":"pips","description":"Banking stress → SP500 ↓ brutal."},
{"id":"ev_commodity","label":"Choc Commodités","type":"event_driven","category":"commodity","unit":"pips","description":"Choc énergie → marges → SP500."},
{"id":"ev_sentiment","label":"Sentiment & Positionnement","type":"event_driven","category":"sentiment","unit":"pips","description":"Flux institutionnels, CTA positioning."},
{"id":"ev_technical","label":"Momentum Technique","type":"event_driven","category":"technical","unit":"pips","description":"Niveaux clés SP500, MA200, cassures."},
{"id":"sp500","label":"S&P 500 Impact Net","type":"output","category":"output","unit":"pips","description":"Pression nette cumulée S&P 500"},
]
},
# ═══════════════════════════════════════════════════════════════════════════════
"TLT": {
"name": "TLT (US Long Bonds)", "output_node": "tlt",
"description": "ETF obligations US 20Y+ — duration longue, sensible aux taux et récession",
"nodes": [
{"id":"fed_terminal_rate","label":"Taux terminal Fed anticipé","type":"structural","category":"monetary","unit":"bps","coefficient":-0.4,
"description":"Taux Fed terminal pricé par les marchés. Hausse → taux longs up → TLT ↓ (duration ~18)."},
{"id":"us_10y_yield","label":"Rendement UST 10Y","type":"structural","category":"monetary","unit":"bps","coefficient":-0.3,
"description":"Rendement 10Y direct. Chaque +1bps ≈ -$0.18 sur $100 TLT (duration modifiée ~18)."},
{"id":"us_30y_yield","label":"Rendement UST 30Y","type":"structural","category":"monetary","unit":"bps","coefficient":-0.25,
"description":"Rendement 30Y. TLT détient principalement des 20-30Y."},
{"id":"inflation_breakeven_10y","label":"Breakeven inflation 10Y","type":"structural","category":"inflation","unit":"bps","coefficient":-0.2,
"description":"Anticipations inflation 10Y. Hausse → taux nominaux ↑ → TLT ↓."},
{"id":"recession_probability","label":"Probabilité récession 12m","type":"structural","category":"macro","unit":"%","coefficient":0.3,
"description":"Probabilité récession 12m (modèle yield curve ou Fed NY). Hausse → flight to safety → TLT ↑."},
{"id":"fed_qt_pace","label":"Rythme QT Fed (Mds$/mois)","type":"structural","category":"monetary","unit":"Mds$","coefficient":-0.1,
"description":"Réduction bilan Fed (quantitative tightening). Plus vite = pression vendeur sur Treasuries → TLT ↓."},
{"id":"us_fiscal_deficit","label":"Déficit fiscal US (%PIB)","type":"structural","category":"macro","unit":"%","coefficient":-0.4,
"description":"Déficit croissant → supply massive de Treasuries → pression vendeuse → TLT ↓."},
{"id":"term_premium","label":"Prime de terme (ACM)","type":"structural","category":"monetary","unit":"bps","coefficient":-0.3,
"description":"Prime de terme (Adrian-Crump-Moench). Hausse = extra rendement exigé → TLT ↓."},
{"id":"foreign_treasury_demand","label":"Demande étrangère Treasuries","type":"structural","category":"flows","unit":"Mds$","coefficient":0.05,
"description":"Achats Treasuries par banques centrales étrangères (Chine, Japon). Fort = TLT ↑."},
{"id":"yield_curve_slope","label":"Pente courbe 2Y-10Y","type":"structural","category":"monetary","unit":"bps","coefficient":0.1,
"description":"Steepening (2Y-10Y ↑) = TLT ↑ relatif (marché price fin de hausse Fed)."},
{"id":"vix_level","label":"Niveau VIX","type":"structural","category":"sentiment","unit":"pts","coefficient":0.5,
"description":"VIX spike → flight to quality Treasuries → TLT ↑ (sauf si crise stagflationniste)."},
{"id":"sp500_correlation","label":"Corrélation SP500 (inverse)","type":"structural","category":"sentiment","unit":"score","coefficient":-0.3,
"description":"En régime normal : SP500 ↑ → TLT ↓ (rotation risque). Score + = corrélation positive anormale."},
{"id":"ev_central_bank","label":"Pression Banques Centrales","type":"event_driven","category":"central_bank","unit":"pips","description":"FOMC décisions, minutes → impact direct TLT."},
{"id":"ev_macro_surprise","label":"Surprise Macro","type":"event_driven","category":"monetary_shock","unit":"pips","description":"CPI, PCE, NFP → réévaluation taux → TLT."},
{"id":"ev_geopolitical","label":"Risque Géopolitique","type":"event_driven","category":"geopolitical","unit":"pips","description":"Crises → flight to safety Treasuries."},
{"id":"ev_growth_shock","label":"Choc Croissance","type":"event_driven","category":"growth_shock","unit":"pips","description":"Récession → TLT ↑ massif."},
{"id":"ev_credit_stress","label":"Stress Crédit","type":"event_driven","category":"credit_stress","unit":"pips","description":"Banking stress → fuite vers Treasuries."},
{"id":"ev_trade_policy","label":"Choc Commercial","type":"event_driven","category":"trade_policy","unit":"pips","description":"Tarifs → incertitude → Treasuries demandés ou vendus."},
{"id":"ev_sentiment","label":"Sentiment & Flux","type":"event_driven","category":"sentiment","unit":"pips","description":"Flux institutionnels bonds."},
{"id":"ev_technical","label":"Momentum Technique","type":"event_driven","category":"technical","unit":"pips","description":"Niveaux clés TLT, moyennes mobiles."},
{"id":"tlt","label":"TLT Impact Net","type":"output","category":"output","unit":"pips","description":"Pression nette cumulée TLT"},
]
},
# ═══════════════════════════════════════════════════════════════════════════════
"GBPUSD": {
"name": "GBP/USD", "output_node": "gbpusd",
"description": "Taux de change Livre sterling / Dollar — sensible aux données UK et BoE",
"nodes": [
{"id":"boe_path_12m","label":"Anticipation BoE 12m","type":"structural","category":"monetary","unit":"bps","coefficient":0.7,
"description":"Variation cumulée taux BoE attendue 12m. Hikes → GBP ↑ → GBP/USD ↑."},
{"id":"fed_path_12m","label":"Anticipation Fed 12m","type":"structural","category":"monetary","unit":"bps","coefficient":-0.7,
"description":"Variation cumulée taux Fed attendue 12m. Hikes → USD ↑ → GBP/USD ↓."},
{"id":"uk_us_rate_differential","label":"Différentiel taux BoE-Fed","type":"structural","category":"monetary","unit":"bps","coefficient":1.0,
"description":"Spread taux directeurs BoE vs Fed. Principal driver EUR/USD."},
{"id":"uk_cpi_yoy","label":"CPI UK YoY","type":"structural","category":"inflation","unit":"%","coefficient":0.7,
"description":"Inflation UK. Persistance → BoE forcé de rester hawkish → GBP ↑."},
{"id":"uk_gdp_growth","label":"Croissance PIB UK","type":"structural","category":"macro","unit":"%","coefficient":3.0,
"description":"PIB UK annualisé. Surprise haussière → GBP ↑."},
{"id":"uk_pmi_composite","label":"PMI composite UK","type":"structural","category":"macro","unit":"pts","coefficient":0.25,
"description":"PMI composite UK. >50 = expansion → GBP ↑."},
{"id":"uk_labor_market","label":"Marché emploi UK","type":"structural","category":"macro","unit":"score","coefficient":0.4,
"description":"Score santé emploi UK (chômage, salaires, emplois). Fort = BoE hawkish → GBP ↑."},
{"id":"uk_political_risk","label":"Risque politique UK","type":"structural","category":"political","unit":"score","coefficient":-0.4,
"description":"Incertitude politique UK (snap election, budget, Brexit séquelles). Hausse → GBP ↓."},
{"id":"uk_current_account","label":"Balance courante UK (%PIB)","type":"structural","category":"flows","unit":"%","coefficient":0.3,
"description":"Déficit courant UK chronique. Amélioration → moins de pression vendeuse GBP."},
{"id":"risk_appetite","label":"Appétit risque mondial","type":"structural","category":"sentiment","unit":"score","coefficient":0.4,
"description":"Risk-on → GBP comme devise cyclique/risquée apprécie vs USD."},
{"id":"cftc_gbp_net","label":"Positions nettes GBP (CoT)","type":"structural","category":"positioning","unit":"k contrats","coefficient":0.08,
"description":"Positions spéculatives nettes GBP CME CoT."},
{"id":"ev_central_bank","label":"Pression Banques Centrales","type":"event_driven","category":"central_bank","unit":"pips","description":"BoE, Fed décisions/minutes."},
{"id":"ev_macro_surprise","label":"Surprise Macro","type":"event_driven","category":"monetary_shock","unit":"pips","description":"CPI UK/US, NFP, GDP UK surprises."},
{"id":"ev_geopolitical","label":"Risque Géopolitique","type":"event_driven","category":"geopolitical","unit":"pips","description":"Conflits → risk-off → GBP vendu."},
{"id":"ev_trade_policy","label":"Choc Commercial","type":"event_driven","category":"trade_policy","unit":"pips","description":"Tarifs US → UK exposé."},
{"id":"ev_growth_shock","label":"Choc Croissance","type":"event_driven","category":"growth_shock","unit":"pips","description":"Récession UK/US."},
{"id":"ev_credit_stress","label":"Stress Crédit","type":"event_driven","category":"credit_stress","unit":"pips","description":"Stress Gilts, banking UK."},
{"id":"ev_sentiment","label":"Sentiment & Flux","type":"event_driven","category":"sentiment","unit":"pips","description":"Positionnement GBP."},
{"id":"ev_technical","label":"Momentum Technique","type":"event_driven","category":"technical","unit":"pips","description":"Niveaux clés cable."},
{"id":"gbpusd","label":"GBP/USD Impact Net","type":"output","category":"output","unit":"pips","description":"Pression nette cumulée GBP/USD"},
]
},
# ═══════════════════════════════════════════════════════════════════════════════
"EEM": {
"name": "EEM (Marchés Émergents)", "output_node": "eem",
"description": "ETF actions marchés émergents — sensible au dollar, Chine et commodités",
"nodes": [
{"id":"dxy_inverse","label":"Indice Dollar (DXY) — impact inverse","type":"structural","category":"monetary","unit":"pts","coefficient":-0.8,
"description":"USD fort → pression sur dettes EM en USD, sorties capitaux EM → EEM ↓. DXY ↑ = EEM ↓."},
{"id":"us_real_rate","label":"Taux réel US 10Y","type":"structural","category":"monetary","unit":"bps","coefficient":-0.6,
"description":"Taux réel US élevé → capitaux retournent aux US → sorties EM → EEM ↓."},
{"id":"fed_path_12m","label":"Anticipation Fed 12m","type":"structural","category":"monetary","unit":"bps","coefficient":-0.5,
"description":"Cuts Fed → dollar faible + taux attractifs EM → capitaux entrent EM → EEM ↑."},
{"id":"china_pmi","label":"PMI manufacturier Chine","type":"structural","category":"macro","unit":"pts","coefficient":0.6,
"description":"PMI Caixin/NBS Chine. Expansion = croissance EM → EEM ↑ (Chine ~28% de l'indice)."},
{"id":"china_growth_stimulus","label":"Stimulus croissance Chine","type":"structural","category":"macro","unit":"score","coefficient":0.8,
"description":"Score stimulus PBOC/gouvernement. Annonces majeures → EEM spike haussier."},
{"id":"commodity_complex","label":"Indice commodités (export EM)","type":"structural","category":"commodity","unit":"score","coefficient":0.4,
"description":"Commodités elevés → exportateurs EM (Brésil, Russie, Afrique du Sud) profitent → EEM ↑."},
{"id":"em_credit_spread","label":"Spread crédit souverain EM","type":"structural","category":"credit","unit":"bps","coefficient":-0.5,
"description":"EMBI spread. Hausse = stress EM → sorties → EEM ↓."},
{"id":"em_capital_flows","label":"Flux capitaux EM nets","type":"structural","category":"flows","unit":"Mds$","coefficient":0.3,
"description":"IIF flux nets vers EM. Entrées soutenues = EEM ↑ structurel."},
{"id":"risk_appetite","label":"Appétit risque mondial","type":"structural","category":"sentiment","unit":"score","coefficient":0.6,
"description":"Risk-on → recherche de rendement EM → EEM ↑."},
{"id":"vix_level","label":"Niveau VIX","type":"structural","category":"sentiment","unit":"pts","coefficient":-0.5,
"description":"VIX spike → sorties EM (flight to quality) → EEM ↓."},
{"id":"us_china_tension","label":"Tensions US-Chine","type":"structural","category":"political","unit":"score","coefficient":-0.5,
"description":"Tensions US-Chine (tarifs, sanctions, tech). Hausse → risque EM → EEM ↓."},
{"id":"ev_central_bank","label":"Pression Banques Centrales","type":"event_driven","category":"central_bank","unit":"pips","description":"Fed pivot, PBOC mesures."},
{"id":"ev_macro_surprise","label":"Surprise Macro","type":"event_driven","category":"monetary_shock","unit":"pips","description":"Données Chine, US macro."},
{"id":"ev_geopolitical","label":"Risque Géopolitique","type":"event_driven","category":"geopolitical","unit":"pips","description":"Tensions régionales EM."},
{"id":"ev_trade_policy","label":"Choc Commercial","type":"event_driven","category":"trade_policy","unit":"pips","description":"Tarifs US-Chine, sanctions."},
{"id":"ev_growth_shock","label":"Choc Croissance","type":"event_driven","category":"growth_shock","unit":"pips","description":"Récession US/Chine → EEM ↓."},
{"id":"ev_commodity","label":"Choc Commodités","type":"event_driven","category":"commodity","unit":"pips","description":"Chocs matières premières → exportateurs EM."},
{"id":"ev_credit_stress","label":"Stress Crédit EM","type":"event_driven","category":"credit_stress","unit":"pips","description":"Stress souverain EM, crise devises EM."},
{"id":"ev_sentiment","label":"Sentiment & Flux","type":"event_driven","category":"sentiment","unit":"pips","description":"Flux ETF EM, risk-on/off."},
{"id":"ev_technical","label":"Momentum Technique","type":"event_driven","category":"technical","unit":"pips","description":"Niveaux EEM, tendances."},
{"id":"eem","label":"EEM Impact Net","type":"output","category":"output","unit":"pips","description":"Pression nette cumulée EEM"},
]
},
# ═══════════════════════════════════════════════════════════════════════════════
"QQQ": {
"name": "QQQ (NASDAQ-100 Tech)", "output_node": "qqq",
"description": "ETF NASDAQ-100 — tech et croissance US, sensible aux taux réels et bénéfices tech",
"nodes": [
{"id":"us_real_rate_10y","label":"Taux réel US 10Y (duration tech)","type":"structural","category":"monetary","unit":"bps","coefficient":-1.5,
"description":"PRINCIPAL driver : tech = duration longue (flux futurs). Taux réels ↑ → actualisation ↑ → PE tech ↓ → QQQ ↓."},
{"id":"fed_path_12m","label":"Anticipation Fed 12m","type":"structural","category":"monetary","unit":"bps","coefficient":-0.8,
"description":"Cuts attendus → taux discount ↓ → PE tech expansion → QQQ ↑."},
{"id":"big_tech_eps_revision","label":"Révisions BPA Big Tech (M7)","type":"structural","category":"earnings","unit":"%","coefficient":15.0,
"description":"Révisions bénéfices Magnificent 7 (AAPL, MSFT, NVDA, GOOGL, AMZN, META, TSLA). +1% ≈ +15 pts QQQ."},
{"id":"ai_capex_cycle","label":"Cycle capex IA","type":"structural","category":"tech","unit":"score","coefficient":0.8,
"description":"Score momentum investissements IA (Hyperscalers capex, NVDA demande GPU). Positif = QQQ ↑."},
{"id":"semiconductor_cycle","label":"Cycle semi-conducteurs","type":"structural","category":"tech","unit":"score","coefficient":0.6,
"description":"Phase cycle semis (book-to-bill, inventaires, commandes). Upcycle = QQQ ↑."},
{"id":"tech_pe_multiple","label":"Multiple PE tech (NTM)","type":"structural","category":"valuation","unit":"x","coefficient":12.0,
"description":"PE forward NASDAQ-100. Expansion = QQQ ↑. Actuellement élevé → sensible aux déceptions."},
{"id":"regulation_risk_tech","label":"Risque réglementaire tech","type":"structural","category":"political","unit":"score","coefficient":-0.5,
"description":"Risque antitrust, régulation IA, vie privée (EU AI Act, FTC actions). Hausse → QQQ ↓."},
{"id":"us_consumer_spending","label":"Dépenses consommateur US","type":"structural","category":"macro","unit":"score","coefficient":0.4,
"description":"Dépenses techno consommateur (iPhone, cloud, publicité). Solide = revenus tech → QQQ ↑."},
{"id":"cloud_growth_enterprise","label":"Croissance cloud enterprise","type":"structural","category":"tech","unit":"%","coefficient":0.6,
"description":"Croissance AWS/Azure/GCP. Moteur marges operating → QQQ."},
{"id":"vix_level","label":"Niveau VIX","type":"structural","category":"sentiment","unit":"pts","coefficient":-0.8,
"description":"VIX. Spike → vente tech en premier (beta élevé) → QQQ ↓ plus que SP500."},
{"id":"retail_options_flow","label":"Flux options retail (call buying)","type":"structural","category":"flows","unit":"score","coefficient":0.4,
"description":"Momentum gamma/delta flows retail sur options tech. FOMO = QQQ squeeze haussier."},
{"id":"china_tech_risk","label":"Risque restrictions tech US-Chine","type":"structural","category":"political","unit":"score","coefficient":-0.4,
"description":"Restrictions export chips, sanctions tech US-Chine. Hausse → revenus tech ↓ → QQQ ↓."},
{"id":"ev_central_bank","label":"Pression Banques Centrales","type":"event_driven","category":"central_bank","unit":"pips","description":"Fed pivot → QQQ amplificateur."},
{"id":"ev_macro_surprise","label":"Surprise Macro","type":"event_driven","category":"monetary_shock","unit":"pips","description":"CPI, NFP → réévaluation Fed → QQQ."},
{"id":"ev_geopolitical","label":"Risque Géopolitique","type":"event_driven","category":"geopolitical","unit":"pips","description":"Tensions → risk-off → sell tech."},
{"id":"ev_trade_policy","label":"Choc Commercial","type":"event_driven","category":"trade_policy","unit":"pips","description":"Tarifs tech, restrictions exports."},
{"id":"ev_growth_shock","label":"Choc Croissance","type":"event_driven","category":"growth_shock","unit":"pips","description":"Récession → dépenses IT coupées → QQQ."},
{"id":"ev_credit_stress","label":"Stress Crédit","type":"event_driven","category":"credit_stress","unit":"pips","description":"Conditions financières → tech financement."},
{"id":"ev_commodity","label":"Choc Commodités","type":"event_driven","category":"commodity","unit":"pips","description":"Énergie → data centers coûts."},
{"id":"ev_sentiment","label":"Sentiment & Positionnement","type":"event_driven","category":"sentiment","unit":"pips","description":"Flux CTAs, hedge funds tech."},
{"id":"ev_technical","label":"Momentum Technique","type":"event_driven","category":"technical","unit":"pips","description":"Niveaux QQQ, MA200, RSI."},
{"id":"qqq","label":"QQQ Impact Net","type":"output","category":"output","unit":"pips","description":"Pression nette cumulée QQQ"},
]
},
} # end INSTRUMENT_MODELS
# ── Category labels (French) ────────────────────────────────────────────────────
CAT_LABELS: dict[str, str] = {
"monetary": "Monétaire",
"inflation": "Inflation",
"macro": "Macro / Croissance",
"political": "Politique",
"flows": "Flux & Réserves",
"positioning": "Positionnement",
"sentiment": "Sentiment & Risque",
"credit": "Crédit",
"earnings": "Bénéfices",
"valuation": "Valorisation",
"tech": "Technologie",
"commodity": "Commodités",
"supply": "Offre",
"output": "Résultat",
# event-driven categories (from causal_graph_templates.category)
"central_bank": "Banques Centrales",
"monetary_shock": "Surprise Macro",
"geopolitical": "Géopolitique",
"trade_policy": "Commerce / Tarifs",
"growth_shock": "Choc Croissance",
"commodity": "Commodités",
"credit_stress": "Stress Crédit",
"sentiment": "Sentiment",
"technical": "Technique",
"positioning": "Positionnement",
"unclassified": "Non Classifié",
}
# ── DB init ─────────────────────────────────────────────────────────────────────
def init_instrument_model_tables(conn):
conn.executescript("""
CREATE TABLE IF NOT EXISTS instrument_models (
id INTEGER PRIMARY KEY,
instrument TEXT UNIQUE NOT NULL,
graph_json TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS instrument_node_overrides (
id INTEGER PRIMARY KEY,
instrument TEXT NOT NULL,
node_id TEXT NOT NULL,
value REAL NOT NULL,
note TEXT,
set_at TEXT DEFAULT (datetime('now')),
UNIQUE(instrument, node_id)
);
""")
conn.commit()
def seed_instrument_models(conn):
"""Insert/update instrument models on startup. Never overwrites user overrides."""
init_instrument_model_tables(conn)
for inst, model in INSTRUMENT_MODELS.items():
existing = conn.execute(
"SELECT id FROM instrument_models WHERE instrument=?", (inst,)
).fetchone()
graph_json = json.dumps({
"name": model["name"],
"description": model["description"],
"output_node": model["output_node"],
"nodes": model["nodes"],
})
if existing:
conn.execute(
"UPDATE instrument_models SET graph_json=?, updated_at=datetime('now') WHERE instrument=?",
(graph_json, inst)
)
else:
conn.execute(
"INSERT INTO instrument_models (instrument, graph_json) VALUES (?,?)",
(inst, graph_json)
)
conn.commit()
# ── Value computation ───────────────────────────────────────────────────────────
def _compute_event_driven_values(conn, instrument: str, ref_date: date_type) -> dict[str, float]:
"""
Pour chaque catégorie event-driven : somme des pips × decay sur toutes les analyses actives.
Retourne {category: float} en pips.
"""
extended_from = ref_date - timedelta(days=180)
inst_upper = instrument.upper()
rows = conn.execute("""
SELECT a.prediction_json,
e.start_date, e.end_date AS event_end_date, e.sub_type,
t.category,
t.calibration_json
FROM causal_event_analyses a
JOIN market_events e ON e.id = a.market_event_id
JOIN causal_graph_templates t ON t.id = a.template_id
WHERE a.instrument = ?
AND e.start_date >= ?
AND e.start_date <= ?
""", (inst_upper, str(extended_from), str(ref_date))).fetchall()
by_cat: dict[str, float] = {}
inst_lower = inst_upper.lower()
for row in rows:
r = dict(row)
try:
predictions = json.loads(r["prediction_json"] or "{}")
calib = json.loads(r["calibration_json"] or "{}")
except Exception:
continue
pips: Optional[float] = None
if inst_lower in predictions:
pips = float(predictions[inst_lower])
else:
for k, v in predictions.items():
if inst_lower in k.lower():
try: pips = float(v); break
except (TypeError, ValueError): pass
if pips is None or pips == 0:
continue
absorption = max(1, int(calib.get("absorption_days", 7)))
dtype = str(calib.get("decay_type", "exp"))
# Guidance events: dynamic absorption until meeting date
ev_end = r.get("event_end_date")
if ev_end and str(r.get("sub_type", "")).startswith("rate_guidance"):
try:
meeting = date_type.fromisoformat(ev_end[:10])
ev_start = date_type.fromisoformat(r["start_date"][:10])
absorption = max(1, (meeting - ev_start).days)
dtype = "linear"
except ValueError:
pass
try:
ev_date = date_type.fromisoformat(r["start_date"][:10])
except ValueError:
continue
days_elapsed = (ref_date - ev_date).days
df = _decay(days_elapsed, absorption, dtype)
if df < 0.01:
continue
cat = r["category"]
by_cat[cat] = by_cat.get(cat, 0.0) + round(pips * df, 2)
return by_cat
def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Optional[dict]:
"""
Retourne le graphe instrument avec les valeurs courantes de chaque nœud.
"""
inst_upper = instrument.upper()
row = conn.execute(
"SELECT graph_json FROM instrument_models WHERE instrument=?", (inst_upper,)
).fetchone()
if not row:
return None
graph = json.loads(row["graph_json"])
try:
ref_date = date_type.fromisoformat(at_date) if at_date else datetime.utcnow().date()
except ValueError:
ref_date = datetime.utcnow().date()
# Load user overrides
override_rows = conn.execute(
"SELECT node_id, value, note, set_at FROM instrument_node_overrides WHERE instrument=?",
(inst_upper,)
).fetchall()
overrides: dict[str, dict] = {r["node_id"]: dict(r) for r in override_rows}
# Compute event-driven values by category
ev_by_cat = _compute_event_driven_values(conn, inst_upper, ref_date)
# Build node states
net_pips = 0.0
nodes_out = []
for node in graph["nodes"]:
nid = node["id"]
ntype = node["type"]
coeff = node.get("coefficient", 1.0)
cat = node["category"]
state = dict(node)
if ntype == "structural":
ov = overrides.get(nid)
if ov:
state["current_value"] = ov["value"]
state["pip_contribution"] = round(ov["value"] * coeff, 1)
state["source"] = "manual"
state["override_note"] = ov.get("note", "")
state["override_set_at"] = ov.get("set_at", "")
else:
state["current_value"] = 0.0
state["pip_contribution"] = 0.0
state["source"] = "neutral"
net_pips += state["pip_contribution"]
elif ntype == "event_driven":
pips = ev_by_cat.get(cat, 0.0)
# manual override possible too
ov = overrides.get(nid)
if ov:
pips = ov["value"]
state["source"] = "manual"
state["override_note"] = ov.get("note", "")
else:
state["source"] = "events" if pips != 0 else "neutral"
state["current_value"] = round(pips, 1)
state["pip_contribution"] = round(pips, 1)
net_pips += pips
elif ntype == "output":
state["current_value"] = None # filled after loop
state["pip_contribution"] = None
state["source"] = "computed"
nodes_out.append(state)
net_pips = round(net_pips, 1)
# Fill output node
for n in nodes_out:
if n["type"] == "output":
n["current_value"] = net_pips
n["pip_contribution"] = net_pips
direction = "bullish" if net_pips > 5 else "bearish" if net_pips < -5 else "neutral"
return {
"instrument": inst_upper,
"name": graph["name"],
"description": graph.get("description", ""),
"at_date": str(ref_date),
"net_pips": net_pips,
"direction": direction,
"nodes": nodes_out,
"output_node": graph["output_node"],
}
def set_node_override(conn, instrument: str, node_id: str, value: float, note: str = "") -> bool:
inst_upper = instrument.upper()
conn.execute("""
INSERT INTO instrument_node_overrides (instrument, node_id, value, note, set_at)
VALUES (?,?,?,?,datetime('now'))
ON CONFLICT(instrument, node_id) DO UPDATE SET value=excluded.value, note=excluded.note, set_at=datetime('now')
""", (inst_upper, node_id, value, note))
conn.commit()
return True
def clear_node_override(conn, instrument: str, node_id: str) -> bool:
inst_upper = instrument.upper()
conn.execute(
"DELETE FROM instrument_node_overrides WHERE instrument=? AND node_id=?",
(inst_upper, node_id)
)
conn.commit()
return True

View File

@@ -35,6 +35,7 @@ import AIDesks from './pages/AIDesks'
import MacroSeriesPage from './pages/MacroSeriesPage'
import EuroSimulator from './pages/EuroSimulator'
import CausalLab from './pages/CausalLab'
import InstrumentModels from './pages/InstrumentModels'
import { useCycleWatcher } from './hooks/useApi'
// ── Keep-alive pages ──────────────────────────────────────────────────────────
@@ -62,6 +63,7 @@ const KEEP_ALIVE_DEFS: { path: string; component: ComponentType }[] = [
{ path: '/calendar', component: CalendarPage },
{ path: '/simulator', component: EuroSimulator },
{ path: '/causal-lab', component: CausalLab },
{ path: '/instrument-models', component: InstrumentModels },
{ path: '/macro-series', component: MacroSeriesPage },
{ path: '/institutional', component: InstitutionalReports },
{ path: '/specialist-desks', component: SpecialistDesks },

View File

@@ -27,6 +27,7 @@ const nav = [
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
{ to: '/simulator', icon: Sliders, label: 'EUR/USD Simulator' },
{ to: '/causal-lab', icon: FlaskConical, label: 'Lab Causal' },
{ to: '/instrument-models', icon: Microscope, label: 'Modèles Instruments' },
{ to: '/macro-series', icon: TrendingUp, label: 'Macro Series' },
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },

View File

@@ -33,6 +33,7 @@ export const KEEP_ALIVE_META: KeepAliveMeta[] = [
{ path: '/calendar', label: 'Calendar', icon: Calendar },
{ path: '/simulator', label: 'Simulator', icon: Sliders },
{ path: '/causal-lab', label: 'Lab Causal', icon: FlaskConical },
{ path: '/instrument-models', label: 'Modèles Instr.', icon: Microscope },
{ path: '/macro-series', label: 'Macro Series', icon: MacroSeriesIcon },
{ path: '/institutional', label: 'Inst. Reports', icon: Building2 },
{ path: '/specialist-desks', label: 'Specialist Desks', icon: Users },

View File

@@ -0,0 +1,637 @@
/**
* InstrumentModels — graphes causaux exhaustifs par instrument.
* Vue tableau + graphe, nœuds éditables, filtres catégorie/type.
*/
import { useEffect, useState, useCallback, useRef } from 'react'
import axios from 'axios'
import clsx from 'clsx'
const api = axios.create({ baseURL: '/api' })
// ── Types ──────────────────────────────────────────────────────────────────────
interface ModelNode {
id: string; label: string; type: 'structural' | 'event_driven' | 'output'
category: string; unit: string; description: string
coefficient?: number
current_value: number | null; pip_contribution: number | null
source: 'manual' | 'events' | 'neutral' | 'computed'
override_note?: string; override_set_at?: string
}
interface ModelState {
instrument: string; name: string; description: string
at_date: string; net_pips: number; direction: 'bullish' | 'bearish' | 'neutral'
nodes: ModelNode[]; output_node: string
}
interface ModelMeta {
instrument: string; name: string; description: string
n_structural: number; n_event_driven: number
}
// ── Constants ─────────────────────────────────────────────────────────────────
const INSTRUMENTS = ['EURUSD','USDJPY','XAUUSD','SP500','TLT','GBPUSD','EEM','QQQ']
const CAT_COLOR: Record<string, string> = {
monetary: 'bg-blue-900/40 text-blue-300 border-blue-700/40',
inflation: 'bg-orange-900/40 text-orange-300 border-orange-700/40',
macro: 'bg-teal-900/40 text-teal-300 border-teal-700/40',
political: 'bg-purple-900/40 text-purple-300 border-purple-700/40',
flows: 'bg-cyan-900/40 text-cyan-300 border-cyan-700/40',
positioning: 'bg-indigo-900/40 text-indigo-300 border-indigo-700/40',
sentiment: 'bg-pink-900/40 text-pink-300 border-pink-700/40',
credit: 'bg-red-900/40 text-red-300 border-red-700/40',
earnings: 'bg-emerald-900/40 text-emerald-300 border-emerald-700/40',
valuation: 'bg-lime-900/40 text-lime-300 border-lime-700/40',
tech: 'bg-violet-900/40 text-violet-300 border-violet-700/40',
commodity: 'bg-amber-900/40 text-amber-300 border-amber-700/40',
supply: 'bg-stone-900/40 text-stone-300 border-stone-700/40',
// event-driven categories
central_bank: 'bg-blue-900/40 text-blue-300 border-blue-700/40',
monetary_shock: 'bg-teal-900/40 text-teal-300 border-teal-700/40',
geopolitical: 'bg-red-900/40 text-red-300 border-red-700/40',
trade_policy: 'bg-orange-900/40 text-orange-300 border-orange-700/40',
growth_shock: 'bg-emerald-900/40 text-emerald-300 border-emerald-700/40',
credit_stress: 'bg-rose-900/40 text-rose-300 border-rose-700/40',
technical: 'bg-slate-700/40 text-slate-300 border-slate-600/40',
output: 'bg-violet-900/40 text-violet-300 border-violet-700/40',
}
const CAT_LABELS: Record<string, string> = {
monetary:'Monétaire', inflation:'Inflation', macro:'Macro/Croissance',
political:'Politique', flows:'Flux & Réserves', positioning:'Positionnement',
sentiment:'Sentiment', credit:'Crédit', earnings:'Bénéfices',
valuation:'Valorisation', tech:'Technologie', commodity:'Commodités',
supply:'Offre', output:'Résultat',
central_bank:'Banques Centrales', monetary_shock:'Surprise Macro',
geopolitical:'Géopolitique', trade_policy:'Commerce', growth_shock:'Croissance',
credit_stress:'Stress Crédit', technical:'Technique',
}
function catBadge(cat: string) {
const cls = CAT_COLOR[cat] ?? 'bg-slate-700/40 text-slate-400 border-slate-600/40'
return (
<span className={clsx('text-[10px] px-1.5 py-0.5 rounded border font-medium', cls)}>
{CAT_LABELS[cat] ?? cat}
</span>
)
}
function pipColor(v: number | null) {
if (v === null) return 'text-slate-500'
if (v > 0) return 'text-emerald-400'
if (v < 0) return 'text-red-400'
return 'text-slate-500'
}
function fmt(v: number | null, unit?: string) {
if (v === null) return '—'
const s = (v > 0 ? '+' : '') + v
return unit ? `${s} ${unit}` : s
}
// ── Node Edit Modal ────────────────────────────────────────────────────────────
function NodeEditModal({
node, instrument, onClose, onSaved
}: {
node: ModelNode; instrument: string
onClose: () => void; onSaved: () => void
}) {
const [val, setVal] = useState(String(node.current_value ?? 0))
const [note, setNote] = useState(node.override_note ?? '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const hasOverride = node.source === 'manual'
async function save() {
const num = parseFloat(val)
if (isNaN(num)) { setError('Valeur invalide'); return }
setSaving(true)
try {
await api.put(`/instrument-models/${instrument}/nodes/${node.id}/override`, { value: num, note })
onSaved()
} catch { setError('Erreur lors de la sauvegarde') }
finally { setSaving(false) }
}
async function clear() {
setSaving(true)
try {
await api.delete(`/instrument-models/${instrument}/nodes/${node.id}/override`)
onSaved()
} catch { setError('Erreur') }
finally { setSaving(false) }
}
const coeff = node.coefficient ?? 1
const preview = node.type === 'structural' ? parseFloat(val || '0') * coeff : parseFloat(val || '0')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-dark-800 border border-slate-700/60 rounded-2xl w-full max-w-md p-6 shadow-2xl">
<div className="flex items-start gap-3 mb-4">
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-slate-200">{node.label}</div>
<div className="flex items-center gap-2 mt-1">{catBadge(node.category)}
<span className="text-[10px] text-slate-500">{node.unit}</span>
</div>
</div>
<button onClick={onClose} className="text-slate-500 hover:text-slate-300 text-lg leading-none"></button>
</div>
<p className="text-xs text-slate-400 leading-relaxed mb-4">{node.description}</p>
{node.type === 'structural' && (
<div className="text-xs text-slate-500 mb-3 flex gap-3">
<span>Coefficient: <strong className="text-slate-300">{coeff}</strong> {node.unit}/pips</span>
<span> Impact: <strong className={clsx(pipColor(preview))}>{preview >= 0 ? '+' : ''}{preview.toFixed(1)} pips</strong></span>
</div>
)}
{node.type === 'event_driven' && (
<div className="text-xs text-slate-500 mb-3">
{node.source === 'events'
? `Valeur auto depuis events actifs: ${fmt(node.current_value, 'pips')}`
: 'Aucun event actif (0 pips auto). Override possible.'}
</div>
)}
<div className="space-y-3">
<div>
<label className="text-xs text-slate-400 mb-1 block">Valeur ({node.unit})</label>
<input
type="number" step="any"
value={val}
onChange={e => setVal(e.target.value)}
className="w-full bg-dark-900 border border-slate-600/60 rounded-lg px-3 py-2 text-sm text-slate-200 focus:border-violet-500/60 focus:outline-none"
autoFocus
/>
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Note (optionnel)</label>
<input
type="text"
value={note}
onChange={e => setNote(e.target.value)}
placeholder="Ex: Anticipation Fed -50bps d'ici fin 2026"
className="w-full bg-dark-900 border border-slate-600/60 rounded-lg px-3 py-2 text-sm text-slate-200 focus:border-violet-500/60 focus:outline-none"
/>
</div>
</div>
{error && <p className="text-xs text-red-400 mt-2">{error}</p>}
<div className="flex gap-2 mt-5">
<button
onClick={save} disabled={saving}
className="flex-1 py-2 rounded-lg bg-violet-700/60 hover:bg-violet-700/80 text-violet-100 text-sm font-medium transition-colors disabled:opacity-50"
>
{saving ? '…' : '💾 Enregistrer'}
</button>
{hasOverride && (
<button
onClick={clear} disabled={saving}
className="px-4 py-2 rounded-lg bg-red-900/40 hover:bg-red-900/60 text-red-300 text-sm border border-red-700/40 transition-colors disabled:opacity-50"
>
Effacer
</button>
)}
<button onClick={onClose} className="px-4 py-2 rounded-lg border border-slate-600/40 text-slate-400 hover:text-slate-200 text-sm transition-colors">
Annuler
</button>
</div>
{hasOverride && node.override_set_at && (
<p className="text-[10px] text-slate-600 mt-3 text-center">
Override défini le {node.override_set_at?.slice(0,10)}
{node.override_note ? ` — "${node.override_note}"` : ''}
</p>
)}
</div>
</div>
)
}
// ── Graph View (SVG DAG) ───────────────────────────────────────────────────────
function GraphView({ model, onNodeClick }: { model: ModelState; onNodeClick: (n: ModelNode) => void }) {
const structural = model.nodes.filter(n => n.type === 'structural')
const eventDriven = model.nodes.filter(n => n.type === 'event_driven')
const outputNode = model.nodes.find(n => n.type === 'output')
const NODE_W = 190, NODE_H = 46, GAP_Y = 10
const COL_X = { structural: 20, event: 240, output: 460 }
const SVG_W = 660
const leftH = structural.length * (NODE_H + GAP_Y)
const rightH = eventDriven.length * (NODE_H + GAP_Y)
const SVG_H = Math.max(leftH, rightH, 120) + 40
function nodeY(arr: ModelNode[], i: number, totalH: number) {
const groupH = arr.length * (NODE_H + GAP_Y)
const startY = (totalH - groupH) / 2 + 20
return startY + i * (NODE_H + GAP_Y)
}
const outputY = SVG_H / 2 - NODE_H / 2
const outX = COL_X.output
const outCX = outX + NODE_W / 2
return (
<div className="overflow-auto">
<svg width={SVG_W} height={SVG_H} className="block mx-auto">
{/* Structural → output edges */}
{structural.map((n, i) => {
const y = nodeY(structural, i, SVG_H)
const cx = COL_X.structural + NODE_W
const cy = y + NODE_H / 2
const pips = n.pip_contribution ?? 0
const color = pips > 0 ? '#34d399' : pips < 0 ? '#f87171' : '#475569'
return (
<path key={n.id}
d={`M${cx},${cy} C${(cx + outX) / 2},${cy} ${(cx + outX) / 2},${outputY + NODE_H / 2} ${outX},${outputY + NODE_H / 2}`}
fill="none" stroke={color} strokeWidth={Math.max(1, Math.min(3, Math.abs(pips) / 10))} strokeOpacity={0.5}
/>
)
})}
{/* Event → output edges */}
{eventDriven.map((n, i) => {
const y = nodeY(eventDriven, i, SVG_H)
const cx = COL_X.event + NODE_W
const cy = y + NODE_H / 2
const pips = n.pip_contribution ?? 0
const color = pips > 0 ? '#34d399' : pips < 0 ? '#f87171' : '#475569'
return (
<path key={n.id}
d={`M${cx},${cy} C${(cx + outX) / 2},${cy} ${(cx + outX) / 2},${outputY + NODE_H / 2} ${outX},${outputY + NODE_H / 2}`}
fill="none" stroke={color} strokeWidth={Math.max(1, Math.min(3, Math.abs(pips) / 10))} strokeOpacity={0.5}
/>
)
})}
{/* Structural nodes */}
{structural.map((n, i) => {
const y = nodeY(structural, i, SVG_H)
const pips = n.pip_contribution ?? 0
const border = pips > 0 ? '#059669' : pips < 0 ? '#dc2626' : '#334155'
const bg = n.source === 'manual' ? '#1e1b4b' : '#0f172a'
return (
<g key={n.id} className="cursor-pointer" onClick={() => onNodeClick(n)}>
<rect x={COL_X.structural} y={y} width={NODE_W} height={NODE_H} rx={8}
fill={bg} stroke={border} strokeWidth={1.5} />
{n.source === 'manual' && (
<rect x={COL_X.structural} y={y} width={4} height={NODE_H} rx={2} fill="#7c3aed" />
)}
<text x={COL_X.structural + 12} y={y + 16} fontSize={10} fill="#94a3b8">{n.label}</text>
<text x={COL_X.structural + 12} y={y + 32} fontSize={11} fill={pips > 0 ? '#34d399' : pips < 0 ? '#f87171' : '#64748b'}
fontWeight="600">
{n.source === 'neutral' ? `0 ${n.unit}` : `${pips >= 0 ? '+' : ''}${pips} pips`}
</text>
</g>
)
})}
{/* Event-driven nodes */}
{eventDriven.map((n, i) => {
const y = nodeY(eventDriven, i, SVG_H)
const pips = n.pip_contribution ?? 0
const border = pips > 0 ? '#059669' : pips < 0 ? '#dc2626' : '#334155'
return (
<g key={n.id} className="cursor-pointer" onClick={() => onNodeClick(n)}>
<rect x={COL_X.event} y={y} width={NODE_W} height={NODE_H} rx={8}
fill="#0f172a" stroke={border} strokeWidth={1.5} strokeDasharray="4 2" />
<text x={COL_X.event + 10} y={y + 16} fontSize={10} fill="#94a3b8">{n.label}</text>
<text x={COL_X.event + 10} y={y + 32} fontSize={11} fill={pips > 0 ? '#34d399' : pips < 0 ? '#f87171' : '#64748b'}
fontWeight="600">
{pips === 0 ? '0 pips' : `${pips >= 0 ? '+' : ''}${pips} pips`}
</text>
</g>
)
})}
{/* Output node */}
{outputNode && (
<g>
<rect x={outX} y={outputY} width={NODE_W} height={NODE_H + 10} rx={10}
fill={model.net_pips > 0 ? '#064e3b' : model.net_pips < 0 ? '#450a0a' : '#1e293b'}
stroke={model.net_pips > 0 ? '#059669' : model.net_pips < 0 ? '#dc2626' : '#475569'}
strokeWidth={2} />
<text x={outX + NODE_W / 2} y={outputY + 18} fontSize={11} fill="#94a3b8" textAnchor="middle">
Impact Net
</text>
<text x={outX + NODE_W / 2} y={outputY + 38} fontSize={16} fontWeight="700"
fill={model.net_pips > 0 ? '#34d399' : model.net_pips < 0 ? '#f87171' : '#94a3b8'}
textAnchor="middle">
{model.net_pips >= 0 ? '+' : ''}{model.net_pips} pips
</text>
</g>
)}
{/* Column labels */}
<text x={COL_X.structural + NODE_W / 2} y={14} fontSize={9} fill="#475569" textAnchor="middle" textDecoration="uppercase">
FACTEURS STRUCTURELS
</text>
<text x={COL_X.event + NODE_W / 2} y={14} fontSize={9} fill="#475569" textAnchor="middle">
PRESSIONS ÉVÉNEMENTIELLES
</text>
<text x={outX + NODE_W / 2} y={14} fontSize={9} fill="#475569" textAnchor="middle">
RÉSULTAT
</text>
</svg>
</div>
)
}
// ── Table View ─────────────────────────────────────────────────────────────────
type SortKey = 'label' | 'category' | 'pip_contribution' | 'source'
type FilterType = 'all' | 'structural' | 'event_driven'
function TableView({ model, onNodeClick }: { model: ModelState; onNodeClick: (n: ModelNode) => void }) {
const [sortKey, setSortKey] = useState<SortKey>('pip_contribution')
const [sortDir, setSortDir] = useState<1 | -1>(-1)
const [filterType, setFilterType] = useState<FilterType>('all')
const [filterCat, setFilterCat] = useState<string>('all')
const cats = Array.from(new Set(model.nodes.map(n => n.category))).filter(c => c !== 'output')
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir(d => d === 1 ? -1 : 1)
else { setSortKey(key); setSortDir(-1) }
}
const rows = model.nodes
.filter(n => n.type !== 'output')
.filter(n => filterType === 'all' || n.type === filterType)
.filter(n => filterCat === 'all' || n.category === filterCat)
.sort((a, b) => {
let va: any = a[sortKey]; let vb: any = b[sortKey]
if (sortKey === 'pip_contribution') {
va = Math.abs(a.pip_contribution ?? 0)
vb = Math.abs(b.pip_contribution ?? 0)
}
if (typeof va === 'string') return sortDir * va.localeCompare(vb)
return sortDir * ((va ?? 0) - (vb ?? 0))
})
function SortTh({ k, label }: { k: SortKey; label: string }) {
const active = sortKey === k
return (
<th className={clsx('text-left text-[11px] font-medium uppercase tracking-wide cursor-pointer select-none py-2 px-3',
active ? 'text-violet-300' : 'text-slate-500 hover:text-slate-300'
)} onClick={() => toggleSort(k)}>
{label} {active ? (sortDir === -1 ? '↓' : '↑') : ''}
</th>
)
}
return (
<div className="space-y-3">
{/* Filters */}
<div className="flex flex-wrap gap-2 items-center">
<div className="flex rounded-lg overflow-hidden border border-slate-700/40 text-xs">
{(['all','structural','event_driven'] as FilterType[]).map(t => (
<button key={t} onClick={() => setFilterType(t)}
className={clsx('px-3 py-1.5 transition-colors',
filterType === t ? 'bg-violet-700/60 text-violet-100' : 'bg-dark-800 text-slate-400 hover:text-slate-200')}>
{t === 'all' ? 'Tous' : t === 'structural' ? 'Structurels' : 'Événementiels'}
</button>
))}
</div>
<select value={filterCat} onChange={e => setFilterCat(e.target.value)}
className="bg-dark-800 border border-slate-700/40 rounded-lg px-3 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-violet-500/60">
<option value="all">Toutes catégories</option>
{cats.map(c => <option key={c} value={c}>{CAT_LABELS[c] ?? c}</option>)}
</select>
<span className="text-xs text-slate-600 ml-auto">{rows.length} facteurs</span>
</div>
{/* Table */}
<div className="overflow-x-auto rounded-xl border border-slate-700/30">
<table className="w-full min-w-[640px]">
<thead className="bg-dark-900/60 border-b border-slate-700/30">
<tr>
<SortTh k="label" label="Facteur" />
<SortTh k="category" label="Catégorie" />
<th className="text-left text-[11px] font-medium uppercase tracking-wide text-slate-500 py-2 px-3">Type</th>
<th className="text-right text-[11px] font-medium uppercase tracking-wide text-slate-500 py-2 px-3">Valeur</th>
<SortTh k="pip_contribution" label="Impact (pips)" />
<SortTh k="source" label="Source" />
<th className="w-8" />
</tr>
</thead>
<tbody className="divide-y divide-slate-700/20">
{rows.map(node => {
const pips = node.pip_contribution ?? 0
const barW = model.net_pips !== 0
? Math.min(100, Math.abs(pips) / Math.max(...model.nodes.map(n => Math.abs(n.pip_contribution ?? 0))) * 100)
: 0
return (
<tr key={node.id}
onClick={() => node.type !== 'output' && onNodeClick(node)}
className="hover:bg-slate-800/30 cursor-pointer transition-colors group">
<td className="py-2.5 px-3">
<div className="flex items-center gap-2">
{node.source === 'manual' && (
<span className="w-1.5 h-1.5 rounded-full bg-violet-500 shrink-0" title="Override manuel" />
)}
<div>
<div className="text-xs text-slate-200 font-medium">{node.label}</div>
<div className="text-[10px] text-slate-600 truncate max-w-[220px]">{node.description.slice(0, 60)}</div>
</div>
</div>
</td>
<td className="py-2.5 px-3">{catBadge(node.category)}</td>
<td className="py-2.5 px-3">
<span className={clsx('text-[10px] px-1.5 py-0.5 rounded border',
node.type === 'structural'
? 'bg-slate-800/60 text-slate-400 border-slate-600/40'
: 'bg-slate-800/40 text-slate-500 border-slate-700/30 italic')}>
{node.type === 'structural' ? 'Structurel' : 'Événementiel'}
</span>
</td>
<td className="py-2.5 px-3 text-right">
{node.type === 'structural' && node.source === 'manual' ? (
<span className="text-xs font-mono text-violet-300">
{fmt(node.current_value, node.unit)}
</span>
) : (
<span className="text-xs text-slate-600"></span>
)}
</td>
<td className="py-2.5 px-3">
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-slate-800/60 rounded-full overflow-hidden shrink-0">
<div className={clsx('h-full rounded-full', pips > 0 ? 'bg-emerald-600/70' : pips < 0 ? 'bg-red-600/70' : 'bg-slate-700')}
style={{ width: `${barW}%` }} />
</div>
<span className={clsx('text-xs font-mono font-semibold w-16 text-right shrink-0', pipColor(pips))}>
{pips === 0 ? '0' : `${pips >= 0 ? '+' : ''}${pips}`}
</span>
</div>
</td>
<td className="py-2.5 px-3">
<span className={clsx('text-[10px]',
node.source === 'manual' ? 'text-violet-400' :
node.source === 'events' ? 'text-blue-400' :
node.source === 'computed'? 'text-violet-400' : 'text-slate-600')}>
{node.source === 'manual' ? '✎ Manuel' :
node.source === 'events' ? '⚡ Events' :
node.source === 'computed'? '∑ Calculé' : '— Neutre'}
</span>
{node.override_note && (
<div className="text-[10px] text-slate-600 italic truncate max-w-[100px]" title={node.override_note}>
{node.override_note}
</div>
)}
</td>
<td className="py-2.5 px-2 text-slate-600 group-hover:text-slate-400 text-sm"></td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
// ── Main Page ──────────────────────────────────────────────────────────────────
export default function InstrumentModels() {
const [selected, setSelected] = useState('EURUSD')
const [model, setModel] = useState<ModelState | null>(null)
const [metas, setMetas] = useState<ModelMeta[]>([])
const [loading, setLoading] = useState(false)
const [view, setView] = useState<'table' | 'graph'>('table')
const [editNode, setEditNode] = useState<ModelNode | null>(null)
const refreshKey = useRef(0)
const load = useCallback((inst = selected) => {
setLoading(true)
api.get(`/instrument-models/${inst}`)
.then(r => setModel(r.data))
.catch(() => setModel(null))
.finally(() => setLoading(false))
}, [selected])
useEffect(() => {
api.get('/instrument-models').then(r => setMetas(r.data)).catch(() => {})
}, [])
useEffect(() => { load(selected) }, [selected])
function onNodeClick(node: ModelNode) {
if (node.type === 'output') return
setEditNode(node)
}
function onSaved() {
setEditNode(null)
load(selected)
}
const dir = model?.direction
const dirCls = dir === 'bullish' ? 'text-emerald-400' : dir === 'bearish' ? 'text-red-400' : 'text-slate-400'
const dirLabel = dir === 'bullish' ? '▲ HAUSSIER' : dir === 'bearish' ? '▼ BAISSIER' : '◼ NEUTRE'
return (
<div className="min-h-screen bg-dark-900 text-slate-200 p-6 space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<div>
<h1 className="text-xl font-bold text-slate-100">Modèles d'Instruments</h1>
<p className="text-xs text-slate-500 mt-0.5">Graphes causaux exhaustifs — facteurs structurels + pressions événementielles</p>
</div>
<div className="ml-auto flex gap-2">
<button onClick={() => setView('table')}
className={clsx('px-3 py-1.5 rounded-lg text-xs border transition-colors',
view === 'table' ? 'bg-violet-700/50 border-violet-600/50 text-violet-200' : 'bg-dark-800 border-slate-700/40 text-slate-400 hover:text-slate-200')}>
☰ Tableau
</button>
<button onClick={() => setView('graph')}
className={clsx('px-3 py-1.5 rounded-lg text-xs border transition-colors',
view === 'graph' ? 'bg-violet-700/50 border-violet-600/50 text-violet-200' : 'bg-dark-800 border-slate-700/40 text-slate-400 hover:text-slate-200')}>
◈ Graphe
</button>
</div>
</div>
{/* Instrument tabs */}
<div className="flex flex-wrap gap-2">
{INSTRUMENTS.map(inst => {
const meta = metas.find(m => m.instrument === inst)
return (
<button key={inst} onClick={() => setSelected(inst)}
className={clsx('px-4 py-2 rounded-xl text-sm font-medium border transition-colors',
selected === inst
? 'bg-violet-700/50 border-violet-600/60 text-violet-100'
: 'bg-dark-800/60 border-slate-700/40 text-slate-400 hover:border-violet-700/40 hover:text-slate-200')}>
{meta?.name ?? inst}
</button>
)
})}
</div>
{/* Net pressure summary */}
{model && (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 px-5 py-4 flex items-center gap-6 flex-wrap">
<div>
<div className="text-[10px] text-slate-500 uppercase tracking-wide">Pression nette</div>
<div className={clsx('text-2xl font-bold font-mono mt-0.5', dirCls)}>
{model.net_pips >= 0 ? '+' : ''}{model.net_pips} <span className="text-base">pips</span>
</div>
</div>
<div className={clsx('px-3 py-1.5 rounded-lg border text-sm font-semibold', dirCls,
dir === 'bullish' ? 'border-emerald-700/40 bg-emerald-900/20'
: dir === 'bearish' ? 'border-red-700/40 bg-red-900/20'
: 'border-slate-700/40 bg-slate-800/20')}>
{dirLabel}
</div>
<div className="text-xs text-slate-500">
<span className="text-slate-300 font-mono">{model.nodes.filter(n => n.type === 'structural').length}</span> facteurs structurels ·{' '}
<span className="text-slate-300 font-mono">{model.nodes.filter(n => n.type === 'event_driven').length}</span> pressions événementielles
</div>
<div className="ml-auto text-[10px] text-slate-600">
Au {model.at_date} · {model.name}
</div>
<button onClick={() => load(selected)} className="text-xs text-slate-500 hover:text-slate-300 border border-slate-700/40 px-2 py-1 rounded transition-colors">
↻ Actualiser
</button>
</div>
)}
{/* Main content */}
{loading && (
<div className="text-center py-20 text-slate-600 text-sm animate-pulse">Chargement du modèle…</div>
)}
{!loading && model && (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-5">
{view === 'table'
? <TableView model={model} onNodeClick={onNodeClick} />
: <GraphView model={model} onNodeClick={onNodeClick} />
}
</div>
)}
{!loading && !model && (
<div className="text-center py-20 text-slate-600">Modèle introuvable pour {selected}</div>
)}
{/* Edit modal */}
{editNode && model && (
<NodeEditModal
node={editNode}
instrument={selected}
onClose={() => setEditNode(null)}
onSaved={onSaved}
/>
)}
</div>
)
}