From 3704724b0b09756c073fffd69e788ddceedb4c45 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sun, 26 Jul 2026 14:10:49 +0200 Subject: [PATCH] feat: risk --- backend/routers/portfolio.py | 156 +++++++++++----- backend/services/database.py | 17 ++ backend/services/portfolio_pricing.py | 159 ++++++++++++++++ backend/services/portfolio_scenarios.py | 229 ++++++++++++++++++++++++ frontend/src/hooks/useApi.ts | 19 ++ frontend/src/pages/Dashboard.tsx | 23 ++- frontend/src/pages/Portfolio.tsx | 87 ++++++++- frontend/src/pages/RiskDashboard.tsx | 86 ++++++++- 8 files changed, 719 insertions(+), 57 deletions(-) create mode 100644 backend/services/portfolio_pricing.py create mode 100644 backend/services/portfolio_scenarios.py diff --git a/backend/routers/portfolio.py b/backend/routers/portfolio.py index 3386ded..34206a7 100644 --- a/backend/routers/portfolio.py +++ b/backend/routers/portfolio.py @@ -40,15 +40,13 @@ class NotesRequest(BaseModel): def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]: - """Compute current value of a position using live prices + Black-Scholes.""" - underlying = pos["underlying"] - q = get_quote(underlying) - S = (q.get("price") if q else None) or pos.get("entry_underlying_price") or 100.0 + """Compute current value of a position — Saxo-first per leg (services.portfolio_pricing) + when the underlying has a saxo_option_symbol linked in the Watchlist, yfinance + historical-vol Black-Scholes otherwise or as a fallback on any Saxo failure. Each leg's + `pricing_source` in the response says plainly which one was actually used.""" + from services.portfolio_pricing import resolve_saxo_chain, price_leg - legs = pos.get("legs", []) - if not legs: - return {**pos, "current_value": pos["capital_invested"], "pnl": 0, "pnl_pct": 0, - "current_underlying": S, "greeks": {}} + underlying = pos["underlying"] # Compute days to expiry expiry_date = pos.get("expiry_date") or "" @@ -62,17 +60,27 @@ def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]: entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date() days_elapsed = (date.today() - entry).days T = max(0.001, (pos.get("expiry_days", 90) - days_elapsed) / 365) + days_to_expiry = T * 365 - from services.data_fetcher import compute_historical_iv as get_iv - sigma = get_iv(underlying) r = 0.05 + chain, surface = resolve_saxo_chain(underlying, target_days=max(int(days_to_expiry), 1)) - total_current_value = 0.0 - total_entry_value = 0.0 - net_delta = 0.0 - net_theta = 0.0 - net_vega = 0.0 - entry_from_legs = False + # yfinance fallback inputs — only actually fetched if no usable Saxo chain, so a + # Saxo-linked instrument never pays for a yfinance round-trip it doesn't need. + fallback_spot = pos.get("entry_underlying_price") or 100.0 + fallback_sigma = 0.20 + if chain is None: + q = get_quote(underlying) + fallback_spot = (q.get("price") if q else None) or fallback_spot + from services.data_fetcher import compute_historical_iv as get_iv + fallback_sigma = get_iv(underlying) + + S = chain["spot"] if chain else fallback_spot + + legs = pos.get("legs", []) + if not legs: + return {**pos, "current_value": pos["capital_invested"], "pnl": 0, "pnl_pct": 0, + "current_underlying": S, "greeks": {}, "pricing_sources": []} # T at entry (full original duration) — used to reprice legs at entry if premium_paid not stored S_entry = float(pos.get("entry_underlying_price") or S) @@ -81,11 +89,20 @@ def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]: try: exp_dt = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() entry_dt = datetime.strptime(entry_date_str[:10], "%Y-%m-%d").date() - T_entry = max(0.001, (exp_dt - entry_dt).days / 365) + days_to_expiry_entry = max(0.001, (exp_dt - entry_dt).days) except Exception: - T_entry = max(0.001, pos.get("expiry_days", 90) / 365) + days_to_expiry_entry = max(0.001, pos.get("expiry_days", 90)) else: - T_entry = max(0.001, pos.get("expiry_days", 90) / 365) + days_to_expiry_entry = max(0.001, pos.get("expiry_days", 90)) + + total_current_value = 0.0 + total_entry_value = 0.0 + net_delta = 0.0 + net_theta = 0.0 + net_vega = 0.0 + priced_legs = [] + sources_seen: set = set() + sigmas_seen: List[float] = [] for leg in legs: K = leg.get("strike") or S @@ -93,21 +110,26 @@ def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]: opt_type = leg.get("option_type", "call") qty = leg.get("quantity", 1) sign = 1 if leg.get("position", "long") == "long" else -1 - bs = black_scholes(S, K, T, r, sigma, opt_type) - leg_value = bs["price"] * qty * 100 * sign + + priced = price_leg(K, opt_type, days_to_expiry, r, chain, surface, expiry_date, fallback_spot, fallback_sigma) + bs = black_scholes(S, K, T, r, priced["sigma"], opt_type) # for greeks, at the same sigma just resolved + sources_seen.add(priced["source"]) + sigmas_seen.append(priced["sigma"]) + leg_value = priced["price"] * qty * 100 * sign total_current_value += leg_value net_delta += bs["delta"] * qty * sign net_theta += bs["theta"] * qty * sign net_vega += bs["vega"] * qty * sign + priced_legs.append({**leg, "current_premium": round(priced["price"], 4), "pricing_source": priced["source"]}) + if leg.get("premium_paid") is not None: total_entry_value += leg["premium_paid"] * qty * 100 * sign - entry_from_legs = True else: # No stored premium: reprice at entry conditions for a consistent PnL baseline - bs_entry = black_scholes(S_entry, K_entry, T_entry, r, sigma, opt_type) - total_entry_value += bs_entry["price"] * qty * 100 * sign + priced_entry = price_leg(K_entry, opt_type, days_to_expiry_entry, r, chain, surface, expiry_date, S_entry, fallback_sigma) + total_entry_value += priced_entry["price"] * qty * 100 * sign - # Entry reference: always from legs (either stored premium or BS at entry conditions) + # Entry reference: always from legs (either stored premium or repriced at entry conditions) ib_entry = pos.get("ib_fees_entry", 0) entry_ref = total_entry_value if total_entry_value != 0 else pos["capital_invested"] pnl = total_current_value - entry_ref - ib_entry @@ -115,13 +137,19 @@ def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]: return { **pos, + "legs": priced_legs, "current_underlying": round(S, 4), "current_value": round(total_current_value, 2), "entry_ref": round(entry_ref, 2), "pnl": round(pnl, 2), "pnl_pct": round(pnl_pct, 2), "days_remaining": max(0, int(T * 365)), - "sigma_used": round(sigma, 4), + # Kept for the frontend's existing "σ (hist. IV)" display — now an average across + # legs since each can carry its own skew-aware sigma when Saxo-priced, rather than + # one flat value for the whole position like the old yfinance-only path. + "sigma_used": round(sum(sigmas_seen) / len(sigmas_seen), 4) if sigmas_seen else None, + "pricing_sources": sorted(sources_seen), + "pricing_source_summary": next(iter(sources_seen)) if len(sources_seen) == 1 else ("mixed" if sources_seen else "yfinance_bs"), "greeks": { "net_delta": round(net_delta, 4), "net_theta": round(net_theta, 4), @@ -138,6 +166,28 @@ def list_positions(status: str = "open"): return positions +@router.get("/positions/{pos_id}/payoff") +def position_payoff(pos_id: str): + """P&L-vs-underlying-price payoff diagram for one position — see + services.portfolio_pricing.compute_payoff for the at-expiry vs. today (current Saxo + vol held fixed) two-curve methodology.""" + from services.portfolio_pricing import compute_payoff + pos = next((p for p in get_positions("open") + get_positions("closed") if p["id"] == pos_id), None) + if not pos: + raise HTTPException(status_code=404, detail=f"Position '{pos_id}' introuvable") + return compute_payoff(pos) + + +@router.get("/scenario-exposure") +def scenario_exposure(): + """Reprices every open position under a handful of named macro scenarios (Risk-Off, + Risk-On, inflation persistante, dollar fort, baisse des matières premières) to surface + concentration on a single underlying bet across differently-named positions — see + services.portfolio_scenarios.compute_scenario_exposure for the methodology.""" + from services.portfolio_scenarios import compute_scenario_exposure + return compute_scenario_exposure() + + @router.get("/summary") def portfolio_summary(): open_pos = get_positions("open") @@ -209,6 +259,8 @@ TICKER_HINTS: Dict[str, str] = { def add_pos(req: AddPositionRequest): import traceback try: + from services.portfolio_pricing import resolve_saxo_chain, price_leg + data = req.model_dump() # Normalize common names to yfinance tickers @@ -216,36 +268,46 @@ def add_pos(req: AddPositionRequest): normalized = TICKER_HINTS.get(raw.lower(), raw) data["underlying"] = normalized - # Fetch live underlying price - q = get_quote(normalized) - S = q.get("price") if q else None - if not S: - hint = TICKER_HINTS.get(raw.lower()) - tip = f" Essayez '{hint}'." if hint else " Utilisez le symbole Yahoo Finance (ex: ^GSPC pour S&P 500, GC=F pour Or, CL=F pour WTI)." - raise HTTPException(status_code=422, detail=f"Ticker '{raw}' introuvable sur Yahoo Finance.{tip}") - if not data.get("entry_underlying_price"): - data["entry_underlying_price"] = S - - # Auto-fill entry date and expiry + # Auto-fill entry date and expiry (needed before pricing, to size the Saxo chain fetch) if not data.get("entry_date"): data["entry_date"] = datetime.utcnow().isoformat()[:10] if not data.get("expiry_date") and data.get("expiry_days"): data["expiry_date"] = (date.today() + timedelta(days=data["expiry_days"])).isoformat() - # Auto-price legs that have no premium_paid using BS at entry - # This ensures P&L starts at ~0 on day 1 (tracking change from entry, not vs. budget) + # Underlying price — Saxo option chain's own spot first (real, and consistent with + # whatever prices the legs below), yfinance only if this underlying has no + # saxo_option_symbol link at all (Config -> Instruments Watchlist -> "Option"). + chain, surface = resolve_saxo_chain(normalized, target_days=data.get("expiry_days", 90)) + S = chain["spot"] if chain else None + sigma = None + if S is None: + q = get_quote(normalized) + S = q.get("price") if q else None + if S is not None: + sigma = compute_historical_iv(req.underlying) + if not S: + hint = TICKER_HINTS.get(raw.lower()) + tip = f" Essayez '{hint}'." if hint else " Utilisez le symbole Yahoo Finance (ex: ^GSPC pour S&P 500, GC=F pour Or, CL=F pour WTI), ou liez-le à un option chain Saxo (Config → Instruments Watchlist)." + raise HTTPException(status_code=422, detail=f"Ticker '{raw}' introuvable sur Yahoo Finance ni lié à un chain Saxo.{tip}") + if not data.get("entry_underlying_price"): + data["entry_underlying_price"] = S + + # Auto-price legs that have no premium_paid — Saxo-first (real quote, else the + # Saxo-fitted vol surface), yfinance-historical-vol Black-Scholes as a last resort. + # This ensures P&L starts at ~0 on day 1 (tracking change from entry, not vs. budget). if S and data.get("legs"): - sigma = compute_historical_iv(req.underlying) - T = max(0.001, data.get("expiry_days", 90) / 365) r = 0.05 + expiry_days = data.get("expiry_days", 90) for leg in data["legs"]: + if not leg.get("strike"): + leg["strike"] = round(S, 2) # ATM if no explicit strike if leg.get("premium_paid") is None: - K = leg.get("strike") or S # ATM if no explicit strike - if not leg.get("strike"): - leg["strike"] = round(S, 2) - opt_type = leg.get("option_type", "call") - bs = black_scholes(S, K, T, r, sigma, opt_type) - leg["premium_paid"] = round(bs["price"], 4) + priced = price_leg( + leg["strike"], leg.get("option_type", "call"), expiry_days, r, + chain, surface, data.get("expiry_date"), S, sigma or 0.20, + ) + leg["premium_paid"] = round(priced["price"], 4) + leg["pricing_source"] = priced["source"] pos_id = add_position(data) return {"id": pos_id, "status": "added"} diff --git a/backend/services/database.py b/backend/services/database.py index deffeef..6a5836f 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -3342,6 +3342,23 @@ def get_instruments_watchlist() -> List[Dict]: return [dict(r) for r in rows] +def get_saxo_option_symbol_for_ticker(ticker: str) -> Optional[str]: + """Cockpit Watchlist's saxo_option_symbol link for this yfinance-style ticker + (e.g. '^NDX' -> 'NQ:XCME') — Portfolio positions store `underlying` in the same raw + format the Watchlist's own `ticker` column uses, so this is a direct case-insensitive + match, no suffix-stripping or alias table needed (see services.instrument_service. + resolve_watchlist_ticker for the OTHER case, where a catalog id and a Watchlist ticker + genuinely differ — that doesn't apply here). Used by services.portfolio_pricing to + decide whether a position's legs can be priced off the real Saxo option chain instead + of a yfinance-historical-vol Black-Scholes simulation.""" + conn = get_conn() + row = conn.execute( + "SELECT saxo_option_symbol FROM instruments_watchlist WHERE ticker = ? COLLATE NOCASE", (ticker,) + ).fetchone() + conn.close() + return row["saxo_option_symbol"] if row and row["saxo_option_symbol"] else None + + def set_instrument_watchlist_saxo_option_symbol(ticker: str, saxo_symbol: Optional[str]) -> bool: """Link (or unlink, if saxo_symbol is None/empty) a tracked instrument to the Saxo symbol whose OPTIONS CHAIN Options Lab should analyze for it — e.g. CL=F -> MCL:XCME diff --git a/backend/services/portfolio_pricing.py b/backend/services/portfolio_pricing.py new file mode 100644 index 0000000..652e84e --- /dev/null +++ b/backend/services/portfolio_pricing.py @@ -0,0 +1,159 @@ +""" +Saxo-first pricing for Portfolio positions — options legs are priced off this Cockpit's own +accumulated Saxo option-chain history (services.option_chain, services.vol_surface) whenever +the position's underlying has a saxo_option_symbol link in the Watchlist (Config -> +Instruments Watchlist -> "Option"), the SAME real market data Options Lab and Strategy +Builder already use. Mirrors services.strategy_engine.entry_price()'s own two-tier pattern: +an exact Saxo bid/ask quote for the listed contract if one happens to exist ("saxo_quote"), +else the real Saxo-fitted vol smile (services.vol_surface.Surface) priced through +Black-Scholes ("saxo_surface") — both grounded in real Saxo data, unlike the previous +unconditional fallback to yfinance's historical realized vol as a stand-in for implied vol +("yfinance_bs"), which is what silently produced a materially different premium than Saxo's +real chain (27.2% yfinance-historical vs Saxo's real ~32% chain IV on the ^NDX example that +prompted this). + +An exact "saxo_quote" match is rare in practice: positions carry a nominal expiry_date/ +expiry_days the AI or user chose freely, not necessarily a real listed Saxo expiry — so +most legs land on "saxo_surface" (real Saxo-implied vol, interpolated to the requested +strike/tenor) rather than a literal listed-contract quote. That's still a real improvement +over yfinance historical vol, and every priced leg carries its `source` so the Portfolio UI +can say plainly which basis was used instead of always labeling everything "Black-Scholes" +regardless of where the inputs actually came from. +""" +from typing import Any, Dict, Optional, Tuple + +from services.options_pricer import black_scholes + + +def resolve_saxo_chain(underlying: str, target_days: int) -> Tuple[Optional[Dict[str, Any]], Optional[Any]]: + """Returns (chain_slice, Surface) for this underlying's linked Saxo option chain, or + (None, None) if it isn't linked, or the chain can't be built right now (Saxo down, no + snapshot yet, entitlement gap, etc.) — callers fall back to yfinance pricing in that case.""" + from services.database import get_saxo_option_symbol_for_ticker + saxo_symbol = get_saxo_option_symbol_for_ticker(underlying) + if not saxo_symbol: + return None, None + try: + from services.option_chain import get_chain_slice + from services.vol_surface import Surface + chain = get_chain_slice(saxo_symbol, target_days=max(target_days, 1)) + if not chain.get("spot"): + return None, None + surface = Surface(chain["spot"], chain["expiries"]) + return chain, surface + except Exception: + return None, None + + +def price_leg( + strike: float, option_type: str, days_to_expiry: float, r: float, + chain: Optional[Dict[str, Any]], surface: Optional[Any], expiry_date: Optional[str], + fallback_spot: float, fallback_sigma: float, +) -> Dict[str, Any]: + """One leg's {price, spot, sigma, source}.""" + T = max(days_to_expiry, 1) / 365 + if chain is not None and surface is not None: + quote = None + if expiry_date: + from services.option_chain import find_quote + quote = find_quote(chain, expiry_date, strike, option_type) + if quote and quote.get("bid", 0) > 0 and quote.get("ask", 0) > 0: + return {"price": quote["mid"], "spot": chain["spot"], "sigma": quote["iv"], "source": "saxo_quote"} + sigma = surface.iv_at(strike, max(days_to_expiry, 1)) + price = black_scholes(chain["spot"], strike, T, r, sigma, option_type)["price"] + return {"price": price, "spot": chain["spot"], "sigma": sigma, "source": "saxo_surface"} + price = black_scholes(fallback_spot, strike, T, r, fallback_sigma, option_type)["price"] + return {"price": price, "spot": fallback_spot, "sigma": fallback_sigma, "source": "yfinance_bs"} + + +SOURCE_LABELS = { + "saxo_quote": "Cotation Saxo réelle", + "saxo_surface": "Surface de vol Saxo (réelle)", + "yfinance_bs": "Black-Scholes (vol historique yfinance)", +} + + +def _intrinsic(S: float, K: float, option_type: str) -> float: + return max(0.0, S - K) if option_type == "call" else max(0.0, K - S) + + +def compute_payoff(pos: Dict[str, Any], n_points: int = 61, range_pct: float = 0.25) -> Dict[str, Any]: + """P&L vs. underlying price across a ±range_pct band around the current spot — two + curves: "at_expiry" (pure intrinsic value, no vol at all — the textbook payoff diagram) + and "today" (Black-Scholes reprice at each hypothetical spot, holding each leg's + CURRENT implied vol fixed — from the real Saxo surface when linked, so the time-value + bulge/skew asymmetry actually reflects Saxo's real market vol instead of a flat + textbook number). Both curves net out entry cost and entry fees, so y=0 is genuine + breakeven, matching what the Position card's PnL already shows at the current spot.""" + from datetime import date, datetime + from services.data_fetcher import get_quote + + underlying = pos["underlying"] + legs = pos.get("legs", []) + if not legs: + return {"spot_range": [], "at_expiry": [], "today": [], "current_spot": None, + "entry_spot": pos.get("entry_underlying_price"), "strikes": [], "pricing_source": None} + + expiry_date = pos.get("expiry_date") or "" + if expiry_date: + try: + exp = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() + days_remaining = max(0, (exp - date.today()).days) + except ValueError: + days_remaining = 0 + else: + entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date() + 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)) + fallback_spot = pos.get("entry_underlying_price") or 100.0 + fallback_sigma = 0.20 + if chain is None: + q = get_quote(underlying) + fallback_spot = (q.get("price") if q else None) or fallback_spot + from services.data_fetcher import compute_historical_iv + fallback_sigma = compute_historical_iv(underlying) + S = chain["spot"] if chain else fallback_spot + + resolved_legs = [] + for leg in legs: + K = leg.get("strike") or S + opt_type = leg.get("option_type", "call") + entry_premium = leg.get("premium_paid") + if entry_premium is None: + entry_premium = price_leg(K, opt_type, pos.get("expiry_days", 90), r, chain, surface, + expiry_date, fallback_spot, fallback_sigma)["price"] + priced_now = price_leg(K, opt_type, days_remaining, r, chain, surface, expiry_date, fallback_spot, fallback_sigma) + resolved_legs.append({ + "strike": K, "option_type": opt_type, "qty": leg.get("quantity", 1), + "sign": 1 if leg.get("position", "long") == "long" else -1, + "entry_premium": entry_premium, "sigma": priced_now["sigma"], + }) + + ib_entry = pos.get("ib_fees_entry", 0) + lo, hi = S * (1 - range_pct), S * (1 + range_pct) + spot_range = [lo + (hi - lo) * i / (n_points - 1) for i in range(n_points)] + T_remaining = days_remaining / 365 + + at_expiry, today = [], [] + for Sx in spot_range: + pnl_exp = -ib_entry + pnl_today = -ib_entry + for leg in resolved_legs: + pnl_exp += leg["sign"] * leg["qty"] * 100 * (_intrinsic(Sx, leg["strike"], leg["option_type"]) - leg["entry_premium"]) + bs_price = (black_scholes(Sx, leg["strike"], T_remaining, r, leg["sigma"], leg["option_type"])["price"] + if T_remaining > 0 else _intrinsic(Sx, leg["strike"], leg["option_type"])) + pnl_today += leg["sign"] * leg["qty"] * 100 * (bs_price - leg["entry_premium"]) + at_expiry.append(round(pnl_exp, 2)) + today.append(round(pnl_today, 2)) + + return { + "spot_range": [round(s, 4) for s in spot_range], + "at_expiry": at_expiry, + "today": today, + "current_spot": round(S, 4), + "entry_spot": pos.get("entry_underlying_price"), + "strikes": sorted({leg["strike"] for leg in resolved_legs}), + "pricing_source": "saxo" if chain else "yfinance_bs", + } diff --git a/backend/services/portfolio_scenarios.py b/backend/services/portfolio_scenarios.py new file mode 100644 index 0000000..026446c --- /dev/null +++ b/backend/services/portfolio_scenarios.py @@ -0,0 +1,229 @@ +""" +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. + - 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." +""" +from typing import Any, Dict, List, Optional, Tuple + +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"}, +] + +# 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), + }, +} + + +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.""" + t = (ticker or "").upper().replace("=X", "").replace("/", "") + if t.startswith("USD"): + return 1 + if t.endswith("USD"): + return -1 + return 0 + + +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, mirroring + services.portfolio_pricing.compute_payoff's methodology but at ONE target spot instead + of a curve (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 datetime import date, datetime + from services.portfolio_pricing import resolve_saxo_chain, price_leg + from services.options_pricer import black_scholes + from services.data_fetcher import get_quote, compute_historical_iv + + underlying = pos["underlying"] + legs = pos.get("legs", []) + if not legs: + return None + + expiry_date = pos.get("expiry_date") or "" + if expiry_date: + try: + exp = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() + days_remaining = max(0, (exp - date.today()).days) + except ValueError: + days_remaining = 0 + else: + entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date() + 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)) + fallback_spot = pos.get("entry_underlying_price") or 100.0 + fallback_sigma = 0.20 + if chain is None: + q = get_quote(underlying) + fallback_spot = (q.get("price") if q else None) or fallback_spot + fallback_sigma = compute_historical_iv(underlying) + S = chain["spot"] if chain else fallback_spot + S_shocked = S * (1 + spot_shock_pct) + T_remaining = days_remaining / 365 + + pnl = -pos.get("ib_fees_entry", 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 + priced_now = price_leg(K, opt_type, days_remaining, r, chain, surface, expiry_date, fallback_spot, fallback_sigma) + entry_premium = leg.get("premium_paid") + if entry_premium is None: + entry_premium = priced_now["price"] + sigma = max(0.01, priced_now["sigma"] + vol_shock_abs) + if T_remaining > 0: + shocked_price = black_scholes(S_shocked, K, T_remaining, r, sigma, opt_type)["price"] + else: + shocked_price = max(0.0, S_shocked - K) if opt_type == "call" else max(0.0, K - S_shocked) + pnl += sign * qty * 100 * (shocked_price - entry_premium) + return pnl + + +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"). + - `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. + """ + from services.database import get_positions + + positions = get_positions("open") + if not positions: + return {"positions": 0, "total_capital": 0, "scenarios": [], "concentration": [], + "dominant_scenario": None, "unpriced": [], "warning": None} + + priced: List[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)) + if ac == "forex": + spot_shock = spot_shock * fx_sign + scenario_pnl[key] = _reprice_position(pos, spot_shock, vol_shock) + + if all(v is None for v in scenario_pnl.values()): + unpriced.append({"id": pos["id"], "title": pos.get("title", pos["underlying"])}) + continue + + priced.append({ + "id": pos["id"], "title": pos.get("title", pos["underlying"]), + "underlying": pos["underlying"], "asset_class": ac, + "capital_invested": max(pos.get("capital_invested") or 0, 0), + "scenario_pnl": scenario_pnl, + }) + + if not priced: + return {"positions": len(positions), "total_capital": 0, "scenarios": [], "concentration": [], + "dominant_scenario": None, "unpriced": unpriced, "warning": None} + + total_capital = sum(p["capital_invested"] for p in priced) or 1.0 + + scenario_results = [] + for scen in SCENARIOS: + 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"], + "portfolio_pnl": round(total_pnl, 2), + "portfolio_pnl_pct": round(total_pnl / total_capital * 100, 2), + "positions": [ + { + "id": p["id"], "title": p["title"], + "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), + } + for p in priced + ], + }) + scenario_results.sort(key=lambda s: -abs(s["portfolio_pnl_pct"])) + + # Concentration: which scenario is each position's single most favorable outcome? + weight_by_scenario: Dict[str, float] = {s["key"]: 0.0 for s in SCENARIOS} + 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"))) + 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), + "pct_of_portfolio": round(w / total_capital * 100, 1)} + for key, w in weight_by_scenario.items() if w > 0 + ] + concentration.sort(key=lambda c: -c["pct_of_portfolio"]) + dominant_scenario = concentration[0] if concentration else None + + warning = None + if dominant_scenario and dominant_scenario["pct_of_portfolio"] >= 60 and len(priced) >= 3: + warning = ( + f"{dominant_scenario['pct_of_portfolio']:.0f}% du portefeuille gagne surtout dans le même " + f"scénario ({dominant_scenario['label']}) — vos {len(priced)} positions ne sont pas aussi " + f"diversifiées qu'il n'y paraît, c'est en grande partie un seul pari macro répété." + ) + + return { + "positions": len(priced), + "total_capital": round(total_capital, 2), + "scenarios": scenario_results, + "concentration": concentration, + "dominant_scenario": dominant_scenario, + "unpriced": unpriced, + "warning": warning, + } diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 74b8ba8..7f530f9 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -317,6 +317,25 @@ export const usePnlHistory = () => queryFn: () => api.get('/portfolio/pnl-history').then(r => r.data), }) +// P&L-vs-underlying-price payoff diagram for one position (services.portfolio_pricing. +// compute_payoff) — at-expiry (pure intrinsic) + today (current vol held fixed) curves. +export const usePositionPayoff = (posId: string, enabled: boolean) => + useQuery({ + queryKey: ['portfolio-payoff', posId], + queryFn: () => api.get(`/portfolio/positions/${posId}/payoff`).then(r => r.data), + enabled, + }) + +// Reprices every open position under a handful of named macro scenarios (Risk-Off, +// Risk-On, inflation persistante, dollar fort, baisse des matières premières) to surface +// when several differently-named positions are really the same underlying bet. +export const usePortfolioScenarioExposure = () => + useQuery({ + queryKey: ['portfolio-scenario-exposure'], + queryFn: () => api.get('/portfolio/scenario-exposure').then(r => r.data), + staleTime: 5 * 60_000, + }) + export const useAddPosition = () => { const qc = useQueryClient() return useMutation({ diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index aad8208..d3e1247 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -6,7 +6,7 @@ import { useRiskDashboard, useRiskRadar, useGeoNews, useCycleStatus, useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useWatchlistHistory, useQuickAddInstrument, useSaxoIvWatchlist, useLatestCycleReport, - useWatchlistCurveRegimes, + useWatchlistCurveRegimes, usePortfolioScenarioExposure, } from '../hooks/useApi' import { Clock, Globe, ArrowUpRight, Newspaper, Waves, Link2 } from 'lucide-react' import { Link, useNavigate } from 'react-router-dom' @@ -117,6 +117,7 @@ export default function Dashboard() { const { data: openPositionsData } = usePortfolioPositions('open') const { data: riskDashboard } = useRiskDashboard() const { data: riskRadarData } = useRiskRadar() + const { data: scenarioExposure } = usePortfolioScenarioExposure() const { data: cycleStatusData } = useCycleStatus() const { data: geoNews } = useGeoNews() const { data: watchlistItems } = useInstrumentsWatchlist() @@ -222,9 +223,19 @@ export default function Dashboard() { // suffix-stripping needed, those were solving a mismatch that didn't actually exist. const nameByUnderlyingTicker: Record = {} for (const w of ((watchlistItems as any[]) ?? [])) nameByUnderlyingTicker[String(w.ticker).trim().toUpperCase()] = w.name || w.ticker + // A handful of positions trade via a liquid options-ETF proxy rather than the + // Watchlist's own quote ticker for the same underlying (e.g. Brent futures options + // aren't broadly available, so Brent exposure trades via BNO — the United States Brent + // Oil Fund ETF — instead of the BZ=F/BRENT quote ticker). Extend here if another proxy + // ticker shows up unrenamed. + const PROXY_TICKER_ALIASES: Record = { 'BNO': 'BRENT' } const underlyingDisplayName = (underlying: string): string => { if (!underlying) return underlying - return nameByUnderlyingTicker[underlying.trim().toUpperCase()] || underlying + const upper = underlying.trim().toUpperCase() + if (nameByUnderlyingTicker[upper]) return nameByUnderlyingTicker[upper] + const proxyTicker = PROXY_TICKER_ALIASES[upper] + if (proxyTicker && nameByUnderlyingTicker[proxyTicker]) return nameByUnderlyingTicker[proxyTicker] + return underlying } // Patterns from the last cycle only (filter by created_at >= cycle started_at) @@ -816,6 +827,8 @@ 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 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}) +
+ )} {radarAxes.length > 0 && ( diff --git a/frontend/src/pages/Portfolio.tsx b/frontend/src/pages/Portfolio.tsx index cf48596..930c401 100644 --- a/frontend/src/pages/Portfolio.tsx +++ b/frontend/src/pages/Portfolio.tsx @@ -2,14 +2,14 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { usePortfolioPositions, usePortfolioSummary, usePnlHistory, - useAddPosition, useClosePosition + useAddPosition, useClosePosition, usePositionPayoff } from '../hooks/useApi' import { useQueryClient, useMutation } from '@tanstack/react-query' import axios from 'axios' import clsx from 'clsx' import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, - CartesianGrid, ReferenceLine, BarChart, Bar + CartesianGrid, ReferenceLine, BarChart, Bar, LineChart, Line } from 'recharts' import { TrendingUp, TrendingDown, Plus, X, DollarSign, BarChart2, RefreshCw, Trash2, ExternalLink, ChevronDown, ChevronUp } from 'lucide-react' import type { TradeIdea } from '../types' @@ -25,6 +25,12 @@ const useDeletePosition = () => { }) } +const PRICING_SOURCE_LABELS: Record = { + saxo_quote: 'Cotation Saxo réelle', + saxo_surface: 'Surface de vol Saxo', + yfinance_bs: 'BS (vol yfinance)', +} + const STRATEGIES = ['Long Call', 'Long Put', 'Bull Call Spread', 'Bear Put Spread', 'Long Straddle', 'Long Strangle', 'Covered Call'] const ASSET_CLASSES = ['energy', 'metals', 'agriculture', 'equities', 'indices', 'forex'] @@ -205,6 +211,58 @@ function AddPositionModal({ prefill, onClose }: AddModalProps) { ) } +// P&L-vs-underlying-price payoff diagram — "at expiry" (pure intrinsic, no vol) vs "today" +// (Black-Scholes reprice holding each leg's current implied vol fixed, from the real Saxo +// surface when the option chain is linked — see services.portfolio_pricing.compute_payoff). +function PositionPayoffChart({ posId, enabled, legs }: { posId: string; enabled: boolean; legs: any[] }) { + const { data, isLoading } = usePositionPayoff(posId, enabled) + if (!enabled) return null + if (isLoading) return
Calcul du payoff…
+ if (!data || !data.spot_range?.length) return null + + const chartData = data.spot_range.map((s: number, i: number) => ({ + spot: s, atExpiry: data.at_expiry[i], today: data.today[i], + })) + const strikes: number[] = data.strikes ?? [] + + return ( +
+
+ Payoff — P&L vs. spot sous-jacent + + {data.pricing_source === 'yfinance_bs' ? 'Vol yfinance (pas de chain Saxo lié)' : 'Vol Saxo réelle'} + +
+ + + + v.toFixed(v >= 1000 ? 0 : 2)} /> + `${(v / 1000).toFixed(0)}k`} /> + [`${v.toFixed(2)}€`, name === 'atExpiry' ? 'À échéance' : "Aujourd'hui"]} + labelFormatter={(v: number) => `Spot ${v.toFixed(2)}`} + /> + + {data.current_spot != null && } + {data.entry_spot != null && } + {strikes.map((k: number) => ( + + ))} + + + + +
+ À échéance + Aujourd'hui (vol actuelle) + Strike{legs.length > 1 ? 's' : ''} +
+
+ ) +} + function PositionCard({ pos }: { pos: Record }) { const navigate = useNavigate() const [showClose, setShowClose] = useState(false) @@ -307,7 +365,14 @@ function PositionCard({ pos }: { pos: Record }) { className="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-400 mb-2 transition-colors" > {showDetails ? : } - Black-Scholes simulation detail + Détail de la valorisation + {pos.pricing_source_summary && ( + + {PRICING_SOURCE_LABELS[pos.pricing_source_summary] ?? pos.pricing_source_summary} + + )} )} @@ -319,7 +384,7 @@ function PositionCard({ pos }: { pos: Record }) { Entry spot: ${Number(pos.entry_underlying_price).toFixed(2)} )} {pos.sigma_used != null && ( - σ (hist. IV): {(pos.sigma_used * 100).toFixed(1)}% + σ (moy. legs): {(pos.sigma_used * 100).toFixed(1)}% )} r: 5% {pos.expiry_days != null && ( @@ -334,6 +399,7 @@ function PositionCard({ pos }: { pos: Record }) { Qty Premium/contract Total cost + Source @@ -364,14 +430,23 @@ function PositionCard({ pos }: { pos: Record }) { {legTotal != null ? `$${legTotal.toFixed(2)}` : '—'} + + {leg.pricing_source && ( + + {PRICING_SOURCE_LABELS[leg.pricing_source] ?? leg.pricing_source} + + )} + ) })}
- Black-Scholes · 1 contract = 100 shares · Price computed at entry time (spot, historical IV, r=5%) + 1 contract = 100 shares · r=5% · Prix réel Saxo si l'option chain de cet instrument est liée (Config → Instruments Watchlist), sinon Black-Scholes sur vol historique yfinance
+ + )} @@ -473,7 +548,7 @@ export default function Portfolio() { Portfolio

- Real-time tracking · Mark-to-market Black-Scholes · Simulated IB fees + Real-time tracking · Mark-to-market Saxo (option chain lié) ou Black-Scholes · Simulated IB fees

diff --git a/frontend/src/pages/RiskDashboard.tsx b/frontend/src/pages/RiskDashboard.tsx index eecaede..5d8ac47 100644 --- a/frontend/src/pages/RiskDashboard.tsx +++ b/frontend/src/pages/RiskDashboard.tsx @@ -1,8 +1,8 @@ import { useState } from 'react' -import { useRiskDashboard, usePatternCorrelations, usePnlTimeline, useRiskExposure, useSimPortfolioRisk } from '../hooks/useApi' +import { useRiskDashboard, usePatternCorrelations, usePnlTimeline, useRiskExposure, useSimPortfolioRisk, usePortfolioScenarioExposure } from '../hooks/useApi' import { ASSET_CLASS_COLORS } from '../constants/assetColors' import clsx from 'clsx' -import { ShieldAlert, TrendingUp, GitBranch, AlertTriangle, CheckCircle, Activity, Brain, RefreshCw, PieChart } from 'lucide-react' +import { ShieldAlert, TrendingUp, GitBranch, AlertTriangle, CheckCircle, Activity, Brain, RefreshCw, PieChart, Layers } from 'lucide-react' // ── Gauge component ────────────────────────────────────────────────────────── function ConcentrationGauge({ label, pct, threshold = 50 }: { label: string; pct: number; threshold?: number }) { @@ -131,6 +131,85 @@ function RecommendationCard({ rec }: { rec: any }) { ) } +// ── Scenario concentration ("same bet, different ticker") ─────────────────── +function ScenarioExposureCard() { + const { data, isLoading } = usePortfolioScenarioExposure() + const exp: any = data + + if (isLoading) return
+ if (!exp || !exp.positions) return null + + const concentration: any[] = exp.concentration ?? [] + const scenarios: any[] = exp.scenarios ?? [] + + return ( +
+
+ Concentration par scénario 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é. +
+ + {exp.warning && ( +
+ + {exp.warning} +
+ )} + +
+ {/* Concentration bars */} +
+
+ % du book dont c'est le scénario le plus favorable +
+
+ {concentration.map((c: any) => ( +
+
+ {c.label} + = 60 ? 'text-amber-400' : 'text-slate-300')}> + {c.pct_of_portfolio}% + +
+
+
+
+
+ ))} +
+
+ + {/* Sensitivity matrix */} +
+
P&L estimé du portefeuille par scénario
+ + + {scenarios.map((s: any) => ( + + + + + ))} + +
{s.label}= 0 ? 'text-emerald-400' : 'text-red-400')}> + {s.portfolio_pnl_pct >= 0 ? '+' : ''}{s.portfolio_pnl_pct}% +
+
+
+ + {exp.unpriced?.length > 0 && ( +
+ {exp.unpriced.length} position(s) non pricée(s) (pas de legs/données) : {exp.unpriced.map((u: any) => u.title).join(', ')} +
+ )} +
+ ) +} + // ── Main page ──────────────────────────────────────────────────────────────── function SimRiskPanel() { @@ -416,6 +495,9 @@ export default function RiskDashboard() { {/* Recommendation */} + {/* Scenario concentration — same bet, different ticker */} + + {/* Alerts */} {(d.concentration_alerts ?? []).length > 0 && (