feat: strategy builder
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user