feat: strategy builder

This commit is contained in:
OpenSquared
2026-07-31 15:00:41 +02:00
parent a94f783d33
commit 3fb61f1690
3 changed files with 79 additions and 204 deletions

View File

@@ -375,29 +375,6 @@ def expected_pnl_scenario(
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 _find_breakevens(
legs: List[Dict[str, Any]], surface: Any, eval_days_expiry: float, r: float,
entry_ref: float, spot: float, contract_size: float, n: int = 300,
@@ -452,13 +429,33 @@ def payoff_heatmap(
price_points = np.unique(np.concatenate([np.linspace(lo, hi, n_prices), np.array(near_breakevens)]))
price_points.sort()
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
]
# Per-cell Greeks (not just P&L) — same net Greeks the tiles above the chart already
# show (per-contract-unit, not scaled by contract_size), just swept across the whole
# price x time grid instead of only "now" vs "scenario" — lets the metric selector show
# how Delta/Gamma/Theta/Vega/Rho actually evolve across the scenario, not just their
# two endpoint values.
rows = []
for d in day_points:
d = float(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)
# 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
# already goes through float() explicitly).
delta_row.append(float(g["delta"]))
gamma_row.append(float(g["gamma"]))
theta_row.append(float(g["theta"]))
vega_row.append(float(g["vega"]))
rho_row.append(float(g["rho"]))
rows.append({
"days_from_now": round(d, 1),
"pnl": pnl_row, "delta": delta_row, "gamma": gamma_row,
"theta": theta_row, "vega": vega_row, "rho": rho_row,
})
return {
"prices": [round(float(p), 4) for p in price_points],
"rows": rows,
@@ -473,52 +470,13 @@ def payoff_curves(
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}
return {"heatmap": heatmap, **priced}