feat: strategy builder

This commit is contained in:
OpenSquared
2026-07-30 13:28:03 +02:00
parent 1c4d8013c4
commit 81165581d7
8 changed files with 361 additions and 69 deletions

View File

@@ -56,7 +56,10 @@ def _settle_leg(leg: BacktestLeg, strike: float, days_to_expiry: int, near_days:
"""Value one leg at the near expiry: intrinsic if it expires there too (the common
case), else a fresh Black-Scholes price for its remaining time (a 'far' leg — closed
alongside the near leg rather than held to its own later expiry, the standard way
calendar/diagonal-style structures are actually managed)."""
calendar/diagonal-style structures are actually managed). A 'stock' leg (Covered
Call/Protective Put/Collar's underlying position) is worth exactly the spot, always."""
if leg.option_type == "stock":
return S_settle
remaining_days = days_to_expiry - near_days
if remaining_days <= 0:
if leg.option_type == "call":
@@ -70,7 +73,7 @@ def _settle_leg(leg: BacktestLeg, strike: float, days_to_expiry: int, near_days:
def run_backtest(req: BacktestRequest):
try:
for leg in req.legs:
if leg.option_type not in ("call", "put") or leg.position not in ("long", "short"):
if leg.option_type not in ("call", "put", "stock") or leg.position not in ("long", "short"):
return {"error": f"Jambe invalide: {leg}"}
ticker = yf.Ticker(req.symbol)
@@ -103,7 +106,7 @@ def run_backtest(req: BacktestRequest):
leg_days = [req.expiry_days if leg.expiry != "far" else req.far_expiry_days for leg in req.legs]
entry_premiums = [
float(black_scholes(S, k, d / 365, r, sigma, leg.option_type)["price"])
S if leg.option_type == "stock" else float(black_scholes(S, k, d / 365, r, sigma, leg.option_type)["price"])
for leg, k, d in zip(req.legs, leg_strikes, leg_days)
]

View File

@@ -146,6 +146,38 @@ def chain(
raise HTTPException(status_code=404, detail=str(e))
@router.get("/presets")
def presets(
symbol: str = Query(...),
horizon_days: int = Query(8),
dte_min: Optional[int] = Query(None),
dte_max: Optional[int] = Query(None),
):
"""The full strategy catalog (services.backtest_strategies.STRATEGIES) built from the
REAL current chain instead of Backtest's synthetic grid — so a preset click here seeds
the leg editor with actually-quoted strikes/expiries, ready to price or replay as-is.
n_expiries=5 (vs. Strategy Builder's own default of 3) so calendar/diagonal presets,
which need two distinct expiries, reliably have a second one to draw from."""
from services.backtest_strategies import STRATEGIES, build_legs
try:
chain_slice = get_chain_slice(symbol, horizon_days, 5, dte_min=dte_min, dte_max=dte_max)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
expiries = chain_slice["expiries"]
if not expiries:
raise HTTPException(status_code=404, detail=f"Aucune échéance exploitable pour '{symbol}'.")
near, far = expiries[0], expiries[1] if len(expiries) > 1 else None
out = []
for key, label, n_legs in STRATEGIES:
legs = build_legs(key, chain_slice["spot"], 0.05, near, far)
if not legs:
continue # e.g. calendar/diagonal with only one real expiry available right now
out.append({"key": key, "label": label, "n_legs": n_legs, "legs": legs})
return out
@router.post("/price")
def price(req: PriceRequest):
if not req.legs:

View File

@@ -47,9 +47,20 @@ STRATEGIES: List[Tuple[str, str, int]] = [
("put_condor", "Put Condor", 4),
("iron_condor", "Iron Condor", 4),
("iron_butterfly", "Iron Butterfly", 4),
("broken_wing_butterfly", "Broken Wing Butterfly", 3),
("ratio_backspread", "Ratio Backspread", 2),
("jade_lizard", "Jade Lizard", 3),
("risk_reversal", "Risk Reversal", 2),
("box_spread", "Box Spread", 4),
("covered_call", "Covered Call (Buy-Write)", 2),
("protective_put", "Protective Put", 2),
("collar", "Collar (tunnel)", 3),
]
_TEMPLATE_STRATEGY_KEYS = {s[0] for s in STRATEGIES[6:]} # everything past the 6 direct ones
# Everything past the 6 direct (single/vertical) strategies is built from a strike LIST
# rather than a plain spot*(1+/-pct) formula.
_TEMPLATE_STRATEGY_KEYS = {s[0] for s in STRATEGIES[6:]}
_STOCK_LEG_STRATEGY_KEYS = {"covered_call", "protective_put", "collar"}
_GRID_STEP_PCT = 0.02
_GRID_HALF_WIDTH = 25 # strikes from -50% to +50% of spot in 2% steps — enough room for
@@ -86,6 +97,119 @@ def _first_by_name(candidates: List[Tuple[str, List[Leg]]], name: str) -> Option
return next((legs for n, legs in candidates if n == name), None)
def _stock_leg(expiry: Dict[str, Any], spot: float, position: str, quantity: int = 1) -> Leg:
"""A position in the underlying itself (Covered Call/Protective Put/Collar), not an
option — see services.strategy_engine's option_type=="stock" handling. strike/expiry
are placeholders never read for pricing: strike=spot is harmless in check_bounded_risk's
strike-range hint, and days_to_expiry is set far out so this leg never wins a
min(l["days_to_expiry"] for l in legs) used elsewhere to pick the position's eval date."""
return {
"expiry_date": expiry["expiry_date"], "days_to_expiry": 36_500,
"strike": round(spot, 4), "option_type": "stock", "position": position, "quantity": quantity,
}
def broken_wing_butterfly(expiry: Dict[str, Any], spot: float) -> List[Leg]:
"""Like a call butterfly but the far wing is pulled wider than the near one — the
resulting asymmetry removes risk on one side entirely (funded by the wider wing's
lower cost) at the expense of a small max loss on the other."""
calls = tmpl.call_strikes(expiry)
if not calls:
return []
atm = _atm_index(calls, spot)
near_k, mid_k, far_k = _at(calls, atm + 1), _at(calls, atm + 3), _at(calls, atm + 8)
if None in (near_k, mid_k, far_k):
return []
return [_leg(expiry, near_k, "call", "long"), _leg(expiry, mid_k, "call", "short", 2), _leg(expiry, far_k, "call", "long")]
def ratio_backspread(expiry: Dict[str, Any], spot: float) -> List[Leg]:
"""Opposite of strategy_templates.ratio_spread: sell the near strike, buy 2x the
farther one — net long gamma/vega, unlimited gain if the underlying makes a big move
past the long strikes, bounded loss in the flat middle zone."""
calls = tmpl.call_strikes(expiry)
if not calls:
return []
atm = _atm_index(calls, spot)
near_k, far_k = _at(calls, atm + 1), _at(calls, atm + 4)
if None in (near_k, far_k):
return []
return [_leg(expiry, near_k, "call", "short"), _leg(expiry, far_k, "call", "long", 2)]
def jade_lizard(expiry: Dict[str, Any], spot: float) -> List[Leg]:
"""Short put + short call spread, sized so the call side's width is fully covered by
the combined credit — no upside risk by construction, only downside (below the short
put) and a capped zone in between."""
puts, calls = tmpl.put_strikes(expiry), tmpl.call_strikes(expiry)
if not puts or not calls:
return []
put_k = _at(puts, _atm_index(puts, spot) - 2)
call_k, far_call_k = _at(calls, _atm_index(calls, spot) + 1), _at(calls, _atm_index(calls, spot) + 3)
if None in (put_k, call_k, far_call_k):
return []
return [_leg(expiry, put_k, "put", "short"), _leg(expiry, call_k, "call", "short"), _leg(expiry, far_call_k, "call", "long")]
def risk_reversal(expiry: Dict[str, Any], spot: float) -> List[Leg]:
"""Sell an OTM put, buy an OTM call — a low-cost (often near-zero) directional bet,
funded by giving up protection below the short put strike."""
puts, calls = tmpl.put_strikes(expiry), tmpl.call_strikes(expiry)
if not puts or not calls:
return []
put_k = _at(puts, _atm_index(puts, spot) - 2)
call_k = _at(calls, _atm_index(calls, spot) + 2)
if None in (put_k, call_k):
return []
return [_leg(expiry, put_k, "put", "short"), _leg(expiry, call_k, "call", "long")]
def box_spread(expiry: Dict[str, Any], spot: float) -> List[Leg]:
"""Synthetic long (long call + short put) at K1 combined with a synthetic short at
K2 — a pure financing structure (locks in the strike-width discounted at the risk-free
rate) whose payoff is independent of the underlying, not a market bet."""
puts, calls = tmpl.put_strikes(expiry), tmpl.call_strikes(expiry)
common = sorted(set(puts) & set(calls))
if len(common) < 2:
return []
atm = _atm_index(common, spot)
k1 = _at(common, atm)
k2 = _at(common, min(atm + 3, len(common) - 1))
if k1 is None or k2 is None or k1 == k2:
return []
return [
_leg(expiry, k1, "call", "long"), _leg(expiry, k2, "call", "short"),
_leg(expiry, k2, "put", "long"), _leg(expiry, k1, "put", "short"),
]
def covered_call(expiry: Dict[str, Any], spot: float) -> List[Leg]:
calls = tmpl.call_strikes(expiry)
call_k = _at(calls, _atm_index(calls, spot) + 2) if calls else None
if call_k is None:
return []
return [_stock_leg(expiry, spot, "long"), _leg(expiry, call_k, "call", "short")]
def protective_put(expiry: Dict[str, Any], spot: float) -> List[Leg]:
puts = tmpl.put_strikes(expiry)
put_k = _at(puts, _atm_index(puts, spot) - 3) if puts else None
if put_k is None:
return []
return [_stock_leg(expiry, spot, "long"), _leg(expiry, put_k, "put", "long")]
def collar(expiry: Dict[str, Any], spot: float) -> List[Leg]:
puts, calls = tmpl.put_strikes(expiry), tmpl.call_strikes(expiry)
if not puts or not calls:
return []
put_k = _at(puts, _atm_index(puts, spot) - 3)
call_k = _at(calls, _atm_index(calls, spot) + 3)
if None in (put_k, call_k):
return []
return [_stock_leg(expiry, spot, "long"), _leg(expiry, put_k, "put", "long"), _leg(expiry, call_k, "call", "short")]
def _vertical(expiry: Dict[str, Any], spot: float, offset_pct: float, option_type: str, buy_near: bool) -> List[Leg]:
"""2-leg vertical, same expiry/type: one leg at spot*(1+/-offset_pct), the other at
3x that offset. buy_near=True -> debit spread (long the closer strike, short the
@@ -144,6 +268,22 @@ def build_legs(
if far_expiry is None:
return []
return _first_by_name(list(tmpl.diagonal_spread(near_expiry, far_expiry, spot)), "Diagonal Spread") or []
if strategy_key == "broken_wing_butterfly":
return broken_wing_butterfly(near_expiry, spot)
if strategy_key == "ratio_backspread":
return ratio_backspread(near_expiry, spot)
if strategy_key == "jade_lizard":
return jade_lizard(near_expiry, spot)
if strategy_key == "risk_reversal":
return risk_reversal(near_expiry, spot)
if strategy_key == "box_spread":
return box_spread(near_expiry, spot)
if strategy_key == "covered_call":
return covered_call(near_expiry, spot)
if strategy_key == "protective_put":
return protective_put(near_expiry, spot)
if strategy_key == "collar":
return collar(near_expiry, spot)
return []
@@ -165,7 +305,9 @@ def default_legs_pct(strategy_key: str, strike_offset_pct: float = 0.05) -> List
{
"option_type": leg["option_type"], "position": leg["position"], "quantity": leg["quantity"],
"strike_pct": round(leg["strike"] / _NOMINAL_SPOT, 4),
"expiry": "near" if leg["days_to_expiry"] == _NOMINAL_NEAR_DAYS else "far",
# A stock leg's placeholder days_to_expiry (36500, "never expires") would
# otherwise misclassify it as "far" here — it's always effectively "near".
"expiry": "near" if leg["option_type"] == "stock" or leg["days_to_expiry"] == _NOMINAL_NEAR_DAYS else "far",
}
for leg in legs
]

View File

@@ -56,7 +56,14 @@ def _quote_spread_pct(quote: Optional[Dict[str, Any]]) -> float:
def entry_price(leg: Dict[str, Any], chain_slice: Dict[str, Any], surface_now: Surface, r: float) -> Dict[str, float]:
"""Real execution price (crossing the spread) + theoretical mid, for one leg today."""
"""Real execution price (crossing the spread) + theoretical mid, for one leg today.
A "stock" leg (Covered Call/Protective Put/Collar's underlying position, not an
option) has no strike/vol at all — its price IS the spot, no spread modeled (the
underlying's own spread is typically far tighter than any option on it, and this
tool doesn't have a live quote for it the way find_quote does for options)."""
if leg["option_type"] == "stock":
S = chain_slice["spot"]
return {"exec_price": S, "mid": S, "spread_pct": 0.0}
quote = find_quote(chain_slice, leg["expiry_date"], leg["strike"], leg["option_type"])
T = max(leg["days_to_expiry"], 0.001) / 365
sigma = surface_now.iv_at(leg["strike"], leg["days_to_expiry"])
@@ -87,14 +94,17 @@ def value_at(
computed and discarded on every one of those calls."""
total = 0.0
for leg in legs:
remaining = leg["days_to_expiry"] - eval_days_from_now
qty = leg.get("quantity", 1)
sign = _sign(leg)
if remaining <= 0:
price = _intrinsic(S, leg["strike"], leg["option_type"])
if leg["option_type"] == "stock":
price = S # a share/lot of the underlying is worth exactly the spot, always
else:
sigma = surface.iv_at(leg["strike"], remaining)
price = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"], include_second_order=False)["price"]
remaining = leg["days_to_expiry"] - eval_days_from_now
if remaining <= 0:
price = _intrinsic(S, leg["strike"], leg["option_type"])
else:
sigma = surface.iv_at(leg["strike"], remaining)
price = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"], include_second_order=False)["price"]
total += sign * price * qty * contract_size
return total
@@ -105,9 +115,14 @@ def greeks_at(legs: List[Dict[str, Any]], S: float, eval_days_from_now: float, s
"vanna": 0.0, "charm": 0.0, "vomma": 0.0, "veta": 0.0, "speed": 0.0, "color": 0.0, "zomma": 0.0,
}
for leg in legs:
remaining = max(leg["days_to_expiry"] - eval_days_from_now, 0.001)
qty = leg.get("quantity", 1)
sign = _sign(leg)
if leg["option_type"] == "stock":
# d(spot)/d(spot) = 1, and every other Greek (gamma, theta, vega, rho, the
# second-order ones) is exactly zero for a position in the underlying itself.
net["delta"] += sign * qty
continue
remaining = max(leg["days_to_expiry"] - eval_days_from_now, 0.001)
sigma = surface.iv_at(leg["strike"], remaining)
g = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"])
for k in net:
@@ -169,14 +184,17 @@ def price_combo(
scenario_mid = 0.0
scenario_exec = 0.0
for leg in legs:
remaining = max(leg["days_to_expiry"] - horizon_days, 0.001)
qty = leg.get("quantity", 1)
sign = _sign(leg)
sigma = surface_scenario.iv_at(leg["strike"], remaining)
theo = black_scholes(spot_scenario, leg["strike"], remaining / 365, r, sigma, leg["option_type"])["price"]
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)
if leg["option_type"] == "stock":
theo = exec_price = spot_scenario # no spread modeled for the underlying itself
else:
remaining = max(leg["days_to_expiry"] - horizon_days, 0.001)
sigma = surface_scenario.iv_at(leg["strike"], remaining)
theo = black_scholes(spot_scenario, leg["strike"], remaining / 365, r, sigma, leg["option_type"])["price"]
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 * contract_size
scenario_exec += sign * exec_price * qty * contract_size

View File

@@ -36,7 +36,11 @@ def replay_position(
saxo_symbol = get_saxo_option_symbol_for_ticker(symbol) or symbol.upper()
signed_qty = [(1 if leg["position"] == "long" else -1) * leg.get("quantity", 1) for leg in legs]
avg_days = sum(leg.get("days_to_expiry", 30) for leg in legs) / len(legs)
# A "stock" leg's placeholder days_to_expiry (~effectively infinite, see
# backtest_strategies._stock_leg) would otherwise skew this — it's not a real option
# expiry and never should influence which expiries the chain fetch favors.
option_legs = [leg for leg in legs if leg["option_type"] != "stock"]
avg_days = sum(leg.get("days_to_expiry", 30) for leg in option_legs) / len(option_legs) if option_legs else 30
points: List[Dict[str, Any]] = []
entry_value = None
@@ -55,6 +59,12 @@ def replay_position(
value = 0.0 # dollar value of the whole position, contract_size already applied
complete = True
for leg, sq in zip(legs, signed_qty):
if leg["option_type"] == "stock":
if chain.get("spot") is None:
complete = False
break
value += sq * chain["spot"] * contract_size
continue
q = find_quote(chain, leg["expiry_date"], leg["strike"], leg["option_type"])
if not q or q["mid"] <= 0:
complete = False

View File

@@ -265,7 +265,7 @@ export const useBacktestSymbols = () =>
staleTime: 60_000,
})
export type BacktestLegPreset = { option_type: 'call' | 'put'; position: 'long' | 'short'; quantity: number; strike_pct: number; expiry: 'near' | 'far' }
export type BacktestLegPreset = { option_type: 'call' | 'put' | 'stock'; position: 'long' | 'short'; quantity: number; strike_pct: number; expiry: 'near' | 'far' }
export type BacktestStrategyInfo = { key: string; label: string; n_legs: number; default_legs: BacktestLegPreset[] }
export const useBacktestStrategies = () =>
useQuery<BacktestStrategyInfo[]>({
@@ -1763,7 +1763,7 @@ export type StrategyLeg = {
expiry_date: string
days_to_expiry: number
strike: number
option_type: 'call' | 'put'
option_type: 'call' | 'put' | 'stock'
position: 'long' | 'short'
quantity: number
}
@@ -1840,6 +1840,22 @@ export const usePriceStrategy = () =>
api.post<PriceCombo>('/strategy-builder/price', body).then(r => r.data),
})
// The 28-strategy catalog (services.backtest_strategies.STRATEGIES) built from the REAL
// current chain — real strikes/expiries, ready to price or replay as-is. A strategy that
// can't be built right now (e.g. calendar/diagonal with only one real expiry available)
// is simply absent from the response.
export type StrategyPreset = { key: string; label: string; n_legs: number; legs: StrategyLeg[] }
export const usePresets = (symbol: string, horizonDays: number, enabled: boolean, dteMin?: number | null, dteMax?: number | null) =>
useQuery<StrategyPreset[]>({
queryKey: ['strategy-builder-presets', symbol, horizonDays, dteMin, dteMax],
queryFn: () => api.get('/strategy-builder/presets', {
params: { symbol, horizon_days: horizonDays, dte_min: dteMin ?? undefined, dte_max: dteMax ?? undefined },
}).then(r => r.data),
enabled: enabled && !!symbol,
staleTime: 30_000,
retry: 1,
})
export type OptimizeConstraints = {
max_legs: number
delta_threshold: number | null

View File

@@ -14,7 +14,12 @@ type EditableLeg = BacktestLegPreset
function legsSummary(legs: LegRow[] | undefined): string {
if (!legs || !legs.length) return '—'
return legs
.map(l => `${l.position === 'long' ? '+' : ''}${l.quantity > 1 ? l.quantity + 'x' : ''}${l.option_type === 'call' ? 'C' : 'P'}${l.strike}`)
.map(l => {
const sign = l.position === 'long' ? '+' : ''
const qty = l.quantity > 1 ? l.quantity + 'x' : ''
if (l.option_type === 'stock') return `${sign}${qty}Spot`
return `${sign}${qty}${l.option_type === 'call' ? 'C' : 'P'}${l.strike}`
})
.join(' / ')
}
@@ -190,11 +195,12 @@ export default function Backtest() {
<div className="flex items-center gap-1">
<select
value={leg.option_type}
onChange={e => updateLeg(idx, { option_type: e.target.value as 'call' | 'put' })}
onChange={e => updateLeg(idx, { option_type: e.target.value as 'call' | 'put' | 'stock' })}
className="flex-1 bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white"
>
<option value="call">Call</option>
<option value="put">Put</option>
<option value="stock">Sous-jacent</option>
</select>
<select
value={leg.position}
@@ -219,22 +225,28 @@ export default function Backtest() {
title="Quantité"
className="w-12 bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white"
/>
<input
type="number" step={0.5} value={Math.round(leg.strike_pct * 1000) / 10}
onChange={e => updateLeg(idx, { strike_pct: (parseFloat(e.target.value) || 100) / 100 })}
title="Strike en % du spot"
className="flex-1 bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white"
/>
<span className="text-[10px] text-slate-600 shrink-0">% spot</span>
<select
value={leg.expiry}
onChange={e => updateLeg(idx, { expiry: e.target.value as 'near' | 'far' })}
title="Échéance"
className="bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white shrink-0"
>
<option value="near">Proche</option>
<option value="far">Lointaine</option>
</select>
{leg.option_type === 'stock' ? (
<span className="flex-1 text-[10px] text-slate-600 italic px-1">position sur le sous-jacent pas de strike/échéance</span>
) : (
<>
<input
type="number" step={0.5} value={Math.round(leg.strike_pct * 1000) / 10}
onChange={e => updateLeg(idx, { strike_pct: (parseFloat(e.target.value) || 100) / 100 })}
title="Strike en % du spot"
className="flex-1 bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white"
/>
<span className="text-[10px] text-slate-600 shrink-0">% spot</span>
<select
value={leg.expiry}
onChange={e => updateLeg(idx, { expiry: e.target.value as 'near' | 'far' })}
title="Échéance"
className="bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white shrink-0"
>
<option value="near">Proche</option>
<option value="far">Lointaine</option>
</select>
</>
)}
</div>
</div>
))}

View File

@@ -5,7 +5,7 @@ import {
import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X, History } from 'lucide-react'
import clsx from 'clsx'
import {
useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useSuggestedProfile, useReplayStrategy,
useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useSuggestedProfile, useReplayStrategy, usePresets,
useScenarios, useSaveScenario, useDeleteScenario,
useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
useSaxoSymbols, useIvForTrade,
@@ -367,38 +367,47 @@ function LegRow({
}) {
const expiry = chain?.expiries.find((e: any) => e.expiry_date === leg.expiry_date)
const rows = expiry ? (leg.option_type === 'call' ? expiry.calls : expiry.puts) : []
const isStock = leg.option_type === 'stock'
return (
<div className="grid grid-cols-12 gap-2 items-center text-xs">
<select
className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.expiry_date}
onChange={(e) => {
const exp = chain.expiries.find((x: any) => x.expiry_date === e.target.value)
onChange({ ...leg, expiry_date: e.target.value, days_to_expiry: exp?.days_to_expiry ?? leg.days_to_expiry })
}}
>
{chain?.expiries.map((e: any) => (
<option key={e.expiry_date} value={e.expiry_date}>{e.expiry_date} ({e.days_to_expiry}j)</option>
))}
</select>
<select
className="col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.option_type}
onChange={(e) => onChange({ ...leg, option_type: e.target.value as 'call' | 'put' })}
>
<option value="call">Call</option>
<option value="put">Put</option>
</select>
<select
className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.strike}
onChange={(e) => onChange({ ...leg, strike: parseFloat(e.target.value) })}
>
{rows.map((r: any) => (
<option key={r.strike} value={r.strike}>{fmtPrice(r.strike)} (bid {fmtPrice(r.bid)} / ask {fmtPrice(r.ask)})</option>
))}
</select>
{isStock ? (
<div className="col-span-8 bg-dark-700/40 border border-slate-700/30 rounded px-2 py-1.5 text-slate-400 italic">
Sous-jacent (spot{chain?.spot != null ? ` ${fmtPrice(chain.spot)}` : ''}) — position sur le sous-jacent lui-même, pas une option
</div>
) : (
<>
<select
className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.expiry_date}
onChange={(e) => {
const exp = chain.expiries.find((x: any) => x.expiry_date === e.target.value)
onChange({ ...leg, expiry_date: e.target.value, days_to_expiry: exp?.days_to_expiry ?? leg.days_to_expiry })
}}
>
{chain?.expiries.map((e: any) => (
<option key={e.expiry_date} value={e.expiry_date}>{e.expiry_date} ({e.days_to_expiry}j)</option>
))}
</select>
<select
className="col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.option_type}
onChange={(e) => onChange({ ...leg, option_type: e.target.value as 'call' | 'put' })}
>
<option value="call">Call</option>
<option value="put">Put</option>
</select>
<select
className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.strike}
onChange={(e) => onChange({ ...leg, strike: parseFloat(e.target.value) })}
>
{rows.map((r: any) => (
<option key={r.strike} value={r.strike}>{fmtPrice(r.strike)} (bid {fmtPrice(r.bid)} / ask {fmtPrice(r.ask)})</option>
))}
</select>
</>
)}
<select
className="col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.position}
@@ -873,6 +882,10 @@ export default function StrategyBuilder() {
const { data: chain, isLoading: chainLoading, isError: chainError, error: chainErrorObj, refetch: refetchChain, isFetching } =
useOptionChainSlice(debouncedSymbol, horizonDays, 3, true, scenario.dte_min, scenario.dte_max)
const { data: ivForTrade } = useIvForTrade(debouncedSymbol)
const { data: presets } = usePresets(debouncedSymbol, horizonDays, !!chain, scenario.dte_min, scenario.dte_max)
const [presetLegCountFilter, setPresetLegCountFilter] = useState<number | null>(null)
const [activePreset, setActivePreset] = useState<string | null>(null)
const filteredPresets = (presets ?? []).filter(p => presetLegCountFilter === null || p.n_legs === presetLegCountFilter)
useEffect(() => {
setScenario(s => ({ ...s, symbol: debouncedSymbol, horizon_days: horizonDays }))
@@ -984,6 +997,52 @@ export default function StrategyBuilder() {
{chain && <ScenarioGrid chain={chain} spot={chain.spot} scenario={scenario} setScenario={setScenario} />}
{chain && <VolSurfaceHeatmap chain={chain} spot={chain.spot} />}
{chain && (
<div className="card space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="stat-label">Catalogue de stratégies (30 fiches — hedge funds)</div>
<div className="flex gap-1">
{[null, 1, 2, 3, 4].map(n => (
<button
key={n ?? 'all'}
onClick={() => setPresetLegCountFilter(n)}
className={clsx('px-2 py-0.5 rounded text-xs border', {
'bg-blue-600 border-blue-500 text-white': presetLegCountFilter === n,
'border-slate-700 text-slate-500': presetLegCountFilter !== n,
})}
>
{n ?? 'Toutes'}
</button>
))}
</div>
</div>
<p className="text-[11px] text-slate-500">
Choisis un préréglage pour préremplir les jambes ci-dessous avec les vrais strikes de la chain actuelle — ajustable ensuite librement (jambes, expiries, quantités).
The Wheel, Dispersion Trading, Gamma Scalping et Convertible Arbitrage ne sont pas listés : ce sont des stratégies dynamiques (rehedging continu) ou multi-instruments (plusieurs sous-jacents, obligation convertible) — pas une position statique qu'on peut poser et rejouer telle quelle.
</p>
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto">
{filteredPresets.map(p => (
<button
key={p.key}
onClick={() => { setLegs(p.legs); setActivePreset(p.key) }}
className={clsx('flex items-center gap-1.5 text-xs px-2 py-1 rounded border transition-all', {
'bg-blue-600/20 border-blue-500/60 text-blue-300': activePreset === p.key,
'border-slate-700/40 text-slate-400 hover:border-slate-600': activePreset !== p.key,
})}
>
{p.label}
<span className={clsx('text-[10px] px-1 rounded', activePreset === p.key ? 'bg-blue-500/20 text-blue-300' : 'bg-slate-700/40 text-slate-500')}>
{p.n_legs}j
</span>
</button>
))}
{presets && filteredPresets.length === 0 && (
<div className="text-xs text-slate-600">Aucun préréglage disponible pour ce filtre en ce moment (échéances insuffisantes dans la chain actuelle).</div>
)}
</div>
</div>
)}
{chain && (
<div className="card space-y-3">
<div className="flex items-center justify-between">