feat: strategy builder

This commit is contained in:
OpenSquared
2026-08-03 09:36:13 +02:00
parent 7e640a09d0
commit 663e7eaa74
6 changed files with 440 additions and 14 deletions

View File

@@ -7,14 +7,15 @@ A "leg" dict: {expiry_date, days_to_expiry, strike, option_type ("call"/"put"),
position ("long"/"short"), quantity}
"""
import math
from typing import Any, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional
import numpy as np
from scipy.optimize import minimize_scalar
from services.options_pricer import black_scholes
from services.option_chain import find_quote
from services.vol_surface import Surface, ScenarioSurface
from services.vol_surface import Surface, ScenarioSurface, apply_scenario
from services.scenario_path import interpolate_path
DEFAULT_SPREAD_PCT = 0.05 # fallback relative bid/ask spread when no live quote is found
DEFAULT_CONTRACT_SIZE = 100_000 # notional per 1 contract/lot (e.g. a standard FX lot); "quantity" on a leg is the number of these
@@ -408,8 +409,8 @@ def _find_breakevens(
def payoff_heatmap(
legs: List[Dict[str, Any]], surface: Any, eval_days_expiry: float, r: float, spot: float,
entry_ref: float, contract_size: float = DEFAULT_CONTRACT_SIZE, n_prices: int = 17, n_days: int = 7,
legs: List[Dict[str, Any]], surface_at_day: Callable[[float], Any], eval_days_expiry: float, r: float,
spot: float, entry_ref: float, contract_size: float = DEFAULT_CONTRACT_SIZE, n_prices: int = 17, n_days: int = 7,
) -> Dict[str, Any]:
"""Price x days-to-expiry grid of P&L — rows are elapsed-day checkpoints from today down
to expiry (top-to-bottom reading matches watching the position age). Columns are
@@ -418,12 +419,20 @@ def payoff_heatmap(
window left more than half the grid flat at max loss/gain for a near-the-money position,
wasting resolution nowhere near where the P&L actually transitions. The exact expiry
breakeven(s) are pinned in as extra columns (breakeven_prices in the response) instead
of only ever landing near one by luck of the price sampling."""
of only ever landing near one by luck of the price sampling.
`surface_at_day` is a function of elapsed days -> a Surface-like object (.iv_at), called
ONCE per row rather than per cell. When the scenario has no time-path, the caller just
passes a constant `lambda d: surface_scenario` (today's single-shock behavior, unchanged);
with a path, each row gets its own interpolated spot/IV/skew/term shock — e.g. a vol pop
described for day 4 onward actually raises the extrinsic value in that row (and every
later one), not just at whatever single instant the old single-point scenario evaluated."""
strikes = [l["strike"] for l in legs if l["option_type"] != "stock"]
half_width = max(max(abs(spot - k) for k in strikes) * 1.4, spot * 0.03) if strikes else spot * 0.15
lo, hi = max(spot - half_width, spot * 0.01), spot + half_width
breakevens = _find_breakevens(legs, surface, eval_days_expiry, r, entry_ref, spot, contract_size)
surface_at_expiry = surface_at_day(eval_days_expiry)
breakevens = _find_breakevens(legs, surface_at_expiry, eval_days_expiry, r, entry_ref, spot, contract_size)
near_breakevens = sorted((p for p in breakevens if lo <= p <= hi), key=lambda p: abs(p - spot))[:2]
price_points = np.unique(np.concatenate([np.linspace(lo, hi, n_prices), np.array(near_breakevens)]))
@@ -437,11 +446,12 @@ def payoff_heatmap(
rows = []
for d in day_points:
d = float(d)
surf = surface_at_day(d)
pnl_row, delta_row, gamma_row, theta_row, vega_row, rho_row = [], [], [], [], [], []
for p in price_points:
p = float(p)
pnl_row.append(round(float(value_at(legs, p, d, surface, r, contract_size) - entry_ref), 2))
g = greeks_at(legs, p, d, surface, r)
pnl_row.append(round(float(value_at(legs, p, d, surf, r, contract_size) - entry_ref), 2))
g = greeks_at(legs, p, d, surf, r)
# greeks_at's own round() leaves numpy float64 as numpy float64 (round() doesn't
# coerce to native Python) — black_scholes is scipy-backed, and FastAPI's default
# JSON encoder can't serialize a bare numpy scalar (unlike pnl_row above, which
@@ -471,12 +481,35 @@ def payoff_curves(
horizon_days: int,
r: float = 0.05,
contract_size: float = DEFAULT_CONTRACT_SIZE,
spot_path: Optional[List[Dict[str, Any]]] = None,
iv_path: Optional[List[Dict[str, Any]]] = None,
skew_path: Optional[List[Dict[str, Any]]] = None,
term_path: Optional[List[Dict[str, Any]]] = None,
base_spot_shock_pct: float = 0.0,
base_iv_level_shift: float = 0.0,
base_skew_tilt: float = 0.0,
base_term_slope_shift: float = 0.0,
manual_grid: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
spot = chain_slice["spot"]
priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r, contract_size)
entry_ref = priced["entry_cost"]
eval_days_expiry = min(l["days_to_expiry"] for l in legs)
heatmap = payoff_heatmap(legs, surface_scenario, eval_days_expiry, r, spot, entry_ref, contract_size)
if spot_path or iv_path or skew_path or term_path:
def surface_at_day(d: float):
return apply_scenario(
surface_now,
spot_shock_pct=interpolate_path(spot_path, d, base_spot_shock_pct),
iv_level_shift=interpolate_path(iv_path, d, base_iv_level_shift),
skew_tilt=interpolate_path(skew_path, d, base_skew_tilt),
term_slope_shift=interpolate_path(term_path, d, base_term_slope_shift),
manual_grid=manual_grid,
)
else:
def surface_at_day(d: float):
return surface_scenario
heatmap = payoff_heatmap(legs, surface_at_day, eval_days_expiry, r, spot, entry_ref, contract_size)
return {"heatmap": heatmap, **priced}