170 lines
8.9 KiB
Python
170 lines
8.9 KiB
Python
"""
|
|
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,
|
|
}
|