feat: risk

This commit is contained in:
OpenSquared
2026-07-26 14:59:51 +02:00
parent 3704724b0b
commit 84ba8f10a2
3 changed files with 110 additions and 74 deletions

View File

@@ -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
]

View File

@@ -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 (
<Link to="/risk" className="card flex flex-col overflow-y-auto hover:border-slate-600/60 transition-all cursor-pointer"
@@ -840,10 +839,22 @@ export default function Dashboard() {
<div className={clsx('text-[10px] font-bold mt-0.5 flex items-center gap-2', alertCount > 0 ? 'text-red-400' : 'text-emerald-400')}>
{alertCount > 0 ? `${alertCount} alert${alertCount > 1 ? 's' : ''}` : 'OK'}
</div>
{scenarioWarning && dominantScenario && (
<div className="mt-1 text-[9px] leading-snug text-amber-400 bg-amber-900/10 border border-amber-700/20 rounded px-1.5 py-1">
{dominantScenario.pct_of_portfolio.toFixed(0)}% du book sur le même pari macro
(<span className="font-semibold">{dominantScenario.label}</span>)
{scenarioRanking.length > 0 && (
<div className="mt-1.5 space-y-1">
<div className="text-[8px] uppercase tracking-wide text-slate-600">Portefeuille aligné sur</div>
{scenarioRanking.map((s: any, i: number) => (
<div key={s.key}>
<div className="flex items-center justify-between text-[9px] mb-0.5">
<span className="text-slate-300 truncate flex items-center gap-1">
<span>{s.emoji}</span>{s.label}
</span>
<span className="font-mono font-bold shrink-0" style={{ color: s.color }}>{s.pct_of_portfolio}%</span>
</div>
<div className="h-1 bg-dark-700 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${Math.min(s.pct_of_portfolio, 100)}%`, background: s.color, opacity: i === 0 ? 1 : 0.6 }} />
</div>
</div>
))}
</div>
)}
{radarAxes.length > 0 && (

View File

@@ -145,11 +145,12 @@ function ScenarioExposureCard() {
return (
<div className="card">
<div className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
<Layers className="w-4 h-4 text-purple-400" /> Concentration par scénario macro
<Layers className="w-4 h-4 text-purple-400" /> Alignement du portefeuille sur le Régime Macro
</div>
<div className="text-[10px] text-slate-500 mb-3">
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
gime macro 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).
</div>
{exp.warning && (
@@ -160,7 +161,7 @@ function ScenarioExposureCard() {
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Concentration bars */}
{/* Concentration ranking */}
<div>
<div className="text-xs font-semibold text-slate-400 mb-2">
% du book dont c'est le scénario le plus favorable
@@ -169,13 +170,15 @@ function ScenarioExposureCard() {
{concentration.map((c: any) => (
<div key={c.key}>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-300">{c.label}</span>
<span className={clsx('font-mono font-bold', c.pct_of_portfolio >= 60 ? 'text-amber-400' : 'text-slate-300')}>
<span className="text-slate-300 flex items-center gap-1.5">
<span>{c.emoji}</span>{c.label}
</span>
<span className="font-mono font-bold" style={{ color: c.color }}>
{c.pct_of_portfolio}%
</span>
</div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div className="h-full rounded-full bg-purple-500/70" style={{ width: `${Math.min(c.pct_of_portfolio, 100)}%` }} />
<div className="h-full rounded-full" style={{ width: `${Math.min(c.pct_of_portfolio, 100)}%`, background: c.color }} />
</div>
</div>
))}
@@ -189,7 +192,9 @@ function ScenarioExposureCard() {
<tbody>
{scenarios.map((s: any) => (
<tr key={s.key} className="border-b border-slate-800/40">
<td className="py-1.5 pr-3 text-slate-300">{s.label}</td>
<td className="py-1.5 pr-3 text-slate-300 flex items-center gap-1.5">
<span>{s.emoji}</span>{s.label}
</td>
<td className={clsx('py-1.5 text-right font-mono font-bold whitespace-nowrap',
s.portfolio_pnl_pct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{s.portfolio_pnl_pct >= 0 ? '+' : ''}{s.portfolio_pnl_pct}%