From 67952e01fa75b7f309f478d7e6618fc8043e469f Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sun, 19 Jul 2026 10:55:56 +0200 Subject: [PATCH] feat: strategy builder --- backend/routers/strategy_builder.py | 2 +- backend/services/strategy_engine.py | 47 +++++++++++++++----------- backend/services/strategy_optimizer.py | 3 +- frontend/src/hooks/useApi.ts | 2 +- frontend/src/pages/StrategyBuilder.tsx | 7 ++-- 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/backend/routers/strategy_builder.py b/backend/routers/strategy_builder.py index c5c320f..1c28652 100644 --- a/backend/routers/strategy_builder.py +++ b/backend/routers/strategy_builder.py @@ -44,7 +44,7 @@ class PriceRequest(BaseModel): class ConstraintsIn(BaseModel): max_legs: int = 4 - delta_threshold: float = 0.15 + delta_threshold: Optional[float] = 0.15 max_loss_cap: Optional[float] = None objective: str = "net_pnl" # "net_pnl" | "return_on_risk" | "prob_weighted" top_n: int = 20 diff --git a/backend/services/strategy_engine.py b/backend/services/strategy_engine.py index 4ff0170..dc153c1 100644 --- a/backend/services/strategy_engine.py +++ b/backend/services/strategy_engine.py @@ -197,18 +197,24 @@ def check_bounded_risk( 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 the dense near-strike grid and the 1-D refinement below (bounded- - ness itself is unaffected — it only needs the tail behavior). The optimizer's bulk scan - (hundreds of candidates) uses this fast path since ranking only needs relative ordering; - the single-candidate /price call uses the full precise path. + `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 only to detect whether the payoff flattens out - # (bounded) toward either extreme, or keeps moving further away. + # 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] @@ -220,25 +226,26 @@ def check_bounded_risk( 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) - if not precise: - return { - "bounded": loss_bounded, - "max_loss": round(min(tail_values), 2) if loss_bounded else None, - "max_gain": round(max(tail_values), 2) if gain_bounded else None, - } - - # 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. 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). Add a dense linear sweep across the legs' own - # strikes to locate the right neighborhood... + # 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) diff --git a/backend/services/strategy_optimizer.py b/backend/services/strategy_optimizer.py index e297b06..ad5978b 100644 --- a/backend/services/strategy_optimizer.py +++ b/backend/services/strategy_optimizer.py @@ -33,7 +33,8 @@ def _score(priced: Dict[str, Any], legs: List[Dict[str, Any]], objective: str, s def _passes_constraints(legs: List[Dict[str, Any]], priced: Dict[str, Any], constraints: Dict[str, Any]) -> bool: if len(legs) > constraints["max_legs"]: return False - if abs(priced["net_delta_now"]) > constraints["delta_threshold"]: + delta_threshold = constraints.get("delta_threshold") + if delta_threshold is not None and abs(priced["net_delta_now"]) > delta_threshold: return False if not priced["bounded_risk"]: return False diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index e6ce0e5..451ca1c 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1594,7 +1594,7 @@ export const usePriceStrategy = () => export type OptimizeConstraints = { max_legs: number - delta_threshold: number + delta_threshold: number | null max_loss_cap?: number | null objective: 'net_pnl' | 'return_on_risk' | 'prob_weighted' top_n?: number diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index de5508d..a9df6ed 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -427,10 +427,11 @@ function OptimizerPanel({
- + setConstraints({ ...constraints, delta_threshold: parseFloat(e.target.value) || 0 })} + type="number" step={0.01} min={0} placeholder="illimité" + value={constraints.delta_threshold ?? ''} + onChange={(e) => setConstraints({ ...constraints, delta_threshold: e.target.value === '' ? null : parseFloat(e.target.value) })} className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200" />