feat: option lab

This commit is contained in:
OpenSquared
2026-07-28 11:14:31 +02:00
parent 568414ca0c
commit d2c393b8e5
11 changed files with 757 additions and 29 deletions

View File

@@ -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(