feat: instrument model
This commit is contained in:
@@ -44,6 +44,27 @@ def list_instrument_models():
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instrument}/regime")
|
||||||
|
def get_instrument_regime(
|
||||||
|
instrument: str,
|
||||||
|
at_date: Optional[str] = Query(None),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Régime de marché courant pour cet instrument (détecté depuis events actifs)."""
|
||||||
|
from services.database import get_conn
|
||||||
|
from services.instrument_models import _compute_event_by_category, detect_regime
|
||||||
|
from datetime import datetime, date as date_type
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
ref_date = date_type.fromisoformat(at_date) if at_date else datetime.utcnow().date()
|
||||||
|
except ValueError:
|
||||||
|
ref_date = datetime.utcnow().date()
|
||||||
|
ev_by_cat = _compute_event_by_category(conn, instrument.upper(), ref_date)
|
||||||
|
return detect_regime(ev_by_cat)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instrument}/timeline")
|
@router.get("/{instrument}/timeline")
|
||||||
def get_instrument_timeline(
|
def get_instrument_timeline(
|
||||||
instrument: str,
|
instrument: str,
|
||||||
|
|||||||
@@ -1,22 +1,139 @@
|
|||||||
"""
|
"""
|
||||||
Instrument Models — Phase 1 : propagation en chaîne réelle.
|
Instrument Models — Phase 2 : saturation non-linéaire + régimes adaptatifs.
|
||||||
|
|
||||||
Architecture DAG (3 couches) :
|
Architecture DAG (3 couches) :
|
||||||
Layer 0 — Inputs :
|
Layer 0 — Inputs :
|
||||||
- input_event : valeur auto depuis causal_event_analyses (par catégorie template)
|
- input_event : valeur auto depuis causal_event_analyses (par catégorie template)
|
||||||
- input_manual : valeur utilisateur (unité native → pips via coefficient_to_pips)
|
- input_manual : valeur utilisateur → tanh(x/scale)*coeff → pips (saturation)
|
||||||
Layer 1 — Intermediate : formules agrégeant les inputs par domaine
|
Layer 1 — Intermediate : formules agrégeant les inputs par domaine
|
||||||
Layer 2 — Output : formule sommant les nœuds intermédiaires
|
Layer 2 — Output : formule avec poids de régime (ex: 1.4*layer_monetary en MONETARY_DOMINANCE)
|
||||||
|
|
||||||
Évaluation : evaluate_graph() de causal_graphs.py (formula-based DAG).
|
Nouveautés Phase 2 :
|
||||||
Lifecycle : montée (rise_days) → plateau → décroissance (absorption_days).
|
- _saturate_pips() : tanh(native/scale)*coeff, slope = coeff à l'origine, sature aux extrêmes
|
||||||
Timeline : simulation jour par jour via simulate_timeline().
|
- detect_regime() : identifie le régime dominant depuis les catégories d'events actifs
|
||||||
|
- REGIME_WEIGHTS : multiplicateurs par couche selon 6 régimes de marché
|
||||||
|
- _apply_regime_weights() : réécrit la formule output avec les poids du régime courant
|
||||||
|
- simulate_timeline() inclut le régime du jour
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
from datetime import datetime, timedelta, date as date_type
|
from datetime import datetime, timedelta, date as date_type
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ── Saturation scales (tanh) par unité native ─────────────────────────────────
|
||||||
|
# tanh(x/scale) : slope=1 à l'origine, sature asymptotiquement à ±1
|
||||||
|
# pips = coefficient_to_pips * scale * tanh(x / scale)
|
||||||
|
# → slope à x=0 : coefficient_to_pips (identique au linéaire)
|
||||||
|
# → 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%
|
||||||
|
"pts%": 3.0,
|
||||||
|
"pts": 15.0, # PMI écart depuis 50 : sature autour ±20pts
|
||||||
|
"score": 3.0, # scores subjectifs -5 à +5
|
||||||
|
"tonnes": 80.0, # tonnes or / CB buying
|
||||||
|
"Mds$": 40.0,
|
||||||
|
"Mds$/sem": 15.0,
|
||||||
|
"k lots": 80.0, # positions CFTC
|
||||||
|
"$/bbl": 25.0,
|
||||||
|
"x": 5.0, # multiples PE
|
||||||
|
"ratio": 1.5,
|
||||||
|
"t": 80.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _saturate_pips(native: float, coeff: float, unit: str, scale_override: Optional[float] = None) -> float:
|
||||||
|
"""Convertit une valeur native en pips avec saturation tanh.
|
||||||
|
Comportement identique au linéaire pour de petites valeurs,
|
||||||
|
sature progressivement pour les valeurs extrêmes.
|
||||||
|
"""
|
||||||
|
scale = scale_override or _SATURATION_SCALES.get(unit)
|
||||||
|
if scale and scale > 0:
|
||||||
|
return coeff * scale * math.tanh(native / scale)
|
||||||
|
return coeff * native
|
||||||
|
|
||||||
|
|
||||||
|
# ── Régimes de marché et poids adaptatifs ─────────────────────────────────────
|
||||||
|
# Chaque régime amplifie certaines couches (>1) et atténue les autres (<1)
|
||||||
|
# Les clés couvrent tous les noms de couches intermédiaires possibles.
|
||||||
|
|
||||||
|
REGIME_WEIGHTS: dict[str, dict[str, float]] = {
|
||||||
|
"MONETARY_DOMINANCE": {
|
||||||
|
# Fed/BCE dominent tout — taux, OIS, anticipations
|
||||||
|
"layer_monetary": 1.40, "layer_rates": 1.40,
|
||||||
|
"layer_growth": 0.80, "layer_uk_macro": 0.80, "layer_macro": 0.80,
|
||||||
|
"layer_risk": 0.70, "layer_risk_credit": 0.70, "layer_refuge": 0.70,
|
||||||
|
"layer_positioning": 1.10, "layer_policy": 1.10,
|
||||||
|
"layer_flows": 1.00, "layer_demand": 1.00,
|
||||||
|
"layer_tech_fundamental": 0.85, "layer_supply": 1.00,
|
||||||
|
"layer_china": 0.90, "layer_dollar": 1.20,
|
||||||
|
"layer_global": 0.80,
|
||||||
|
},
|
||||||
|
"GEOPOLITICAL_RISK": {
|
||||||
|
# Tensions géo → flight to safety, or, JPY, USD
|
||||||
|
"layer_monetary": 0.80, "layer_rates": 0.80,
|
||||||
|
"layer_growth": 0.90, "layer_uk_macro": 0.85, "layer_macro": 0.85,
|
||||||
|
"layer_risk": 1.50, "layer_risk_credit": 1.50, "layer_refuge": 1.60,
|
||||||
|
"layer_positioning": 1.10, "layer_policy": 1.30,
|
||||||
|
"layer_flows": 1.00, "layer_demand": 1.20,
|
||||||
|
"layer_tech_fundamental": 0.80, "layer_supply": 0.90,
|
||||||
|
"layer_china": 0.80, "layer_dollar": 0.90,
|
||||||
|
"layer_global": 1.30,
|
||||||
|
},
|
||||||
|
"CREDIT_STRESS": {
|
||||||
|
# Banking stress / liquidity crunch → risk-off massif
|
||||||
|
"layer_monetary": 0.90, "layer_rates": 0.90,
|
||||||
|
"layer_growth": 1.10, "layer_uk_macro": 1.10, "layer_macro": 1.10,
|
||||||
|
"layer_risk": 1.60, "layer_risk_credit": 1.70, "layer_refuge": 1.50,
|
||||||
|
"layer_positioning": 0.80, "layer_policy": 0.80,
|
||||||
|
"layer_flows": 0.70, "layer_demand": 0.80,
|
||||||
|
"layer_tech_fundamental": 0.75, "layer_supply": 0.90,
|
||||||
|
"layer_china": 0.85, "layer_dollar": 1.10,
|
||||||
|
"layer_global": 1.20,
|
||||||
|
},
|
||||||
|
"GROWTH_SCARE": {
|
||||||
|
# Récession / choc croissance → pivots BC, safe haven, EM sell
|
||||||
|
"layer_monetary": 1.10, "layer_rates": 1.10,
|
||||||
|
"layer_growth": 1.50, "layer_uk_macro": 1.50, "layer_macro": 1.50,
|
||||||
|
"layer_risk": 1.20, "layer_risk_credit": 1.20, "layer_refuge": 1.30,
|
||||||
|
"layer_positioning": 0.90, "layer_policy": 0.90,
|
||||||
|
"layer_flows": 0.85, "layer_demand": 0.80,
|
||||||
|
"layer_tech_fundamental": 0.70, "layer_supply": 0.90,
|
||||||
|
"layer_china": 1.30, "layer_dollar": 0.90,
|
||||||
|
"layer_global": 1.10,
|
||||||
|
},
|
||||||
|
"COMMODITY_SHOCK": {
|
||||||
|
# Choc énergie/matières premières → inflation, EM, or
|
||||||
|
"layer_monetary": 1.10, "layer_rates": 1.10,
|
||||||
|
"layer_growth": 1.20, "layer_uk_macro": 1.10, "layer_macro": 1.20,
|
||||||
|
"layer_risk": 1.10, "layer_risk_credit": 1.00, "layer_refuge": 1.20,
|
||||||
|
"layer_positioning": 1.00, "layer_policy": 1.00,
|
||||||
|
"layer_flows": 1.00, "layer_demand": 1.40,
|
||||||
|
"layer_tech_fundamental": 0.90, "layer_supply": 1.20,
|
||||||
|
"layer_china": 1.20, "layer_dollar": 0.95,
|
||||||
|
"layer_global": 1.10,
|
||||||
|
},
|
||||||
|
"BALANCED": {
|
||||||
|
# Régime neutre : aucune amplification
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mapping catégories d'events → régimes candidats
|
||||||
|
_CAT_TO_REGIME: dict[str, str] = {
|
||||||
|
"central_bank": "MONETARY_DOMINANCE",
|
||||||
|
"monetary_shock": "MONETARY_DOMINANCE",
|
||||||
|
"geopolitical": "GEOPOLITICAL_RISK",
|
||||||
|
"trade_policy": "GEOPOLITICAL_RISK",
|
||||||
|
"credit_stress": "CREDIT_STRESS",
|
||||||
|
"growth_shock": "GROWTH_SCARE",
|
||||||
|
"commodity": "COMMODITY_SHOCK",
|
||||||
|
"sentiment": "BALANCED",
|
||||||
|
"technical": "BALANCED",
|
||||||
|
"positioning": "BALANCED",
|
||||||
|
"unclassified": "BALANCED",
|
||||||
|
}
|
||||||
|
|
||||||
# ── Lifecycle & decay ──────────────────────────────────────────────────────────
|
# ── Lifecycle & decay ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _decay(days: int, absorption: int, dtype: str) -> float:
|
def _decay(days: int, absorption: int, dtype: str) -> float:
|
||||||
@@ -402,6 +519,82 @@ INSTRUMENT_MODELS: dict[str, dict] = {
|
|||||||
} # end INSTRUMENT_MODELS
|
} # end INSTRUMENT_MODELS
|
||||||
|
|
||||||
|
|
||||||
|
# ── Regime detection ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def detect_regime(ev_by_cat: dict[str, float]) -> dict:
|
||||||
|
"""
|
||||||
|
Détermine le régime de marché dominant depuis la pression event par catégorie.
|
||||||
|
Retourne : {regime, label, scores, dominant_cat, weights}
|
||||||
|
"""
|
||||||
|
# Score de chaque catégorie = |pips|
|
||||||
|
cat_scores = {cat: abs(v) for cat, v in ev_by_cat.items() if v != 0}
|
||||||
|
if not cat_scores:
|
||||||
|
return {
|
||||||
|
"regime": "BALANCED", "label": "Équilibré",
|
||||||
|
"scores": {}, "dominant_cat": None,
|
||||||
|
"weights": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cumul de score par régime candidat
|
||||||
|
regime_scores: dict[str, float] = {}
|
||||||
|
for cat, score in cat_scores.items():
|
||||||
|
r = _CAT_TO_REGIME.get(cat, "BALANCED")
|
||||||
|
regime_scores[r] = regime_scores.get(r, 0.0) + score
|
||||||
|
|
||||||
|
dominant_regime = max(regime_scores, key=lambda r: regime_scores[r])
|
||||||
|
|
||||||
|
# Si le score maximal ne dépasse pas 3 pips → BALANCED
|
||||||
|
max_score = regime_scores.get(dominant_regime, 0.0)
|
||||||
|
if max_score < 3.0 or dominant_regime == "BALANCED":
|
||||||
|
dominant_regime = "BALANCED"
|
||||||
|
|
||||||
|
dominant_cat = max(
|
||||||
|
(c for c in cat_scores if _CAT_TO_REGIME.get(c) == dominant_regime),
|
||||||
|
key=lambda c: cat_scores[c],
|
||||||
|
default=None,
|
||||||
|
) if dominant_regime != "BALANCED" else None
|
||||||
|
|
||||||
|
REGIME_LABELS = {
|
||||||
|
"MONETARY_DOMINANCE": "Dominance Monétaire",
|
||||||
|
"GEOPOLITICAL_RISK": "Risque Géopolitique",
|
||||||
|
"CREDIT_STRESS": "Stress Crédit",
|
||||||
|
"GROWTH_SCARE": "Choc Croissance",
|
||||||
|
"COMMODITY_SHOCK": "Choc Commodités",
|
||||||
|
"BALANCED": "Équilibré",
|
||||||
|
}
|
||||||
|
|
||||||
|
weights = REGIME_WEIGHTS.get(dominant_regime, {})
|
||||||
|
return {
|
||||||
|
"regime": dominant_regime,
|
||||||
|
"label": REGIME_LABELS.get(dominant_regime, dominant_regime),
|
||||||
|
"scores": {r: round(s, 1) for r, s in regime_scores.items()},
|
||||||
|
"dominant_cat": dominant_cat,
|
||||||
|
"weights": weights,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_regime_weights(formula: str, weights: dict[str, float]) -> str:
|
||||||
|
"""
|
||||||
|
Injecte les multiplicateurs de régime dans une formule d'output.
|
||||||
|
Ex: "layer_monetary + layer_risk" + {layer_monetary:1.4, layer_risk:1.5}
|
||||||
|
→ "1.40 * layer_monetary + 1.50 * layer_risk"
|
||||||
|
Les termes sans poids restent à 1.0 (non modifiés).
|
||||||
|
"""
|
||||||
|
if not weights:
|
||||||
|
return formula
|
||||||
|
terms = [t.strip() for t in formula.split('+')]
|
||||||
|
out = []
|
||||||
|
for term in terms:
|
||||||
|
# Extraire l'id de couche (premier token alphanumérique_)
|
||||||
|
layer_id = term.strip()
|
||||||
|
w = weights.get(layer_id, 1.0)
|
||||||
|
if abs(w - 1.0) < 0.01:
|
||||||
|
out.append(layer_id)
|
||||||
|
else:
|
||||||
|
out.append(f"{w:.2f} * {layer_id}")
|
||||||
|
return " + ".join(out)
|
||||||
|
|
||||||
|
|
||||||
# ── DB ─────────────────────────────────────────────────────────────────────────
|
# ── DB ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def init_instrument_model_tables(conn):
|
def init_instrument_model_tables(conn):
|
||||||
@@ -517,13 +710,14 @@ def _compute_event_by_category(conn, instrument: str, ref_date: date_type) -> di
|
|||||||
|
|
||||||
# ── Graph evaluation ───────────────────────────────────────────────────────────
|
# ── Graph evaluation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _build_inputs(graph_def: dict, overrides: dict, ev_by_cat: dict) -> dict:
|
def _build_inputs(
|
||||||
|
graph_def: dict, overrides: dict, ev_by_cat: dict,
|
||||||
|
saturation: bool = True,
|
||||||
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Build the inputs dict for evaluate_graph():
|
Build inputs dict for evaluate_graph().
|
||||||
- input_event nodes → value from event contributions (category mapping)
|
Phase 2 : les nœuds input_manual utilisent _saturate_pips() (tanh)
|
||||||
- input_manual nodes → user_value × coefficient_to_pips
|
au lieu d'une conversion purement linéaire.
|
||||||
Any node with an override uses that value directly (already in pips for events;
|
|
||||||
for manual nodes the override IS the native-unit value → converted to pips).
|
|
||||||
"""
|
"""
|
||||||
inputs: dict[str, float] = {}
|
inputs: dict[str, float] = {}
|
||||||
for node in graph_def["nodes"]:
|
for node in graph_def["nodes"]:
|
||||||
@@ -532,29 +726,37 @@ def _build_inputs(graph_def: dict, overrides: dict, ev_by_cat: dict) -> dict:
|
|||||||
ov = overrides.get(nid)
|
ov = overrides.get(nid)
|
||||||
|
|
||||||
if ntype == "input_event":
|
if ntype == "input_event":
|
||||||
if ov:
|
# Events sont déjà en pips → pas de saturation (déjà non-linéaire via lifecycle)
|
||||||
inputs[nid] = float(ov["value"]) # override IS pips
|
inputs[nid] = float(ov["value"]) if ov else ev_by_cat.get(node.get("event_category", ""), 0.0)
|
||||||
else:
|
|
||||||
cat = node.get("event_category", "")
|
|
||||||
inputs[nid] = ev_by_cat.get(cat, 0.0)
|
|
||||||
|
|
||||||
elif ntype == "input_manual":
|
elif ntype == "input_manual":
|
||||||
if ov:
|
coeff = float(node.get("coefficient_to_pips", 1.0))
|
||||||
coeff = float(node.get("coefficient_to_pips", 1.0))
|
native = float(ov["value"]) if ov else 0.0
|
||||||
inputs[nid] = float(ov["value"]) * coeff
|
if native == 0.0:
|
||||||
|
inputs[nid] = 0.0
|
||||||
|
elif saturation:
|
||||||
|
inputs[nid] = _saturate_pips(native, coeff, node.get("unit", ""), node.get("saturation_scale"))
|
||||||
else:
|
else:
|
||||||
inputs[nid] = 0.0 # neutral
|
inputs[nid] = coeff * native
|
||||||
|
|
||||||
return inputs
|
return inputs
|
||||||
|
|
||||||
|
|
||||||
def _graph_json_for_eval(graph_def: dict) -> dict:
|
def _graph_json_for_eval(graph_def: dict, regime_weights: Optional[dict] = None) -> dict:
|
||||||
"""Convert our model graph_def to the format expected by evaluate_graph()."""
|
"""
|
||||||
|
Convertit le graph_def au format attendu par evaluate_graph().
|
||||||
|
Phase 2 : si regime_weights est fourni, la formule du nœud output est
|
||||||
|
réécrite avec les multiplicateurs de régime.
|
||||||
|
"""
|
||||||
|
output_id = graph_def.get("output_node", "")
|
||||||
nodes = []
|
nodes = []
|
||||||
for n in graph_def["nodes"]:
|
for n in graph_def["nodes"]:
|
||||||
entry: dict = {"id": n["id"]}
|
entry: dict = {"id": n["id"]}
|
||||||
if n.get("formula"):
|
formula = n.get("formula")
|
||||||
entry["formula"] = n["formula"]
|
if formula:
|
||||||
|
if n["id"] == output_id and regime_weights:
|
||||||
|
formula = _apply_regime_weights(formula, regime_weights)
|
||||||
|
entry["formula"] = formula
|
||||||
nodes.append(entry)
|
nodes.append(entry)
|
||||||
return {"nodes": nodes, "coefficients": {}}
|
return {"nodes": nodes, "coefficients": {}}
|
||||||
|
|
||||||
@@ -562,7 +764,10 @@ def _graph_json_for_eval(graph_def: dict) -> dict:
|
|||||||
# ── Public API ─────────────────────────────────────────────────────────────────
|
# ── Public API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Optional[dict]:
|
def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Optional[dict]:
|
||||||
"""Full model state: all node values computed via DAG evaluation."""
|
"""
|
||||||
|
Full model state : DAG evaluation avec saturation (Phase 2) + poids de régime.
|
||||||
|
Retourne aussi {regime: {regime, label, weights, scores}}.
|
||||||
|
"""
|
||||||
inst_upper = instrument.upper()
|
inst_upper = instrument.upper()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT graph_json FROM instrument_models WHERE instrument=?", (inst_upper,)
|
"SELECT graph_json FROM instrument_models WHERE instrument=?", (inst_upper,)
|
||||||
@@ -583,57 +788,79 @@ def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Opt
|
|||||||
).fetchall()}
|
).fetchall()}
|
||||||
|
|
||||||
ev_by_cat = _compute_event_by_category(conn, inst_upper, ref_date)
|
ev_by_cat = _compute_event_by_category(conn, inst_upper, ref_date)
|
||||||
inputs = _build_inputs(graph_def, overrides, ev_by_cat)
|
|
||||||
|
|
||||||
# DAG evaluation (propagates through intermediate nodes via formulas)
|
# Phase 2 : détection régime + poids adaptatifs
|
||||||
|
regime_info = detect_regime(ev_by_cat)
|
||||||
|
regime_weights = regime_info["weights"]
|
||||||
|
|
||||||
|
# Inputs avec saturation tanh
|
||||||
|
inputs = _build_inputs(graph_def, overrides, ev_by_cat, saturation=True)
|
||||||
|
|
||||||
|
# DAG evaluation avec formule output pondérée par régime
|
||||||
from services.causal_graphs import evaluate_graph
|
from services.causal_graphs import evaluate_graph
|
||||||
gj = _graph_json_for_eval(graph_def)
|
gj = _graph_json_for_eval(graph_def, regime_weights)
|
||||||
all_vals = evaluate_graph(gj, inputs)
|
all_vals = evaluate_graph(gj, inputs)
|
||||||
|
|
||||||
output_id = graph_def["output_node"]
|
output_id = graph_def["output_node"]
|
||||||
net_pips = round(float(all_vals.get(output_id, 0.0)), 1)
|
net_pips = round(float(all_vals.get(output_id, 0.0)), 1)
|
||||||
|
|
||||||
nodes_out = []
|
nodes_out = []
|
||||||
for node in graph_def["nodes"]:
|
for node in graph_def["nodes"]:
|
||||||
nid = node["id"]
|
nid = node["id"]
|
||||||
ntype = node.get("node_type", "")
|
ntype = node.get("node_type", "")
|
||||||
val = round(float(all_vals.get(nid, 0.0)), 1)
|
val = round(float(all_vals.get(nid, 0.0)), 1)
|
||||||
ov = overrides.get(nid)
|
ov = overrides.get(nid)
|
||||||
state = dict(node)
|
st = dict(node)
|
||||||
state["computed_value"] = val
|
st["computed_value"] = val
|
||||||
state["pip_contribution"] = val
|
st["pip_contribution"] = val
|
||||||
|
|
||||||
if ntype == "input_event":
|
if ntype == "input_event":
|
||||||
cat = node.get("event_category", "")
|
cat = node.get("event_category", "")
|
||||||
state["source"] = "manual" if ov else ("events" if val != 0.0 else "neutral")
|
st["source"] = "manual" if ov else ("events" if val != 0.0 else "neutral")
|
||||||
state["raw_value"] = ov["value"] if ov else round(ev_by_cat.get(cat, 0.0), 1)
|
st["raw_value"] = ov["value"] if ov else round(ev_by_cat.get(cat, 0.0), 1)
|
||||||
if ov:
|
if ov:
|
||||||
state["override_note"] = ov.get("note", "")
|
st["override_note"] = ov.get("note", "")
|
||||||
state["override_set_at"] = ov.get("set_at", "")
|
st["override_set_at"] = ov.get("set_at", "")
|
||||||
|
|
||||||
elif ntype == "input_manual":
|
elif ntype == "input_manual":
|
||||||
state["source"] = "manual" if ov else "neutral"
|
coeff = float(node.get("coefficient_to_pips", 1.0))
|
||||||
state["raw_value"] = ov["value"] if ov else 0.0 # in native unit
|
native = float(ov["value"]) if ov else 0.0
|
||||||
|
# Retourne la valeur native sans saturation (pour affichage)
|
||||||
|
st["source"] = "manual" if ov else "neutral"
|
||||||
|
st["raw_value"] = native
|
||||||
|
# pip_contribution inclut la saturation (= all_vals[nid])
|
||||||
|
# On expose aussi la valeur linéaire pour comparaison
|
||||||
|
st["pip_linear"] = round(coeff * native, 1)
|
||||||
|
st["pip_saturated"] = val
|
||||||
|
st["saturation_pct"] = (
|
||||||
|
round((1.0 - val / (coeff * native)) * 100, 1)
|
||||||
|
if native != 0 and coeff != 0
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
if ov:
|
if ov:
|
||||||
state["override_note"] = ov.get("note", "")
|
st["override_note"] = ov.get("note", "")
|
||||||
state["override_set_at"] = ov.get("set_at", "")
|
st["override_set_at"] = ov.get("set_at", "")
|
||||||
|
|
||||||
elif ntype in ("intermediate", "output"):
|
elif ntype in ("intermediate", "output"):
|
||||||
state["source"] = "computed"
|
st["source"] = "computed"
|
||||||
|
# Pour les intermédiaires, expose le multiplicateur de régime
|
||||||
|
if ntype == "intermediate":
|
||||||
|
st["regime_weight"] = regime_weights.get(nid, 1.0)
|
||||||
|
|
||||||
nodes_out.append(state)
|
nodes_out.append(st)
|
||||||
|
|
||||||
direction = "bullish" if net_pips > 5 else "bearish" if net_pips < -5 else "neutral"
|
direction = "bullish" if net_pips > 5 else "bearish" if net_pips < -5 else "neutral"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"instrument": inst_upper,
|
"instrument": inst_upper,
|
||||||
"name": graph_def["name"],
|
"name": graph_def["name"],
|
||||||
"description": graph_def.get("description", ""),
|
"description": graph_def.get("description", ""),
|
||||||
"at_date": str(ref_date),
|
"at_date": str(ref_date),
|
||||||
"net_pips": net_pips,
|
"net_pips": net_pips,
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"nodes": nodes_out,
|
"nodes": nodes_out,
|
||||||
"output_node": output_id,
|
"output_node": output_id,
|
||||||
|
"regime": regime_info,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -721,12 +948,10 @@ def simulate_timeline(
|
|||||||
})
|
})
|
||||||
|
|
||||||
from services.causal_graphs import evaluate_graph
|
from services.causal_graphs import evaluate_graph
|
||||||
gj = _graph_json_for_eval(graph_def)
|
|
||||||
|
|
||||||
timeline = []
|
timeline = []
|
||||||
cur = date_from
|
cur = date_from
|
||||||
while cur <= today:
|
while cur <= today:
|
||||||
# Event contributions for this day
|
|
||||||
ev_by_cat: dict[str, float] = {}
|
ev_by_cat: dict[str, float] = {}
|
||||||
for ev in events:
|
for ev in events:
|
||||||
if ev["ev_date"] > cur:
|
if ev["ev_date"] > cur:
|
||||||
@@ -738,13 +963,17 @@ def simulate_timeline(
|
|||||||
cat = ev["category"]
|
cat = ev["category"]
|
||||||
ev_by_cat[cat] = ev_by_cat.get(cat, 0.0) + round(ev["pips"] * df, 2)
|
ev_by_cat[cat] = ev_by_cat.get(cat, 0.0) + round(ev["pips"] * df, 2)
|
||||||
|
|
||||||
inputs = _build_inputs(graph_def, overrides, ev_by_cat)
|
# Phase 2 : régime du jour → poids adaptatifs dans la formule output
|
||||||
vals = evaluate_graph(gj, inputs)
|
ri = detect_regime(ev_by_cat)
|
||||||
net = round(float(vals.get(output_id, 0.0)), 1)
|
gj = _graph_json_for_eval(graph_def, ri["weights"])
|
||||||
|
inputs = _build_inputs(graph_def, overrides, ev_by_cat, saturation=True)
|
||||||
|
vals = evaluate_graph(gj, inputs)
|
||||||
|
net = round(float(vals.get(output_id, 0.0)), 1)
|
||||||
|
|
||||||
timeline.append({
|
timeline.append({
|
||||||
"date": str(cur),
|
"date": str(cur),
|
||||||
"net_pips": net,
|
"net_pips": net,
|
||||||
|
"regime": ri["regime"],
|
||||||
"nodes": {k: round(float(v), 1) for k, v in vals.items()},
|
"nodes": {k: round(float(v), 1) for k, v in vals.items()},
|
||||||
})
|
})
|
||||||
cur += timedelta(days=1)
|
cur += timedelta(days=1)
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ interface ModelNode {
|
|||||||
source: 'manual' | 'events' | 'neutral' | 'computed'
|
source: 'manual' | 'events' | 'neutral' | 'computed'
|
||||||
override_note?: string
|
override_note?: string
|
||||||
override_set_at?: string
|
override_set_at?: string
|
||||||
|
// Phase 2
|
||||||
|
pip_linear?: number
|
||||||
|
pip_saturated?: number
|
||||||
|
saturation_pct?: number
|
||||||
|
regime_weight?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegimeInfo {
|
||||||
|
regime: string
|
||||||
|
label: string
|
||||||
|
dominant_cat: string | null
|
||||||
|
scores: Record<string, number>
|
||||||
|
weights: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelState {
|
interface ModelState {
|
||||||
@@ -46,11 +59,13 @@ interface ModelState {
|
|||||||
direction: 'bullish' | 'bearish' | 'neutral'
|
direction: 'bullish' | 'bearish' | 'neutral'
|
||||||
nodes: ModelNode[]
|
nodes: ModelNode[]
|
||||||
output_node: string
|
output_node: string
|
||||||
|
regime: RegimeInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TimelinePoint {
|
interface TimelinePoint {
|
||||||
date: string
|
date: string
|
||||||
net_pips: number
|
net_pips: number
|
||||||
|
regime?: string
|
||||||
nodes: Record<string, number>
|
nodes: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +167,46 @@ function DirectionBadge({ direction, pips }: { direction: string; pips: number }
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Regime Badge ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const REGIME_META: Record<string, { color: string; bg: string; icon: string }> = {
|
||||||
|
MONETARY_DOMINANCE: { color: 'text-blue-400', bg: 'bg-blue-900/30 border-blue-700/40', icon: '🏦' },
|
||||||
|
GEOPOLITICAL_RISK: { color: 'text-orange-400', bg: 'bg-orange-900/30 border-orange-700/40', icon: '⚠️' },
|
||||||
|
CREDIT_STRESS: { color: 'text-red-400', bg: 'bg-red-900/30 border-red-700/40', icon: '🔴' },
|
||||||
|
GROWTH_SCARE: { color: 'text-amber-400', bg: 'bg-amber-900/30 border-amber-700/40', icon: '📉' },
|
||||||
|
COMMODITY_SHOCK: { color: 'text-yellow-400', bg: 'bg-yellow-900/30 border-yellow-700/40','icon': '🛢️' },
|
||||||
|
BALANCED: { color: 'text-slate-400', bg: 'bg-slate-800/50 border-slate-700/40', icon: '⚖️' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegimeBadge({ regime }: { regime: RegimeInfo }) {
|
||||||
|
const meta = REGIME_META[regime.regime] || REGIME_META.BALANCED
|
||||||
|
const topWeights = Object.entries(regime.weights)
|
||||||
|
.filter(([, w]) => w > 1.0)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('inline-flex flex-col gap-1 px-3 py-2 rounded-lg border text-xs', meta.bg)}>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span>{meta.icon}</span>
|
||||||
|
<span className={clsx('font-semibold', meta.color)}>{regime.label}</span>
|
||||||
|
{regime.dominant_cat && (
|
||||||
|
<span className="text-slate-500">· {regime.dominant_cat}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{topWeights.length > 0 && (
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{topWeights.map(([layer, w]) => (
|
||||||
|
<span key={layer} className={clsx('font-mono', meta.color)}>
|
||||||
|
{layer.replace('layer_', '')} ×{w.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Node Edit Modal ───────────────────────────────────────────────────────────
|
// ── Node Edit Modal ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function NodeEditModal({ node, instrument, onClose, onSaved }: {
|
function NodeEditModal({ node, instrument, onClose, onSaved }: {
|
||||||
@@ -287,9 +342,15 @@ function NodeCard({ node, onEdit }: { node: ModelNode; onEdit: (n: ModelNode) =>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{node.node_type === 'intermediate' && node.formula && (
|
{node.node_type === 'intermediate' && (
|
||||||
<div className="mt-1 text-slate-600 text-xs truncate">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
{node.formula.split('+').length} sources
|
{node.formula && <span className="text-slate-600 text-xs">{node.formula.split('+').length} sources</span>}
|
||||||
|
{node.regime_weight !== undefined && node.regime_weight !== 1.0 && (
|
||||||
|
<span className={clsx('text-xs font-mono font-bold',
|
||||||
|
node.regime_weight > 1.0 ? 'text-amber-400' : 'text-slate-500')}>
|
||||||
|
×{node.regime_weight.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -467,7 +528,16 @@ function TableView({ nodes, onEdit }: { nodes: ModelNode[]; onEdit: (n: ModelNod
|
|||||||
{node.node_type === 'input_manual' && node.coefficient_to_pips !== undefined && (
|
{node.node_type === 'input_manual' && node.coefficient_to_pips !== undefined && (
|
||||||
<div className="text-slate-500">
|
<div className="text-slate-500">
|
||||||
Coeff : {node.coefficient_to_pips} pips/{node.unit}
|
Coeff : {node.coefficient_to_pips} pips/{node.unit}
|
||||||
{node.raw_value !== undefined ? ` · ${node.raw_value} ${node.unit} → ${fmt(node.pip_contribution)} pips` : ''}
|
{node.raw_value !== undefined ? ` · ${node.raw_value} ${node.unit}` : ''}
|
||||||
|
{node.pip_linear !== undefined && node.pip_saturated !== undefined && (
|
||||||
|
<span>
|
||||||
|
{' '}→ linéaire <span className="text-slate-400">{fmt(node.pip_linear)}</span>
|
||||||
|
{' '}/ saturé <span className={pipColor(node.pip_saturated)}>{fmt(node.pip_saturated)}</span>
|
||||||
|
{node.saturation_pct !== undefined && node.saturation_pct > 1 && (
|
||||||
|
<span className="text-amber-500/70"> (sat. {node.saturation_pct.toFixed(0)}%)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{node.node_type === 'intermediate' && node.formula && (
|
{node.node_type === 'intermediate' && node.formula && (
|
||||||
@@ -545,6 +615,28 @@ function TimelineView({ instrument }: { instrument: string }) {
|
|||||||
const toX = (i: number) => mL + (i / Math.max(data.length - 1, 1)) * cW
|
const toX = (i: number) => mL + (i / Math.max(data.length - 1, 1)) * cW
|
||||||
const toY = (v: number) => mT + (1 - (v - minV) / (maxV - minV)) * cH
|
const toY = (v: number) => mT + (1 - (v - minV) / (maxV - minV)) * cH
|
||||||
|
|
||||||
|
// Regime background bands
|
||||||
|
const REGIME_CANVAS_COLORS: Record<string, string> = {
|
||||||
|
MONETARY_DOMINANCE: 'rgba(59,130,246,0.07)',
|
||||||
|
GEOPOLITICAL_RISK: 'rgba(249,115,22,0.07)',
|
||||||
|
CREDIT_STRESS: 'rgba(239,68,68,0.07)',
|
||||||
|
GROWTH_SCARE: 'rgba(245,158,11,0.07)',
|
||||||
|
COMMODITY_SHOCK: 'rgba(234,179,8,0.07)',
|
||||||
|
BALANCED: 'rgba(0,0,0,0)',
|
||||||
|
}
|
||||||
|
let bandStart = 0, bandRegime = data[0]?.regime || 'BALANCED'
|
||||||
|
for (let i = 1; i <= data.length; i++) {
|
||||||
|
const r = i < data.length ? (data[i]?.regime || 'BALANCED') : null
|
||||||
|
if (r !== bandRegime || i === data.length) {
|
||||||
|
const color = REGIME_CANVAS_COLORS[bandRegime]
|
||||||
|
if (color !== 'rgba(0,0,0,0)') {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.fillRect(toX(bandStart), mT, toX(i - 1) - toX(bandStart), cH)
|
||||||
|
}
|
||||||
|
bandStart = i; bandRegime = r || 'BALANCED'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Grid
|
// Grid
|
||||||
ctx.lineWidth = 1
|
ctx.lineWidth = 1
|
||||||
const nG = 5
|
const nG = 5
|
||||||
@@ -733,10 +825,13 @@ export default function InstrumentModels() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-xs text-slate-600 space-y-0.5 self-start">
|
<div className="flex flex-col gap-2 self-start">
|
||||||
<div>{state.nodes.filter(n => n.source === 'events').length} events actifs</div>
|
{state.regime && <RegimeBadge regime={state.regime}/>}
|
||||||
<div>{state.nodes.filter(n => n.source === 'manual').length} overrides manuels</div>
|
<div className="text-xs text-slate-600 space-y-0.5">
|
||||||
<div className="text-slate-700">{state.at_date}</div>
|
<div>{state.nodes.filter(n => n.source === 'events').length} events actifs</div>
|
||||||
|
<div>{state.nodes.filter(n => n.source === 'manual').length} overrides manuels</div>
|
||||||
|
<div className="text-slate-700">{state.at_date}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user