From 84ba8f10a2a466e197438418db79635068345b9b Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sun, 26 Jul 2026 14:59:51 +0200 Subject: [PATCH] feat: risk --- backend/services/portfolio_scenarios.py | 140 ++++++++++++++---------- frontend/src/pages/Dashboard.tsx | 23 +++- frontend/src/pages/RiskDashboard.tsx | 21 ++-- 3 files changed, 110 insertions(+), 74 deletions(-) diff --git a/backend/services/portfolio_scenarios.py b/backend/services/portfolio_scenarios.py index 026446c..6c5df4a 100644 --- a/backend/services/portfolio_scenarios.py +++ b/backend/services/portfolio_scenarios.py @@ -1,70 +1,89 @@ """ -Portfolio scenario-exposure — answers "if macro scenario X happens, how does my ACTUAL -book of open positions react, and how many of my positions are really the same bet wearing -different tickers?" Distinct from two pre-existing, coarser tools: - - services.data_fetcher.score_macro_scenarios(): the GLOBAL 8-scenario macro regime, - qualitative asset-class bias only (bullish/bearish/neutral), used for the top-level - regime badge — not calibrated to numeric spot shocks and has no gold-bearish or - forex-directional case, so it can't tell two option positions apart. +Portfolio scenario-exposure — answers "which of our 8 macro scenarios is my ACTUAL book of +open positions really a bet on, and how many differently-named positions are secretly the +same bet?" Deliberately reuses the SAME 8 scenarios as the global macro regime +(services.data_fetcher.SCENARIO_META / SCENARIO_ASSET_BIAS — goldilocks, desinflation, +soft_landing, reflation, stagflation, inflation_shock, recession, crise_liquidite) as its +single source of truth for labels/colors/emoji and directional bias, so this tool can never +show a scenario name or direction the rest of the app doesn't already agree with. + +Distinct from two other pre-existing, coarser tools: + - services.data_fetcher.score_macro_scenarios(): scores which of the 8 scenarios the + CURRENT macro gauges look like (top-down, market-wide) — this module instead scores + which of the 8 scenarios OUR OWN POSITIONS are betting on (bottom-up, portfolio-wide). + Same 8 buckets, opposite direction of inference. - services.database.get_risk_dashboard()/get_risk_clusters(): buckets capital by asset_class and by a geopolitical-trigger keyword match — blind to whether a position is long or short its underlying, so a bullish and a bearish position on the same - ticker land in the same bucket. -This module instead reprices each position's REAL legs (same Saxo-first pricing as -services.portfolio_pricing, used by mark-to-market and the payoff chart) under a small set -of named spot/vol shocks, so positions on different tickers that both profit from the same -shock get flagged as the SAME risk bet — e.g. a short S&P call spread, a long gold put -spread and a short crude call spread can all really be "one Risk-Off bet, three times." + ticker land in the same bucket. Kept as a separate, complementary lens (thematic + capital exposure) rather than merged with this one (directional scenario alignment). + +The evaluation itself is deliberately NOT an LLM call: it reprices each position's REAL legs +(same Saxo-first pricing as services.portfolio_pricing, used by mark-to-market and the +payoff chart) under each scenario's spot/vol shock via Black-Scholes. Same positions in, +same percentages out, every time — auditable and tied to real strikes/greeks, matching how +Curve Regime and the payoff diagram already work elsewhere in this app. """ from typing import Any, Dict, List, Optional, Tuple +from services.data_fetcher import SCENARIO_META, SCENARIO_ASSET_BIAS + SCENARIOS: List[Dict[str, str]] = [ - {"key": "risk_off", "label": "Risk-Off / Ralentissement"}, - {"key": "risk_on", "label": "Reprise économique / Risk-On"}, - {"key": "inflation_persistante", "label": "Inflation persistante"}, - {"key": "dollar_fort", "label": "Dollar fort"}, - {"key": "commodities_baisse", "label": "Baisse des matières premières"}, + {"key": key, "label": meta["label"], "color": meta["color"], "emoji": meta["emoji"]} + for key, meta in SCENARIO_META.items() ] -# asset_class -> scenario_key -> (spot_shock_pct, vol_shock_abs added to the leg's resolved sigma) -_DIMENSION_SHOCKS: Dict[str, Dict[str, Tuple[float, float]]] = { - "indices": { - "risk_off": (-0.08, 0.06), "risk_on": (0.07, -0.03), "inflation_persistante": (-0.05, 0.04), - "dollar_fort": (-0.02, 0.01), "commodities_baisse": (0.01, -0.01), - }, - "equities": { - "risk_off": (-0.08, 0.06), "risk_on": (0.07, -0.03), "inflation_persistante": (-0.05, 0.04), - "dollar_fort": (-0.02, 0.01), "commodities_baisse": (0.01, -0.01), - }, - "energy": { - "risk_off": (-0.10, 0.08), "risk_on": (0.08, -0.04), "inflation_persistante": (0.10, 0.05), - "dollar_fort": (-0.05, 0.02), "commodities_baisse": (-0.12, 0.03), - }, - "metals": { - "risk_off": (0.05, 0.03), "risk_on": (-0.04, -0.02), "inflation_persistante": (0.08, 0.04), - "dollar_fort": (-0.06, 0.02), "commodities_baisse": (-0.08, 0.02), - }, - "agriculture": { - "risk_off": (-0.03, 0.03), "risk_on": (0.03, -0.02), "inflation_persistante": (0.09, 0.04), - "dollar_fort": (-0.04, 0.01), "commodities_baisse": (-0.10, 0.03), - }, - "forex": { - # Expressed as "USD strength" moves — sign is flipped per-pair by _fx_dollar_sign() - # depending on whether USD is the base or quote currency. - "risk_off": (0.03, 0.03), "risk_on": (-0.03, -0.02), "inflation_persistante": (-0.02, 0.03), - "dollar_fort": (0.05, 0.01), "commodities_baisse": (0.01, -0.01), - }, - "rates": { - "risk_off": (0.04, 0.02), "risk_on": (-0.03, -0.01), "inflation_persistante": (-0.06, 0.03), - "dollar_fort": (0.01, 0.01), "commodities_baisse": (0.01, -0.01), - }, +# Qualitative bias label (as used in SCENARIO_ASSET_BIAS) -> (spot_shock_pct, vol_shock_abs +# added to the leg's resolved sigma). The only numeric calibration this module adds on top +# of the existing qualitative macro-regime table. +_BIAS_TO_SHOCK: Dict[str, Tuple[float, float]] = { + "bullish+": (0.08, -0.03), + "bullish": (0.04, -0.015), + "neutral": (0.0, 0.0), + "bearish": (-0.04, 0.015), + "bearish+": (-0.08, 0.03), } +# SCENARIO_ASSET_BIAS has no "rates" axis (bond ETFs like IEF/TLT) — extended here with the +# same 8 scenario keys, standard macro logic: bonds rally when yields fall (recession, +# desinflation, flight-to-quality in a liquidity crisis), sell off when inflation runs hot +# (reflation, stagflation, inflation shock). +_RATES_BIAS: Dict[str, str] = { + "goldilocks": "neutral", "desinflation": "bullish+", "soft_landing": "bullish", + "reflation": "bearish", "stagflation": "bearish+", "inflation_shock": "bearish+", + "recession": "bullish+", "crise_liquidite": "bullish", +} + +# SCENARIO_ASSET_BIAS's forex axis is only ever "neutral" or "defensive" (a flight-to-USD +# flag, not a direction) — translated here into a dollar-strength shock. "defensive" +# scenarios push capital into USD (dollar up); "neutral" scenarios leave it flat. The actual +# sign applied to a given pair (EURUSD falls / USDJPY rises on dollar strength) is resolved +# per-position by _fx_dollar_sign(). +_FOREX_DEFENSIVE_SHOCK: Tuple[float, float] = (0.04, 0.02) + +_DIRECT_ASSET_CLASSES = {"energy", "metals", "indices", "equities", "agriculture"} + + +def _dimension_shock(asset_class: str, scenario_key: str) -> Tuple[float, float]: + """Spot/vol shock for one asset_class under one of the 8 canonical scenarios, derived + from SCENARIO_ASSET_BIAS wherever that axis exists, with a documented extension for + rates (not covered by the shared table) and forex (covered only as a non-directional + flight-to-USD flag there).""" + bias_row = SCENARIO_ASSET_BIAS[scenario_key] + if asset_class in _DIRECT_ASSET_CLASSES: + return _BIAS_TO_SHOCK.get(bias_row.get(asset_class, "neutral"), (0.0, 0.0)) + if asset_class == "rates": + return _BIAS_TO_SHOCK.get(_RATES_BIAS.get(scenario_key, "neutral"), (0.0, 0.0)) + if asset_class == "forex": + return _FOREX_DEFENSIVE_SHOCK if bias_row.get("forex") == "defensive" else (0.0, 0.0) + # Unknown/uncategorized asset_class — fall back to the indices axis (equity-like default). + return _BIAS_TO_SHOCK.get(bias_row.get("indices", "neutral"), (0.0, 0.0)) + def _fx_dollar_sign(ticker: str) -> int: """+1 if USD is the base currency (pair rises when USD strengthens, e.g. USDJPY), -1 if USD is the quote currency (pair falls when USD strengthens, e.g. EURUSD), - 0 for a non-USD cross where the "dollar strength" dimension doesn't clearly apply.""" + 0 for a non-USD cross where the dollar-strength dimension doesn't clearly apply.""" t = (ticker or "").upper().replace("=X", "").replace("/", "") if t.startswith("USD"): return 1 @@ -131,12 +150,14 @@ def _reprice_position(pos: Dict[str, Any], spot_shock_pct: float, vol_shock_abs: def compute_scenario_exposure() -> Dict[str, Any]: - """Reprices every open position under each named scenario, then aggregates two views: - - `scenarios`: per-scenario portfolio-wide estimated P&L (the "sensitivity matrix"). + """Reprices every open position under each of the 8 canonical macro scenarios, then + aggregates two views: + - `scenarios`: per-scenario portfolio-wide estimated P&L (the "sensitivity matrix"), + sorted by |impact| so the scenarios that matter most float to the top. - `concentration`: for each position, the scenario that would benefit it MOST, then - the capital-weighted % of the portfolio sharing that same dominant scenario (the - "X% of your book is really one bet" bars) — the whole point being to surface when - several differently-named positions are actually the same directional wager. + the capital-weighted % of the portfolio sharing that same dominant scenario — the + "our book is really one bet, repeated" ranking, using the exact same scenario + names/colors as the global macro regime badge. """ from services.database import get_positions @@ -149,13 +170,12 @@ def compute_scenario_exposure() -> Dict[str, Any]: unpriced: List[Dict[str, Any]] = [] for pos in positions: ac = (pos.get("asset_class") or "indices").lower() - dims = _DIMENSION_SHOCKS.get(ac, _DIMENSION_SHOCKS["indices"]) fx_sign = _fx_dollar_sign(pos["underlying"]) if ac == "forex" else 1 scenario_pnl: Dict[str, Optional[float]] = {} for scen in SCENARIOS: key = scen["key"] - spot_shock, vol_shock = dims.get(key, (0.0, 0.0)) + spot_shock, vol_shock = _dimension_shock(ac, key) if ac == "forex": spot_shock = spot_shock * fx_sign scenario_pnl[key] = _reprice_position(pos, spot_shock, vol_shock) @@ -182,7 +202,7 @@ def compute_scenario_exposure() -> Dict[str, Any]: key = scen["key"] total_pnl = sum(p["scenario_pnl"].get(key) or 0 for p in priced) scenario_results.append({ - "key": key, "label": scen["label"], + "key": key, "label": scen["label"], "color": scen["color"], "emoji": scen["emoji"], "portfolio_pnl": round(total_pnl, 2), "portfolio_pnl_pct": round(total_pnl / total_capital * 100, 2), "positions": [ @@ -203,7 +223,7 @@ def compute_scenario_exposure() -> Dict[str, Any]: weight_by_scenario[best_key] = weight_by_scenario.get(best_key, 0.0) + p["capital_invested"] concentration = [ - {"key": key, "label": next(s["label"] for s in SCENARIOS if s["key"] == key), + {"key": key, **{k: v for k, v in next(s for s in SCENARIOS if s["key"] == key).items() if k != "key"}, "pct_of_portfolio": round(w / total_capital * 100, 1)} for key, w in weight_by_scenario.items() if w > 0 ] diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index d3e1247..3a5b8b0 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -827,8 +827,7 @@ export default function Dashboard() { .filter(d => d.value > 0) .sort((a, b) => b.value - a.value) const radarAxes = ((riskRadarData as any)?.axes ?? []).map((a: any) => ({ ...a, value: a.value ?? 0 })) - const dominantScenario = (scenarioExposure as any)?.dominant_scenario - const scenarioWarning = (scenarioExposure as any)?.warning + const scenarioRanking: any[] = ((scenarioExposure as any)?.concentration ?? []).slice(0, 3) return ( 0 ? 'text-red-400' : 'text-emerald-400')}> {alertCount > 0 ? `${alertCount} alert${alertCount > 1 ? 's' : ''}` : 'OK'} - {scenarioWarning && dominantScenario && ( -
- ⚠ {dominantScenario.pct_of_portfolio.toFixed(0)}% du book sur le même pari macro - ({dominantScenario.label}) + {scenarioRanking.length > 0 && ( +
+
Portefeuille aligné sur
+ {scenarioRanking.map((s: any, i: number) => ( +
+
+ + {s.emoji}{s.label} + + {s.pct_of_portfolio}% +
+
+
+
+
+ ))}
)} {radarAxes.length > 0 && ( diff --git a/frontend/src/pages/RiskDashboard.tsx b/frontend/src/pages/RiskDashboard.tsx index 5d8ac47..3c94736 100644 --- a/frontend/src/pages/RiskDashboard.tsx +++ b/frontend/src/pages/RiskDashboard.tsx @@ -145,11 +145,12 @@ function ScenarioExposureCard() { return (
- Concentration par scénario macro + Alignement du portefeuille sur le Régime Macro
- Repricing Black-Scholes réel (pricing Saxo-first) de chaque position sous 5 scénarios — - révèle quand plusieurs positions différentes sont en réalité le même pari répété. + Repricing Black-Scholes réel (pricing Saxo-first) de chaque position sous les 8 scénarios du + régime macro — révèle quand plusieurs positions différentes sont en réalité le même pari répété. + Vue distincte des Risk Factors ci-dessous (thème/classe d'actif touché, pas le sens du pari).
{exp.warning && ( @@ -160,7 +161,7 @@ function ScenarioExposureCard() { )}
- {/* Concentration bars */} + {/* Concentration ranking */}
% du book dont c'est le scénario le plus favorable @@ -169,13 +170,15 @@ function ScenarioExposureCard() { {concentration.map((c: any) => (
- {c.label} - = 60 ? 'text-amber-400' : 'text-slate-300')}> + + {c.emoji}{c.label} + + {c.pct_of_portfolio}%
-
+
))} @@ -189,7 +192,9 @@ function ScenarioExposureCard() { {scenarios.map((s: any) => ( - {s.label} + + {s.emoji}{s.label} + = 0 ? 'text-emerald-400' : 'text-red-400')}> {s.portfolio_pnl_pct >= 0 ? '+' : ''}{s.portfolio_pnl_pct}%