feat: instrument model
This commit is contained in:
@@ -44,6 +44,18 @@ class NodeConfigBody(BaseModel):
|
||||
macro_key: Optional[str] = None # "" to clear, None = no-op
|
||||
|
||||
|
||||
class NodeScenarioBody(BaseModel):
|
||||
node_id: str
|
||||
label: str
|
||||
horizon: str = "mt" # 'ct' | 'mt' | 'lt'
|
||||
target_date: str # YYYY-MM-DD
|
||||
target_value: float
|
||||
confidence: float = 0.7 # 0.0 – 1.0
|
||||
trajectory: str = "linear" # 'step' | 'linear' | 'exp'
|
||||
absorption_days: int = 30
|
||||
notes: Optional[str] = ""
|
||||
|
||||
|
||||
class CalibrateBody(BaseModel):
|
||||
ref_date: Optional[str] = None
|
||||
|
||||
@@ -539,6 +551,48 @@ def calibrate_intercept(
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/{instrument}/scenarios")
|
||||
def list_scenarios(instrument: str, node_id: Optional[str] = Query(None)) -> List[Dict[str, Any]]:
|
||||
"""Tous les scénarios CT/MT/LT d'un instrument (ou d'un nœud spécifique)."""
|
||||
from services.database import get_conn
|
||||
from services.instrument_models import get_node_scenarios
|
||||
conn = get_conn()
|
||||
try:
|
||||
return get_node_scenarios(conn, instrument.upper(), node_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/{instrument}/scenarios")
|
||||
def create_scenario(instrument: str, body: NodeScenarioBody) -> Dict[str, Any]:
|
||||
"""Crée un scénario forecast sur un nœud."""
|
||||
from services.database import get_conn
|
||||
from services.instrument_models import add_node_scenario
|
||||
conn = get_conn()
|
||||
try:
|
||||
sid = add_node_scenario(
|
||||
conn, instrument.upper(), body.node_id, body.label,
|
||||
body.horizon, body.target_date, body.target_value,
|
||||
body.confidence, body.trajectory, body.absorption_days, body.notes or "",
|
||||
)
|
||||
return {"ok": True, "id": sid, "node_id": body.node_id}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/{instrument}/scenarios/{scenario_id}")
|
||||
def remove_scenario(instrument: str, scenario_id: int) -> Dict[str, Any]:
|
||||
"""Supprime un scénario forecast."""
|
||||
from services.database import get_conn
|
||||
from services.instrument_models import delete_node_scenario
|
||||
conn = get_conn()
|
||||
try:
|
||||
delete_node_scenario(conn, scenario_id)
|
||||
return {"ok": True, "id": scenario_id}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.patch("/{instrument}/nodes/{node_id}")
|
||||
def update_node_config(
|
||||
instrument: str,
|
||||
|
||||
@@ -194,6 +194,170 @@ def build_macro_node_timeline(
|
||||
return result
|
||||
|
||||
|
||||
def build_node_combined_timeline(
|
||||
conn, instrument: str, node_id: str, macro_key: Optional[str],
|
||||
date_from: date_type, date_to: date_type,
|
||||
) -> dict[str, float]:
|
||||
"""
|
||||
Construit la timeline complète d'un nœud en fusionnant :
|
||||
1. Valeurs passées (ff_calendar actuals via macro_key)
|
||||
2. Scénarios utilisateur futurs (CT/MT/LT) avec interpolation pondérée par confidence
|
||||
|
||||
Logique :
|
||||
- Pour les dates ≤ aujourd'hui : ff_calendar actuals (si disponibles)
|
||||
- Pour les dates futures : interpolation depuis la dernière valeur connue
|
||||
vers chaque scénario, pondérée par confidence
|
||||
- Si plusieurs scénarios se chevauchent : moyenne pondérée par confidence
|
||||
"""
|
||||
today = date_type.today()
|
||||
|
||||
# 1. Baseline depuis ff_calendar (passé uniquement en pratique)
|
||||
ff_tl = build_macro_node_timeline(conn, macro_key, date_from, date_to) if macro_key else {}
|
||||
|
||||
# 2. Scénarios utilisateur
|
||||
try:
|
||||
scen_rows = conn.execute(
|
||||
"""SELECT id, label, horizon, target_date, target_value, confidence, trajectory, absorption_days
|
||||
FROM node_forecast_scenarios
|
||||
WHERE instrument=? AND node_id=?
|
||||
ORDER BY target_date ASC""",
|
||||
(instrument.upper(), node_id)
|
||||
).fetchall()
|
||||
scenarios = [dict(r) for r in scen_rows]
|
||||
except Exception:
|
||||
scenarios = []
|
||||
|
||||
if not scenarios:
|
||||
return ff_tl # Pas de scénarios → juste ff_calendar
|
||||
|
||||
# 3. Valeur de départ (dernière connue depuis ff_calendar ou override statique)
|
||||
base_value: Optional[float] = None
|
||||
if ff_tl:
|
||||
# Dernière valeur connue avant ou à aujourd'hui
|
||||
for d in sorted(ff_tl.keys(), reverse=True):
|
||||
if d <= str(today):
|
||||
base_value = ff_tl[d]
|
||||
break
|
||||
if base_value is None:
|
||||
base_value = next(iter(ff_tl.values()), None)
|
||||
|
||||
# Fallback sur override statique si pas de ff_calendar
|
||||
if base_value is None:
|
||||
try:
|
||||
ov = conn.execute(
|
||||
"SELECT value FROM instrument_node_overrides WHERE instrument=? AND node_id=?",
|
||||
(instrument.upper(), node_id)
|
||||
).fetchone()
|
||||
if ov:
|
||||
base_value = float(ov["value"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if base_value is None:
|
||||
return ff_tl # Pas de base → on ne peut pas projeter
|
||||
|
||||
# 4. Construire les waypoints (date, value, confidence) depuis les scénarios
|
||||
# Le point de départ est toujours (today, base_value, 1.0)
|
||||
waypoints: list[tuple[date_type, float, float]] = [(today, base_value, 1.0)]
|
||||
for s in scenarios:
|
||||
try:
|
||||
td = date_type.fromisoformat(s["target_date"])
|
||||
if td > today:
|
||||
waypoints.append((td, float(s["target_value"]), float(s["confidence"])))
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
waypoints.sort(key=lambda x: x[0])
|
||||
|
||||
# 5. Construire la timeline journalière
|
||||
result: dict[str, float] = {}
|
||||
cur = date_from
|
||||
while cur <= date_to:
|
||||
cur_str = str(cur)
|
||||
|
||||
if cur <= today and cur_str in ff_tl:
|
||||
# Passé → priorité ff_calendar
|
||||
result[cur_str] = ff_tl[cur_str]
|
||||
else:
|
||||
# Futur → interpolation entre waypoints
|
||||
prev_wp: Optional[tuple[date_type, float, float]] = None
|
||||
next_wp: Optional[tuple[date_type, float, float]] = None
|
||||
for wp in waypoints:
|
||||
if wp[0] <= cur:
|
||||
prev_wp = wp
|
||||
elif next_wp is None:
|
||||
next_wp = wp
|
||||
break
|
||||
|
||||
if prev_wp is None and next_wp is None:
|
||||
if ff_tl:
|
||||
# Dernier connu
|
||||
result[cur_str] = ff_tl.get(str(today)) or list(ff_tl.values())[-1]
|
||||
elif prev_wp is None:
|
||||
result[cur_str] = round(next_wp[1], 4) # type: ignore[index]
|
||||
elif next_wp is None:
|
||||
result[cur_str] = round(prev_wp[1], 4) # hold last value
|
||||
else:
|
||||
# Interpolation linéaire entre prev et next, pondérée par confidence
|
||||
total_d = (next_wp[0] - prev_wp[0]).days
|
||||
elapsed = (cur - prev_wp[0]).days
|
||||
frac = elapsed / total_d if total_d > 0 else 0.0
|
||||
# Valeur interpolée brute
|
||||
interp = prev_wp[1] + frac * (next_wp[1] - prev_wp[1])
|
||||
# La confidence du prochain scénario pondère le drift :
|
||||
# confidence=1.0 → drift complet vers interp
|
||||
# confidence=0.5 → 50% du drift, reste à mi-chemin entre prev et interp
|
||||
conf = next_wp[2]
|
||||
blended = prev_wp[1] + (interp - prev_wp[1]) * conf
|
||||
result[cur_str] = round(blended, 4)
|
||||
|
||||
cur += timedelta(days=1)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── Scenario CRUD ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_node_scenarios(conn, instrument: str, node_id: Optional[str] = None) -> list[dict]:
|
||||
"""Retourne tous les scénarios d'un instrument (ou d'un nœud spécifique)."""
|
||||
if node_id:
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM node_forecast_scenarios
|
||||
WHERE instrument=? AND node_id=? ORDER BY target_date ASC""",
|
||||
(instrument.upper(), node_id)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM node_forecast_scenarios WHERE instrument=? ORDER BY node_id, target_date ASC",
|
||||
(instrument.upper(),)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def add_node_scenario(
|
||||
conn, instrument: str, node_id: str, label: str,
|
||||
horizon: str, target_date: str, target_value: float,
|
||||
confidence: float = 0.7, trajectory: str = "linear",
|
||||
absorption_days: int = 30, notes: str = "",
|
||||
) -> int:
|
||||
"""Ajoute un scénario forecast sur un nœud. Retourne l'id créé."""
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO node_forecast_scenarios
|
||||
(instrument, node_id, label, horizon, target_date, target_value,
|
||||
confidence, trajectory, absorption_days, notes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
||||
(instrument.upper(), node_id, label, horizon, target_date, float(target_value),
|
||||
float(confidence), trajectory, int(absorption_days), notes or "")
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def delete_node_scenario(conn, scenario_id: int) -> bool:
|
||||
conn.execute("DELETE FROM node_forecast_scenarios WHERE id=?", (scenario_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
|
||||
# ── Saturation scales (tanh) par unité native ─────────────────────────────────
|
||||
# tanh(x/scale) : slope=1 à l'origine, sature asymptotiquement à ±1
|
||||
# pips = coefficient_to_pips * scale * tanh(x / scale)
|
||||
@@ -201,10 +365,11 @@ def build_macro_node_timeline(
|
||||
# → max pips : coefficient_to_pips * scale (jamais dépassé)
|
||||
_SATURATION_SCALES: dict[str, float] = {
|
||||
"bps": 200.0, # différentiels de taux : sature autour ±300bps
|
||||
"%": 3.0, # CPI / PIB différentiels : sature autour ±5%
|
||||
"%": 3.0, # CPI / PIB / taux en % absolu : sature autour ±5%
|
||||
"pts%": 3.0,
|
||||
"pts": 15.0, # PMI écart depuis 50 : sature autour ±20pts
|
||||
"score": 3.0, # scores subjectifs -5 à +5
|
||||
"K": 200.0, # emplois en milliers : sature autour ±400K
|
||||
"tonnes": 80.0, # tonnes or / CB buying
|
||||
"Mds$": 40.0,
|
||||
"Mds$/sem": 15.0,
|
||||
@@ -364,57 +529,49 @@ INSTRUMENT_MODELS: dict[str, dict] = {
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
"EURUSD": {
|
||||
"name": "EUR/USD",
|
||||
"description": "Taux de change Euro/Dollar — modèle causal 3 couches avec 4 domaines d'influence",
|
||||
"description": "EUR/USD — Graphe causal macro-natif : noeuds = variables macro réelles auto-synchronisées depuis FF Calendar",
|
||||
"output_node": "eurusd",
|
||||
"price_intercept": 1.10,
|
||||
"pip_to_price": 0.0001,
|
||||
"yf_ticker": "EURUSD=X",
|
||||
"nodes": [
|
||||
# ── Layer 0a : event inputs ───────────────────────────────────────────────
|
||||
{"id":"in_cb", "label":"Banques Centrales", "node_type":"input_event", "category":"central_bank", "unit":"pips","display_col":0,"description":"Décisions Fed/BCE, minutes, forward guidance. Décroissance exp ~14j.","event_category":"central_bank"},
|
||||
{"id":"in_macro", "label":"Surprises Macro (données)","node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"description":"CPI, NFP, PIB, PMI US/EU. Décroissance rapide ~12j.","event_category":"monetary_shock"},
|
||||
{"id":"in_geo", "label":"Risque Géopolitique", "node_type":"input_event", "category":"geopolitical", "unit":"pips","display_col":0,"description":"Conflits, sanctions, tensions. Décroissance linéaire ~21j.","event_category":"geopolitical"},
|
||||
{"id":"in_trade", "label":"Choc Commercial/Tarifs", "node_type":"input_event", "category":"trade_policy", "unit":"pips","display_col":0,"description":"Tarifs US-UE, accords commerciaux.","event_category":"trade_policy"},
|
||||
{"id":"in_growth", "label":"Choc Croissance", "node_type":"input_event", "category":"growth_shock", "unit":"pips","display_col":0,"description":"Chocs perspectives croissance.","event_category":"growth_shock"},
|
||||
{"id":"in_credit", "label":"Stress Crédit/Liquidité", "node_type":"input_event", "category":"credit_stress", "unit":"pips","display_col":0,"description":"Banking stress, spreads IG/HY.","event_category":"credit_stress"},
|
||||
{"id":"in_technical", "label":"Momentum Technique", "node_type":"input_event", "category":"technical", "unit":"pips","display_col":0,"description":"Cassures niveaux clés, tendances.","event_category":"technical"},
|
||||
{"id":"in_commodity", "label":"Choc Commodités", "node_type":"input_event", "category":"commodity", "unit":"pips","display_col":0,"description":"Pétrole, gaz, métaux → inflation EU.","event_category":"commodity"},
|
||||
{"id":"in_sentiment", "label":"Sentiment/Positionnement","node_type":"input_event", "category":"sentiment", "unit":"pips","display_col":0,"description":"Flux institutionnels, risk-on/off.","event_category":"sentiment"},
|
||||
# ── Layer 0b : manual structural inputs ──────────────────────────────────
|
||||
{"id":"m_rate_diff_2y", "label":"Spread OIS 2Y USD-EUR", "node_type":"input_manual","category":"monetary", "unit":"bps", "coefficient_to_pips": 1.5, "display_col":1,"description":"Principal driver court terme. Positif = USD plus rémunérateur → pair ↓"},
|
||||
{"id":"m_fed_path", "label":"Anticipation Fed 12m", "node_type":"input_manual","category":"monetary", "unit":"bps", "coefficient_to_pips": 0.8, "display_col":1,"description":"Cuts attendus → USD ↓ → pair ↑. Hikes → USD ↑ → pair ↓"},
|
||||
{"id":"m_ecb_path", "label":"Anticipation BCE 12m", "node_type":"input_manual","category":"monetary", "unit":"bps", "coefficient_to_pips":-0.8, "display_col":1,"description":"Cuts BCE → EUR ↓. Hikes → EUR ↑"},
|
||||
{"id":"m_us_real_rate", "label":"Taux réel US 10Y (TIPS)", "node_type":"input_manual","category":"monetary", "unit":"bps", "coefficient_to_pips":-0.5, "display_col":1,"description":"Hausse → USD attractif → pair ↓"},
|
||||
{"id":"m_eu_real_rate", "label":"Taux réel EU 10Y", "node_type":"input_manual","category":"monetary", "unit":"bps", "coefficient_to_pips": 0.5, "display_col":1,"description":"Hausse → EUR attractif → pair ↑"},
|
||||
{"id":"m_carry", "label":"Score carry EUR/USD", "node_type":"input_manual","category":"monetary", "unit":"score", "coefficient_to_pips": 0.6, "display_col":1,"description":"Score attractivité carry (+= EUR avantageux)"},
|
||||
{"id":"m_us_growth_adv", "label":"Avantage croissance US vs EU","node_type":"input_manual","category":"macro", "unit":"pts%", "coefficient_to_pips":-15.0,"display_col":1,"description":"Différentiel PIB US-EU. US surperform → USD ↑ → pair ↓"},
|
||||
{"id":"m_eu_pmi", "label":"PMI composite Eurozone", "node_type":"input_manual","category":"macro", "unit":"pts", "coefficient_to_pips": 0.3, "display_col":1,"description":"Expansion EU → EUR ↑"},
|
||||
{"id":"m_energy_price", "label":"Prix énergie ($/bbl delta)", "node_type":"input_manual","category":"macro", "unit":"$/bbl", "coefficient_to_pips":-0.25,"display_col":1,"description":"Énergie chère → déficit commercial EU → EUR ↓"},
|
||||
{"id":"m_us_cpi", "label":"CPI US YoY", "node_type":"input_manual","category":"inflation", "unit":"%", "coefficient_to_pips":-0.8, "display_col":1,"description":"Inflation US → Fed hawkish → USD ↑ → pair ↓"},
|
||||
{"id":"m_eu_cpi", "label":"HICP Eurozone YoY", "node_type":"input_manual","category":"inflation", "unit":"%", "coefficient_to_pips": 0.8, "display_col":1,"description":"Inflation EU → BCE hawkish → EUR ↑"},
|
||||
{"id":"m_risk_appetite", "label":"Appétit risque mondial", "node_type":"input_manual","category":"sentiment", "unit":"score", "coefficient_to_pips": 0.5, "display_col":1,"description":"Risk-on → sorties USD → pair ↑"},
|
||||
{"id":"m_vix", "label":"Niveau VIX", "node_type":"input_manual","category":"sentiment", "unit":"pts", "coefficient_to_pips":-0.4, "display_col":1,"description":"VIX spike → flight to USD → pair ↓"},
|
||||
{"id":"m_eu_fragmentation","label":"Risque fragmentation EU", "node_type":"input_manual","category":"political", "unit":"score", "coefficient_to_pips":-0.5, "display_col":1,"description":"Risque politique EU → EUR ↓"},
|
||||
{"id":"m_us_political", "label":"Incertitude politique US", "node_type":"input_manual","category":"political", "unit":"score", "coefficient_to_pips": 0.3, "display_col":1,"description":"Incertitude US → USD ↓ → pair ↑"},
|
||||
{"id":"m_cftc_eur", "label":"Positions nettes EUR (CoT)", "node_type":"input_manual","category":"positioning","unit":"k lots","coefficient_to_pips": 0.1, "display_col":1,"description":"Net long EUR CFTC. Extrême → risque retournement"},
|
||||
{"id":"m_dollar_reserve", "label":"Demande réserves USD", "node_type":"input_manual","category":"flows", "unit":"score", "coefficient_to_pips":-0.4, "display_col":1,"description":"Demande réserves → USD structurellement fort → pair ↓"},
|
||||
# ── Layer 1 : intermediate (4 domaines) ──────────────────────────────────
|
||||
{"id":"layer_monetary", "label":"▶ Pression Monétaire & Taux", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||||
"formula":"in_cb + in_macro + m_rate_diff_2y + m_fed_path + m_ecb_path + m_us_real_rate + m_eu_real_rate + m_carry",
|
||||
"description":"Domaine monétaire : taux directeurs, OIS, anticipations Fed/BCE, carry. Driver n°1 de l'EURUSD."},
|
||||
{"id":"layer_growth", "label":"▶ Pression Macro & Croissance", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||||
"formula":"in_growth + in_commodity + m_us_growth_adv + m_eu_pmi + m_energy_price + m_us_cpi + m_eu_cpi",
|
||||
"description":"Domaine macro : croissance relative, inflation, énergie. Impact via anticipations BC."},
|
||||
{"id":"layer_risk", "label":"▶ Pression Risque & Géopolitique", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||||
"formula":"in_geo + in_credit + in_sentiment + m_risk_appetite + m_vix + m_eu_fragmentation",
|
||||
"description":"Domaine risque : géopolitique, stress crédit, aversion au risque (USD safe haven)."},
|
||||
{"id":"layer_positioning","label":"▶ Pression Positionnement & Flux", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||||
"formula":"in_trade + in_technical + m_cftc_eur + m_dollar_reserve + m_us_political",
|
||||
"description":"Domaine positionnement : flux spéculatifs, technicalités, réserves, politique."},
|
||||
# ── Layer 0a : chocs événementiels (surprises court terme) ───────────────
|
||||
{"id":"in_cb", "label":"Choc Banques Centrales", "node_type":"input_event","category":"central_bank", "unit":"pips","display_col":0,"event_category":"central_bank", "description":"Surprises Fed/BCE : décisions inattendues, guidance hawkish/dovish, minutes."},
|
||||
{"id":"in_geo", "label":"Choc Géopolitique", "node_type":"input_event","category":"geopolitical", "unit":"pips","display_col":0,"event_category":"geopolitical", "description":"Conflits, sanctions, tensions → flight to USD."},
|
||||
{"id":"in_trade", "label":"Choc Commercial/Tarifs", "node_type":"input_event","category":"trade_policy", "unit":"pips","display_col":0,"event_category":"trade_policy", "description":"Tarifs US-UE, représailles → USD/EUR volatilité."},
|
||||
{"id":"in_credit","label":"Stress Crédit", "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress", "description":"Stress bancaire, spreads → USD safe haven."},
|
||||
# ── Layer 0b : politique monétaire (taux directeurs absolus) ─────────────
|
||||
# macro_key = id → auto-sync depuis ff_calendar sans mapping manuel
|
||||
{"id":"fed_rate","label":"Taux Fed (%)", "node_type":"input_manual","category":"monetary","unit":"%","coefficient_to_pips":-30, "macro_key":"fed_rate", "display_col":1,"description":"Taux directeur Fed en %. Hausse → USD fort → pair ↓. Auto-sync depuis FF Calendar."},
|
||||
{"id":"ecb_rate","label":"Taux BCE (%)", "node_type":"input_manual","category":"monetary","unit":"%","coefficient_to_pips":+30, "macro_key":"ecb_rate", "display_col":1,"description":"Taux directeur BCE en %. Hausse → EUR fort → pair ↑. Auto-sync depuis FF Calendar."},
|
||||
# ── Layer 0c : inflation ─────────────────────────────────────────────────
|
||||
{"id":"us_cpi", "label":"CPI US YoY (%)", "node_type":"input_manual","category":"inflation","unit":"%","coefficient_to_pips":-10, "macro_key":"us_cpi_yoy","display_col":1,"description":"Inflation US YoY. Hausse → anticipations Fed hawkish → USD ↑ → pair ↓."},
|
||||
{"id":"eu_cpi", "label":"HICP Eurozone (%)", "node_type":"input_manual","category":"inflation","unit":"%","coefficient_to_pips":+10, "macro_key":"eu_cpi_yoy","display_col":1,"description":"Inflation EU YoY. Hausse → BCE hawkish → EUR ↑ → pair ↑."},
|
||||
# ── Layer 0d : croissance ─────────────────────────────────────────────────
|
||||
{"id":"us_gdp", "label":"GDP US QoQ (%)", "node_type":"input_manual","category":"growth","unit":"%","coefficient_to_pips":-15, "macro_key":"us_gdp", "display_col":1,"description":"Croissance US trimestrielle. Surperformance → USD ↑ → pair ↓."},
|
||||
{"id":"eu_gdp", "label":"GDP Eurozone QoQ (%)","node_type":"input_manual","category":"growth","unit":"%","coefficient_to_pips":+15, "macro_key":"eu_gdp", "display_col":1,"description":"Croissance EU trimestrielle. Surperformance → EUR ↑ → pair ↑."},
|
||||
# ── Layer 0e : emploi & activité ─────────────────────────────────────────
|
||||
{"id":"us_nfp", "label":"NFP US (K/mois)", "node_type":"input_manual","category":"labor","unit":"K","coefficient_to_pips":-0.05, "macro_key":"us_nfp", "display_col":1,"description":"Emplois non-agricoles US en K. 150K= neutre. Plus → USD ↑ → pair ↓."},
|
||||
{"id":"eu_pmi", "label":"PMI EU (écart/50)", "node_type":"input_manual","category":"activity","unit":"pts","coefficient_to_pips":+2.5,"macro_key":"eu_pmi", "display_col":1,"description":"PMI Composite Eurozone MOINS 50 (+4 = PMI=54, expansion → EUR ↑)."},
|
||||
{"id":"us_pmi", "label":"PMI US (écart/50)", "node_type":"input_manual","category":"activity","unit":"pts","coefficient_to_pips":-2.5,"macro_key":"us_pmi", "display_col":1,"description":"PMI ISM US MOINS 50 (+3 = PMI=53, expansion → USD ↑ → pair ↓)."},
|
||||
# ── Layer 0f : sentiment & risque (pas de macro_key — saisi ou events) ───
|
||||
{"id":"vix", "label":"VIX (niveau)", "node_type":"input_manual","category":"risk","unit":"pts","coefficient_to_pips":-1.5, "display_col":1,"description":"Volatilité equity US. Spike → safe haven USD → pair ↓."},
|
||||
{"id":"us_equity","label":"S&P 500 momentum", "node_type":"input_manual","category":"risk","unit":"score","coefficient_to_pips":+0.3, "display_col":1,"description":"Risk-on US. Hausse → appétit risque → EUR ↑. Score -5/+5."},
|
||||
{"id":"eu_fragm","label":"Fragmentation EU", "node_type":"input_manual","category":"political","unit":"score","coefficient_to_pips":-0.5, "display_col":1,"description":"Risque fragmentation zone euro, stress BTP/Bund. Score 0-5 → EUR ↓."},
|
||||
# ── Layer 1 : domaines synthèse ───────────────────────────────────────────
|
||||
{"id":"layer_monetary","label":"▶ Différentiel Monétaire","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||||
"formula":"in_cb + fed_rate + ecb_rate + us_cpi + eu_cpi",
|
||||
"description":"Taux directeurs + inflation → différentiel de politique monétaire Fed/BCE."},
|
||||
{"id":"layer_growth", "label":"▶ Différentiel Croissance","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||||
"formula":"us_gdp + eu_gdp + us_nfp + eu_pmi + us_pmi",
|
||||
"description":"Croissance relative US vs EU. Positif = EU surperform → EUR ↑."},
|
||||
{"id":"layer_risk", "label":"▶ Sentiment & Risque", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||||
"formula":"in_geo + in_trade + in_credit + vix + us_equity + eu_fragm",
|
||||
"description":"Risque géopolitique, sentiment, appétit risque → impact EUR/USD."},
|
||||
# ── Layer 2 : output ──────────────────────────────────────────────────────
|
||||
{"id":"eurusd","label":"EUR/USD — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
|
||||
"formula":"layer_monetary + layer_growth + layer_risk + layer_positioning",
|
||||
"description":"Pression nette cumulée = Σ(4 domaines). Positif = biais haussier EUR/USD."},
|
||||
{"id":"eurusd","label":"EUR/USD — Biais Net","node_type":"output","category":"output","unit":"pips","display_col":3,
|
||||
"formula":"layer_monetary + layer_growth + layer_risk",
|
||||
"description":"Biais net EUR/USD. Positif = haussier EUR. Divisé en 3 piliers : monétaire, croissance, risque."},
|
||||
]
|
||||
},
|
||||
|
||||
@@ -816,6 +973,22 @@ def init_instrument_model_tables(conn):
|
||||
calibration_json TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS node_forecast_scenarios (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
instrument TEXT NOT NULL,
|
||||
node_id TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
horizon TEXT NOT NULL DEFAULT 'mt',
|
||||
target_date TEXT NOT NULL,
|
||||
target_value REAL NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0.7,
|
||||
trajectory TEXT NOT NULL DEFAULT 'linear',
|
||||
absorption_days INTEGER NOT NULL DEFAULT 30,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_nfs_inst_node
|
||||
ON node_forecast_scenarios(instrument, node_id);
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
@@ -1251,14 +1424,18 @@ def simulate_timeline(
|
||||
|
||||
from services.causal_graphs import evaluate_graph
|
||||
|
||||
# ── Macro-guidance : noeuds input_manual avec macro_key → overrides time-varying ──
|
||||
# ── Timelines combinées (ff_calendar actuals + scénarios CT/MT/LT) ────────
|
||||
# Pour chaque nœud input_manual : fusion de la baseline ff_calendar et des
|
||||
# scénarios utilisateur (trajectoire blended par confidence vers chaque waypoint)
|
||||
macro_node_timelines: dict[str, dict[str, float]] = {}
|
||||
for node in graph_def.get("nodes", []):
|
||||
mk = node.get("macro_key")
|
||||
if mk and node.get("node_type") == "input_manual":
|
||||
tl = build_macro_node_timeline(conn, mk, date_from, today)
|
||||
if tl:
|
||||
macro_node_timelines[node["id"]] = tl
|
||||
if node.get("node_type") != "input_manual":
|
||||
continue
|
||||
mk = node.get("macro_key")
|
||||
nid = node["id"]
|
||||
tl = build_node_combined_timeline(conn, inst_upper, nid, mk, date_from, today)
|
||||
if tl:
|
||||
macro_node_timelines[nid] = tl
|
||||
|
||||
has_macro = bool(macro_node_timelines)
|
||||
|
||||
|
||||
@@ -168,6 +168,21 @@ interface MacroGuidanceItem {
|
||||
} | null
|
||||
}
|
||||
|
||||
interface NodeScenario {
|
||||
id: number
|
||||
instrument: string
|
||||
node_id: string
|
||||
label: string
|
||||
horizon: 'ct' | 'mt' | 'lt'
|
||||
target_date: string
|
||||
target_value: number
|
||||
confidence: number
|
||||
trajectory: 'step' | 'linear' | 'exp'
|
||||
absorption_days: number
|
||||
notes?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const INSTRUMENTS = ['EURUSD','USDJPY','XAUUSD','SP500','TLT','GBPUSD','EEM','QQQ']
|
||||
@@ -1990,134 +2005,254 @@ function CalibrationView({ instrument, eventDetails }: {
|
||||
|
||||
// ── MacroConfigView ────────────────────────────────────────────────────────────
|
||||
|
||||
function MacroConfigView({ instrument, nodes }: { instrument: string; nodes: ModelNode[] }) {
|
||||
const [guidance, setGuidance] = useState<MacroGuidanceItem[]>([])
|
||||
const [localKeys, setLocalKeys] = useState<Record<string, string>>({})
|
||||
const [saved, setSaved] = useState<Record<string, boolean>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
const HORIZON_META = {
|
||||
ct: { label: 'CT', color: 'text-sky-400', bg: 'bg-sky-900/30 border-sky-700/40', desc: 'Court terme (< 3 mois)' },
|
||||
mt: { label: 'MT', color: 'text-amber-400', bg: 'bg-amber-900/30 border-amber-700/40', desc: 'Moyen terme (3-12 mois)' },
|
||||
lt: { label: 'LT', color: 'text-violet-400', bg: 'bg-violet-900/30 border-violet-700/40',desc: 'Long terme (> 12 mois)' },
|
||||
}
|
||||
const TRAJ_OPTIONS = [
|
||||
{ value: 'step', label: 'Choc immédiat (step)' },
|
||||
{ value: 'linear', label: 'Dérive linéaire' },
|
||||
{ value: 'exp', label: 'Convergence exp.' },
|
||||
]
|
||||
|
||||
const manualNodes = nodes.filter(n => n.node_type === 'input_manual')
|
||||
interface AddScenarioForm {
|
||||
label: string; horizon: 'ct'|'mt'|'lt'; target_date: string; target_value: string
|
||||
confidence: string; trajectory: string; absorption_days: string; notes: string
|
||||
}
|
||||
const EMPTY_FORM: AddScenarioForm = {
|
||||
label: '', horizon: 'mt', target_date: '', target_value: '', confidence: '0.7',
|
||||
trajectory: 'linear', absorption_days: '30', notes: '',
|
||||
}
|
||||
|
||||
function NodeScenarioSection({
|
||||
instrument, node, guidance,
|
||||
}: { instrument: string; node: ModelNode; guidance: MacroGuidanceItem | undefined }) {
|
||||
const [scenarios, setScenarios] = useState<NodeScenario[]>([])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [addForm, setAddForm] = useState<AddScenarioForm | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const init: Record<string, string> = {}
|
||||
for (const n of manualNodes) init[n.id] = n.macro_key ?? ''
|
||||
setLocalKeys(init)
|
||||
}, [nodes])
|
||||
if (!expanded) return
|
||||
api.get(`/instrument-models/${instrument}/scenarios`, { params: { node_id: node.id } })
|
||||
.then(r => setScenarios(r.data))
|
||||
.catch(() => {})
|
||||
}, [expanded, instrument, node.id])
|
||||
|
||||
const refreshGuidance = useCallback(() => {
|
||||
async function submitScenario() {
|
||||
if (!addForm) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.post(`/instrument-models/${instrument}/scenarios`, {
|
||||
node_id: node.id,
|
||||
label: addForm.label || `${node.label} ${addForm.horizon.toUpperCase()}`,
|
||||
horizon: addForm.horizon,
|
||||
target_date: addForm.target_date,
|
||||
target_value: parseFloat(addForm.target_value),
|
||||
confidence: parseFloat(addForm.confidence),
|
||||
trajectory: addForm.trajectory,
|
||||
absorption_days: parseInt(addForm.absorption_days, 10),
|
||||
notes: addForm.notes,
|
||||
})
|
||||
const r = await api.get(`/instrument-models/${instrument}/scenarios`, { params: { node_id: node.id } })
|
||||
setScenarios(r.data)
|
||||
setAddForm(null)
|
||||
} catch {}
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
async function deleteScenario(id: number) {
|
||||
await api.delete(`/instrument-models/${instrument}/scenarios/${id}`)
|
||||
setScenarios(prev => prev.filter(s => s.id !== id))
|
||||
}
|
||||
|
||||
const hm = HORIZON_META
|
||||
const mk = node.macro_key
|
||||
const curV = guidance?.current_value
|
||||
const nxt = guidance?.next_event
|
||||
|
||||
return (
|
||||
<div className={clsx('rounded-lg border transition-colors',
|
||||
(mk || scenarios.length > 0)
|
||||
? 'border-violet-700/30 bg-violet-900/10'
|
||||
: 'border-slate-700/20 bg-dark-800/30')}>
|
||||
|
||||
{/* Row header */}
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-white font-medium truncate">{node.label}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-slate-600">{node.unit} · ×{node.coefficient_to_pips}</span>
|
||||
{mk && <span className="text-xs text-violet-400 font-mono">{mk}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current value from ff_calendar */}
|
||||
{curV != null && (
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-sm font-bold font-mono text-white">{curV.toFixed(2)}<span className="text-xs text-slate-500 ml-1">{node.unit}</span></div>
|
||||
{nxt && (
|
||||
<div className="text-xs text-amber-400">
|
||||
→ {nxt.forecast != null ? nxt.forecast.toFixed(2) : '?'} <span className="text-slate-600">dans {nxt.days_until}j</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scenario count + expand */}
|
||||
<button onClick={() => setExpanded(e => !e)}
|
||||
className="flex items-center gap-1.5 shrink-0 text-xs text-slate-500 hover:text-white transition-colors">
|
||||
{scenarios.length > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-violet-800/50 text-violet-300 font-mono">
|
||||
{scenarios.length}
|
||||
</span>
|
||||
)}
|
||||
{expanded ? <ChevronUp className="w-3.5 h-3.5"/> : <ChevronDown className="w-3.5 h-3.5"/>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded section */}
|
||||
{expanded && (
|
||||
<div className="border-t border-slate-700/30 px-3 pt-2 pb-3 space-y-2">
|
||||
|
||||
{/* Existing scenarios */}
|
||||
{scenarios.map(s => {
|
||||
const hMeta = hm[s.horizon] ?? hm.mt
|
||||
return (
|
||||
<div key={s.id} className={clsx('flex items-center gap-2 px-2 py-1.5 rounded border text-xs', hMeta.bg)}>
|
||||
<span className={clsx('font-semibold w-6 shrink-0', hMeta.color)}>{hMeta.label}</span>
|
||||
<span className="text-white flex-1 truncate font-medium">{s.label}</span>
|
||||
<span className="text-slate-400 shrink-0">{s.target_date}</span>
|
||||
<span className="font-mono font-bold text-white shrink-0">{s.target_value.toFixed(2)}</span>
|
||||
<span className="text-slate-500 shrink-0">{Math.round(s.confidence*100)}%</span>
|
||||
<span className="text-slate-600 shrink-0">{s.trajectory}</span>
|
||||
<button onClick={() => deleteScenario(s.id)}
|
||||
className="text-slate-600 hover:text-red-400 transition-colors shrink-0">
|
||||
<Trash2 className="w-3 h-3"/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Add form */}
|
||||
{addForm ? (
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['ct','mt','lt'] as const).map(h => (
|
||||
<button key={h} onClick={() => setAddForm(f => f && ({...f, horizon: h}))}
|
||||
className={clsx('py-1 rounded border text-xs font-semibold transition-colors', hm[h].bg,
|
||||
addForm.horizon === h ? hm[h].color : 'text-slate-500')}>
|
||||
{hm[h].label} <span className="font-normal opacity-60">{h === 'ct' ? '< 3m' : h === 'mt' ? '3-12m' : '> 1an'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input type="text" placeholder="Label (ex: Fed surprise cut)"
|
||||
value={addForm.label}
|
||||
onChange={e => setAddForm(f => f && ({...f, label: e.target.value}))}
|
||||
className="col-span-2 text-xs bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-white placeholder-slate-600 focus:outline-none focus:border-violet-500/50"/>
|
||||
<div>
|
||||
<div className="text-xs text-slate-600 mb-1">Date cible</div>
|
||||
<input type="date"
|
||||
value={addForm.target_date}
|
||||
onChange={e => setAddForm(f => f && ({...f, target_date: e.target.value}))}
|
||||
className="w-full text-xs bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-white focus:outline-none"/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-600 mb-1">Valeur cible ({node.unit})</div>
|
||||
<input type="number" step="any"
|
||||
placeholder={curV != null ? curV.toFixed(2) : '0.00'}
|
||||
value={addForm.target_value}
|
||||
onChange={e => setAddForm(f => f && ({...f, target_value: e.target.value}))}
|
||||
className="w-full text-xs bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-white focus:outline-none"/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-600 mb-1">Confidence</div>
|
||||
<input type="range" min="0" max="1" step="0.05"
|
||||
value={addForm.confidence}
|
||||
onChange={e => setAddForm(f => f && ({...f, confidence: e.target.value}))}
|
||||
className="w-full accent-violet-500"/>
|
||||
<div className="text-xs text-violet-400 text-center">{Math.round(parseFloat(addForm.confidence)*100)}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-600 mb-1">Trajectoire</div>
|
||||
<select value={addForm.trajectory}
|
||||
onChange={e => setAddForm(f => f && ({...f, trajectory: e.target.value}))}
|
||||
className="w-full text-xs bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-white focus:outline-none">
|
||||
{TRAJ_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setAddForm(null)}
|
||||
className="text-xs text-slate-600 hover:text-slate-400 px-3 py-1.5 transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
<button onClick={submitScenario} disabled={saving || !addForm.target_date || !addForm.target_value}
|
||||
className="text-xs bg-violet-600 hover:bg-violet-500 disabled:opacity-40 text-white px-3 py-1.5 rounded transition-colors">
|
||||
{saving ? 'Saving…' : '+ Ajouter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setAddForm({...EMPTY_FORM})}
|
||||
className="flex items-center gap-1 text-xs text-slate-600 hover:text-violet-400 transition-colors">
|
||||
<Plus className="w-3 h-3"/> Ajouter un scénario CT/MT/LT
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MacroConfigView({ instrument, nodes }: { instrument: string; nodes: ModelNode[] }) {
|
||||
const [guidance, setGuidance] = useState<MacroGuidanceItem[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
api.get(`/instrument-models/${instrument}/macro-guidance`)
|
||||
.then(r => setGuidance(r.data))
|
||||
.catch(() => {})
|
||||
}, [instrument])
|
||||
|
||||
useEffect(() => { refreshGuidance() }, [refreshGuidance])
|
||||
|
||||
async function saveMacroKey(nodeId: string, mk: string) {
|
||||
setSaving(nodeId)
|
||||
try {
|
||||
await api.patch(`/instrument-models/${instrument}/nodes/${nodeId}`, { macro_key: mk })
|
||||
setSaved(prev => ({ ...prev, [nodeId]: true }))
|
||||
setTimeout(() => setSaved(prev => ({ ...prev, [nodeId]: false })), 2000)
|
||||
refreshGuidance()
|
||||
} catch {}
|
||||
setSaving(null)
|
||||
}
|
||||
|
||||
const linkedCount = Object.values(localKeys).filter(Boolean).length
|
||||
const manualNodes = nodes.filter(n => n.node_type === 'input_manual')
|
||||
const linkedCount = manualNodes.filter(n => n.macro_key).length
|
||||
const scenarioCount = guidance.length // approximate
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">Paramètres macro des nœuds</div>
|
||||
<div className="text-sm font-medium text-white">Paramètres macro & scénarios</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
Liez chaque nœud manuel à une variable macro-économique. La machine interpolera
|
||||
entre les publications FF Calendar pour créer une guidance temporelle des fondamentaux.
|
||||
Chaque nœud macro-lié reçoit ses valeurs depuis FF Calendar.
|
||||
Ajoutez des scénarios CT/MT/LT pour projeter la guidance fondamentale dans le temps.
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-violet-400 font-mono">
|
||||
{linkedCount}/{manualNodes.length} liés
|
||||
<span className="text-xs text-violet-400 font-mono shrink-0">
|
||||
{linkedCount}/{manualNodes.length} liés FF
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Guidance summary cards — nodes that already have macro keys */}
|
||||
{guidance.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{guidance.map(g => (
|
||||
<div key={g.node_id} className="rounded-lg border border-violet-700/30 bg-violet-900/10 p-3">
|
||||
<div className="text-xs text-violet-400 font-medium truncate">{g.node_label}</div>
|
||||
<div className="text-xs text-slate-500 mb-1.5">{g.macro_key}</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-base font-bold font-mono text-white">
|
||||
{g.current_value != null ? g.current_value.toFixed(2) : '—'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{g.unit}</span>
|
||||
</div>
|
||||
{g.next_event && (
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
<span className="text-amber-400">→ {g.next_event.forecast != null ? g.next_event.forecast.toFixed(2) : '?'}</span>
|
||||
<span className="ml-1">dans {g.next_event.days_until}j</span>
|
||||
</div>
|
||||
)}
|
||||
{g.next_event && (
|
||||
<div className="text-xs text-slate-600 mt-0.5 truncate" title={g.next_event.name}>
|
||||
{g.next_event.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node mapping table */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-slate-600 uppercase tracking-wider mb-2">Mapping nœud → clé macro</div>
|
||||
{manualNodes.map(node => {
|
||||
const mk = localKeys[node.id] ?? ''
|
||||
const isSaving = saving === node.id
|
||||
const isSaved = saved[node.id]
|
||||
|
||||
return (
|
||||
<div key={node.id}
|
||||
className={clsx('flex items-center gap-3 px-3 py-2 rounded-lg border transition-colors',
|
||||
mk ? 'border-violet-700/30 bg-violet-900/10' : 'border-slate-700/20 bg-dark-800/30')}>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-white truncate">{node.label}</div>
|
||||
<div className="text-xs text-slate-600">{node.unit} · ×{node.coefficient_to_pips}</div>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={mk}
|
||||
onChange={e => {
|
||||
const v = e.target.value
|
||||
setLocalKeys(prev => ({ ...prev, [node.id]: v }))
|
||||
saveMacroKey(node.id, v)
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="text-xs bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-white w-44 shrink-0 focus:outline-none focus:border-violet-500/50">
|
||||
{MACRO_KEY_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="w-5 shrink-0 text-center">
|
||||
{isSaving && <span className="text-xs text-blue-400 animate-pulse">…</span>}
|
||||
{isSaved && <Check className="w-3.5 h-3.5 text-emerald-400"/>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{manualNodes.map(node => (
|
||||
<NodeScenarioSection
|
||||
key={node.id}
|
||||
instrument={instrument}
|
||||
node={node}
|
||||
guidance={guidance.find(g => g.node_id === node.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{linkedCount > 0 && (
|
||||
<div className="rounded-lg border border-slate-700/30 bg-dark-800/40 p-3 text-xs text-slate-500">
|
||||
<span className="text-white font-medium">{linkedCount} nœud{linkedCount > 1 ? 's' : ''} macro-lié{linkedCount > 1 ? 's' : ''}</span>
|
||||
{' '}— la simulation Timeline utilisera des valeurs time-varying pour ces nœuds,
|
||||
interpolant entre les publications passées et les forecasts des prochains events.
|
||||
Le niveau fondamental ne sera plus statique mais guidé par les données FF Calendar.
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border border-slate-700/30 bg-dark-800/40 p-3 text-xs text-slate-500 space-y-1">
|
||||
<div className="text-white font-medium">Comment fonctionne la guidance ?</div>
|
||||
<div>• <span className="text-sky-400">CT</span> — choc/event imminent (ex: réunion Fed dans 3 sem → surprise cut à 3.75%)</div>
|
||||
<div>• <span className="text-amber-400">MT</span> — régime macro 3-12 mois (ex: regain inflation → stagnation à 3.80%)</div>
|
||||
<div>• <span className="text-violet-400">LT</span> — vision structurelle (ex: cycle de baisse → 2.00% fin 2027)</div>
|
||||
<div className="text-slate-600 pt-1">La Timeline interpolera chaque nœud vers sa cible avec la confidence pondérée. Relancez la simulation pour voir la courbe fondamentale évoluer.</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user