From d2c393b8e56ae9559295cba7b3e4e4650dfa24f5 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Tue, 28 Jul 2026 11:14:31 +0200 Subject: [PATCH] feat: option lab --- backend/routers/portfolio.py | 17 +- backend/routers/saxo.py | 16 ++ backend/services/option_chain.py | 29 ++-- backend/services/options_pricer.py | 42 +++-- backend/services/pricing_check.py | 164 ++++++++++++++++++++ backend/services/strategy_comparison.py | 169 ++++++++++++++++++++ backend/services/strategy_engine.py | 7 +- backend/services/strategy_optimizer.py | 3 +- frontend/src/hooks/useApi.ts | 55 +++++++ frontend/src/pages/OptionsLab.tsx | 196 +++++++++++++++++++++++- frontend/src/pages/Portfolio.tsx | 88 ++++++++++- 11 files changed, 757 insertions(+), 29 deletions(-) create mode 100644 backend/services/pricing_check.py create mode 100644 backend/services/strategy_comparison.py diff --git a/backend/routers/portfolio.py b/backend/routers/portfolio.py index 2288dd6..66633df 100644 --- a/backend/routers/portfolio.py +++ b/backend/routers/portfolio.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query import traceback as tb_mod from pydantic import BaseModel from typing import Optional, List, Dict, Any @@ -179,6 +179,21 @@ def position_payoff(pos_id: str): return compute_payoff(pos) +@router.get("/positions/{pos_id}/retrospective-optimal") +def position_retrospective_optimal(pos_id: str, as_of: str = Query(None)): + """"What would have been optimal, in hindsight?" — reprices the position's real legs + and runs the Strategy Builder optimizer against the REAL historical Saxo chain and the + REALIZED spot/IV move since entry (not a guessed scenario) — see + services.strategy_comparison.compute_retrospective_comparison. Expensive (runs the full + template-search optimizer), so this is on-demand from the Portfolio position detail, not + auto-computed for every open position.""" + from services.strategy_comparison import compute_retrospective_comparison + 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_retrospective_comparison(pos, as_of=as_of) + + @router.get("/scenario-exposure") def scenario_exposure(): """Reprices every open position under a handful of named macro scenarios (Risk-Off, diff --git a/backend/routers/saxo.py b/backend/routers/saxo.py index 1498232..691a614 100644 --- a/backend/routers/saxo.py +++ b/backend/routers/saxo.py @@ -248,3 +248,19 @@ def saxo_iv_snapshot(symbol: str): def saxo_iv_history(symbol: str, days: int = Query(90, ge=1, le=730)): from services.saxo_iv_engine import get_saxo_iv_history return get_saxo_iv_history(symbol, days) + + +@router.get("/pricing-check") +def saxo_pricing_check( + ticker: str = Query(...), + date_a: str = Query(..., description="YYYY-MM-DD"), + date_b: str = Query(..., description="YYYY-MM-DD"), + target_dte: Optional[int] = Query(None, ge=1, le=365, description="Overrides the default (expiry closest to date_b)"), +): + """Options Lab — was this option well priced between two dates? Picks the strike closest + to the underlying's actual outcome at date_b (hindsight), and by default the expiry + closest to date_b too (same hindsight principle, overridable via target_dte), reprices + it at both dates from real Saxo history, and decomposes the price move into + Delta/Theta/Vega contributions — see services.pricing_check.analyze_option_pricing.""" + from services.pricing_check import analyze_option_pricing + return analyze_option_pricing(ticker, date_a, date_b, target_dte) diff --git a/backend/services/option_chain.py b/backend/services/option_chain.py index 5a8e99a..f17e882 100644 --- a/backend/services/option_chain.py +++ b/backend/services/option_chain.py @@ -13,9 +13,10 @@ from typing import Any, Dict, List, Optional def get_chain_slice( symbol: str, target_days: int = 8, n_expiries: int = 3, dte_min: Optional[int] = None, dte_max: Optional[int] = None, + as_of: Optional[str] = None, ) -> Dict[str, Any]: """ - Builds a chain slice from the latest accumulated Saxo snapshot rows for `symbol` + Builds a chain slice from the accumulated Saxo snapshot rows for `symbol` (services/database.get_latest_saxo_snapshot_rows). Returns the `n_expiries` expirations closest to target_days, each with calls/puts rows shaped {strike, bid, ask, mid, last, iv, open_interest, volume} — same shape regardless @@ -26,19 +27,27 @@ def get_chain_slice( scenario at a short horizon (e.g. target_days=8) while still building legs from longer-dated options (e.g. dte_min=20, dte_max=60), which target_days alone can't express since it drives both the evaluation date and (until now) the expiry pick. - """ - from services.database import get_latest_saxo_snapshot_rows - flat_rows = get_latest_saxo_snapshot_rows(symbol.upper()) + `as_of` (an ISO date/datetime string), when given, reconstructs the chain as it stood + at or before that moment instead of "now" — services.database.get_snapshot_rows_asof, + same row shape, just filtered by created_at. This is what powers the Portfolio + retrospective comparison (services.strategy_comparison): it needs the chain as it + really was on a position's entry_date, not today's. Every days-to-expiry figure is + computed relative to `as_of` in that case, not date.today() — using today's date to + size a historical chain would silently misdate every contract in it. + """ + from services.database import get_latest_saxo_snapshot_rows, get_snapshot_rows_asof + + flat_rows = get_snapshot_rows_asof(symbol.upper(), as_of) if as_of else get_latest_saxo_snapshot_rows(symbol.upper()) if not flat_rows: raise ValueError( - f"Aucun historique Saxo pour '{symbol}' — ajoutez-le à la watchlist " - f"(Config → Saxo) et attendez le prochain cycle de snapshot (~5 min)." + f"Aucun historique Saxo pour '{symbol}'" + (f" à la date {as_of}" if as_of else "") + + " — ajoutez-le à la watchlist (Config → Saxo) et attendez le prochain cycle de snapshot (~5 min)." ) spot = next((r["spot"] for r in flat_rows if r.get("spot") is not None), None) - as_of = max((r["created_at"] for r in flat_rows if r.get("created_at")), default=None) - today = date.today() + snapshot_as_of = max((r["created_at"] for r in flat_rows if r.get("created_at")), default=None) + reference_date = datetime.strptime(as_of[:10], "%Y-%m-%d").date() if as_of else date.today() by_expiry: Dict[str, List[Dict[str, Any]]] = {} for r in flat_rows: @@ -46,7 +55,7 @@ def get_chain_slice( by_expiry.setdefault(r["expiry_date"], []).append(r) def _days_to(expiry_date: str) -> int: - return (datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() - today).days + return (datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() - reference_date).days candidates = list(by_expiry.keys()) if dte_min is not None or dte_max is not None: @@ -98,7 +107,7 @@ def get_chain_slice( "symbol": symbol.upper(), "proxy": symbol.upper(), "spot": round(float(spot), 6) if spot is not None else None, - "as_of": as_of, + "as_of": snapshot_as_of, "expiries": expiries_out, } diff --git a/backend/services/options_pricer.py b/backend/services/options_pricer.py index 1584112..1be70e1 100644 --- a/backend/services/options_pricer.py +++ b/backend/services/options_pricer.py @@ -5,7 +5,10 @@ from datetime import datetime, timedelta import math -def black_scholes(S: float, K: float, T: float, r: float, sigma: float, option_type: str = "call") -> Dict[str, float]: +def black_scholes( + S: float, K: float, T: float, r: float, sigma: float, option_type: str = "call", + include_second_order: bool = True, +) -> Dict[str, float]: """Black-Scholes pricing + Greeks (first-order delta/gamma/theta/vega/rho, plus the second-order Greeks used by Strategy Builder's "advanced sensitivities" panel: vanna, charm, vomma/volga, veta, speed, color, zomma — vera deliberately omitted, see project @@ -16,15 +19,24 @@ def black_scholes(S: float, K: float, T: float, r: float, sigma: float, option_t is verified against finite-difference bumps of this same function's own first-order outputs (see scratchpad test_second_order_greeks.py from the Phase 3 build), not just hand-derived from a textbook, since these third-derivative formulas are easy to get - subtly wrong.""" + subtly wrong. + + `include_second_order=False` skips that block entirely — strategy_engine.value_at() + (the workhorse of check_bounded_risk's ~700-point grid search per candidate, itself + called for every candidate the optimizer scans) only ever reads `["price"]`, so paying + for 7 unused derivatives on every one of those hundreds of thousands of calls was pure + waste discovered while profiling the Phase 4 retrospective-comparison feature — this + flag is what fixed it, not a hypothetical optimization.""" S = float(S or 100.0) K = float(K or S) T = float(T or 0.001) sigma = float(sigma or 0.25) if T <= 0 or sigma <= 0: intrinsic = max(0, S - K) if option_type == "call" else max(0, K - S) - return {"price": intrinsic, "delta": 0, "gamma": 0, "theta": 0, "vega": 0, "rho": 0, - "vanna": 0, "charm": 0, "vomma": 0, "veta": 0, "speed": 0, "color": 0, "zomma": 0} + result = {"price": intrinsic, "delta": 0, "gamma": 0, "theta": 0, "vega": 0, "rho": 0} + if include_second_order: + result.update({"vanna": 0, "charm": 0, "vomma": 0, "veta": 0, "speed": 0, "color": 0, "zomma": 0}) + return result sqrtT = math.sqrt(T) d1 = (math.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * sqrtT) @@ -44,6 +56,17 @@ def black_scholes(S: float, K: float, T: float, r: float, sigma: float, option_t theta = (-(S * phi_d1 * sigma) / (2 * sqrtT) - r * K * math.exp(-r * T) * norm.cdf(d2 if option_type == "call" else -d2)) / 365 vega = S * phi_d1 * sqrtT / 100 + result = { + "price": round(price, 4), + "delta": round(delta, 4), + "gamma": round(gamma, 6), + "theta": round(theta, 4), + "vega": round(vega, 4), + "rho": round(rho, 4), + } + if not include_second_order: + return result + # Second-order — same for calls and puts (this pricer carries no dividend yield, so the # extra q-term that would otherwise make charm/veta/color differ by option_type is zero). vanna = (-phi_d1 * d2 / sigma) / 100 @@ -54,13 +77,7 @@ def black_scholes(S: float, K: float, T: float, r: float, sigma: float, option_t color = (phi_d1 / (2 * S * T * sigma * sqrtT) * (2 * r * T + 1 + d1 * (2 * r * T - d2 * sigma * sqrtT) / (sigma * sqrtT))) / 365 zomma = (gamma * (d1 * d2 - 1) / sigma) / 100 - return { - "price": round(price, 4), - "delta": round(delta, 4), - "gamma": round(gamma, 6), - "theta": round(theta, 4), - "vega": round(vega, 4), - "rho": round(rho, 4), + result.update({ "vanna": round(vanna, 6), "charm": round(charm, 6), "vomma": round(vomma, 6), @@ -68,7 +85,8 @@ def black_scholes(S: float, K: float, T: float, r: float, sigma: float, option_t "speed": round(speed, 8), "color": round(color, 8), "zomma": round(zomma, 6), - } + }) + return result def compute_pnl_curve( diff --git a/backend/services/pricing_check.py b/backend/services/pricing_check.py new file mode 100644 index 0000000..0876d33 --- /dev/null +++ b/backend/services/pricing_check.py @@ -0,0 +1,164 @@ +""" +Options Lab — "was this option well priced between two dates?" A fine-grained pricing audit +for a single contract on an instrument, reusing the same `as_of` historical reconstruction +built for the Portfolio retrospective comparison (services.strategy_comparison / +services.option_chain's as_of param — see project memory). + +The strike is chosen WITH HINDSIGHT: the one closest to where the underlying actually ended +up by `date_b` ("as if we'd guessed the strike correctly"). That's deliberate — a contract +near-the-money-at-the-outcome is the one whose value is most sensitive to the realized move, +which makes it the most revealing lens on whether the volatility priced in at `date_a` was +actually justified by what happened, rather than picking an arbitrary strike that stayed +deep OTM/ITM the whole time and would show almost nothing either way. + +The price move for each leg is decomposed via its own real Greeks at date_a — Delta×Δspot + +Theta×elapsed_days + Vega×ΔIV — the "explained" move; whatever's left over ("residual") is +what a pure Black-Scholes/Greeks story doesn't account for (gamma curvature, skew shift, +liquidity/spread noise, or a genuine pricing anomaly). +""" +from typing import Any, Dict, List, Optional + + +def _realized_vol(yf_ticker: str, date_a: str, date_b: str) -> Optional[float]: + """Annualized realized vol of the underlying's own daily closes over [date_a, date_b] — + compared against the option's implied vol at date_a to answer "was IV a good forecast + of what actually happened," the classic IV-vs-RV question.""" + import numpy as np + from services.data_fetcher import get_historical + + hist = get_historical(yf_ticker, start=date_a, end=date_b, interval="1d") + closes = [h["close"] for h in hist if h.get("close")] + if len(closes) < 3: + return None + log_returns = np.diff(np.log(closes)) + if len(log_returns) < 2: + return None + return float(np.std(log_returns, ddof=1) * np.sqrt(252)) + + +def _leg_quote(rows: List[Dict[str, Any]], expiry_date: str, strike: float, option_type: str) -> Optional[Dict[str, Any]]: + for r in rows: + if r.get("expiry_date") == expiry_date and abs((r.get("strike") or -1e9) - strike) < 1e-6 and r.get("option_type") == option_type: + bid, ask = r.get("bid") or 0.0, r.get("ask") or 0.0 + mid = r.get("mid") or (round((bid + ask) / 2, 6) if (bid > 0 and ask > 0) else 0.0) + vol_pct = r.get("volatility_pct") + return {"bid": bid, "ask": ask, "mid": mid, "iv": (float(vol_pct) / 100.0) if vol_pct is not None else None} + return None + + +def analyze_option_pricing(ticker: str, date_a: str, date_b: str, target_dte: Optional[int] = None) -> Dict[str, Any]: + from datetime import datetime + from services.database import get_saxo_option_symbol_for_ticker, get_snapshot_rows_asof + from services.option_chain import get_chain_slice + from services.options_pricer import black_scholes + + saxo_symbol = get_saxo_option_symbol_for_ticker(ticker) + if not saxo_symbol: + return {"available": False, "reason": f"'{ticker}' n'est pas lié à un chain Saxo (Config → Instruments Watchlist)."} + if date_b <= date_a: + return {"available": False, "reason": "La date de fin doit être postérieure à la date de départ."} + + # Same hindsight principle as the strike selection below: by default, target the expiry + # closest to date_b (not an arbitrary fixed DTE) — so the contract is still evaluated + # right around the moment we actually care about, rather than risking one that's been + # expired for weeks by date_b just because target_dte was picked independently of it. + # An explicit target_dte still overrides this, e.g. to deliberately look at a + # longer-dated contract than the comparison window itself. + if target_dte is None: + target_dte = ( + datetime.strptime(date_b[:10], "%Y-%m-%d").date() - datetime.strptime(date_a[:10], "%Y-%m-%d").date() + ).days + + try: + chain_a = get_chain_slice(saxo_symbol, target_days=target_dte, n_expiries=1, as_of=date_a) + except ValueError as e: + return {"available": False, "reason": f"Pas d'historique Saxo au {date_a} : {e}"} + + expiry = chain_a["expiries"][0] + expiry_date = expiry["expiry_date"] + spot_a = chain_a["spot"] + strikes_a = sorted({row["strike"] for row in expiry["calls"]} | {row["strike"] for row in expiry["puts"]}) + if not strikes_a or spot_a is None: + return {"available": False, "reason": "Aucun strike/spot exploitable dans le chain à cette date."} + + rows_b = get_snapshot_rows_asof(saxo_symbol, date_b) + if not rows_b: + return {"available": False, "reason": f"Pas d'historique Saxo au {date_b}."} + spot_b = next((r["spot"] for r in rows_b if r.get("spot") is not None), None) + if spot_b is None: + return {"available": False, "reason": "Spot manquant dans l'historique Saxo à la date de fin."} + + # "As if we'd guessed the strike" — closest to where the underlying actually ended up. + chosen_strike = min(strikes_a, key=lambda k: abs(k - spot_b)) + + rows_a = get_snapshot_rows_asof(saxo_symbol, date_a) + d_a = datetime.strptime(date_a[:10], "%Y-%m-%d").date() + d_b = datetime.strptime(date_b[:10], "%Y-%m-%d").date() + d_exp = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() + elapsed_days = (d_b - d_a).days + days_to_expiry_a = (d_exp - d_a).days + days_to_expiry_b = (d_exp - d_b).days + expired_by_b = days_to_expiry_b <= 0 + + r = 0.05 + legs_out: Dict[str, Any] = {} + for opt_type in ("call", "put"): + q_a = _leg_quote(rows_a, expiry_date, chosen_strike, opt_type) + if not q_a or q_a["mid"] <= 0 or q_a["iv"] is None: + legs_out[opt_type] = {"available": False} + continue + + greeks_a = black_scholes(spot_a, chosen_strike, max(days_to_expiry_a, 1) / 365, r, q_a["iv"], opt_type) + intrinsic_a = max(0.0, spot_a - chosen_strike) if opt_type == "call" else max(0.0, chosen_strike - spot_a) + time_value_a = q_a["mid"] - intrinsic_a + + if expired_by_b: + price_b = max(0.0, spot_b - chosen_strike) if opt_type == "call" else max(0.0, chosen_strike - spot_b) + iv_b, intrinsic_b, time_value_b = None, price_b, 0.0 + else: + q_b = _leg_quote(rows_b, expiry_date, chosen_strike, opt_type) + if not q_b or q_b["mid"] <= 0: + legs_out[opt_type] = {"available": False} + continue + price_b, iv_b = q_b["mid"], q_b["iv"] + intrinsic_b = max(0.0, spot_b - chosen_strike) if opt_type == "call" else max(0.0, chosen_strike - spot_b) + time_value_b = price_b - intrinsic_b + + actual_change = price_b - q_a["mid"] + delta_pnl = greeks_a["delta"] * (spot_b - spot_a) + theta_pnl = greeks_a["theta"] * elapsed_days + vega_pnl = greeks_a["vega"] * ((iv_b - q_a["iv"]) * 100) if iv_b is not None else 0.0 + explained = delta_pnl + theta_pnl + vega_pnl + + legs_out[opt_type] = { + "available": True, + "price_a": round(q_a["mid"], 4), "price_b": round(price_b, 4), "actual_change": round(actual_change, 4), + "iv_a": round(q_a["iv"], 4), "iv_b": round(iv_b, 4) if iv_b is not None else None, + "intrinsic_a": round(intrinsic_a, 4), "time_value_a": round(time_value_a, 4), + "intrinsic_b": round(intrinsic_b, 4), "time_value_b": round(time_value_b, 4), + "greeks_a": {k: greeks_a[k] for k in ("delta", "gamma", "theta", "vega")}, + "attribution": { + "delta_pnl": round(delta_pnl, 4), "theta_pnl": round(theta_pnl, 4), "vega_pnl": round(vega_pnl, 4), + "explained": round(explained, 4), "residual": round(actual_change - explained, 4), + }, + } + + realized_vol = _realized_vol(ticker, date_a, date_b) + iv_a_ref = next((legs_out[t]["iv_a"] for t in ("call", "put") if legs_out.get(t, {}).get("available")), None) + + return { + "available": True, + "ticker": ticker, "saxo_symbol": saxo_symbol, + "date_a": date_a, "date_b": date_b, "elapsed_days": elapsed_days, + "target_dte_used": target_dte, + "expiry_date": expiry_date, "expired_by_date_b": expired_by_b, + "spot_a": round(spot_a, 6), "spot_b": round(spot_b, 6), + "spot_change_pct": round((spot_b - spot_a) / spot_a * 100, 2) if spot_a else None, + "chosen_strike": chosen_strike, + "iv_a": iv_a_ref, + "realized_vol": round(realized_vol, 4) if realized_vol is not None else None, + "vol_risk_premium": ( + round(iv_a_ref - realized_vol, 4) if (iv_a_ref is not None and realized_vol is not None) else None + ), + "legs": legs_out, + } diff --git a/backend/services/strategy_comparison.py b/backend/services/strategy_comparison.py new file mode 100644 index 0000000..e08bee6 --- /dev/null +++ b/backend/services/strategy_comparison.py @@ -0,0 +1,169 @@ +""" +Retrospective "what would have been optimal" comparison for an existing Portfolio position +— reuses the Strategy Builder optimizer (services.strategy_optimizer.optimize) against the +REAL historical option chain reconstructed as of the position's entry_date +(services.option_chain.get_chain_slice's `as_of` param, backed by +services.database.get_snapshot_rows_asof — the accumulated Saxo snapshot history, not +synthetic data), scored against the REALIZED spot/IV move between entry and the comparison +date rather than a guessed scenario — it's not a forecast, it's what actually happened. + +Results are compared in PERCENTAGE terms (return on capital / return on risk), never raw +dollars: Strategy Builder's own pricer (services.strategy_engine, contract_size configurable, +FX-lot-style default 100_000) and Portfolio's pricer (services.portfolio_pricing, hardcoded +qty*100 equity-option-style) use different contract-size conventions for historical reasons — +comparing their dollar outputs directly would silently misstate the comparison by orders of +magnitude (the exact class of bug this project hit before with the COMEX copper scale issue). +A % return is convention-agnostic since the contract size cancels out of the ratio. +""" +from datetime import date, datetime +from typing import Any, Dict, Optional + + +def _reprice_actual_legs_pct( + legs: list, chain_entry: Dict[str, Any], surface_entry, surface_realized, + horizon_days: int, capital_invested: float, ib_fees_entry: float, +) -> Optional[float]: + """% return of the position's REAL legs, priced at entry (services.portfolio_pricing's + own qty*100 convention — mirrored here, not imported, since that module's functions are + tied to "now" market data, not a historical `as_of` chain) then repriced under the + realized move. Returns None if the position has no legs to price.""" + from services.options_pricer import black_scholes + from services.option_chain import find_quote + + if not legs: + return None + r = 0.05 + S_entry = chain_entry["spot"] + S_realized = surface_realized.spot + T_remaining = max(horizon_days, 0) / 365 + + pnl = -ib_fees_entry + for leg in legs: + K = leg.get("strike") or S_entry + opt_type = leg.get("option_type", "call") + qty = leg.get("quantity", 1) + sign = 1 if leg.get("position", "long") == "long" else -1 + days_to_expiry = leg.get("days_to_expiry", 90) + + entry_premium = leg.get("premium_paid") + if entry_premium is None: + quote = find_quote(chain_entry, leg.get("expiry_date", ""), K, opt_type) + if quote and quote.get("bid", 0) > 0 and quote.get("ask", 0) > 0: + entry_premium = quote["mid"] + else: + sigma_entry = surface_entry.iv_at(K, days_to_expiry) + entry_premium = black_scholes(S_entry, K, max(days_to_expiry, 1) / 365, r, sigma_entry, opt_type)["price"] + + remaining = max(days_to_expiry - horizon_days, 0.001) + sigma_realized = surface_realized.iv_at(K, remaining) + realized_price = ( + black_scholes(S_realized, K, remaining / 365, r, sigma_realized, opt_type)["price"] + if T_remaining > 0 and remaining > 0.001 + else (max(0.0, S_realized - K) if opt_type == "call" else max(0.0, K - S_realized)) + ) + pnl += sign * qty * 100 * (realized_price - entry_premium) + + if not capital_invested: + return None + return round(pnl / capital_invested * 100, 2) + + +def compute_retrospective_comparison(pos: Dict[str, Any], as_of: Optional[str] = None) -> Dict[str, Any]: + from services.database import get_saxo_option_symbol_for_ticker + from services.option_chain import get_chain_slice + from services.vol_surface import build_surface, apply_scenario + from services.strategy_optimizer import optimize as run_optimizer + + underlying = pos["underlying"] + saxo_symbol = get_saxo_option_symbol_for_ticker(underlying) + if not saxo_symbol: + return {"available": False, "reason": f"'{underlying}' n'est pas lié à un chain Saxo (Config → Instruments Watchlist)."} + + entry_date = pos["entry_date"][:10] + as_of_date = (as_of or pos.get("close_date") or date.today().isoformat())[:10] + if as_of_date <= entry_date: + return {"available": False, "reason": "La date de comparaison doit être postérieure à la date d'entrée."} + + horizon_days = ( + datetime.strptime(as_of_date, "%Y-%m-%d").date() - datetime.strptime(entry_date, "%Y-%m-%d").date() + ).days + target_days_entry = pos.get("expiry_days", 90) + + try: + chain_entry = get_chain_slice(saxo_symbol, target_days=target_days_entry, n_expiries=3, as_of=entry_date) + except ValueError as e: + return {"available": False, "reason": f"Pas d'historique Saxo à la date d'entrée ({entry_date}) : {e}"} + try: + chain_realized = get_chain_slice( + saxo_symbol, target_days=max(target_days_entry - horizon_days, 1), n_expiries=3, + as_of=as_of_date if as_of else None, + ) + except ValueError as e: + return {"available": False, "reason": f"Pas d'historique Saxo à la date de comparaison ({as_of_date}) : {e}"} + + surface_entry = build_surface(chain_entry) + surface_realized_base = build_surface(chain_realized) + + spot_entry = chain_entry["spot"] + spot_realized = chain_realized["spot"] + if not spot_entry or not spot_realized: + return {"available": False, "reason": "Spot manquant dans l'historique Saxo à l'une des deux dates."} + + realized_spot_shock_pct = round((spot_realized - spot_entry) / spot_entry * 100, 2) + iv_entry = surface_entry.iv_at(spot_entry, target_days_entry) + iv_realized = surface_realized_base.iv_at(spot_realized, max(target_days_entry - horizon_days, 1)) + realized_iv_shift = round(iv_realized - iv_entry, 4) + + # The same realized shock, applied on top of the ENTRY surface — puts the "actual + # position" and "optimal candidates" repricing on the exact same footing (both start + # from what was really quoted at entry, both move by what really happened afterwards). + surface_realized = apply_scenario(surface_entry, spot_shock_pct=realized_spot_shock_pct, iv_level_shift=realized_iv_shift) + + actual_return_pct = _reprice_actual_legs_pct( + pos.get("legs", []), chain_entry, surface_entry, surface_realized, + horizon_days, pos.get("capital_invested") or 0, pos.get("ib_fees_entry", 0), + ) + + optimal_candidates = run_optimizer( + symbol=saxo_symbol, horizon_days=horizon_days, + spot_shock_pct=realized_spot_shock_pct, iv_level_shift=realized_iv_shift, + skew_tilt=0.0, term_slope_shift=0.0, manual_grid=None, n_expiries=3, rate=0.05, + constraints={"max_legs": 4, "delta_threshold": None, "max_loss_cap": None}, + objective="net_pnl", top_n=5, as_of=entry_date, + ) + + # NOT "return on max_loss": check_bounded_risk's worst-case search is unreliable for + # multi-expiry (calendar/diagonal) structures — the far leg is still alive and vol- + # dependent at the near leg's expiry, so its "max loss" can come out implausibly small + # regardless of precise=True/False, producing a nonsense ratio (verified empirically: + # >9000% "return on risk" on a diagonal in testing). Comparing against the SAME capital + # basis as the actual position (capital_invested) sidesteps that search entirely — "if + # you'd put the same money into this instead" is also a more direct answer to "what + # should I have done" than a max-loss ratio would be. Repriced at contract_size=100 to + # match Portfolio's own per-contract convention (see module docstring) rather than + # Strategy Builder's default FX-lot size, so the dollar P&L this produces is actually on + # the same footing as capital_invested, not just a same-shaped ratio. + from services.strategy_engine import price_combo + capital = pos.get("capital_invested") or 0 + for c in optimal_candidates: + try: + precise = price_combo( + c["legs"], chain_entry, surface_entry, surface_realized, horizon_days, + r=0.05, contract_size=100, precise=True, + ) + c["net_pnl"], c["max_gain"], c["max_loss"] = precise["net_pnl"], precise["max_gain"], precise["max_loss"] + c["net_delta_now"] = precise["net_delta_now"] + c["return_on_capital_pct"] = round(precise["net_pnl"] / capital * 100, 2) if capital else None + except Exception: + c["return_on_capital_pct"] = None + + return { + "available": True, + "underlying": underlying, "saxo_symbol": saxo_symbol, + "entry_date": entry_date, "as_of": as_of_date, "horizon_days": horizon_days, + "spot_entry": round(spot_entry, 6), "spot_realized": round(spot_realized, 6), + "realized_spot_shock_pct": realized_spot_shock_pct, + "iv_entry": round(iv_entry, 4), "iv_realized": round(iv_realized, 4), "realized_iv_shift": realized_iv_shift, + "actual_return_pct": actual_return_pct, + "optimal_candidates": optimal_candidates, + } diff --git a/backend/services/strategy_engine.py b/backend/services/strategy_engine.py index 3b383dc..152c28b 100644 --- a/backend/services/strategy_engine.py +++ b/backend/services/strategy_engine.py @@ -81,7 +81,10 @@ def value_at( r: float, contract_size: float = DEFAULT_CONTRACT_SIZE, ) -> float: - """Signed portfolio value (BS reprice for unexpired legs, intrinsic for expired ones).""" + """Signed portfolio value (BS reprice for unexpired legs, intrinsic for expired ones). + check_bounded_risk calls this ~700 times per candidate it evaluates — second-order + Greeks are never read here, so they're skipped (include_second_order=False) rather than + computed and discarded on every one of those calls.""" total = 0.0 for leg in legs: remaining = leg["days_to_expiry"] - eval_days_from_now @@ -91,7 +94,7 @@ def value_at( price = _intrinsic(S, leg["strike"], leg["option_type"]) else: sigma = surface.iv_at(leg["strike"], remaining) - price = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"])["price"] + price = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"], include_second_order=False)["price"] total += sign * price * qty * contract_size return total diff --git a/backend/services/strategy_optimizer.py b/backend/services/strategy_optimizer.py index 5734253..806ed81 100644 --- a/backend/services/strategy_optimizer.py +++ b/backend/services/strategy_optimizer.py @@ -273,9 +273,10 @@ def optimize( dte_min: Optional[int] = None, dte_max: Optional[int] = None, greek_profile: Optional[Dict[str, Any]] = None, + as_of: Optional[str] = None, ) -> List[Dict[str, Any]]: r = rate + rate_shock_bps / 10000.0 - chain_slice = get_chain_slice(symbol, horizon_days, n_expiries, dte_min=dte_min, dte_max=dte_max) + chain_slice = get_chain_slice(symbol, horizon_days, n_expiries, dte_min=dte_min, dte_max=dte_max, as_of=as_of) surface_now = build_surface(chain_slice) surface_scenario = apply_scenario( surface_now, spot_shock_pct=spot_shock_pct, iv_level_shift=iv_level_shift, diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 62f627b..5162897 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -326,6 +326,31 @@ export const usePositionPayoff = (posId: string, enabled: boolean) => enabled, }) +// "What would have been optimal, in hindsight?" — reprices the position's real legs and +// runs the Strategy Builder optimizer against the REAL historical Saxo chain + the REALIZED +// spot/IV move since entry (not a guessed scenario). Expensive (full optimizer run), so +// on-demand only — not auto-fetched, call refetch() from a button. +export type RetrospectiveCandidate = { + template_name: string; legs: StrategyLeg[]; score: number; return_on_capital_pct: number | null + net_pnl: number; max_gain: number | null; max_loss: number | null; net_delta_now: number +} +export type RetrospectiveComparison = { + available: boolean; reason?: string + underlying?: string; entry_date?: string; as_of?: string; horizon_days?: number + spot_entry?: number; spot_realized?: number; realized_spot_shock_pct?: number + iv_entry?: number; iv_realized?: number; realized_iv_shift?: number + actual_return_pct?: number | null + optimal_candidates?: RetrospectiveCandidate[] +} + +export const useRetrospectiveOptimal = (posId: string, asOf?: string) => + useQuery({ + queryKey: ['portfolio-retrospective-optimal', posId, asOf], + queryFn: () => api.get(`/portfolio/positions/${posId}/retrospective-optimal`, { params: asOf ? { as_of: asOf } : {} }).then(r => r.data), + enabled: false, + staleTime: 5 * 60_000, + }) + // 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. @@ -1048,6 +1073,36 @@ export const useSaxoIvSnapshot = (symbol: string) => staleTime: 5 * 60_000, }) +// Options Lab — "was this option well priced between two dates?" Picks the strike closest +// to the underlying's realized outcome at date_b (hindsight), reprices at both dates from +// real Saxo history, decomposes the move into Delta/Theta/Vega + a residual. +export type PricingCheckLeg = { + available: boolean + price_a?: number; price_b?: number; actual_change?: number + iv_a?: number | null; iv_b?: number | null + intrinsic_a?: number; time_value_a?: number; intrinsic_b?: number; time_value_b?: number + greeks_a?: { delta: number; gamma: number; theta: number; vega: number } + attribution?: { delta_pnl: number; theta_pnl: number; vega_pnl: number; explained: number; residual: number } +} +export type PricingCheckResult = { + available: boolean; reason?: string + ticker?: string; date_a?: string; date_b?: string; elapsed_days?: number; target_dte_used?: number + expiry_date?: string; expired_by_date_b?: boolean + spot_a?: number; spot_b?: number; spot_change_pct?: number; chosen_strike?: number + iv_a?: number | null; realized_vol?: number | null; vol_risk_premium?: number | null + legs?: { call: PricingCheckLeg; put: PricingCheckLeg } +} + +// targetDte omitted/undefined -> backend defaults to the expiry closest to dateB (hindsight, +// same principle as the strike selection) — only pass it to force a different expiry. +export const usePricingCheck = (ticker: string, dateA: string, dateB: string, targetDte: number | undefined, enabled: boolean) => + useQuery({ + queryKey: ['options-pricing-check', ticker, dateA, dateB, targetDte], + queryFn: () => api.get('/saxo/pricing-check', { params: { ticker, date_a: dateA, date_b: dateB, target_dte: targetDte } }).then(r => r.data), + enabled: enabled && !!ticker && !!dateA && !!dateB, + staleTime: 5 * 60_000, + }) + export const useSaxoIvHistory = (symbol: string, days = 90) => useQuery({ queryKey: ['saxo-iv-history', symbol, days], diff --git a/frontend/src/pages/OptionsLab.tsx b/frontend/src/pages/OptionsLab.tsx index 1abbdf9..f300b72 100644 --- a/frontend/src/pages/OptionsLab.tsx +++ b/frontend/src/pages/OptionsLab.tsx @@ -1,9 +1,9 @@ import { useState } from 'react' import { useIvWatchlist, useIvSnapshot, useIvHistory, useWatchlistTickers, useAddWatchlistTicker, useRemoveWatchlistTicker, - useSaxoIvWatchlist, useSaxoIvSnapshot, useSaxoIvHistory, useInstrumentsWatchlist, + useSaxoIvWatchlist, useSaxoIvSnapshot, useSaxoIvHistory, useInstrumentsWatchlist, usePricingCheck, } from '../hooks/useApi' -import { Activity, TrendingUp, TrendingDown, Minus, RefreshCw, ChevronDown, ChevronUp, Database, Plus, Trash2, List, Link2 } from 'lucide-react' +import { Activity, TrendingUp, TrendingDown, Minus, RefreshCw, ChevronDown, ChevronUp, Database, Plus, Trash2, List, Link2, Search } from 'lucide-react' import { api } from '../hooks/useApi' import { useQueryClient } from '@tanstack/react-query' import clsx from 'clsx' @@ -528,6 +528,175 @@ function WatchlistManager() { ) } +// ── Pricing check — "était-ce bien pricé entre 2 dates ?" ─────────────────────── +function fmtSigned(v: number | null | undefined, digits = 2): string { + if (v == null) return '—' + return `${v >= 0 ? '+' : ''}${v.toFixed(digits)}` +} + +function PricingCheckLegCard({ label, leg }: { label: string; leg: any }) { + if (!leg?.available) { + return ( +
+
{label}
+
Pas de cotation exploitable pour cette jambe.
+
+ ) + } + const attr = leg.attribution + return ( +
+
+
{label}
+
= 0 ? 'text-emerald-400' : 'text-red-400')}> + {fmtSigned(leg.actual_change, 4)} +
+
+
+
Prix
+
{leg.price_a?.toFixed(4)} → {leg.price_b?.toFixed(4)}
+
IV
+
+ {leg.iv_a != null ? `${(leg.iv_a * 100).toFixed(1)}%` : '—'} → {leg.iv_b != null ? `${(leg.iv_b * 100).toFixed(1)}%` : 'expiré'} +
+
Intrinsèque
+
{leg.intrinsic_a?.toFixed(2)} → {leg.intrinsic_b?.toFixed(2)}
+
Valeur temps
+
{leg.time_value_a?.toFixed(2)} → {leg.time_value_b?.toFixed(2)}
+
+ {attr && ( +
+
Décomposition (Greeks à la date A)
+
+
Δ spot × Delta{fmtSigned(attr.delta_pnl)}
+
Temps × Theta{fmtSigned(attr.theta_pnl)}
+
Δ IV × Vega{fmtSigned(attr.vega_pnl)}
+
+ Expliqué par les Greeks{fmtSigned(attr.explained)} +
+
+ Résidu (gamma, skew, anomalie…) + Math.abs(leg.actual_change) * 0.3 ? 'text-amber-400' : 'text-slate-200')}> + {fmtSigned(attr.residual)} + +
+
+
+ )} +
+ ) +} + +function PricingCheckPanel() { + const { data: watchlistInstruments } = useInstrumentsWatchlist() + const tickers: any[] = (watchlistInstruments as any[]) ?? [] + const [ticker, setTicker] = useState('') + const [dateA, setDateA] = useState('') + const [dateB, setDateB] = useState('') + const [targetDte, setTargetDte] = useState(undefined) + const [run, setRun] = useState(false) + + const { data, isFetching, refetch } = usePricingCheck(ticker, dateA, dateB, targetDte, run) + + const handleAnalyze = () => { + setRun(true) + refetch() + } + + return ( +
+
+
+ Vérification de pricing historique +
+

+ Choisit, avec le recul, le strike le plus proche de là où le sous-jacent a réellement fini — le contrat le + plus révélateur pour juger si la volatilité était bien pricée à la date de départ. Repricing réel depuis + l'historique Saxo accumulé, décomposé Delta/Theta/Vega. +

+
+
+ + +
+
+ + setDateA(e.target.value)} + className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200" /> +
+
+ + setDateB(e.target.value)} + className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200" /> +
+
+ + setTargetDte(e.target.value === '' ? undefined : parseInt(e.target.value))} + className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200" /> +
+
+ +
+ + {data && !data.available && ( +
{data.reason}
+ )} + + {data?.available && ( + <> +
+
+
Spot
+
{data.spot_a?.toFixed(2)} → {data.spot_b?.toFixed(2)}
+
= 0 ? 'text-emerald-400' : 'text-red-400')}> + {fmtSigned(data.spot_change_pct)}% +
+
+
+
Strike choisi (recul)
+
{data.chosen_strike}
+
+ échéance {data.expiry_date}{data.expired_by_date_b ? ' (expirée)' : ''} · {data.target_dte_used}j visés +
+
+
+
IV (départ) vs Vol réalisée
+
+ {data.iv_a != null ? `${(data.iv_a * 100).toFixed(1)}%` : '—'} / {data.realized_vol != null ? `${(data.realized_vol * 100).toFixed(1)}%` : '—'} +
+
+
+
Prime de risque de vol
+
= 0 ? 'text-emerald-400' : 'text-red-400')}> + {data.vol_risk_premium != null ? `${fmtSigned(data.vol_risk_premium * 100, 1)}pts` : '—'} +
+
{(data.vol_risk_premium ?? 0) >= 0 ? 'IV a surpayé la vol réalisée' : 'IV a sous-payé la vol réalisée'}
+
+
+ +
+ + +
+ + )} +
+ ) +} + // ── Main page ───────────────────────────────────────────────────────────────── export default function OptionsLab() { // yfinance-based IV watchlist — disabled per user request (2026-07-21): Options Lab @@ -562,6 +731,7 @@ export default function OptionsLab() { // // Show bootstrap banner if most items have no meaningful IV Rank (stuck at 50 or null) // const needsBootstrap = items.length > 0 && items.filter(i => i.iv_rank == null || i.iv_rank === 50).length > items.length * 0.6 + const [activeTab, setActiveTab] = useState<'iv-rank' | 'pricing-check'>('iv-rank') const { data: saxoData, isLoading: saxoLoading, refetch: refetchSaxo, isFetching: saxoFetching } = useSaxoIvWatchlist() const { data: watchlistInstruments } = useInstrumentsWatchlist() // Cross-reference the Saxo IV watchlist (keyed by Saxo option symbol, e.g. "MCLU6") @@ -608,8 +778,28 @@ export default function OptionsLab() { Saxo broker data · IV Rank · Term Structure · Skew

+
+ + +
+ {activeTab === 'pricing-check' && } + + {activeTab === 'iv-rank' && ( + <> {/* Légende */}
@@ -729,6 +919,8 @@ export default function OptionsLab() { {/* yfinance IV Watchlist Manager — disabled along with the section above. */} + + )}
) } diff --git a/frontend/src/pages/Portfolio.tsx b/frontend/src/pages/Portfolio.tsx index 930c401..c41cfb5 100644 --- a/frontend/src/pages/Portfolio.tsx +++ b/frontend/src/pages/Portfolio.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { usePortfolioPositions, usePortfolioSummary, usePnlHistory, - useAddPosition, useClosePosition, usePositionPayoff + useAddPosition, useClosePosition, usePositionPayoff, useRetrospectiveOptimal } from '../hooks/useApi' import { useQueryClient, useMutation } from '@tanstack/react-query' import axios from 'axios' @@ -263,6 +263,91 @@ function PositionPayoffChart({ posId, enabled, legs }: { posId: string; enabled: ) } +function RetrospectiveComparisonCard({ posId, enabled }: { posId: string; enabled: boolean }) { + const { data, isFetching, refetch, isFetched } = useRetrospectiveOptimal(posId) + if (!enabled) return null + + return ( +
+
+ + Comparaison rétrospective — qu'aurait-il fallu faire ? + + +
+ + {!isFetched && !isFetching && ( +

+ Reconstruit le chain Saxo réel à la date d'entrée et le compare au mouvement réellement survenu depuis — + pas un scénario deviné. Fait tourner l'optimiseur complet (~1 min), calculé à la demande. +

+ )} + {isFetching && ( +

Recherche parmi les stratégies possibles à la date d'entrée…

+ )} + + {data && !data.available && ( +

{data.reason}

+ )} + + {data?.available && ( +
+
+ Du {data.entry_date} au {data.as_of} ({data.horizon_days}j) — mouvement réel :{' '} + = 0 ? 'text-emerald-400' : 'text-red-400')}> + spot {(data.realized_spot_shock_pct ?? 0) >= 0 ? '+' : ''}{data.realized_spot_shock_pct}% + + {', '} + + IV {(data.realized_iv_shift ?? 0) >= 0 ? '+' : ''}{((data.realized_iv_shift ?? 0) * 100).toFixed(1)}pts + +
+ +
+
+ Votre position : + = 0 ? 'text-emerald-400' : 'text-red-400')}> + {data.actual_return_pct != null ? `${data.actual_return_pct >= 0 ? '+' : ''}${data.actual_return_pct}%` : '—'} + +
+
+ + + + + + + + + + + {(data.optimal_candidates ?? []).map((c, i) => ( + + + + + + ))} + +
Structure optimale (rétrospective)Retour sur même capitalΔ net
{c.template_name} + {c.return_on_capital_pct != null ? `${c.return_on_capital_pct >= 0 ? '+' : ''}${c.return_on_capital_pct}%` : '—'} + {c.net_delta_now.toFixed(3)}
+

+ Comparaison en % du même capital investi que votre position réelle — pas en dollars bruts, les deux + moteurs de pricing (Portfolio et Strategy Builder) n'utilisent pas la même convention de taille de contrat. +

+
+ )} +
+ ) +} + function PositionCard({ pos }: { pos: Record }) { const navigate = useNavigate() const [showClose, setShowClose] = useState(false) @@ -447,6 +532,7 @@ function PositionCard({ pos }: { pos: Record }) {
+ )}