Files
OpenFin/backend/services/strategy_engine.py
2026-07-31 09:32:33 +02:00

478 lines
24 KiB
Python

"""
Generic N-leg (1-4) option strategy pricer: entry cost with real broker spread,
scenario repricing at a future horizon on a shocked vol surface, payoff curves,
greeks, and bounded-risk / non-directional checks.
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
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
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
def to_native(obj: Any) -> Any:
"""Recursively converts numpy scalars (bool_, int64, float64, ...) to native Python
types. Comparisons/aggregations over numpy-typed inputs (e.g. bid/ask sourced from a
DB row that came back as a numpy type, or scipy's norm.cdf) can leave a stray
numpy.bool_/numpy.float64 buried in a nested result — FastAPI's default JSON encoder
doesn't know those types and fails ('X object is not iterable', then a secondary
'vars() argument must have __dict__ attribute' from its own fallback). Applied once
at the API boundary (price_combo/payoff_curves/optimizer results) rather than chasing
the exact field through the whole pricing pipeline."""
if isinstance(obj, dict):
return {k: to_native(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [to_native(v) for v in obj]
if isinstance(obj, np.generic):
return obj.item()
return obj
def _sign(leg: Dict[str, Any]) -> int:
return 1 if leg.get("position", "long") == "long" else -1
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 _quote_spread_pct(quote: Optional[Dict[str, Any]]) -> float:
if not quote or quote["bid"] <= 0 or quote["ask"] <= 0:
return DEFAULT_SPREAD_PCT
mid = (quote["bid"] + quote["ask"]) / 2
if mid <= 0:
return DEFAULT_SPREAD_PCT
return (quote["ask"] - quote["bid"]) / mid
def entry_price(leg: Dict[str, Any], chain_slice: Dict[str, Any], surface_now: Surface, r: float) -> Dict[str, float]:
"""Real execution price (crossing the spread) + theoretical mid, for one leg today.
A "stock" leg (Covered Call/Protective Put/Collar's underlying position, not an
option) has no strike/vol at all — its price IS the spot, no spread modeled (the
underlying's own spread is typically far tighter than any option on it, and this
tool doesn't have a live quote for it the way find_quote does for options)."""
if leg["option_type"] == "stock":
S = chain_slice["spot"]
return {"exec_price": S, "mid": S, "spread_pct": 0.0}
quote = find_quote(chain_slice, leg["expiry_date"], leg["strike"], leg["option_type"])
T = max(leg["days_to_expiry"], 0.001) / 365
sigma = surface_now.iv_at(leg["strike"], leg["days_to_expiry"])
theo_mid = black_scholes(chain_slice["spot"], leg["strike"], T, r, sigma, leg["option_type"])["price"]
if quote and quote["bid"] > 0 and quote["ask"] > 0:
exec_price = quote["ask"] if leg.get("position", "long") == "long" else quote["bid"]
mid = quote["mid"] or theo_mid
else:
spread = theo_mid * DEFAULT_SPREAD_PCT
exec_price = theo_mid + spread / 2 if leg.get("position", "long") == "long" else max(0.0, theo_mid - spread / 2)
mid = theo_mid
return {"exec_price": exec_price, "mid": mid, "spread_pct": _quote_spread_pct(quote)}
def value_at(
legs: List[Dict[str, Any]],
S: float,
eval_days_from_now: float,
surface: Any,
r: float,
contract_size: float = DEFAULT_CONTRACT_SIZE,
) -> float:
"""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:
qty = leg.get("quantity", 1)
sign = _sign(leg)
if leg["option_type"] == "stock":
price = S # a share/lot of the underlying is worth exactly the spot, always
else:
remaining = leg["days_to_expiry"] - eval_days_from_now
if remaining <= 0:
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"], include_second_order=False)["price"]
total += sign * price * qty * contract_size
return total
def greeks_at(legs: List[Dict[str, Any]], S: float, eval_days_from_now: float, surface: Any, r: float) -> Dict[str, float]:
net = {
"delta": 0.0, "gamma": 0.0, "theta": 0.0, "vega": 0.0, "rho": 0.0,
"vanna": 0.0, "charm": 0.0, "vomma": 0.0, "veta": 0.0, "speed": 0.0, "color": 0.0, "zomma": 0.0,
}
for leg in legs:
qty = leg.get("quantity", 1)
sign = _sign(leg)
if leg["option_type"] == "stock":
# d(spot)/d(spot) = 1, and every other Greek (gamma, theta, vega, rho, the
# second-order ones) is exactly zero for a position in the underlying itself.
net["delta"] += sign * qty
continue
remaining = max(leg["days_to_expiry"] - eval_days_from_now, 0.001)
sigma = surface.iv_at(leg["strike"], remaining)
g = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"])
for k in net:
net[k] += g[k] * qty * sign
return {k: round(v, 6) for k, v in net.items()}
def vanna_simulation(
legs: List[Dict[str, Any]], S: float, eval_days_from_now: float, surface: Any, r: float,
spot_shock_pct: float = -5.0, iv_shock_pts: float = 8.0,
) -> Dict[str, float]:
"""A concrete joint spot+IV shock reprice — "if spot drops 5% and IV jumps 8pts, what
actually happens to my net delta" — rather than a bare "vanna is positive/negative"
label. Uses a real Black-Scholes reprice (not the linear vanna approximation) so it's
accurate for shocks this large, matching the same "show a simulation, not a sign"
principle the payoff diagram already uses elsewhere in Strategy Builder."""
delta_before = greeks_at(legs, S, eval_days_from_now, surface, r)["delta"]
class _ShockedSurface:
def iv_at(self, strike: float, days: float) -> float:
return max(0.01, surface.iv_at(strike, days) + iv_shock_pts / 100.0)
S_shocked = S * (1 + spot_shock_pct / 100.0)
delta_after = greeks_at(legs, S_shocked, eval_days_from_now, _ShockedSurface(), r)["delta"]
return {
"spot_shock_pct": spot_shock_pct,
"iv_shock_pts": iv_shock_pts,
"delta_before": delta_before,
"delta_after": delta_after,
"delta_change": round(delta_after - delta_before, 6),
}
def price_combo(
legs: List[Dict[str, Any]],
chain_slice: Dict[str, Any],
surface_now: Surface,
surface_scenario: ScenarioSurface,
horizon_days: int,
r: float = 0.05,
contract_size: float = DEFAULT_CONTRACT_SIZE,
precise: bool = True,
) -> Dict[str, Any]:
spot_now = chain_slice["spot"]
spot_scenario = surface_scenario.spot
entry_ref = 0.0
entry_ref_mid = 0.0
for leg in legs:
ep = entry_price(leg, chain_slice, surface_now, r)
sign = _sign(leg)
qty = leg.get("quantity", 1)
entry_ref += sign * ep["exec_price"] * qty * contract_size
entry_ref_mid += sign * ep["mid"] * qty * contract_size
# Scenario exit: apply each leg's own bid/ask spread (est. from entry quote) to the
# theoretical scenario value, since we don't have a live quote for the future date.
scenario_mid = 0.0
scenario_exec = 0.0
for leg in legs:
qty = leg.get("quantity", 1)
sign = _sign(leg)
if leg["option_type"] == "stock":
theo = exec_price = spot_scenario # no spread modeled for the underlying itself
else:
remaining = max(leg["days_to_expiry"] - horizon_days, 0.001)
sigma = surface_scenario.iv_at(leg["strike"], remaining)
theo = black_scholes(spot_scenario, leg["strike"], remaining / 365, r, sigma, leg["option_type"])["price"]
quote = find_quote(chain_slice, leg["expiry_date"], leg["strike"], leg["option_type"])
spread_pct = _quote_spread_pct(quote)
exec_price = theo * (1 - spread_pct / 2) if leg.get("position", "long") == "long" else theo * (1 + spread_pct / 2)
scenario_mid += sign * theo * qty * contract_size
scenario_exec += sign * exec_price * qty * contract_size
net_pnl = scenario_exec - entry_ref
broker_cost = (entry_ref - entry_ref_mid) + (scenario_mid - scenario_exec)
# Uses surface_scenario (not surface_now): for a single-expiry combo (condor,
# butterfly, straddle...) all legs are simultaneously intrinsic at eval_days, so the
# surface is never actually consulted there and this changes nothing. For a
# calendar/diagonal spread, the far leg is still alive at the near leg's expiry and
# DOES need a vol assumption to be priced — using surface_now there silently mixed
# "today's vol" into a number sitting next to net_pnl (which uses the scenario's
# shocked vol), producing a max_gain that could be below net_pnl. Pricing both with
# the same scenario vol view keeps them consistent.
bounded = check_bounded_risk(legs, entry_ref, surface_scenario, spot_now, r, contract_size, precise)
delta_now = greeks_at(legs, spot_now, 0, surface_now, r)["delta"]
delta_scenario = greeks_at(legs, spot_scenario, horizon_days, surface_scenario, r)["delta"]
return to_native({
"entry_cost": round(entry_ref, 2),
"entry_cost_mid": round(entry_ref_mid, 2),
"scenario_value": round(scenario_exec, 2),
"scenario_value_mid": round(scenario_mid, 2),
"net_pnl": round(net_pnl, 2),
"broker_spread_cost": round(broker_cost, 2),
"max_gain": bounded["max_gain"],
"max_loss": bounded["max_loss"],
"bounded_risk": bounded["bounded"],
"greeks_now": greeks_at(legs, spot_now, 0, surface_now, r),
"greeks_scenario": greeks_at(legs, spot_scenario, horizon_days, surface_scenario, r),
"net_delta_now": delta_now,
"net_delta_scenario": delta_scenario,
# Skipped during the optimizer's bulk scan (precise=False, hundreds of candidates
# per request) — only computed for the single position actually loaded/priced.
"vanna_simulation": vanna_simulation(legs, spot_now, 0, surface_now, r) if precise else None,
})
def check_bounded_risk(
legs: List[Dict[str, Any]], entry_ref: float, surface: Any, spot: float, r: float = 0.05,
contract_size: float = DEFAULT_CONTRACT_SIZE, precise: bool = True,
) -> Dict[str, Any]:
"""
Scan a wide log-spaced spot range at expiry and inspect both tails independently for LOSS
vs GAIN direction. "Bounded risk" only requires the loss side to be capped — a long
straddle (loss capped at the premium, gain uncapped) is a textbook risk-bounded,
non-directional trade and must not be excluded just because its gain is open-ended.
A tail is loss-bounded if moving further to that extreme does not make the P&L any worse
than a point already well into that tail; symmetrically for gain-bounded.
"At expiry" means the earliest expiry among the legs. For a single-expiry combo that's
every leg simultaneously (pure intrinsic, no vol assumption involved at all). For a
calendar/diagonal spread it's only the near leg — the far leg is still alive and needs
`surface` to be priced, so max_gain/max_loss there is only as good as that vol input.
`precise=False` skips only the 1-D refinement below (the expensive part — two
minimize_scalar calls). The dense near-strike grid stays even in the fast path: a long
straddle's max loss (or a butterfly's max gain) sits exactly AT a strike too, same as a
calendar spread's peak, and the wide log-spaced tail grid alone is coarse enough near
the money to miss it by an order of magnitude, not just a rounding difference (confirmed:
on a real long straddle, tail-grid-only reported max_loss=-117 when the true V-bottom at
the strike was -1140 — nearly 10x off, and wrong enough to silently let a candidate past
a max_loss_cap filter it should have failed). The optimizer's bulk scan (hundreds of
candidates) uses this fast path for speed; the single-candidate /price call refines
further with minimize_scalar for pixel-perfect precision.
"""
eval_days = min(l["days_to_expiry"] for l in legs)
def f(s: float) -> float:
return value_at(legs, s, eval_days, surface, r, contract_size) - entry_ref
# Wide, log-spaced tail grid — used to detect whether the payoff flattens out (bounded)
# toward either extreme, or keeps moving further away.
tail_grid = np.geomspace(spot * 0.05, spot * 20, 300)
tail_values = [f(float(p)) for p in tail_grid]
tail_n = max(3, len(tail_values) // 30)
tol = max(abs(entry_ref), 1.0) * 0.01
lo_edge, lo_in = tail_values[0], tail_values[tail_n]
hi_edge, hi_in = tail_values[-1], tail_values[-1 - tail_n]
loss_bounded = (lo_edge >= lo_in - tol) and (hi_edge >= hi_in - tol)
gain_bounded = (lo_edge <= lo_in + tol) and (hi_edge <= hi_in + tol)
# Dense linear sweep across the legs' own strikes — needed even in the fast path (see
# docstring above), only the final 1-D refinement is reserved for precise=True.
strikes = [l["strike"] for l in legs]
lo_k, hi_k = min(strikes) * 0.7, max(strikes) * 1.3
near_grid = np.linspace(max(lo_k, spot * 0.05), min(hi_k, spot * 20), 400)
near_values = [f(float(p)) for p in near_grid]
combined_values = tail_values + near_values
if not precise:
return {
"bounded": loss_bounded,
"max_loss": round(min(combined_values), 2) if loss_bounded else None,
"max_gain": round(max(combined_values), 2) if gain_bounded else None,
}
# Any FIXED grid — however dense — is a different finite sampling of the same
# continuous curve than whatever grid a chart or another caller uses, so two "close but
# not identical" readings of the same peak are pretty much guaranteed (confirmed: this
# grid vs the payoff chart's own grid gave two different peak heights for the same
# trade). Refine with a bounded 1-D optimizer around the grid's own best point instead.
grid_all = np.concatenate([tail_grid, near_grid])
values_all = np.concatenate([tail_values, near_values])
order = np.argsort(grid_all)
grid_sorted, values_sorted = grid_all[order], values_all[order]
# ...then refine with a bounded 1-D optimizer in a NARROW bracket around that grid
# point (the payoff at a fixed date/vol is smooth in spot, built from Black-Scholes).
# A wide bracket is actively harmful here: tested directly on a real calendar spread,
# minimize_scalar given a wide bound (±50% of the strike) converged on a flat false
# optimum far from the true spike (-65 instead of +1170) because Brent's method isn't
# guaranteed to find the global optimum over a non-unimodal interval. Anchoring the
# bracket tightly around the grid's own best point keeps the search unimodal, and
# taking max()/min() against the grid value means refinement can never do worse.
def refine(is_max: bool) -> float:
idx = int(np.argmax(values_sorted)) if is_max else int(np.argmin(values_sorted))
grid_value = float(values_sorted[idx])
step = grid_sorted[min(idx + 1, len(grid_sorted) - 1)] - grid_sorted[max(idx - 1, 0)]
step = float(step) if step > 0 else spot * 0.001
lo_b, hi_b = grid_sorted[idx] - 3 * step, grid_sorted[idx] + 3 * step
if lo_b >= hi_b:
return grid_value
res = minimize_scalar((lambda s: -f(s)) if is_max else f, bounds=(float(lo_b), float(hi_b)), method="bounded")
refined = -res.fun if is_max else res.fun
return max(refined, grid_value) if is_max else min(refined, grid_value)
return {
"bounded": loss_bounded,
"max_loss": round(refine(False), 2) if loss_bounded else None,
"max_gain": round(refine(True), 2) if gain_bounded else None,
}
def payoff_curve_expiry(legs: List[Dict[str, Any]], surface_now: Surface, spot: float, n: int = 100, contract_size: float = DEFAULT_CONTRACT_SIZE) -> List[Dict[str, float]]:
eval_days = min(l["days_to_expiry"] for l in legs)
prices = np.linspace(spot * 0.5, spot * 1.5, n)
return [
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days, surface_now, 0.05, contract_size)), 2)}
for p in prices
]
def expected_pnl_scenario(
legs: List[Dict[str, Any]],
surface_scenario: ScenarioSurface,
horizon_days: int,
r: float,
entry_ref: float,
n: int = 200,
contract_size: float = DEFAULT_CONTRACT_SIZE,
) -> float:
"""
Probability-weighted expected P&L at the scenario date: integrates the payoff over a
risk-neutral lognormal density for the underlying, centered on the scenario spot with a
variance driven by the scenario surface's ATM IV over the residual time to the nearest
leg's expiry (the remaining uncertainty once the scenario date is reached).
"""
spot_scenario = surface_scenario.spot
remaining = max(min(l["days_to_expiry"] for l in legs) - horizon_days, 1)
sigma = surface_scenario.iv_at(spot_scenario, remaining)
T = remaining / 365
sd = sigma * math.sqrt(T)
if sd <= 1e-6:
return value_at(legs, spot_scenario, horizon_days, surface_scenario, r, contract_size) - entry_ref
mu = math.log(spot_scenario) + (r - 0.5 * sigma ** 2) * T
grid = np.geomspace(spot_scenario * 0.15, spot_scenario * 4, n)
log_grid = np.log(grid)
density = np.exp(-0.5 * ((log_grid - mu) / sd) ** 2) / (grid * sd * math.sqrt(2 * math.pi))
pnl = np.array([value_at(legs, float(s), horizon_days, surface_scenario, r, contract_size) - entry_ref for s in grid])
numerator = np.trapz(pnl * density, grid)
denominator = np.trapz(density, grid)
return float(numerator / denominator) if denominator > 1e-12 else float(pnl.mean())
def time_decay_slices(
legs: List[Dict[str, Any]], prices: np.ndarray, surface: Any, eval_days_expiry: float, r: float,
entry_ref: float, contract_size: float = DEFAULT_CONTRACT_SIZE, n_slices: int = 4,
) -> List[Dict[str, Any]]:
"""Payoff curve at n_slices evenly-spaced elapsed-day checkpoints between today (0) and
the nearest leg's expiry — the "T+0/T+10/T+20..." view that shows how the curve morphs
from today's time-value-laden shape into the kinked expiry payoff, instead of only the
two endpoints at_expiry/at_scenario give. Uses the same scenario vol view as those two
curves (see payoff_curves' own comment) so the only thing that varies between slices is
time decay, not the vol assumption."""
day_points = np.linspace(0, eval_days_expiry, n_slices)
slices = []
for d in day_points:
d = float(d)
label = "Aujourd'hui" if d < 0.5 else ("Échéance" if d >= eval_days_expiry - 0.5 else f"J+{round(d)}")
points = [
{"underlying": round(float(p), 4), "pnl": round(float(value_at(legs, float(p), d, surface, r, contract_size) - entry_ref), 2)}
for p in prices
]
slices.append({"days_from_now": round(d, 1), "label": label, "points": points})
return slices
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 = 9, 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
underlying prices zoomed closer to spot than the line chart (a heatmap only reads well
over the range where the color actually varies)."""
lo, hi = spot * 0.85, spot * 1.15
price_points = np.linspace(lo, hi, n_prices)
day_points = np.linspace(0, eval_days_expiry, n_days)
rows = [
{
"days_from_now": round(float(d), 1),
"pnl": [round(float(value_at(legs, float(p), float(d), surface, r, contract_size) - entry_ref), 2) for p in price_points],
}
for d in day_points
]
return {"prices": [round(float(p), 4) for p in price_points], "rows": rows}
def payoff_curves(
legs: List[Dict[str, Any]],
chain_slice: Dict[str, Any],
surface_now: Surface,
surface_scenario: ScenarioSurface,
horizon_days: int,
r: float = 0.05,
n: int = 100,
contract_size: float = DEFAULT_CONTRACT_SIZE,
) -> 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"]
lo, hi = spot * 0.6, spot * 1.4
# A calendar/ratio spread's payoff can spike sharply right at a strike — a plain
# uniform sweep over the full 0.6x-1.4x range (n points) can straddle right over that
# peak without ever sampling it (same issue fixed in check_bounded_risk). Blend a
# coarse baseline (overall shape) with a dense window around the legs' own strikes.
# The exact spot/scenario spot are forced in as sample points too — otherwise hovering
# right on the "Spot"/"Scénario" reference line reads the nearest grid point, which can
# sit meaningfully off the true value on a peak this steep, and disagree with the
# entry_cost/net_pnl tiles (computed at the exact spot, not a grid sample).
baseline = np.linspace(lo, hi, n)
strikes = [l["strike"] for l in legs]
lo_k, hi_k = max(min(strikes) * 0.9, lo), min(max(strikes) * 1.1, hi)
near_strikes = np.linspace(lo_k, hi_k, n * 3)
exact_points = np.array([spot, surface_scenario.spot] + strikes)
prices = np.unique(np.concatenate([baseline, near_strikes, exact_points]))
prices.sort()
eval_days_expiry = min(l["days_to_expiry"] for l in legs)
# Both curves share the scenario's volatility view (surface_scenario) — only the
# evaluation DATE differs: "à échéance" prices the day the near leg expires (matching
# the Max gain/Max perte tile, itself computed with surface_scenario for the same
# reason — see check_bounded_risk), "à J+8" prices your chosen scenario horizon. Using
# surface_now here instead would silently mix in today's un-shocked vol, producing a
# curve whose peak doesn't match the Max gain tile right next to it.
at_expiry = [
{"underlying": round(float(p), 4), "pnl": round(float(value_at(legs, float(p), eval_days_expiry, surface_scenario, r, contract_size) - entry_ref), 2)}
for p in prices
]
at_scenario = [
{"underlying": round(float(p), 4), "pnl": round(float(value_at(legs, float(p), horizon_days, surface_scenario, r, contract_size) - entry_ref), 2)}
for p in prices
]
# Coarser price grid than the two headline curves above — this trades some precision
# for keeping a single /price request's added cost bounded (n_slices/heatmap cells x
# their own price points, on top of the ~1400 value_at calls at_expiry/at_scenario
# above already need).
slice_prices = np.linspace(lo, hi, 80)
time_slices = time_decay_slices(legs, slice_prices, surface_scenario, eval_days_expiry, r, entry_ref, contract_size)
heatmap = payoff_heatmap(legs, surface_scenario, eval_days_expiry, r, spot, entry_ref, contract_size)
return {"at_expiry": at_expiry, "at_scenario": at_scenario, "time_slices": time_slices, "heatmap": heatmap, **priced}