feat: option lab
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
164
backend/services/pricing_check.py
Normal file
164
backend/services/pricing_check.py
Normal 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,
|
||||
}
|
||||
169
backend/services/strategy_comparison.py
Normal file
169
backend/services/strategy_comparison.py
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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,
|
||||
// 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<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) =>
|
||||
useQuery({
|
||||
queryKey: ['saxo-iv-history', symbol, days],
|
||||
|
||||
@@ -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 (
|
||||
<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 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.
|
||||
</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 ─────────────────────────────────────────────────────────────────
|
||||
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
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{activeTab === 'pricing-check' && <PricingCheckPanel />}
|
||||
|
||||
{activeTab === 'iv-rank' && (
|
||||
<>
|
||||
{/* Légende */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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.
|
||||
<WatchlistManager />
|
||||
*/}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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> }) {
|
||||
const navigate = useNavigate()
|
||||
const [showClose, setShowClose] = useState(false)
|
||||
@@ -447,6 +532,7 @@ function PositionCard({ pos }: { pos: Record<string, any> }) {
|
||||
</div>
|
||||
|
||||
<PositionPayoffChart posId={pos.id} enabled={showDetails} legs={pos.legs} />
|
||||
<RetrospectiveComparisonCard posId={pos.id} enabled={showDetails} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user