185 lines
9.6 KiB
Python
185 lines
9.6 KiB
Python
"""
|
|
Saxo-first pricing for Portfolio positions — options legs are priced off this Cockpit's own
|
|
accumulated Saxo option-chain history (services.option_chain, services.vol_surface) whenever
|
|
the position's underlying has a saxo_option_symbol link in the Watchlist (Config ->
|
|
Instruments Watchlist -> "Option"), the SAME real market data Options Lab and Strategy
|
|
Builder already use. Mirrors services.strategy_engine.entry_price()'s own two-tier pattern:
|
|
an exact Saxo bid/ask quote for the listed contract if one happens to exist ("saxo_quote"),
|
|
else the real Saxo-fitted vol smile (services.vol_surface.Surface) priced through
|
|
Black-Scholes ("saxo_surface") — both grounded in real Saxo data, unlike the previous
|
|
unconditional fallback to yfinance's historical realized vol as a stand-in for implied vol
|
|
("yfinance_bs"), which is what silently produced a materially different premium than Saxo's
|
|
real chain (27.2% yfinance-historical vs Saxo's real ~32% chain IV on the ^NDX example that
|
|
prompted this).
|
|
|
|
An exact "saxo_quote" match is rare in practice: positions carry a nominal expiry_date/
|
|
expiry_days the AI or user chose freely, not necessarily a real listed Saxo expiry — so
|
|
most legs land on "saxo_surface" (real Saxo-implied vol, interpolated to the requested
|
|
strike/tenor) rather than a literal listed-contract quote. That's still a real improvement
|
|
over yfinance historical vol, and every priced leg carries its `source` so the Portfolio UI
|
|
can say plainly which basis was used instead of always labeling everything "Black-Scholes"
|
|
regardless of where the inputs actually came from.
|
|
"""
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
from services.options_pricer import black_scholes
|
|
|
|
|
|
def resolve_saxo_chain(
|
|
underlying: str, target_days: int, sanity_reference: Optional[float] = None,
|
|
) -> Tuple[Optional[Dict[str, Any]], Optional[Any]]:
|
|
"""Returns (chain_slice, Surface) for this underlying's linked Saxo option chain, or
|
|
(None, None) if it isn't linked, or the chain can't be built right now (Saxo down, no
|
|
snapshot yet, entitlement gap, etc.) — callers fall back to yfinance pricing in that case.
|
|
|
|
`sanity_reference` (typically the position's entry_underlying_price, or a fresh
|
|
yfinance quote at creation time) guards against a chain whose "spot" is on a different
|
|
scale than the rest of the app expects. Saxo's option-chain snapshot has no dedicated
|
|
underlying-quote field (see saxo_client.snapshot_options_chain's MidStrikePrice-as-spot
|
|
comment), so for at least one real instrument (COMEX copper, HG=F) it has been observed
|
|
coming back ~100x too large — silently corrupting every downstream Black-Scholes reprice
|
|
for that position (current spot showing $649 against a real ~$6.3/lb). A >5x or <0.2x
|
|
deviation from a known-good reference is never a real intraday/short-term move for the
|
|
instruments this app trades, so it's treated as a mis-scaled/wrong quote rather than a
|
|
genuine price — safer to fall back to yfinance than to trust an unverifiable number."""
|
|
from services.database import get_saxo_option_symbol_for_ticker
|
|
saxo_symbol = get_saxo_option_symbol_for_ticker(underlying)
|
|
if not saxo_symbol:
|
|
return None, None
|
|
try:
|
|
from services.option_chain import get_chain_slice
|
|
from services.vol_surface import Surface
|
|
chain = get_chain_slice(saxo_symbol, target_days=max(target_days, 1))
|
|
spot = chain.get("spot")
|
|
if not spot:
|
|
return None, None
|
|
if sanity_reference and sanity_reference > 0:
|
|
ratio = spot / sanity_reference
|
|
if ratio > 5 or ratio < 0.2:
|
|
import logging
|
|
logging.getLogger(__name__).warning(
|
|
f"[portfolio_pricing] Saxo chain spot for '{underlying}' ({spot}) is "
|
|
f"{ratio:.1f}x the reference ({sanity_reference}) — treating as a "
|
|
f"mis-scaled/unusable quote, falling back to yfinance."
|
|
)
|
|
return None, None
|
|
surface = Surface(spot, chain["expiries"])
|
|
return chain, surface
|
|
except Exception:
|
|
return None, None
|
|
|
|
|
|
def price_leg(
|
|
strike: float, option_type: str, days_to_expiry: float, r: float,
|
|
chain: Optional[Dict[str, Any]], surface: Optional[Any], expiry_date: Optional[str],
|
|
fallback_spot: float, fallback_sigma: float,
|
|
) -> Dict[str, Any]:
|
|
"""One leg's {price, spot, sigma, source}."""
|
|
T = max(days_to_expiry, 1) / 365
|
|
if chain is not None and surface is not None:
|
|
quote = None
|
|
if expiry_date:
|
|
from services.option_chain import find_quote
|
|
quote = find_quote(chain, expiry_date, strike, option_type)
|
|
if quote and quote.get("bid", 0) > 0 and quote.get("ask", 0) > 0:
|
|
return {"price": quote["mid"], "spot": chain["spot"], "sigma": quote["iv"], "source": "saxo_quote"}
|
|
sigma = surface.iv_at(strike, max(days_to_expiry, 1))
|
|
price = black_scholes(chain["spot"], strike, T, r, sigma, option_type)["price"]
|
|
return {"price": price, "spot": chain["spot"], "sigma": sigma, "source": "saxo_surface"}
|
|
price = black_scholes(fallback_spot, strike, T, r, fallback_sigma, option_type)["price"]
|
|
return {"price": price, "spot": fallback_spot, "sigma": fallback_sigma, "source": "yfinance_bs"}
|
|
|
|
|
|
SOURCE_LABELS = {
|
|
"saxo_quote": "Cotation Saxo réelle",
|
|
"saxo_surface": "Surface de vol Saxo (réelle)",
|
|
"yfinance_bs": "Black-Scholes (vol historique yfinance)",
|
|
}
|
|
|
|
|
|
def _intrinsic(S: float, K: float, option_type: str) -> float:
|
|
return max(0.0, S - K) if option_type == "call" else max(0.0, K - S)
|
|
|
|
|
|
def compute_payoff(pos: Dict[str, Any], n_points: int = 61, range_pct: float = 0.25) -> Dict[str, Any]:
|
|
"""P&L vs. underlying price across a ±range_pct band around the current spot — two
|
|
curves: "at_expiry" (pure intrinsic value, no vol at all — the textbook payoff diagram)
|
|
and "today" (Black-Scholes reprice at each hypothetical spot, holding each leg's
|
|
CURRENT implied vol fixed — from the real Saxo surface when linked, so the time-value
|
|
bulge/skew asymmetry actually reflects Saxo's real market vol instead of a flat
|
|
textbook number). Both curves net out entry cost and entry fees, so y=0 is genuine
|
|
breakeven, matching what the Position card's PnL already shows at the current spot."""
|
|
from datetime import date, datetime
|
|
from services.data_fetcher import get_quote
|
|
|
|
underlying = pos["underlying"]
|
|
legs = pos.get("legs", [])
|
|
if not legs:
|
|
return {"spot_range": [], "at_expiry": [], "today": [], "current_spot": None,
|
|
"entry_spot": pos.get("entry_underlying_price"), "strikes": [], "pricing_source": None}
|
|
|
|
expiry_date = pos.get("expiry_date") or ""
|
|
if expiry_date:
|
|
try:
|
|
exp = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date()
|
|
days_remaining = max(0, (exp - date.today()).days)
|
|
except ValueError:
|
|
days_remaining = 0
|
|
else:
|
|
entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date()
|
|
days_remaining = max(0, pos.get("expiry_days", 90) - (date.today() - entry).days)
|
|
|
|
r = 0.05
|
|
chain, surface = resolve_saxo_chain(underlying, target_days=max(days_remaining, 1),
|
|
sanity_reference=pos.get("entry_underlying_price"))
|
|
fallback_spot = pos.get("entry_underlying_price") or 100.0
|
|
fallback_sigma = 0.20
|
|
if chain is None:
|
|
q = get_quote(underlying)
|
|
fallback_spot = (q.get("price") if q else None) or fallback_spot
|
|
from services.data_fetcher import compute_historical_iv
|
|
fallback_sigma = compute_historical_iv(underlying)
|
|
S = chain["spot"] if chain else fallback_spot
|
|
|
|
resolved_legs = []
|
|
for leg in legs:
|
|
K = leg.get("strike") or S
|
|
opt_type = leg.get("option_type", "call")
|
|
entry_premium = leg.get("premium_paid")
|
|
if entry_premium is None:
|
|
entry_premium = price_leg(K, opt_type, pos.get("expiry_days", 90), r, chain, surface,
|
|
expiry_date, fallback_spot, fallback_sigma)["price"]
|
|
priced_now = price_leg(K, opt_type, days_remaining, r, chain, surface, expiry_date, fallback_spot, fallback_sigma)
|
|
resolved_legs.append({
|
|
"strike": K, "option_type": opt_type, "qty": leg.get("quantity", 1),
|
|
"sign": 1 if leg.get("position", "long") == "long" else -1,
|
|
"entry_premium": entry_premium, "sigma": priced_now["sigma"],
|
|
})
|
|
|
|
ib_entry = pos.get("ib_fees_entry", 0)
|
|
lo, hi = S * (1 - range_pct), S * (1 + range_pct)
|
|
spot_range = [lo + (hi - lo) * i / (n_points - 1) for i in range(n_points)]
|
|
T_remaining = days_remaining / 365
|
|
|
|
at_expiry, today = [], []
|
|
for Sx in spot_range:
|
|
pnl_exp = -ib_entry
|
|
pnl_today = -ib_entry
|
|
for leg in resolved_legs:
|
|
pnl_exp += leg["sign"] * leg["qty"] * 100 * (_intrinsic(Sx, leg["strike"], leg["option_type"]) - leg["entry_premium"])
|
|
bs_price = (black_scholes(Sx, leg["strike"], T_remaining, r, leg["sigma"], leg["option_type"])["price"]
|
|
if T_remaining > 0 else _intrinsic(Sx, leg["strike"], leg["option_type"]))
|
|
pnl_today += leg["sign"] * leg["qty"] * 100 * (bs_price - leg["entry_premium"])
|
|
at_expiry.append(round(pnl_exp, 2))
|
|
today.append(round(pnl_today, 2))
|
|
|
|
return {
|
|
"spot_range": [round(s, 4) for s in spot_range],
|
|
"at_expiry": at_expiry,
|
|
"today": today,
|
|
"current_spot": round(S, 4),
|
|
"entry_spot": pos.get("entry_underlying_price"),
|
|
"strikes": sorted({leg["strike"] for leg in resolved_legs}),
|
|
"pricing_source": "saxo" if chain else "yfinance_bs",
|
|
}
|