feat: strategy builder
This commit is contained in:
@@ -44,7 +44,7 @@ class PriceRequest(BaseModel):
|
|||||||
|
|
||||||
class ConstraintsIn(BaseModel):
|
class ConstraintsIn(BaseModel):
|
||||||
max_legs: int = 4
|
max_legs: int = 4
|
||||||
delta_threshold: float = 0.15
|
delta_threshold: Optional[float] = 0.15
|
||||||
max_loss_cap: Optional[float] = None
|
max_loss_cap: Optional[float] = None
|
||||||
objective: str = "net_pnl" # "net_pnl" | "return_on_risk" | "prob_weighted"
|
objective: str = "net_pnl" # "net_pnl" | "return_on_risk" | "prob_weighted"
|
||||||
top_n: int = 20
|
top_n: int = 20
|
||||||
|
|||||||
@@ -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
|
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.
|
`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-
|
`precise=False` skips only the 1-D refinement below (the expensive part — two
|
||||||
ness itself is unaffected — it only needs the tail behavior). The optimizer's bulk scan
|
minimize_scalar calls). The dense near-strike grid stays even in the fast path: a long
|
||||||
(hundreds of candidates) uses this fast path since ranking only needs relative ordering;
|
straddle's max loss (or a butterfly's max gain) sits exactly AT a strike too, same as a
|
||||||
the single-candidate /price call uses the full precise path.
|
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)
|
eval_days = min(l["days_to_expiry"] for l in legs)
|
||||||
|
|
||||||
def f(s: float) -> float:
|
def f(s: float) -> float:
|
||||||
return value_at(legs, s, eval_days, surface, r, contract_size) - entry_ref
|
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
|
# Wide, log-spaced tail grid — used to detect whether the payoff flattens out (bounded)
|
||||||
# (bounded) toward either extreme, or keeps moving further away.
|
# toward either extreme, or keeps moving further away.
|
||||||
tail_grid = np.geomspace(spot * 0.05, spot * 20, 300)
|
tail_grid = np.geomspace(spot * 0.05, spot * 20, 300)
|
||||||
tail_values = [f(float(p)) for p in tail_grid]
|
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)
|
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)
|
gain_bounded = (lo_edge <= lo_in + tol) and (hi_edge <= hi_in + tol)
|
||||||
|
|
||||||
if not precise:
|
# Dense linear sweep across the legs' own strikes — needed even in the fast path (see
|
||||||
return {
|
# docstring above), only the final 1-D refinement is reserved for precise=True.
|
||||||
"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...
|
|
||||||
strikes = [l["strike"] for l in legs]
|
strikes = [l["strike"] for l in legs]
|
||||||
lo_k, hi_k = min(strikes) * 0.7, max(strikes) * 1.3
|
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_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]
|
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])
|
grid_all = np.concatenate([tail_grid, near_grid])
|
||||||
values_all = np.concatenate([tail_values, near_values])
|
values_all = np.concatenate([tail_values, near_values])
|
||||||
order = np.argsort(grid_all)
|
order = np.argsort(grid_all)
|
||||||
|
|||||||
@@ -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:
|
def _passes_constraints(legs: List[Dict[str, Any]], priced: Dict[str, Any], constraints: Dict[str, Any]) -> bool:
|
||||||
if len(legs) > constraints["max_legs"]:
|
if len(legs) > constraints["max_legs"]:
|
||||||
return False
|
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
|
return False
|
||||||
if not priced["bounded_risk"]:
|
if not priced["bounded_risk"]:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1594,7 +1594,7 @@ export const usePriceStrategy = () =>
|
|||||||
|
|
||||||
export type OptimizeConstraints = {
|
export type OptimizeConstraints = {
|
||||||
max_legs: number
|
max_legs: number
|
||||||
delta_threshold: number
|
delta_threshold: number | null
|
||||||
max_loss_cap?: number | null
|
max_loss_cap?: number | null
|
||||||
objective: 'net_pnl' | 'return_on_risk' | 'prob_weighted'
|
objective: 'net_pnl' | 'return_on_risk' | 'prob_weighted'
|
||||||
top_n?: number
|
top_n?: number
|
||||||
|
|||||||
@@ -427,10 +427,11 @@ function OptimizerPanel({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-slate-400 block mb-1">Seuil neutralité Δ</label>
|
<label className="text-slate-400 block mb-1">Seuil neutralité Δ (optionnel)</label>
|
||||||
<input
|
<input
|
||||||
type="number" step={0.01} min={0} value={constraints.delta_threshold}
|
type="number" step={0.01} min={0} placeholder="illimité"
|
||||||
onChange={(e) => setConstraints({ ...constraints, delta_threshold: parseFloat(e.target.value) || 0 })}
|
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"
|
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user