feat: option lab

This commit is contained in:
OpenSquared
2026-07-28 11:14:31 +02:00
parent 568414ca0c
commit d2c393b8e5
11 changed files with 757 additions and 29 deletions

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Query
import traceback as tb_mod import traceback as tb_mod
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
@@ -179,6 +179,21 @@ def position_payoff(pos_id: str):
return compute_payoff(pos) 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") @router.get("/scenario-exposure")
def scenario_exposure(): def scenario_exposure():
"""Reprices every open position under a handful of named macro scenarios (Risk-Off, """Reprices every open position under a handful of named macro scenarios (Risk-Off,

View File

@@ -248,3 +248,19 @@ def saxo_iv_snapshot(symbol: str):
def saxo_iv_history(symbol: str, days: int = Query(90, ge=1, le=730)): def saxo_iv_history(symbol: str, days: int = Query(90, ge=1, le=730)):
from services.saxo_iv_engine import get_saxo_iv_history from services.saxo_iv_engine import get_saxo_iv_history
return get_saxo_iv_history(symbol, days) 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)

View File

@@ -13,9 +13,10 @@ from typing import Any, Dict, List, Optional
def get_chain_slice( def get_chain_slice(
symbol: str, target_days: int = 8, n_expiries: int = 3, symbol: str, target_days: int = 8, n_expiries: int = 3,
dte_min: Optional[int] = None, dte_max: Optional[int] = None, dte_min: Optional[int] = None, dte_max: Optional[int] = None,
as_of: Optional[str] = None,
) -> Dict[str, Any]: ) -> 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` (services/database.get_latest_saxo_snapshot_rows). Returns the `n_expiries`
expirations closest to target_days, each with calls/puts rows shaped expirations closest to target_days, each with calls/puts rows shaped
{strike, bid, ask, mid, last, iv, open_interest, volume} — same shape regardless {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 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 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. 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: if not flat_rows:
raise ValueError( raise ValueError(
f"Aucun historique Saxo pour '{symbol}' — ajoutez-le à la watchlist " f"Aucun historique Saxo pour '{symbol}'" + (f" à la date {as_of}" if as_of else "") +
f"(Config → Saxo) et attendez le prochain cycle de snapshot (~5 min)." " — 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) 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) snapshot_as_of = max((r["created_at"] for r in flat_rows if r.get("created_at")), default=None)
today = date.today() 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]]] = {} by_expiry: Dict[str, List[Dict[str, Any]]] = {}
for r in flat_rows: for r in flat_rows:
@@ -46,7 +55,7 @@ def get_chain_slice(
by_expiry.setdefault(r["expiry_date"], []).append(r) by_expiry.setdefault(r["expiry_date"], []).append(r)
def _days_to(expiry_date: str) -> int: 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()) candidates = list(by_expiry.keys())
if dte_min is not None or dte_max is not None: if dte_min is not None or dte_max is not None:
@@ -98,7 +107,7 @@ def get_chain_slice(
"symbol": symbol.upper(), "symbol": symbol.upper(),
"proxy": symbol.upper(), "proxy": symbol.upper(),
"spot": round(float(spot), 6) if spot is not None else None, "spot": round(float(spot), 6) if spot is not None else None,
"as_of": as_of, "as_of": snapshot_as_of,
"expiries": expiries_out, "expiries": expiries_out,
} }

View File

@@ -5,7 +5,10 @@ from datetime import datetime, timedelta
import math 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 """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, second-order Greeks used by Strategy Builder's "advanced sensitivities" panel: vanna,
charm, vomma/volga, veta, speed, color, zomma — vera deliberately omitted, see project 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 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 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 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) S = float(S or 100.0)
K = float(K or S) K = float(K or S)
T = float(T or 0.001) T = float(T or 0.001)
sigma = float(sigma or 0.25) sigma = float(sigma or 0.25)
if T <= 0 or sigma <= 0: if T <= 0 or sigma <= 0:
intrinsic = max(0, S - K) if option_type == "call" else max(0, K - S) 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, result = {"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} 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) sqrtT = math.sqrt(T)
d1 = (math.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * sqrtT) 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 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 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 # 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). # extra q-term that would otherwise make charm/veta/color differ by option_type is zero).
vanna = (-phi_d1 * d2 / sigma) / 100 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 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 zomma = (gamma * (d1 * d2 - 1) / sigma) / 100
return { result.update({
"price": round(price, 4),
"delta": round(delta, 4),
"gamma": round(gamma, 6),
"theta": round(theta, 4),
"vega": round(vega, 4),
"rho": round(rho, 4),
"vanna": round(vanna, 6), "vanna": round(vanna, 6),
"charm": round(charm, 6), "charm": round(charm, 6),
"vomma": round(vomma, 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), "speed": round(speed, 8),
"color": round(color, 8), "color": round(color, 8),
"zomma": round(zomma, 6), "zomma": round(zomma, 6),
} })
return result
def compute_pnl_curve( def compute_pnl_curve(

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -81,7 +81,10 @@ def value_at(
r: float, r: float,
contract_size: float = DEFAULT_CONTRACT_SIZE, contract_size: float = DEFAULT_CONTRACT_SIZE,
) -> float: ) -> 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 total = 0.0
for leg in legs: for leg in legs:
remaining = leg["days_to_expiry"] - eval_days_from_now remaining = leg["days_to_expiry"] - eval_days_from_now
@@ -91,7 +94,7 @@ def value_at(
price = _intrinsic(S, leg["strike"], leg["option_type"]) price = _intrinsic(S, leg["strike"], leg["option_type"])
else: else:
sigma = surface.iv_at(leg["strike"], remaining) 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 total += sign * price * qty * contract_size
return total return total

View File

@@ -273,9 +273,10 @@ def optimize(
dte_min: Optional[int] = None, dte_min: Optional[int] = None,
dte_max: Optional[int] = None, dte_max: Optional[int] = None,
greek_profile: Optional[Dict[str, Any]] = None, greek_profile: Optional[Dict[str, Any]] = None,
as_of: Optional[str] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
r = rate + rate_shock_bps / 10000.0 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_now = build_surface(chain_slice)
surface_scenario = apply_scenario( surface_scenario = apply_scenario(
surface_now, spot_shock_pct=spot_shock_pct, iv_level_shift=iv_level_shift, surface_now, spot_shock_pct=spot_shock_pct, iv_level_shift=iv_level_shift,

View File

@@ -326,6 +326,31 @@ export const usePositionPayoff = (posId: string, enabled: boolean) =>
enabled, 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<RetrospectiveComparison>({
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, // 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 // 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. // when several differently-named positions are really the same underlying bet.
@@ -1048,6 +1073,36 @@ export const useSaxoIvSnapshot = (symbol: string) =>
staleTime: 5 * 60_000, 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<PricingCheckResult>({
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) => export const useSaxoIvHistory = (symbol: string, days = 90) =>
useQuery({ useQuery({
queryKey: ['saxo-iv-history', symbol, days], queryKey: ['saxo-iv-history', symbol, days],

View File

@@ -1,9 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { import {
useIvWatchlist, useIvSnapshot, useIvHistory, useWatchlistTickers, useAddWatchlistTicker, useRemoveWatchlistTicker, useIvWatchlist, useIvSnapshot, useIvHistory, useWatchlistTickers, useAddWatchlistTicker, useRemoveWatchlistTicker,
useSaxoIvWatchlist, useSaxoIvSnapshot, useSaxoIvHistory, useInstrumentsWatchlist, useSaxoIvWatchlist, useSaxoIvSnapshot, useSaxoIvHistory, useInstrumentsWatchlist, usePricingCheck,
} from '../hooks/useApi' } 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 { api } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import clsx from 'clsx' 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 (
<div className="card-sm">
<div className="stat-label mb-1">{label}</div>
<div className="text-xs text-slate-600">Pas de cotation exploitable pour cette jambe.</div>
</div>
)
}
const attr = leg.attribution
return (
<div className="card-sm space-y-2">
<div className="flex items-center justify-between">
<div className="stat-label">{label}</div>
<div className={clsx('text-sm font-mono font-bold', leg.actual_change >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{fmtSigned(leg.actual_change, 4)}
</div>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
<div className="text-slate-500">Prix</div>
<div className="text-right font-mono text-slate-300">{leg.price_a?.toFixed(4)} {leg.price_b?.toFixed(4)}</div>
<div className="text-slate-500">IV</div>
<div className="text-right font-mono text-slate-300">
{leg.iv_a != null ? `${(leg.iv_a * 100).toFixed(1)}%` : '—'} {leg.iv_b != null ? `${(leg.iv_b * 100).toFixed(1)}%` : 'expiré'}
</div>
<div className="text-slate-500">Intrinsèque</div>
<div className="text-right font-mono text-slate-300">{leg.intrinsic_a?.toFixed(2)} {leg.intrinsic_b?.toFixed(2)}</div>
<div className="text-slate-500">Valeur temps</div>
<div className="text-right font-mono text-slate-300">{leg.time_value_a?.toFixed(2)} {leg.time_value_b?.toFixed(2)}</div>
</div>
{attr && (
<div className="pt-2 border-t border-slate-700/30">
<div className="text-[10px] text-slate-600 uppercase tracking-wide mb-1">Décomposition (Greeks à la date A)</div>
<div className="space-y-0.5 text-[11px] font-mono">
<div className="flex justify-between"><span className="text-slate-500">Δ spot × Delta</span><span className="text-slate-300">{fmtSigned(attr.delta_pnl)}</span></div>
<div className="flex justify-between"><span className="text-slate-500">Temps × Theta</span><span className="text-slate-300">{fmtSigned(attr.theta_pnl)}</span></div>
<div className="flex justify-between"><span className="text-slate-500">Δ IV × Vega</span><span className="text-slate-300">{fmtSigned(attr.vega_pnl)}</span></div>
<div className="flex justify-between border-t border-slate-700/20 pt-0.5 mt-0.5">
<span className="text-slate-400">Expliqué par les Greeks</span><span className="text-slate-200 font-semibold">{fmtSigned(attr.explained)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Résidu (gamma, skew, anomalie)</span>
<span className={clsx('font-semibold', Math.abs(attr.residual) > Math.abs(leg.actual_change) * 0.3 ? 'text-amber-400' : 'text-slate-200')}>
{fmtSigned(attr.residual)}
</span>
</div>
</div>
</div>
)}
</div>
)
}
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<number | undefined>(undefined)
const [run, setRun] = useState(false)
const { data, isFetching, refetch } = usePricingCheck(ticker, dateA, dateB, targetDte, run)
const handleAnalyze = () => {
setRun(true)
refetch()
}
return (
<div className="space-y-4">
<div className="card">
<div className="text-sm font-bold text-white flex items-center gap-2 mb-1">
<Search className="w-4 h-4 text-blue-400" /> Vérification de pricing historique
</div>
<p className="text-[11px] text-slate-500 mb-3">
Choisit, avec le recul, le strike le plus proche de 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.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<label className="text-slate-400 block mb-1">Instrument</label>
<select value={ticker} onChange={(e) => setTicker(e.target.value)}
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200">
<option value="">—</option>
{tickers.map(t => <option key={t.ticker} value={t.ticker}>{t.name || t.ticker}</option>)}
</select>
</div>
<div>
<label className="text-slate-400 block mb-1">Date A (départ)</label>
<input type="date" value={dateA} onChange={(e) => setDateA(e.target.value)}
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200" />
</div>
<div>
<label className="text-slate-400 block mb-1">Date B (fin)</label>
<input type="date" value={dateB} onChange={(e) => setDateB(e.target.value)}
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200" />
</div>
<div>
<label className="text-slate-400 block mb-1" title="Vide = échéance la plus proche de la date B (même logique que le strike)">
DTE cible (optionnel)
</label>
<input type="number" min={1} max={365} placeholder="auto (date B)" value={targetDte ?? ''}
onChange={(e) => 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" />
</div>
</div>
<button
onClick={handleAnalyze}
disabled={!ticker || !dateA || !dateB || isFetching}
className="mt-3 flex items-center gap-1.5 text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-1.5 rounded font-semibold"
>
<Search className="w-3.5 h-3.5" /> {isFetching ? 'Analyse' : 'Analyser'}
</button>
</div>
{data && !data.available && (
<div className="card border-amber-700/40 bg-amber-900/10 text-xs text-amber-300">{data.reason}</div>
)}
{data?.available && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="card-sm text-center">
<div className="text-[10px] text-slate-500">Spot</div>
<div className="text-sm font-mono text-white">{data.spot_a?.toFixed(2)} → {data.spot_b?.toFixed(2)}</div>
<div className={clsx('text-[11px] font-mono', (data.spot_change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{fmtSigned(data.spot_change_pct)}%
</div>
</div>
<div className="card-sm text-center">
<div className="text-[10px] text-slate-500">Strike choisi (recul)</div>
<div className="text-sm font-mono text-white">{data.chosen_strike}</div>
<div className="text-[10px] text-slate-600">
échéance {data.expiry_date}{data.expired_by_date_b ? ' (expirée)' : ''} · {data.target_dte_used}j visés
</div>
</div>
<div className="card-sm text-center">
<div className="text-[10px] text-slate-500">IV (départ) vs Vol réalisée</div>
<div className="text-sm font-mono text-white">
{data.iv_a != null ? `${(data.iv_a * 100).toFixed(1)}%` : ''} / {data.realized_vol != null ? `${(data.realized_vol * 100).toFixed(1)}%` : ''}
</div>
</div>
<div className="card-sm text-center">
<div className="text-[10px] text-slate-500">Prime de risque de vol</div>
<div className={clsx('text-sm font-mono font-bold', (data.vol_risk_premium ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{data.vol_risk_premium != null ? `${fmtSigned(data.vol_risk_premium * 100, 1)}pts` : ''}
</div>
<div className="text-[9px] text-slate-600">{(data.vol_risk_premium ?? 0) >= 0 ? 'IV a surpayé la vol réalisée' : 'IV a sous-payé la vol réalisée'}</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<PricingCheckLegCard label="Call" leg={data.legs?.call} />
<PricingCheckLegCard label="Put" leg={data.legs?.put} />
</div>
</>
)}
</div>
)
}
// ── Main page ───────────────────────────────────────────────────────────────── // ── Main page ─────────────────────────────────────────────────────────────────
export default function OptionsLab() { export default function OptionsLab() {
// yfinance-based IV watchlist — disabled per user request (2026-07-21): Options Lab // 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) // // 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 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: saxoData, isLoading: saxoLoading, refetch: refetchSaxo, isFetching: saxoFetching } = useSaxoIvWatchlist()
const { data: watchlistInstruments } = useInstrumentsWatchlist() const { data: watchlistInstruments } = useInstrumentsWatchlist()
// Cross-reference the Saxo IV watchlist (keyed by Saxo option symbol, e.g. "MCLU6") // 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 Saxo broker data · IV Rank · Term Structure · Skew
</p> </p>
</div> </div>
<div className="flex gap-1 bg-dark-700 p-1 rounded">
<button onClick={() => setActiveTab('iv-rank')}
className={clsx('px-3 py-1.5 rounded text-xs font-semibold transition-colors', {
'bg-blue-600 text-white': activeTab === 'iv-rank',
'text-slate-400 hover:text-slate-200': activeTab !== 'iv-rank',
})}>
IV Rank
</button>
<button onClick={() => setActiveTab('pricing-check')}
className={clsx('px-3 py-1.5 rounded text-xs font-semibold transition-colors', {
'bg-blue-600 text-white': activeTab === 'pricing-check',
'text-slate-400 hover:text-slate-200': activeTab !== 'pricing-check',
})}>
Vérification de pricing
</button>
</div>
</div> </div>
{activeTab === 'pricing-check' && <PricingCheckPanel />}
{activeTab === 'iv-rank' && (
<>
{/* Légende */} {/* Légende */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="card bg-red-900/10 border-red-700/20 text-xs"> <div className="card bg-red-900/10 border-red-700/20 text-xs">
@@ -729,6 +919,8 @@ export default function OptionsLab() {
{/* yfinance IV Watchlist Manager — disabled along with the section above. {/* yfinance IV Watchlist Manager — disabled along with the section above.
<WatchlistManager /> <WatchlistManager />
*/} */}
</>
)}
</div> </div>
) )
} }

View File

@@ -2,7 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
usePortfolioPositions, usePortfolioSummary, usePnlHistory, usePortfolioPositions, usePortfolioSummary, usePnlHistory,
useAddPosition, useClosePosition, usePositionPayoff useAddPosition, useClosePosition, usePositionPayoff, useRetrospectiveOptimal
} from '../hooks/useApi' } from '../hooks/useApi'
import { useQueryClient, useMutation } from '@tanstack/react-query' import { useQueryClient, useMutation } from '@tanstack/react-query'
import axios from 'axios' 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 (
<div className="mt-3 pt-3 border-t border-slate-700/30">
<div className="flex items-center justify-between mb-1.5">
<span className="text-slate-500 uppercase tracking-wide text-[10px]">
Comparaison rétrospective — qu'aurait-il fallu faire ?
</span>
<button
onClick={() => refetch()}
disabled={isFetching}
className="text-[10px] bg-blue-600/80 hover:bg-blue-500 disabled:opacity-50 text-white px-2 py-1 rounded font-semibold"
>
{isFetching ? 'Calcul… (~1 min)' : isFetched ? 'Recalculer' : 'Calculer'}
</button>
</div>
{!isFetched && !isFetching && (
<p className="text-[11px] text-slate-600">
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.
</p>
)}
{isFetching && (
<p className="text-[11px] text-slate-600">Recherche parmi les stratégies possibles à la date d'entrée…</p>
)}
{data && !data.available && (
<p className="text-[11px] text-amber-400">{data.reason}</p>
)}
{data?.available && (
<div className="space-y-2">
<div className="text-[11px] text-slate-400">
Du {data.entry_date} au {data.as_of} ({data.horizon_days}j) — mouvement réel :{' '}
<span className={clsx('font-mono font-semibold', (data.realized_spot_shock_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
spot {(data.realized_spot_shock_pct ?? 0) >= 0 ? '+' : ''}{data.realized_spot_shock_pct}%
</span>
{', '}
<span className="font-mono font-semibold text-slate-300">
IV {(data.realized_iv_shift ?? 0) >= 0 ? '+' : ''}{((data.realized_iv_shift ?? 0) * 100).toFixed(1)}pts
</span>
</div>
<div className="flex items-center gap-4 text-xs">
<div>
<span className="text-slate-500">Votre position : </span>
<span className={clsx('font-mono font-bold', (data.actual_return_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{data.actual_return_pct != null ? `${data.actual_return_pct >= 0 ? '+' : ''}${data.actual_return_pct}%` : ''}
</span>
</div>
</div>
<table className="w-full text-[11px]">
<thead>
<tr className="text-slate-500 text-left">
<th className="py-1 pr-2">Structure optimale (rétrospective)</th>
<th className="py-1 pr-2 text-right" title="Même capital que votre position réelle">Retour sur même capital</th>
<th className="py-1 text-right">Δ net</th>
</tr>
</thead>
<tbody>
{(data.optimal_candidates ?? []).map((c, i) => (
<tr key={i} className="border-t border-slate-700/20">
<td className="py-1 pr-2 text-slate-300">{c.template_name}</td>
<td className="py-1 pr-2 text-right font-mono text-slate-200">
{c.return_on_capital_pct != null ? `${c.return_on_capital_pct >= 0 ? '+' : ''}${c.return_on_capital_pct}%` : ''}
</td>
<td className="py-1 text-right font-mono text-slate-500">{c.net_delta_now.toFixed(3)}</td>
</tr>
))}
</tbody>
</table>
<p className="text-[10px] text-slate-600 italic">
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.
</p>
</div>
)}
</div>
)
}
function PositionCard({ pos }: { pos: Record<string, any> }) { function PositionCard({ pos }: { pos: Record<string, any> }) {
const navigate = useNavigate() const navigate = useNavigate()
const [showClose, setShowClose] = useState(false) const [showClose, setShowClose] = useState(false)
@@ -447,6 +532,7 @@ function PositionCard({ pos }: { pos: Record<string, any> }) {
</div> </div>
<PositionPayoffChart posId={pos.id} enabled={showDetails} legs={pos.legs} /> <PositionPayoffChart posId={pos.id} enabled={showDetails} legs={pos.legs} />
<RetrospectiveComparisonCard posId={pos.id} enabled={showDetails} />
</div> </div>
)} )}