feat: instrument model

This commit is contained in:
OpenSquared
2026-07-03 00:04:37 +02:00
parent ff6f0b390d
commit a8ee808937
4 changed files with 817 additions and 104 deletions

View File

@@ -23,6 +23,26 @@ class BulkOverrideBody(BaseModel):
overrides: List[BulkOverrideItem]
class VirtualEvent(BaseModel):
date: str
category: str = "unclassified"
pips: float = 0.0
label: str = "Event virtuel"
absorption_days: int = 14
rise_days: int = 1
plateau_days: int = 0
decay_type: str = "exp"
class WhatIfBody(BaseModel):
period: str = "1y"
virtual_events: List[VirtualEvent] = []
class CalibrateBody(BaseModel):
ref_date: Optional[str] = None
@router.get("", response_model=List[Dict[str, Any]])
def list_instrument_models():
from services.database import get_conn
@@ -136,6 +156,32 @@ def get_instrument_regime(
conn.close()
@router.get("/{instrument}/price-history")
def get_price_history(
instrument: str,
period: str = Query("1y", description="5d|1mo|3mo|6mo|1y|2y"),
refresh: bool = Query(False),
) -> Dict[str, Any]:
"""Cours historiques réels depuis yfinance (cache SQLite 6h)."""
from services.database import get_conn
from services.price_history import get_price_history as fetch_prices
from services.instrument_models import INSTRUMENT_MODELS
conn = get_conn()
try:
inst = instrument.upper()
prices = fetch_prices(conn, inst, period, force_refresh=refresh)
meta = INSTRUMENT_MODELS.get(inst, {})
return {
"instrument": inst,
"ticker": meta.get("yf_ticker", ""),
"period": period,
"n_points": len(prices),
"prices": prices,
}
finally:
conn.close()
@router.get("/{instrument}/timeline")
def get_instrument_timeline(
instrument: str,
@@ -154,6 +200,56 @@ def get_instrument_timeline(
conn.close()
@router.post("/{instrument}/timeline-whatif")
def timeline_whatif(
instrument: str,
body: WhatIfBody,
) -> List[Dict[str, Any]]:
"""Simulation what-if avec events virtuels injectés dans la timeline."""
from services.database import get_conn
from services.instrument_models import simulate_timeline
conn = get_conn()
try:
ve_list = [ve.dict() for ve in body.virtual_events]
data = simulate_timeline(conn, instrument.upper(), body.period, virtual_events=ve_list)
if not data:
raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}")
return data
finally:
conn.close()
@router.post("/{instrument}/calibrate")
def calibrate_intercept(
instrument: str,
body: CalibrateBody,
) -> Dict[str, Any]:
"""Auto-calcule l'intercept depuis le cours réel à une date de référence."""
from services.database import get_conn
from services.instrument_models import get_model_state, INSTRUMENT_MODELS
from services.price_history import calibrate_intercept as do_calibrate, get_price_history
conn = get_conn()
try:
inst = instrument.upper()
state = get_model_state(conn, inst, body.ref_date)
if not state:
raise HTTPException(status_code=404, detail=f"Modèle introuvable")
# Make sure prices are cached
get_price_history(conn, inst, "3mo")
intercept = do_calibrate(conn, inst, state["structural_pips"], body.ref_date)
meta = INSTRUMENT_MODELS.get(inst, {})
return {
"instrument": inst,
"ref_date": body.ref_date,
"structural_pips": state["structural_pips"],
"pip_to_price": meta.get("pip_to_price", 0.0001),
"calibrated_intercept": intercept,
"current_intercept": meta.get("price_intercept", 0.0),
}
finally:
conn.close()
@router.get("/{instrument}")
def get_instrument_model(
instrument: str,

View File

@@ -193,6 +193,9 @@ INSTRUMENT_MODELS: dict[str, dict] = {
"name": "EUR/USD",
"description": "Taux de change Euro/Dollar — modèle causal 3 couches avec 4 domaines d'influence",
"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"},
@@ -246,6 +249,9 @@ INSTRUMENT_MODELS: dict[str, dict] = {
"USDJPY": {
"name": "USD/JPY", "output_node": "usdjpy",
"description": "Carry & safe haven — yield diff 10Y + BoJ + risk appetite",
"price_intercept": 145.0,
"pip_to_price": 0.01,
"yf_ticker": "USDJPY=X",
"nodes": [
{"id":"in_cb", "label":"Banques Centrales", "node_type":"input_event","category":"central_bank", "unit":"pips","display_col":0,"event_category":"central_bank","description":"Fed/BoJ décisions."},
{"id":"in_macro", "label":"Surprises Macro", "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"NFP, CPI US, Tankan, CPI Japon."},
@@ -283,6 +289,9 @@ INSTRUMENT_MODELS: dict[str, dict] = {
"XAUUSD": {
"name": "XAU/USD (Or)", "output_node": "xauusd",
"description": "Or/Dollar — taux réels, dollar, géopolitique, banques centrales",
"price_intercept": 2800.0,
"pip_to_price": 1.0,
"yf_ticker": "GC=F",
"nodes": [
{"id":"in_cb", "label":"Banques Centrales", "node_type":"input_event","category":"central_bank", "unit":"pips","display_col":0,"event_category":"central_bank","description":"Fed (taux réels) → or."},
{"id":"in_macro", "label":"Surprises Macro", "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"CPI, PCE → anticipations taux réels → or."},
@@ -324,6 +333,9 @@ INSTRUMENT_MODELS: dict[str, dict] = {
"SP500": {
"name": "S&P 500", "output_node": "sp500",
"description": "Indice actions US — taux, bénéfices, risque, liquidités",
"price_intercept": 5000.0,
"pip_to_price": 1.0,
"yf_ticker": "^GSPC",
"nodes": [
{"id":"in_cb", "label":"Banques Centrales", "node_type":"input_event","category":"central_bank","unit":"pips","display_col":0,"event_category":"central_bank","description":"Fed pivot/hike → SP500 directement."},
{"id":"in_macro", "label":"Surprises Macro", "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"NFP, CPI, PIB US."},
@@ -366,6 +378,9 @@ INSTRUMENT_MODELS: dict[str, dict] = {
"TLT": {
"name": "TLT (US Long Bonds)", "output_node": "tlt",
"description": "ETF obligations US 20Y+ — duration, inflation, récession, supply",
"price_intercept": 85.0,
"pip_to_price": 0.01,
"yf_ticker": "TLT",
"nodes": [
{"id":"in_cb", "label":"Banques Centrales","node_type":"input_event","category":"central_bank","unit":"pips","display_col":0,"event_category":"central_bank","description":"FOMC décisions/minutes → impact direct TLT."},
{"id":"in_macro", "label":"Surprises Macro", "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"CPI, PCE, NFP → réévaluation taux."},
@@ -406,6 +421,9 @@ INSTRUMENT_MODELS: dict[str, dict] = {
"GBPUSD": {
"name": "GBP/USD", "output_node": "gbpusd",
"description": "Livre sterling/Dollar — BoE, données UK, risque politique",
"price_intercept": 1.26,
"pip_to_price": 0.0001,
"yf_ticker": "GBPUSD=X",
"nodes": [
{"id":"in_cb", "label":"Banques Centrales","node_type":"input_event","category":"central_bank","unit":"pips","display_col":0,"event_category":"central_bank","description":"BoE, Fed."},
{"id":"in_macro", "label":"Surprises Macro", "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"CPI UK/US, NFP, GDP UK."},
@@ -441,6 +459,9 @@ INSTRUMENT_MODELS: dict[str, dict] = {
"EEM": {
"name": "EEM (Marchés Émergents)", "output_node": "eem",
"description": "ETF EM — dollar, Chine, commodités, risk appetite",
"price_intercept": 42.0,
"pip_to_price": 0.01,
"yf_ticker": "EEM",
"nodes": [
{"id":"in_cb", "label":"Banques Centrales","node_type":"input_event","category":"central_bank","unit":"pips","display_col":0,"event_category":"central_bank","description":"Fed pivot → EM bénéficient du dollar faible."},
{"id":"in_macro", "label":"Surprises Macro", "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"Données Chine, US macro."},
@@ -478,6 +499,9 @@ INSTRUMENT_MODELS: dict[str, dict] = {
"QQQ": {
"name": "QQQ (NASDAQ-100 Tech)", "output_node": "qqq",
"description": "Tech US — taux réels, bénéfices big tech, IA, réglementation",
"price_intercept": 480.0,
"pip_to_price": 0.10,
"yf_ticker": "QQQ",
"nodes": [
{"id":"in_cb", "label":"Banques Centrales","node_type":"input_event","category":"central_bank","unit":"pips","display_col":0,"event_category":"central_bank","description":"Fed pivot → QQQ amplificateur (duration longue)."},
{"id":"in_macro", "label":"Surprises Macro", "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"CPI, NFP → réévaluation Fed → QQQ."},
@@ -804,6 +828,20 @@ def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Opt
output_id = graph_def["output_node"]
net_pips = round(float(all_vals.get(output_id, 0.0)), 1)
# Compute structural pips (manual inputs only, no events, BALANCED regime)
inputs_struct = _build_inputs(graph_def, overrides, {}, saturation=True)
gj_struct = _graph_json_for_eval(graph_def, {})
vals_struct = evaluate_graph(gj_struct, inputs_struct)
structural_pips = round(float(vals_struct.get(output_id, 0.0)), 1)
event_pips = round(net_pips - structural_pips, 1)
meta = INSTRUMENT_MODELS.get(inst_upper, {})
price_intercept = meta.get("price_intercept", 0.0)
pip_to_price = meta.get("pip_to_price", 0.0001)
yf_ticker = meta.get("yf_ticker", "")
fundamental_level = round(price_intercept + structural_pips * pip_to_price, 6)
synthetic_price = round(price_intercept + net_pips * pip_to_price, 6)
nodes_out = []
for node in graph_def["nodes"]:
nid = node["id"]
@@ -852,20 +890,28 @@ def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Opt
direction = "bullish" if net_pips > 5 else "bearish" if net_pips < -5 else "neutral"
return {
"instrument": inst_upper,
"name": graph_def["name"],
"description": graph_def.get("description", ""),
"at_date": str(ref_date),
"net_pips": net_pips,
"direction": direction,
"nodes": nodes_out,
"output_node": output_id,
"regime": regime_info,
"instrument": inst_upper,
"name": graph_def["name"],
"description": graph_def.get("description", ""),
"at_date": str(ref_date),
"net_pips": net_pips,
"structural_pips": structural_pips,
"event_pips": event_pips,
"price_intercept": price_intercept,
"pip_to_price": pip_to_price,
"yf_ticker": yf_ticker,
"fundamental_level": fundamental_level,
"synthetic_price": synthetic_price,
"direction": direction,
"nodes": nodes_out,
"output_node": output_id,
"regime": regime_info,
}
def simulate_timeline(
conn, instrument: str, period: str = "1y"
conn, instrument: str, period: str = "1y",
virtual_events: Optional[list] = None,
) -> list[dict]:
"""
Simulate all node values day by day over the period.
@@ -945,8 +991,31 @@ def simulate_timeline(
events.append({
"ev_date": ev_date, "category": r["category"], "pips": pips,
"rise": rise, "plateau": plateau, "absorption": absorption, "dtype": dtype,
"virtual": False,
})
# Inject virtual events
for ve in (virtual_events or []):
try:
ev_date = date_type.fromisoformat(str(ve["date"])[:10])
events.append({
"ev_date": ev_date,
"category": ve.get("category", "unclassified"),
"pips": float(ve.get("pips", 0.0)),
"rise": int(ve.get("rise_days", 1)),
"plateau": int(ve.get("plateau_days", 0)),
"absorption": int(ve.get("absorption_days", 14)),
"dtype": ve.get("decay_type", "exp"),
"virtual": True,
"label": ve.get("label", "Event virtuel"),
})
except (KeyError, ValueError):
continue
meta = INSTRUMENT_MODELS.get(inst_upper, {})
price_intercept = meta.get("price_intercept", 0.0)
pip_to_price = meta.get("pip_to_price", 0.0001)
from services.causal_graphs import evaluate_graph
timeline = []
@@ -970,11 +1039,21 @@ def simulate_timeline(
vals = evaluate_graph(gj, inputs)
net = round(float(vals.get(output_id, 0.0)), 1)
# Structural pips (manual only, no events)
inputs_struct = _build_inputs(graph_def, overrides, {}, saturation=True)
gj_struct = _graph_json_for_eval(graph_def, {})
vals_struct = evaluate_graph(gj_struct, inputs_struct)
structural_pips = round(float(vals_struct.get(output_id, 0.0)), 1)
timeline.append({
"date": str(cur),
"net_pips": net,
"regime": ri["regime"],
"nodes": {k: round(float(v), 1) for k, v in vals.items()},
"date": str(cur),
"net_pips": net,
"structural_pips": structural_pips,
"event_pips": round(net - structural_pips, 1),
"fundamental_level": round(price_intercept + structural_pips * pip_to_price, 6),
"synthetic_price": round(price_intercept + net * pip_to_price, 6),
"regime": ri["regime"],
"nodes": {k: round(float(v), 1) for k, v in vals.items()},
})
cur += timedelta(days=1)

View File

@@ -0,0 +1,176 @@
"""
Price History — fetch + cache des cours historiques via yfinance.
Cache SQLite dans la table price_history_cache.
"""
import json
import sqlite3
from datetime import datetime, timedelta, date as date_type
from typing import Optional
YF_TICKERS: dict[str, str] = {
"EURUSD": "EURUSD=X",
"USDJPY": "USDJPY=X",
"XAUUSD": "GC=F",
"SP500": "^GSPC",
"TLT": "TLT",
"GBPUSD": "GBPUSD=X",
"EEM": "EEM",
"QQQ": "QQQ",
}
PRICE_LABELS: dict[str, str] = {
"EURUSD": "EUR/USD",
"USDJPY": "USD/JPY",
"XAUUSD": "Or ($/oz)",
"SP500": "S&P 500",
"TLT": "TLT ETF",
"GBPUSD": "GBP/USD",
"EEM": "EEM ETF",
"QQQ": "QQQ ETF",
}
PERIOD_DAYS: dict[str, int] = {
"5d": 7, "1mo": 35, "3mo": 95, "6mo": 190, "1y": 370, "2y": 740,
}
def _ensure_cache_table(conn):
conn.execute("""
CREATE TABLE IF NOT EXISTS price_history_cache (
id INTEGER PRIMARY KEY,
instrument TEXT NOT NULL,
date TEXT NOT NULL,
close REAL NOT NULL,
fetched_at TEXT DEFAULT (datetime('now')),
UNIQUE(instrument, date)
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_price_cache_inst_date
ON price_history_cache(instrument, date)
""")
conn.commit()
def _fetch_yf(instrument: str, period_days: int) -> list[dict]:
"""Fetch from yfinance. Returns [{date, close}] sorted ascending."""
try:
import yfinance as yf
ticker = YF_TICKERS.get(instrument.upper())
if not ticker:
return []
# yfinance period string
if period_days <= 7: p = "5d"
elif period_days <= 35: p = "1mo"
elif period_days <= 95: p = "3mo"
elif period_days <= 190: p = "6mo"
elif period_days <= 370: p = "1y"
else: p = "2y"
df = yf.download(ticker, period=p, interval="1d", progress=False, auto_adjust=True)
if df is None or df.empty:
return []
# Handle MultiIndex columns (yfinance 0.2+)
if hasattr(df.columns, 'levels'):
df.columns = df.columns.get_level_values(0)
close_col = next((c for c in ["Close", "Adj Close", "close"] if c in df.columns), None)
if not close_col:
return []
result = []
for idx, row in df.iterrows():
dt = str(idx)[:10]
v = float(row[close_col])
if v and v == v: # not NaN
result.append({"date": dt, "close": round(v, 6)})
return result
except Exception:
return []
def get_price_history(
conn,
instrument: str,
period: str = "1y",
force_refresh: bool = False,
) -> list[dict]:
"""
Retourne [{date, close}] pour l'instrument sur la période.
Cache SQLite — rafraîchit si les données datent de plus de 6h.
"""
_ensure_cache_table(conn)
inst = instrument.upper()
days = PERIOD_DAYS.get(period, 370)
date_from = (datetime.utcnow().date() - timedelta(days=days)).isoformat()
# Check cache freshness
cache_ok = False
if not force_refresh:
newest = conn.execute(
"SELECT fetched_at FROM price_history_cache WHERE instrument=? ORDER BY fetched_at DESC LIMIT 1",
(inst,)
).fetchone()
if newest:
try:
age_h = (datetime.utcnow() - datetime.fromisoformat(str(newest[0])[:19])).total_seconds() / 3600
cache_ok = age_h < 6.0
except Exception:
pass
if not cache_ok:
rows = _fetch_yf(inst, days + 30)
if rows:
conn.executemany(
"INSERT OR REPLACE INTO price_history_cache (instrument, date, close) VALUES (?,?,?)",
[(inst, r["date"], r["close"]) for r in rows]
)
conn.commit()
# Read from cache
rows_db = conn.execute(
"SELECT date, close FROM price_history_cache WHERE instrument=? AND date>=? ORDER BY date ASC",
(inst, date_from)
).fetchall()
return [{"date": r[0], "close": r[1]} for r in rows_db]
def calibrate_intercept(
conn,
instrument: str,
model_pips_at_ref: float,
ref_date: Optional[str] = None,
) -> Optional[float]:
"""
Calcule l'intercept = real_price - model_pips × pip_to_price au point de référence.
Si ref_date non fourni, utilise il y a 30 jours.
"""
from services.instrument_models import INSTRUMENT_MODELS
meta = INSTRUMENT_MODELS.get(instrument.upper(), {})
pip_to_price = meta.get("pip_to_price", 0.0001)
if ref_date is None:
ref_date = (datetime.utcnow().date() - timedelta(days=30)).isoformat()
# Find nearest price to ref_date
row = conn.execute(
"""SELECT date, close FROM price_history_cache
WHERE instrument=? AND date<=? ORDER BY date DESC LIMIT 1""",
(instrument.upper(), ref_date)
).fetchone()
if not row:
# Try fetching
history = get_price_history(conn, instrument, "3mo", force_refresh=True)
row = conn.execute(
"SELECT date, close FROM price_history_cache WHERE instrument=? AND date<=? ORDER BY date DESC LIMIT 1",
(instrument.upper(), ref_date)
).fetchone()
if not row:
return None
real_price = float(row[1])
intercept = real_price - model_pips_at_ref * pip_to_price
return round(intercept, 6)