feat: risk
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Portfolio scenario-exposure — answers "which of our 8 macro scenarios is my ACTUAL book of
|
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
|
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
|
same bet?" Reuses the SAME 8 scenarios as the global macro regime
|
||||||
(services.data_fetcher.SCENARIO_META / SCENARIO_ASSET_BIAS — goldilocks, desinflation,
|
(services.data_fetcher.SCENARIO_META / SCENARIO_ASSET_BIAS — goldilocks, desinflation,
|
||||||
soft_landing, reflation, stagflation, inflation_shock, recession, crise_liquidite) as its
|
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
|
single source of truth for labels/colors/emoji and directional bias, so this tool can never
|
||||||
@@ -14,15 +14,37 @@ Distinct from two other pre-existing, coarser tools:
|
|||||||
Same 8 buckets, opposite direction of inference.
|
Same 8 buckets, opposite direction of inference.
|
||||||
- services.database.get_risk_dashboard()/get_risk_clusters(): buckets capital by
|
- 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
|
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
|
is long or short its underlying. Kept as a separate, complementary lens.
|
||||||
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
|
v2 methodology (v1 assigned each position to a single "best" scenario via argmax, which
|
||||||
(same Saxo-first pricing as services.portfolio_pricing, used by mark-to-market and the
|
collapsed multi-dimensional option payoffs into one arbitrary bucket and let ties resolve
|
||||||
payoff chart) under each scenario's spot/vol shock via Black-Scholes. Same positions in,
|
by scenario list order rather than economics):
|
||||||
same percentages out, every time — auditable and tied to real strikes/greeks, matching how
|
- A position "aligns" with a scenario only if repricing it under that scenario's shock
|
||||||
Curve Regime and the payoff diagram already work elsewhere in this app.
|
actually produces a POSITIVE P&L — not merely "the least bad of 8", so a structurally
|
||||||
|
one-sided axis (e.g. metals is bullish/neutral in all 8 scenarios, never bearish) no
|
||||||
|
longer forces a bearish gold position into a fake "best" scenario; it shows up as
|
||||||
|
genuinely unaligned instead.
|
||||||
|
- A position's weight is split PROPORTIONALLY across every scenario it aligns with
|
||||||
|
(weighted by how much it gains in each), instead of winner-take-all — so a position
|
||||||
|
that profits comparably in two scenarios (e.g. Recession and Crise de liquidité often
|
||||||
|
carry the same "bearish+" bias for indices) contributes to both instead of an arbitrary
|
||||||
|
single pick.
|
||||||
|
- Weight itself is the position's actual capital-at-risk (worst point on its real
|
||||||
|
at-expiry payoff curve, from services.portfolio_pricing.compute_payoff, already used by
|
||||||
|
the payoff-diagram feature) rather than the user-entered capital_invested field, which
|
||||||
|
isn't guaranteed to equal true max loss for spreads.
|
||||||
|
- Redundant correlated bets (e.g. Crude & Brent short call spreads) are surfaced via an
|
||||||
|
"effective N" independent-bets count, reusing services.portfolio_risk's existing
|
||||||
|
pairwise-correlation helper — the same diversification math already used by the risk
|
||||||
|
radar's Correlation axis.
|
||||||
|
- Each position is annotated with its real dollar delta/vega (from
|
||||||
|
services.options_pricer.black_scholes, which already computes them on every call — just
|
||||||
|
not extracted before now) and its current services.curve_regime classification, so the
|
||||||
|
UI can show the position's actual nature instead of guessing from its strategy name.
|
||||||
|
|
||||||
|
The evaluation itself is still NOT an LLM call: same positions in, same numbers 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 typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
@@ -63,6 +85,10 @@ _FOREX_DEFENSIVE_SHOCK: Tuple[float, float] = (0.04, 0.02)
|
|||||||
|
|
||||||
_DIRECT_ASSET_CLASSES = {"energy", "metals", "indices", "equities", "agriculture"}
|
_DIRECT_ASSET_CLASSES = {"energy", "metals", "indices", "equities", "agriculture"}
|
||||||
|
|
||||||
|
# Same futures/ETF proxy mapping as frontend/src/pages/Dashboard.tsx's PROXY_TICKER_ALIASES
|
||||||
|
# — BNO (Brent ETF, used as the options proxy) isn't itself a Watchlist ticker, BRENT is.
|
||||||
|
_CURVE_REGIME_TICKER_ALIASES = {"BNO": "BRENT"}
|
||||||
|
|
||||||
|
|
||||||
def _dimension_shock(asset_class: str, scenario_key: str) -> Tuple[float, float]:
|
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
|
"""Spot/vol shock for one asset_class under one of the 8 canonical scenarios, derived
|
||||||
@@ -92,21 +118,15 @@ def _fx_dollar_sign(ticker: str) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _reprice_position(pos: Dict[str, Any], spot_shock_pct: float, vol_shock_abs: float) -> Optional[float]:
|
def _resolve_position_market(pos: Dict[str, Any]):
|
||||||
"""Real Black-Scholes reprice of this position's legs at a shocked spot/vol, mirroring
|
"""Shared market-data resolution (Saxo chain if linked, else yfinance fallback) used by
|
||||||
services.portfolio_pricing.compute_payoff's methodology but at ONE target spot instead
|
both the scenario reprice and the greeks snapshot, so both read the exact same spot/vol
|
||||||
of a curve (no time decay applied — a "if this happened right now" snapshot). Returns
|
the payoff chart and mark-to-market already use."""
|
||||||
estimated P&L in currency units, or None if the position has no legs to price."""
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from services.portfolio_pricing import resolve_saxo_chain, price_leg
|
from services.portfolio_pricing import resolve_saxo_chain
|
||||||
from services.options_pricer import black_scholes
|
|
||||||
from services.data_fetcher import get_quote, compute_historical_iv
|
from services.data_fetcher import get_quote, compute_historical_iv
|
||||||
|
|
||||||
underlying = pos["underlying"]
|
underlying = pos["underlying"]
|
||||||
legs = pos.get("legs", [])
|
|
||||||
if not legs:
|
|
||||||
return None
|
|
||||||
|
|
||||||
expiry_date = pos.get("expiry_date") or ""
|
expiry_date = pos.get("expiry_date") or ""
|
||||||
if expiry_date:
|
if expiry_date:
|
||||||
try:
|
try:
|
||||||
@@ -118,7 +138,6 @@ def _reprice_position(pos: Dict[str, Any], spot_shock_pct: float, vol_shock_abs:
|
|||||||
entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date()
|
entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date()
|
||||||
days_remaining = max(0, pos.get("expiry_days", 90) - (date.today() - entry).days)
|
days_remaining = max(0, pos.get("expiry_days", 90) - (date.today() - entry).days)
|
||||||
|
|
||||||
r = 0.05
|
|
||||||
chain, surface = resolve_saxo_chain(underlying, target_days=max(days_remaining, 1))
|
chain, surface = resolve_saxo_chain(underlying, target_days=max(days_remaining, 1))
|
||||||
fallback_spot = pos.get("entry_underlying_price") or 100.0
|
fallback_spot = pos.get("entry_underlying_price") or 100.0
|
||||||
fallback_sigma = 0.20
|
fallback_sigma = 0.20
|
||||||
@@ -127,6 +146,21 @@ def _reprice_position(pos: Dict[str, Any], spot_shock_pct: float, vol_shock_abs:
|
|||||||
fallback_spot = (q.get("price") if q else None) or fallback_spot
|
fallback_spot = (q.get("price") if q else None) or fallback_spot
|
||||||
fallback_sigma = compute_historical_iv(underlying)
|
fallback_sigma = compute_historical_iv(underlying)
|
||||||
S = chain["spot"] if chain else fallback_spot
|
S = chain["spot"] if chain else fallback_spot
|
||||||
|
return chain, surface, S, days_remaining, expiry_date, fallback_spot, fallback_sigma
|
||||||
|
|
||||||
|
|
||||||
|
def _reprice_position(pos: Dict[str, Any], spot_shock_pct: float, vol_shock_abs: float) -> Optional[float]:
|
||||||
|
"""Real Black-Scholes reprice of this position's legs at a shocked spot/vol (no time
|
||||||
|
decay applied — a "if this happened right now" snapshot). Returns estimated P&L in
|
||||||
|
currency units, or None if the position has no legs to price."""
|
||||||
|
from services.portfolio_pricing import price_leg
|
||||||
|
from services.options_pricer import black_scholes
|
||||||
|
|
||||||
|
legs = pos.get("legs", [])
|
||||||
|
if not legs:
|
||||||
|
return None
|
||||||
|
chain, surface, S, days_remaining, expiry_date, fallback_spot, fallback_sigma = _resolve_position_market(pos)
|
||||||
|
r = 0.05
|
||||||
S_shocked = S * (1 + spot_shock_pct)
|
S_shocked = S * (1 + spot_shock_pct)
|
||||||
T_remaining = days_remaining / 365
|
T_remaining = days_remaining / 365
|
||||||
|
|
||||||
@@ -149,23 +183,98 @@ def _reprice_position(pos: Dict[str, Any], spot_shock_pct: float, vol_shock_abs:
|
|||||||
return pnl
|
return pnl
|
||||||
|
|
||||||
|
|
||||||
|
def _position_greeks(pos: Dict[str, Any]) -> Dict[str, Optional[float]]:
|
||||||
|
"""Real position-level dollar delta/vega at the CURRENT (unshocked) market — from
|
||||||
|
services.options_pricer.black_scholes, which computes them on every pricing call
|
||||||
|
already. Used purely to annotate each position's actual nature (directional vs.
|
||||||
|
volatility play), not fed back into the scenario P&L math above."""
|
||||||
|
from services.options_pricer import black_scholes
|
||||||
|
|
||||||
|
legs = pos.get("legs", [])
|
||||||
|
if not legs:
|
||||||
|
return {"delta_dollars": None, "vega_dollars": None, "nature": None}
|
||||||
|
_, _, S, days_remaining, _, _, fallback_sigma = _resolve_position_market(pos)
|
||||||
|
r = 0.05
|
||||||
|
T_remaining = max(days_remaining, 1) / 365
|
||||||
|
delta_shares = 0.0
|
||||||
|
vega_dollars = 0.0
|
||||||
|
for leg in legs:
|
||||||
|
K = leg.get("strike") or S
|
||||||
|
opt_type = leg.get("option_type", "call")
|
||||||
|
qty = leg.get("quantity", 1)
|
||||||
|
sign = 1 if leg.get("position", "long") == "long" else -1
|
||||||
|
g = black_scholes(S, K, T_remaining, r, fallback_sigma, opt_type)
|
||||||
|
mult = sign * qty * 100
|
||||||
|
delta_shares += mult * g["delta"]
|
||||||
|
vega_dollars += mult * g["vega"]
|
||||||
|
delta_dollars = delta_shares * S / 100 # P&L per 1% move in the underlying
|
||||||
|
capital = max(pos.get("capital_invested") or 1000.0, 1.0)
|
||||||
|
d_impact = abs(delta_dollars) # P&L for a 1% move
|
||||||
|
v_impact = abs(vega_dollars) * 3 # P&L for a typical 3-vol-point move
|
||||||
|
if v_impact > d_impact * 1.5:
|
||||||
|
nature = "Volatilité longue" if vega_dollars > 0 else "Volatilité courte"
|
||||||
|
elif d_impact < 0.01 * capital:
|
||||||
|
nature = "Neutre / range"
|
||||||
|
else:
|
||||||
|
nature = "Directionnelle haussière" if delta_dollars > 0 else "Directionnelle baissière"
|
||||||
|
return {"delta_dollars": round(delta_dollars, 2), "vega_dollars": round(vega_dollars, 2), "nature": nature}
|
||||||
|
|
||||||
|
|
||||||
|
def _capital_at_risk(pos: Dict[str, Any]) -> float:
|
||||||
|
"""Worst point on the position's real at-expiry payoff curve (±25% spot range,
|
||||||
|
services.portfolio_pricing.compute_payoff — same curve the payoff-diagram chart shows)
|
||||||
|
as a proxy for capital genuinely at risk, since the user-entered capital_invested field
|
||||||
|
isn't guaranteed to equal true max loss for a spread. Falls back to capital_invested if
|
||||||
|
the curve never dips negative within that range (e.g. a well-covered structure) or
|
||||||
|
can't be computed."""
|
||||||
|
try:
|
||||||
|
from services.portfolio_pricing import compute_payoff
|
||||||
|
curve = compute_payoff(pos).get("at_expiry") or []
|
||||||
|
worst = min(curve) if curve else None
|
||||||
|
if worst is not None and worst < 0:
|
||||||
|
return abs(worst)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return max(pos.get("capital_invested") or 1000.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _curve_regime_lookup() -> Dict[str, str]:
|
||||||
|
from services.database import get_all_curve_regime_cache
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for row in get_all_curve_regime_cache():
|
||||||
|
if row.get("regime_label"):
|
||||||
|
out[row["ticker"].upper()] = row["regime_label"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def compute_scenario_exposure() -> Dict[str, Any]:
|
def compute_scenario_exposure() -> Dict[str, Any]:
|
||||||
"""Reprices every open position under each of the 8 canonical macro scenarios, then
|
"""Reprices every open position under each of the 8 canonical macro scenarios, then
|
||||||
aggregates two views:
|
builds three complementary views:
|
||||||
- `scenarios`: per-scenario portfolio-wide estimated P&L (the "sensitivity matrix"),
|
- `scenarios`: per-scenario portfolio-wide estimated P&L (the sensitivity matrix),
|
||||||
sorted by |impact| so the scenarios that matter most float to the top.
|
sorted by |impact|, covering every priced position regardless of alignment.
|
||||||
- `concentration`: for each position, the scenario that would benefit it MOST, then
|
- `concentration`: capital-at-risk-weighted % of the book aligned with each scenario
|
||||||
the capital-weighted % of the portfolio sharing that same dominant scenario — the
|
— a position only counts toward a scenario if it genuinely profits there, and its
|
||||||
"our book is really one bet, repeated" ranking, using the exact same scenario
|
weight is split across every scenario it profits in (proportional to the gain), not
|
||||||
names/colors as the global macro regime badge.
|
assigned to a single "winner". Percentages need not sum to 100 — the gap is
|
||||||
|
`pct_unaligned`, positions that don't profit in ANY of the 8 scenarios.
|
||||||
|
- `position_details`: per-position nature (real dollar delta/vega), current Curve
|
||||||
|
Regime, and which scenarios it aligns with — the transparency layer behind the
|
||||||
|
ranking, so the numbers are inspectable rather than a black box.
|
||||||
|
- `effective_n_positions`: correlation-adjusted count of genuinely independent bets
|
||||||
|
(services.portfolio_risk's pairwise-correlation helper), since e.g. two positions on
|
||||||
|
Crude and Brent are not two independent risks.
|
||||||
"""
|
"""
|
||||||
from services.database import get_positions
|
from services.database import get_positions
|
||||||
|
from services.portfolio_risk import _compute_avg_pairwise_correlation
|
||||||
|
|
||||||
positions = get_positions("open")
|
positions = get_positions("open")
|
||||||
if not positions:
|
if not positions:
|
||||||
return {"positions": 0, "total_capital": 0, "scenarios": [], "concentration": [],
|
return {"positions": 0, "total_capital": 0, "effective_n_positions": 0,
|
||||||
"dominant_scenario": None, "unpriced": [], "warning": None}
|
"avg_pairwise_correlation": None, "scenarios": [], "concentration": [],
|
||||||
|
"position_details": [], "dominant_scenario": None, "pct_unaligned": 0,
|
||||||
|
"unpriced": [], "warning": None}
|
||||||
|
|
||||||
|
curve_regimes = _curve_regime_lookup()
|
||||||
priced: List[Dict[str, Any]] = []
|
priced: List[Dict[str, Any]] = []
|
||||||
unpriced: List[Dict[str, Any]] = []
|
unpriced: List[Dict[str, Any]] = []
|
||||||
for pos in positions:
|
for pos in positions:
|
||||||
@@ -184,19 +293,26 @@ def compute_scenario_exposure() -> Dict[str, Any]:
|
|||||||
unpriced.append({"id": pos["id"], "title": pos.get("title", pos["underlying"])})
|
unpriced.append({"id": pos["id"], "title": pos.get("title", pos["underlying"])})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
ticker_key = pos["underlying"].upper()
|
||||||
|
ticker_key = _CURVE_REGIME_TICKER_ALIASES.get(ticker_key, ticker_key)
|
||||||
priced.append({
|
priced.append({
|
||||||
"id": pos["id"], "title": pos.get("title", pos["underlying"]),
|
"id": pos["id"], "title": pos.get("title", pos["underlying"]),
|
||||||
"underlying": pos["underlying"], "asset_class": ac,
|
"underlying": pos["underlying"], "asset_class": ac,
|
||||||
"capital_invested": max(pos.get("capital_invested") or 0, 0),
|
"risk_weight": _capital_at_risk(pos),
|
||||||
"scenario_pnl": scenario_pnl,
|
"scenario_pnl": scenario_pnl,
|
||||||
|
"greeks": _position_greeks(pos),
|
||||||
|
"curve_regime": curve_regimes.get(ticker_key),
|
||||||
})
|
})
|
||||||
|
|
||||||
if not priced:
|
if not priced:
|
||||||
return {"positions": len(positions), "total_capital": 0, "scenarios": [], "concentration": [],
|
return {"positions": len(positions), "total_capital": 0, "effective_n_positions": 0,
|
||||||
"dominant_scenario": None, "unpriced": unpriced, "warning": None}
|
"avg_pairwise_correlation": None, "scenarios": [], "concentration": [],
|
||||||
|
"position_details": [], "dominant_scenario": None, "pct_unaligned": 0,
|
||||||
|
"unpriced": unpriced, "warning": None}
|
||||||
|
|
||||||
total_capital = sum(p["capital_invested"] for p in priced) or 1.0
|
total_capital = sum(p["risk_weight"] for p in priced) or 1.0
|
||||||
|
|
||||||
|
# ── Sensitivity matrix — every priced position, every scenario, no alignment filter ──
|
||||||
scenario_results = []
|
scenario_results = []
|
||||||
for scen in SCENARIOS:
|
for scen in SCENARIOS:
|
||||||
key = scen["key"]
|
key = scen["key"]
|
||||||
@@ -209,41 +325,86 @@ def compute_scenario_exposure() -> Dict[str, Any]:
|
|||||||
{
|
{
|
||||||
"id": p["id"], "title": p["title"],
|
"id": p["id"], "title": p["title"],
|
||||||
"pnl": round(p["scenario_pnl"].get(key) or 0, 2),
|
"pnl": round(p["scenario_pnl"].get(key) or 0, 2),
|
||||||
"pnl_pct": round((p["scenario_pnl"].get(key) or 0) / max(p["capital_invested"], 1) * 100, 1),
|
"pnl_pct": round((p["scenario_pnl"].get(key) or 0) / max(p["risk_weight"], 1) * 100, 1),
|
||||||
}
|
}
|
||||||
for p in priced
|
for p in priced
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
scenario_results.sort(key=lambda s: -abs(s["portfolio_pnl_pct"]))
|
scenario_results.sort(key=lambda s: -abs(s["portfolio_pnl_pct"]))
|
||||||
|
|
||||||
# Concentration: which scenario is each position's single most favorable outcome?
|
# ── Alignment — proportional split across every genuinely profitable scenario ──
|
||||||
weight_by_scenario: Dict[str, float] = {s["key"]: 0.0 for s in SCENARIOS}
|
weight_by_scenario: Dict[str, float] = {s["key"]: 0.0 for s in SCENARIOS}
|
||||||
|
position_details = []
|
||||||
|
unaligned_weight = 0.0
|
||||||
for p in priced:
|
for p in priced:
|
||||||
best_key = max(p["scenario_pnl"], key=lambda k: (p["scenario_pnl"].get(k) if p["scenario_pnl"].get(k) is not None else float("-inf")))
|
positive = {k: v for k, v in p["scenario_pnl"].items() if v is not None and v > 0}
|
||||||
weight_by_scenario[best_key] = weight_by_scenario.get(best_key, 0.0) + p["capital_invested"]
|
aligned_scenarios = []
|
||||||
|
if positive:
|
||||||
|
total_positive = sum(positive.values())
|
||||||
|
for k, v in sorted(positive.items(), key=lambda kv: -kv[1]):
|
||||||
|
share = v / total_positive
|
||||||
|
weight_by_scenario[k] += p["risk_weight"] * share
|
||||||
|
meta = next(s for s in SCENARIOS if s["key"] == k)
|
||||||
|
aligned_scenarios.append({
|
||||||
|
"key": k, "label": meta["label"], "color": meta["color"], "emoji": meta["emoji"],
|
||||||
|
"share_of_position": round(share * 100, 1),
|
||||||
|
"pnl_pct": round(v / max(p["risk_weight"], 1) * 100, 1),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
unaligned_weight += p["risk_weight"]
|
||||||
|
|
||||||
|
position_details.append({
|
||||||
|
"id": p["id"], "title": p["title"], "underlying": p["underlying"],
|
||||||
|
"asset_class": p["asset_class"], "risk_weight": round(p["risk_weight"], 2),
|
||||||
|
"nature": p["greeks"]["nature"], "delta_dollars": p["greeks"]["delta_dollars"],
|
||||||
|
"vega_dollars": p["greeks"]["vega_dollars"], "curve_regime": p["curve_regime"],
|
||||||
|
"aligned_scenarios": aligned_scenarios[:2],
|
||||||
|
"is_unaligned": not positive,
|
||||||
|
})
|
||||||
|
|
||||||
concentration = [
|
concentration = [
|
||||||
{"key": key, **{k: v for k, v in next(s for s in SCENARIOS if s["key"] == key).items() if k != "key"},
|
{"key": s["key"], "label": s["label"], "color": s["color"], "emoji": s["emoji"],
|
||||||
"pct_of_portfolio": round(w / total_capital * 100, 1)}
|
"pct_of_portfolio": round(weight_by_scenario[s["key"]] / total_capital * 100, 1)}
|
||||||
for key, w in weight_by_scenario.items() if w > 0
|
for s in SCENARIOS if weight_by_scenario[s["key"]] > 0
|
||||||
]
|
]
|
||||||
concentration.sort(key=lambda c: -c["pct_of_portfolio"])
|
concentration.sort(key=lambda c: -c["pct_of_portfolio"])
|
||||||
dominant_scenario = concentration[0] if concentration else None
|
dominant_scenario = concentration[0] if concentration else None
|
||||||
|
pct_unaligned = round(unaligned_weight / total_capital * 100, 1)
|
||||||
|
|
||||||
|
underlyings = sorted({p["underlying"] for p in priced})
|
||||||
|
avg_corr = _compute_avg_pairwise_correlation(underlyings) if len(underlyings) >= 2 else None
|
||||||
|
n = len(priced)
|
||||||
|
if avg_corr is not None:
|
||||||
|
effective_n = round(n / (1 + (n - 1) * max(avg_corr, 0.0)), 2)
|
||||||
|
else:
|
||||||
|
effective_n = n
|
||||||
|
|
||||||
warning = None
|
warning = None
|
||||||
if dominant_scenario and dominant_scenario["pct_of_portfolio"] >= 60 and len(priced) >= 3:
|
if dominant_scenario and dominant_scenario["pct_of_portfolio"] >= 40 and n >= 3:
|
||||||
warning = (
|
warning = (
|
||||||
f"{dominant_scenario['pct_of_portfolio']:.0f}% du portefeuille gagne surtout dans le même "
|
f"{dominant_scenario['pct_of_portfolio']:.0f}% du portefeuille (pondéré par capital à "
|
||||||
f"scénario ({dominant_scenario['label']}) — vos {len(priced)} positions ne sont pas aussi "
|
f"risque) gagne dans le même scénario ({dominant_scenario['label']})"
|
||||||
f"diversifiées qu'il n'y paraît, c'est en grande partie un seul pari macro répété."
|
+ (f", avec seulement {effective_n:.1f} paris réellement indépendants sur {n} positions "
|
||||||
|
f"(sous-jacents corrélés)" if effective_n < n * 0.7 else "")
|
||||||
|
+ " — vos positions ne sont pas aussi diversifiées qu'il n'y paraît."
|
||||||
|
)
|
||||||
|
elif pct_unaligned >= 25 and n >= 3:
|
||||||
|
warning = (
|
||||||
|
f"{pct_unaligned:.0f}% du portefeuille ne profite d'aucun des 8 scénarios macro "
|
||||||
|
f"(pari structurel non couvert par cette grille de lecture, ex. positions courtes "
|
||||||
|
f"sur un actif refuge)."
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"positions": len(priced),
|
"positions": n,
|
||||||
"total_capital": round(total_capital, 2),
|
"total_capital": round(total_capital, 2),
|
||||||
|
"effective_n_positions": effective_n,
|
||||||
|
"avg_pairwise_correlation": round(avg_corr, 3) if avg_corr is not None else None,
|
||||||
"scenarios": scenario_results,
|
"scenarios": scenario_results,
|
||||||
"concentration": concentration,
|
"concentration": concentration,
|
||||||
|
"position_details": position_details,
|
||||||
"dominant_scenario": dominant_scenario,
|
"dominant_scenario": dominant_scenario,
|
||||||
|
"pct_unaligned": pct_unaligned,
|
||||||
"unpriced": unpriced,
|
"unpriced": unpriced,
|
||||||
"warning": warning,
|
"warning": warning,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ function ScenarioExposureCard() {
|
|||||||
|
|
||||||
const concentration: any[] = exp.concentration ?? []
|
const concentration: any[] = exp.concentration ?? []
|
||||||
const scenarios: any[] = exp.scenarios ?? []
|
const scenarios: any[] = exp.scenarios ?? []
|
||||||
|
const positionDetails: any[] = exp.position_details ?? []
|
||||||
|
const redundant = exp.effective_n_positions != null && exp.effective_n_positions < exp.positions * 0.75
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -148,9 +150,29 @@ function ScenarioExposureCard() {
|
|||||||
<Layers className="w-4 h-4 text-purple-400" /> Alignement du portefeuille sur le Régime Macro
|
<Layers className="w-4 h-4 text-purple-400" /> Alignement du portefeuille sur le Régime Macro
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-slate-500 mb-3">
|
<div className="text-[10px] text-slate-500 mb-3">
|
||||||
Repricing Black-Scholes réel (pricing Saxo-first) de chaque position sous les 8 scénarios du
|
Une position ne compte pour un scénario que si elle y gagne réellement (repricing Black-Scholes
|
||||||
régime macro — révèle quand plusieurs positions différentes sont en réalité le même pari répété.
|
réel, Saxo-first), répartie entre tous les scénarios où elle est gagnante — pas un vote unique
|
||||||
Vue distincte des Risk Factors ci-dessous (thème/classe d'actif touché, pas le sens du pari).
|
au scénario "le moins pire". Vue distincte des Risk Factors ci-dessous (thème touché, pas le sens du pari).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Headline stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||||
|
<div className="bg-dark-700/40 rounded px-2 py-1.5 text-center">
|
||||||
|
<div className="text-sm font-bold font-mono text-slate-200">{exp.positions}</div>
|
||||||
|
<div className="text-[9px] text-slate-500">positions</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-dark-700/40 rounded px-2 py-1.5 text-center">
|
||||||
|
<div className={clsx('text-sm font-bold font-mono', redundant ? 'text-amber-400' : 'text-slate-200')}>
|
||||||
|
{exp.effective_n_positions ?? '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-slate-500">paris indépendants (N eff.)</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-dark-700/40 rounded px-2 py-1.5 text-center">
|
||||||
|
<div className={clsx('text-sm font-bold font-mono', exp.pct_unaligned >= 25 ? 'text-amber-400' : 'text-slate-200')}>
|
||||||
|
{exp.pct_unaligned}%
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-slate-500">non couvert par les 8 scénarios</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{exp.warning && (
|
{exp.warning && (
|
||||||
@@ -164,25 +186,29 @@ function ScenarioExposureCard() {
|
|||||||
{/* Concentration ranking */}
|
{/* Concentration ranking */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold text-slate-400 mb-2">
|
<div className="text-xs font-semibold text-slate-400 mb-2">
|
||||||
% du book dont c'est le scénario le plus favorable
|
% du capital à risque aligné avec chaque scénario
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2.5">
|
{concentration.length === 0 ? (
|
||||||
{concentration.map((c: any) => (
|
<div className="text-xs text-slate-600">Aucune position ne gagne dans un des 8 scénarios.</div>
|
||||||
<div key={c.key}>
|
) : (
|
||||||
<div className="flex justify-between text-xs mb-1">
|
<div className="space-y-2.5">
|
||||||
<span className="text-slate-300 flex items-center gap-1.5">
|
{concentration.map((c: any) => (
|
||||||
<span>{c.emoji}</span>{c.label}
|
<div key={c.key}>
|
||||||
</span>
|
<div className="flex justify-between text-xs mb-1">
|
||||||
<span className="font-mono font-bold" style={{ color: c.color }}>
|
<span className="text-slate-300 flex items-center gap-1.5">
|
||||||
{c.pct_of_portfolio}%
|
<span>{c.emoji}</span>{c.label}
|
||||||
</span>
|
</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" style={{ width: `${Math.min(c.pct_of_portfolio, 100)}%`, background: c.color }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
|
))}
|
||||||
<div className="h-full rounded-full" style={{ width: `${Math.min(c.pct_of_portfolio, 100)}%`, background: c.color }} />
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sensitivity matrix */}
|
{/* Sensitivity matrix */}
|
||||||
@@ -206,6 +232,39 @@ function ScenarioExposureCard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Per-position transparency layer */}
|
||||||
|
{positionDetails.length > 0 && (
|
||||||
|
<div className="mt-4 pt-3 border-t border-slate-700/30">
|
||||||
|
<div className="text-xs font-semibold text-slate-400 mb-2">Détail par position</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{positionDetails.map((p: any) => (
|
||||||
|
<div key={p.id} className="flex items-center gap-2 text-[11px] bg-dark-700/30 rounded px-2 py-1.5">
|
||||||
|
<span className="text-slate-200 font-medium w-40 truncate shrink-0">{p.title}</span>
|
||||||
|
<span className="text-slate-500 w-32 truncate shrink-0">{p.nature}</span>
|
||||||
|
<span className="text-slate-600 font-mono text-[10px] w-28 shrink-0">
|
||||||
|
Δ {p.delta_dollars != null ? p.delta_dollars.toFixed(0) : '—'} · v {p.vega_dollars != null ? p.vega_dollars.toFixed(0) : '—'}
|
||||||
|
</span>
|
||||||
|
{p.curve_regime && (
|
||||||
|
<span className="text-[9px] text-cyan-400/80 bg-cyan-900/20 rounded px-1.5 py-0.5 shrink-0">{p.curve_regime}</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1 flex items-center gap-1 justify-end flex-wrap">
|
||||||
|
{p.is_unaligned ? (
|
||||||
|
<span className="text-[9px] text-slate-600 italic">non aligné (perd dans les 8 scénarios)</span>
|
||||||
|
) : (
|
||||||
|
p.aligned_scenarios.map((a: any) => (
|
||||||
|
<span key={a.key} className="text-[9px] rounded px-1.5 py-0.5"
|
||||||
|
style={{ background: `${a.color}22`, color: a.color }}>
|
||||||
|
{a.emoji} {a.label} {a.share_of_position}%
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{exp.unpriced?.length > 0 && (
|
{exp.unpriced?.length > 0 && (
|
||||||
<div className="text-[10px] text-slate-600 mt-3 pt-2 border-t border-slate-700/30">
|
<div className="text-[10px] text-slate-600 mt-3 pt-2 border-t border-slate-700/30">
|
||||||
{exp.unpriced.length} position(s) non pricée(s) (pas de legs/données) : {exp.unpriced.map((u: any) => u.title).join(', ')}
|
{exp.unpriced.length} position(s) non pricée(s) (pas de legs/données) : {exp.unpriced.map((u: any) => u.title).join(', ')}
|
||||||
|
|||||||
Reference in New Issue
Block a user