feat: strategy builder
This commit is contained in:
@@ -16,6 +16,7 @@ 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:
|
||||
@@ -77,6 +78,7 @@ def value_at(
|
||||
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)."""
|
||||
total = 0.0
|
||||
@@ -89,7 +91,7 @@ def value_at(
|
||||
else:
|
||||
sigma = surface.iv_at(leg["strike"], remaining)
|
||||
price = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"])["price"]
|
||||
total += sign * price * qty * 100
|
||||
total += sign * price * qty * contract_size
|
||||
return total
|
||||
|
||||
|
||||
@@ -113,6 +115,7 @@ def price_combo(
|
||||
surface_scenario: ScenarioSurface,
|
||||
horizon_days: int,
|
||||
r: float = 0.05,
|
||||
contract_size: float = DEFAULT_CONTRACT_SIZE,
|
||||
) -> Dict[str, Any]:
|
||||
spot_now = chain_slice["spot"]
|
||||
spot_scenario = surface_scenario.spot
|
||||
@@ -123,8 +126,8 @@ def price_combo(
|
||||
ep = entry_price(leg, chain_slice, surface_now, r)
|
||||
sign = _sign(leg)
|
||||
qty = leg.get("quantity", 1)
|
||||
entry_ref += sign * ep["exec_price"] * qty * 100
|
||||
entry_ref_mid += sign * ep["mid"] * qty * 100
|
||||
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.
|
||||
@@ -139,8 +142,8 @@ def price_combo(
|
||||
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 * 100
|
||||
scenario_exec += sign * exec_price * qty * 100
|
||||
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)
|
||||
@@ -153,7 +156,7 @@ def price_combo(
|
||||
# "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)
|
||||
bounded = check_bounded_risk(legs, entry_ref, surface_scenario, spot_now, r, contract_size)
|
||||
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"]
|
||||
|
||||
@@ -174,7 +177,7 @@ def price_combo(
|
||||
})
|
||||
|
||||
|
||||
def check_bounded_risk(legs: List[Dict[str, Any]], entry_ref: float, surface: Any, spot: float, r: float = 0.05) -> Dict[str, Any]:
|
||||
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) -> 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
|
||||
@@ -190,29 +193,45 @@ def check_bounded_risk(legs: List[Dict[str, Any]], entry_ref: float, surface: An
|
||||
`surface` to be priced, so max_gain/max_loss there is only as good as that vol input.
|
||||
"""
|
||||
eval_days = min(l["days_to_expiry"] for l in legs)
|
||||
grid = np.geomspace(spot * 0.05, spot * 20, 300)
|
||||
values = [value_at(legs, float(p), eval_days, surface, r) - entry_ref for p in grid]
|
||||
|
||||
tail_n = max(3, len(values) // 30)
|
||||
# Wide, log-spaced tail grid — used only 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 = [value_at(legs, float(p), eval_days, surface, r, contract_size) - entry_ref 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 = values[0], values[tail_n]
|
||||
hi_edge, hi_in = values[-1], values[-1 - tail_n]
|
||||
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)
|
||||
|
||||
# A calendar spread's (or ratio spread's) real best/worst case is a sharp peak right at
|
||||
# a strike, not out in the tails — over a log-spaced 0.05x-20x sweep the two nearest
|
||||
# samples can straddle right over it, missing the true extremum entirely (confirmed:
|
||||
# for a real calendar spread the tail grid reported max_gain=-0.05 while the payoff at
|
||||
# the strike itself was +0.22 — the grid simply never sampled that point). Add a dense
|
||||
# linear sweep across the legs' own strikes to capture it.
|
||||
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 = [value_at(legs, float(p), eval_days, surface, r, contract_size) - entry_ref for p in near_grid]
|
||||
|
||||
all_values = tail_values + near_values
|
||||
|
||||
return {
|
||||
"bounded": loss_bounded,
|
||||
"max_loss": round(min(values), 2) if loss_bounded else None,
|
||||
"max_gain": round(max(values), 2) if gain_bounded else None,
|
||||
"max_loss": round(min(all_values), 2) if loss_bounded else None,
|
||||
"max_gain": round(max(all_values), 2) if gain_bounded else None,
|
||||
}
|
||||
|
||||
|
||||
def payoff_curve_expiry(legs: List[Dict[str, Any]], surface_now: Surface, spot: float, n: int = 100) -> List[Dict[str, float]]:
|
||||
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)), 2)}
|
||||
{"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
|
||||
]
|
||||
|
||||
@@ -224,6 +243,7 @@ def expected_pnl_scenario(
|
||||
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
|
||||
@@ -237,13 +257,13 @@ def expected_pnl_scenario(
|
||||
T = remaining / 365
|
||||
sd = sigma * math.sqrt(T)
|
||||
if sd <= 1e-6:
|
||||
return value_at(legs, spot_scenario, horizon_days, surface_scenario, r) - entry_ref
|
||||
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) - entry_ref for s in grid])
|
||||
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)
|
||||
@@ -258,9 +278,10 @@ def payoff_curves(
|
||||
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)
|
||||
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
|
||||
@@ -268,11 +289,11 @@ def payoff_curves(
|
||||
eval_days_expiry = min(l["days_to_expiry"] for l in legs)
|
||||
|
||||
at_expiry = [
|
||||
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days_expiry, surface_now, r) - entry_ref), 2)}
|
||||
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days_expiry, surface_now, r, contract_size) - entry_ref), 2)}
|
||||
for p in prices
|
||||
]
|
||||
at_scenario = [
|
||||
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), horizon_days, surface_scenario, r) - entry_ref), 2)}
|
||||
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), horizon_days, surface_scenario, r, contract_size) - entry_ref), 2)}
|
||||
for p in prices
|
||||
]
|
||||
return {"at_expiry": at_expiry, "at_scenario": at_scenario, **priced}
|
||||
|
||||
Reference in New Issue
Block a user