diff --git a/backend/routers/instruments.py b/backend/routers/instruments.py index a3c26e8..c2f657d 100644 --- a/backend/routers/instruments.py +++ b/backend/routers/instruments.py @@ -4,7 +4,7 @@ Exposes per-instrument snapshot (price, indicators, regime, trend, events) and A """ import json import math -from datetime import datetime, timedelta +from datetime import datetime, timedelta, date as date_type from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel from typing import List, Dict, Any, Optional @@ -227,3 +227,166 @@ def get_theoretical_curve( result.append({"date": d, **entry}) return result + + +# ── Libellés lisibles par catégorie ─────────────────────────────────────────── +_CAT_LABELS: Dict[str, str] = { + "central_bank": "Banque Centrale", + "monetary_shock": "Surprise Macro", + "geopolitical": "Géopolitique", + "commodity": "Commodités", + "growth_shock": "Croissance", + "trade_policy": "Commerce / Tarifs", + "credit_stress": "Stress Crédit", + "sentiment": "Sentiment & Position.", + "technical": "Technique", + "positioning": "Flux Institutionnels", + "unclassified": "Non Classifié", +} + + +@router.get("/{instrument_id}/factor-state") +def get_factor_state( + instrument_id: str, + at_date: Optional[str] = Query(None, description="YYYY-MM-DD (défaut: aujourd'hui)"), +) -> Dict[str, Any]: + """ + Pression nette actuelle sur l'instrument : somme de toutes les contributions + d'events actifs pondérées par leur courbe de dissipation. + + Retourne une décomposition par catégorie causale (Banque Centrale, Surprise Macro…) + avec détail par event, ainsi que le NET en pips et la direction. + """ + from services.database import get_conn + + try: + ref_date = date_type.fromisoformat(at_date) if at_date else datetime.utcnow().date() + except ValueError: + ref_date = datetime.utcnow().date() + + # Cherche les analyses pour cet instrument dans les 180 jours précédents + extended_from = ref_date - timedelta(days=180) + inst_upper = instrument_id.upper() + + conn = get_conn() + try: + rows = conn.execute(""" + SELECT a.prediction_json, + e.start_date AS event_date, + e.name AS event_name, + e.sub_type AS event_sub_type, + e.end_date AS event_end_date, + t.name AS template_name, + t.category AS category, + t.calibration_json + FROM causal_event_analyses a + JOIN market_events e ON e.id = a.market_event_id + JOIN causal_graph_templates t ON t.id = a.template_id + WHERE a.instrument = ? + AND e.start_date >= ? + AND e.start_date <= ? + ORDER BY e.start_date DESC + """, (inst_upper, str(extended_from), str(ref_date))).fetchall() + finally: + conn.close() + + inst_lower = inst_upper.lower() + by_category: Dict[str, Dict] = {} + seen_events: set = set() # évite les doublons (même event × multi-analyse) + + for row in rows: + r = dict(row) + event_key = (r["event_name"], r["event_date"]) + if event_key in seen_events: + continue + seen_events.add(event_key) + + try: + predictions = json.loads(r["prediction_json"] or "{}") + calib = json.loads(r["calibration_json"] or "{}") + except Exception: + continue + + # Pips prédits pour cet instrument (cherche node_id == inst_lower ou contenant) + pips_full: Optional[float] = None + if inst_lower in predictions: + pips_full = float(predictions[inst_lower]) + else: + for k, v in predictions.items(): + if inst_lower in k.lower(): + try: + pips_full = float(v) + break + except (TypeError, ValueError): + pass + + if pips_full is None or pips_full == 0: + continue + + absorption_days = max(1, int(calib.get("absorption_days", 7))) + decay_type = str(calib.get("decay_type", "exp")) + + try: + ev_date = date_type.fromisoformat(r["event_date"][:10]) + except ValueError: + continue + + # Pour les guidance events : end_date = meeting date → absorption dynamique + ev_end = r.get("event_end_date") + if ev_end and r.get("event_sub_type", "").startswith("rate_guidance"): + try: + meeting = date_type.fromisoformat(ev_end[:10]) + absorption_days = max(1, (meeting - ev_date).days) + decay_type = "linear" # anticipation linéaire jusqu'à la réunion + except ValueError: + pass + + days_elapsed = (ref_date - ev_date).days + df = _decay(days_elapsed, absorption_days, decay_type) + if df < 0.01: + continue + + current_pips = round(pips_full * df, 1) + cat = r["category"] + + if cat not in by_category: + by_category[cat] = { + "label": _CAT_LABELS.get(cat, cat), + "pips": 0.0, + "contributions": [], + } + + by_category[cat]["pips"] += current_pips + by_category[cat]["contributions"].append({ + "event_name": r["event_name"], + "event_date": r["event_date"][:10], + "template_name": r["template_name"], + "pips_full": round(pips_full, 1), + "days_elapsed": days_elapsed, + "absorption_days": absorption_days, + "decay_pct": round(df * 100), + "pips_current": current_pips, + }) + + # Arrondi + tri par |pips| décroissant + for v in by_category.values(): + v["pips"] = round(v["pips"], 1) + v["contributions"].sort(key=lambda c: abs(c["pips_current"]), reverse=True) + + categories = sorted(by_category.values(), key=lambda x: abs(x["pips"]), reverse=True) + net_pips = round(sum(v["pips"] for v in by_category.values()), 1) + + direction = "neutral" + if net_pips > 5: + direction = "bullish" + elif net_pips < -5: + direction = "bearish" + + return { + "instrument": inst_upper, + "at_date": str(ref_date), + "net_pips": net_pips, + "direction": direction, + "categories": categories, + "n_events": len(seen_events), + } diff --git a/backend/services/guidance_sync.py b/backend/services/guidance_sync.py index c257d30..1239841 100644 --- a/backend/services/guidance_sync.py +++ b/backend/services/guidance_sync.py @@ -96,18 +96,33 @@ def _find_existing(conn, currency: str, meeting_date: str) -> Optional[dict]: def _upsert_analyses(conn, event_id: int, template_id: int, instruments: list[str], signal: float): """ Crée/recrée les causal_event_analyses pour que l'event apparaisse - dans la Frise de chaque instrument concerné. + dans la Frise et dans la theoretical-curve de chaque instrument. + Calcule prediction_json via evaluate_graph pour alimenter le factor engine. """ conn.execute( "DELETE FROM causal_event_analyses WHERE market_event_id=?", (event_id,) ) - inputs = json.dumps({"guidance_signal": signal}) + inputs_dict = {"guidance_signal": signal} + inputs_json = json.dumps(inputs_dict) + + # Évalue le graphe pour obtenir les pips prédits par instrument + node_values: dict = {} + try: + from services.causal_graphs import evaluate_graph, get_template + tmpl = get_template(conn, template_id) + if tmpl: + node_values = evaluate_graph(tmpl["graph_json"], inputs_dict) + except Exception as e: + logger.warning(f"[guidance_sync] evaluate_graph failed: {e}") + + prediction_json = json.dumps(node_values) if node_values else None + for inst in instruments: conn.execute(""" INSERT INTO causal_event_analyses - (market_event_id, template_id, instrument, inputs_json, analyzed_at) - VALUES (?, ?, ?, ?, datetime('now')) - """, (event_id, template_id, inst, inputs)) + (market_event_id, template_id, instrument, inputs_json, prediction_json, analyzed_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + """, (event_id, template_id, inst, inputs_json, prediction_json)) def _build_title(currency: str, signal: float, meeting_date: str) -> str: diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index 307da86..f3dadc8 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -1213,6 +1213,117 @@ const PERIODS = [ { key: '5y', label: '5Y' }, ] +// ── Types factor-state ──────────────────────────────────────────────────────── +interface FactorContrib { + event_name: string; event_date: string; template_name: string + pips_full: number; days_elapsed: number; absorption_days: number + decay_pct: number; pips_current: number +} +interface FactorCategory { + label: string; pips: number; contributions: FactorContrib[] +} +interface FactorState { + instrument: string; at_date: string; net_pips: number + direction: 'bullish' | 'bearish' | 'neutral' + categories: FactorCategory[]; n_events: number +} + +// ── PressureCockpit ─────────────────────────────────────────────────────────── +function PressureCockpit({ instrumentId, refreshKey }: { instrumentId: string; refreshKey: number }) { + const [state, setState] = useState(null) + const [loading, setLoading] = useState(false) + const [expanded, setExpanded] = useState(null) + + useEffect(() => { + if (!instrumentId) return + setLoading(true) + api.get(`/instruments/${instrumentId}/factor-state`) + .then(r => setState(r.data)) + .catch(() => setState(null)) + .finally(() => setLoading(false)) + }, [instrumentId, refreshKey]) + + if (loading) return ( +
+ Calcul pression en cours… +
+ ) + if (!state) return null + + const { net_pips, direction, categories } = state + const maxAbs = Math.max(...categories.map(c => Math.abs(c.pips)), 1) + const netCls = direction === 'bullish' ? 'text-emerald-400' : direction === 'bearish' ? 'text-red-400' : 'text-slate-400' + const netLabel = direction === 'bullish' ? '▲ HAUSSIER' : direction === 'bearish' ? '▼ BAISSIER' : '◼ NEUTRE' + + return ( +
+ {/* NET */} +
+ Pression nette + + {net_pips >= 0 ? '+' : ''}{net_pips} pips + + + {netLabel} + + {state.n_events} event{state.n_events > 1 ? 's' : ''} actifs +
+ + {/* Barres par catégorie */} + {categories.length === 0 ? ( +

Aucun event actif avec prédiction pour cet instrument.

+ ) : ( +
+ {categories.map(cat => { + const barW = Math.round(Math.abs(cat.pips) / maxAbs * 100) + const isPos = cat.pips >= 0 + const isOpen = expanded === cat.label + return ( +
+ + + {/* Détail events */} + {isOpen && ( +
+ {cat.contributions.map((c, i) => ( +
+ {c.event_name} + {c.event_date} · {c.decay_pct}% actif ({c.days_elapsed}j) + = 0 ? 'text-emerald-500' : 'text-red-500')}> + {c.pips_current >= 0 ? '+' : ''}{c.pips_current} + +
+ ))} +
+ )} +
+ ) + })} +
+ )} +
+ ) +} + export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { instrumentIdProp?: string; isVisible?: boolean } = {}) { const { id: paramId = localStorage.getItem('last_instrument') || 'EURUSD=X' } = useParams<{ id: string }>() const navigate = useNavigate() @@ -1600,6 +1711,14 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i : null return (
+ {/* Pression nette actuelle */} +
+
+ Pression Nette +
+ +
+ {/* Frise des graphes causaux (inclut l'event) */}