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 """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 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 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 remaining_days = days_to_expiry - near_days
if remaining_days <= 0: if remaining_days <= 0:
if leg.option_type == "call": 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): def run_backtest(req: BacktestRequest):
try: try:
for leg in req.legs: 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}"} return {"error": f"Jambe invalide: {leg}"}
ticker = yf.Ticker(req.symbol) 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] leg_days = [req.expiry_days if leg.expiry != "far" else req.far_expiry_days for leg in req.legs]
entry_premiums = [ 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) 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)) 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") @router.post("/price")
def price(req: PriceRequest): def price(req: PriceRequest):
if not req.legs: if not req.legs:

View File

@@ -47,9 +47,20 @@ STRATEGIES: List[Tuple[str, str, int]] = [
("put_condor", "Put Condor", 4), ("put_condor", "Put Condor", 4),
("iron_condor", "Iron Condor", 4), ("iron_condor", "Iron Condor", 4),
("iron_butterfly", "Iron Butterfly", 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_STEP_PCT = 0.02
_GRID_HALF_WIDTH = 25 # strikes from -50% to +50% of spot in 2% steps — enough room for _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) 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]: 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 """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 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: if far_expiry is None:
return [] return []
return _first_by_name(list(tmpl.diagonal_spread(near_expiry, far_expiry, spot)), "Diagonal Spread") or [] 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 [] 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"], "option_type": leg["option_type"], "position": leg["position"], "quantity": leg["quantity"],
"strike_pct": round(leg["strike"] / _NOMINAL_SPOT, 4), "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 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]: 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"]) quote = find_quote(chain_slice, leg["expiry_date"], leg["strike"], leg["option_type"])
T = max(leg["days_to_expiry"], 0.001) / 365 T = max(leg["days_to_expiry"], 0.001) / 365
sigma = surface_now.iv_at(leg["strike"], leg["days_to_expiry"]) 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.""" computed and discarded on every one of those calls."""
total = 0.0 total = 0.0
for leg in legs: for leg in legs:
remaining = leg["days_to_expiry"] - eval_days_from_now
qty = leg.get("quantity", 1) qty = leg.get("quantity", 1)
sign = _sign(leg) sign = _sign(leg)
if remaining <= 0: if leg["option_type"] == "stock":
price = _intrinsic(S, leg["strike"], leg["option_type"]) price = S # a share/lot of the underlying is worth exactly the spot, always
else: else:
sigma = surface.iv_at(leg["strike"], remaining) remaining = leg["days_to_expiry"] - eval_days_from_now
price = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"], include_second_order=False)["price"] 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 total += sign * price * qty * contract_size
return total 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, "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: for leg in legs:
remaining = max(leg["days_to_expiry"] - eval_days_from_now, 0.001)
qty = leg.get("quantity", 1) qty = leg.get("quantity", 1)
sign = _sign(leg) 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) sigma = surface.iv_at(leg["strike"], remaining)
g = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"]) g = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"])
for k in net: for k in net:
@@ -169,14 +184,17 @@ def price_combo(
scenario_mid = 0.0 scenario_mid = 0.0
scenario_exec = 0.0 scenario_exec = 0.0
for leg in legs: for leg in legs:
remaining = max(leg["days_to_expiry"] - horizon_days, 0.001)
qty = leg.get("quantity", 1) qty = leg.get("quantity", 1)
sign = _sign(leg) sign = _sign(leg)
sigma = surface_scenario.iv_at(leg["strike"], remaining) if leg["option_type"] == "stock":
theo = black_scholes(spot_scenario, leg["strike"], remaining / 365, r, sigma, leg["option_type"])["price"] theo = exec_price = spot_scenario # no spread modeled for the underlying itself
quote = find_quote(chain_slice, leg["expiry_date"], leg["strike"], leg["option_type"]) else:
spread_pct = _quote_spread_pct(quote) remaining = max(leg["days_to_expiry"] - horizon_days, 0.001)
exec_price = theo * (1 - spread_pct / 2) if leg.get("position", "long") == "long" else theo * (1 + spread_pct / 2) 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_mid += sign * theo * qty * contract_size
scenario_exec += sign * exec_price * 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() 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] 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]] = [] points: List[Dict[str, Any]] = []
entry_value = None entry_value = None
@@ -55,6 +59,12 @@ def replay_position(
value = 0.0 # dollar value of the whole position, contract_size already applied value = 0.0 # dollar value of the whole position, contract_size already applied
complete = True complete = True
for leg, sq in zip(legs, signed_qty): 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"]) q = find_quote(chain, leg["expiry_date"], leg["strike"], leg["option_type"])
if not q or q["mid"] <= 0: if not q or q["mid"] <= 0:
complete = False complete = False

View File

@@ -265,7 +265,7 @@ export const useBacktestSymbols = () =>
staleTime: 60_000, 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 type BacktestStrategyInfo = { key: string; label: string; n_legs: number; default_legs: BacktestLegPreset[] }
export const useBacktestStrategies = () => export const useBacktestStrategies = () =>
useQuery<BacktestStrategyInfo[]>({ useQuery<BacktestStrategyInfo[]>({
@@ -1763,7 +1763,7 @@ export type StrategyLeg = {
expiry_date: string expiry_date: string
days_to_expiry: number days_to_expiry: number
strike: number strike: number
option_type: 'call' | 'put' option_type: 'call' | 'put' | 'stock'
position: 'long' | 'short' position: 'long' | 'short'
quantity: number quantity: number
} }
@@ -1840,6 +1840,22 @@ export const usePriceStrategy = () =>
api.post<PriceCombo>('/strategy-builder/price', body).then(r => r.data), 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 = { export type OptimizeConstraints = {
max_legs: number max_legs: number
delta_threshold: number | null delta_threshold: number | null

View File

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

View File

@@ -5,7 +5,7 @@ import {
import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X, History } from 'lucide-react' import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X, History } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useSuggestedProfile, useReplayStrategy, useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useSuggestedProfile, useReplayStrategy, usePresets,
useScenarios, useSaveScenario, useDeleteScenario, useScenarios, useSaveScenario, useDeleteScenario,
useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy, useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
useSaxoSymbols, useIvForTrade, useSaxoSymbols, useIvForTrade,
@@ -367,38 +367,47 @@ function LegRow({
}) { }) {
const expiry = chain?.expiries.find((e: any) => e.expiry_date === leg.expiry_date) 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 rows = expiry ? (leg.option_type === 'call' ? expiry.calls : expiry.puts) : []
const isStock = leg.option_type === 'stock'
return ( return (
<div className="grid grid-cols-12 gap-2 items-center text-xs"> <div className="grid grid-cols-12 gap-2 items-center text-xs">
<select {isStock ? (
className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200" <div className="col-span-8 bg-dark-700/40 border border-slate-700/30 rounded px-2 py-1.5 text-slate-400 italic">
value={leg.expiry_date} Sous-jacent (spot{chain?.spot != null ? ` ${fmtPrice(chain.spot)}` : ''}) — position sur le sous-jacent lui-même, pas une option
onChange={(e) => { </div>
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 }) <>
}} <select
> className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
{chain?.expiries.map((e: any) => ( value={leg.expiry_date}
<option key={e.expiry_date} value={e.expiry_date}>{e.expiry_date} ({e.days_to_expiry}j)</option> onChange={(e) => {
))} const exp = chain.expiries.find((x: any) => x.expiry_date === e.target.value)
</select> onChange({ ...leg, expiry_date: e.target.value, days_to_expiry: exp?.days_to_expiry ?? leg.days_to_expiry })
<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} {chain?.expiries.map((e: any) => (
onChange={(e) => onChange({ ...leg, option_type: e.target.value as 'call' | 'put' })} <option key={e.expiry_date} value={e.expiry_date}>{e.expiry_date} ({e.days_to_expiry}j)</option>
> ))}
<option value="call">Call</option> </select>
<option value="put">Put</option> <select
</select> className="col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
<select value={leg.option_type}
className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200" onChange={(e) => onChange({ ...leg, option_type: e.target.value as 'call' | 'put' })}
value={leg.strike} >
onChange={(e) => onChange({ ...leg, strike: parseFloat(e.target.value) })} <option value="call">Call</option>
> <option value="put">Put</option>
{rows.map((r: any) => ( </select>
<option key={r.strike} value={r.strike}>{fmtPrice(r.strike)} (bid {fmtPrice(r.bid)} / ask {fmtPrice(r.ask)})</option> <select
))} className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
</select> 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 <select
className="col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200" className="col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.position} value={leg.position}
@@ -873,6 +882,10 @@ export default function StrategyBuilder() {
const { data: chain, isLoading: chainLoading, isError: chainError, error: chainErrorObj, refetch: refetchChain, isFetching } = const { data: chain, isLoading: chainLoading, isError: chainError, error: chainErrorObj, refetch: refetchChain, isFetching } =
useOptionChainSlice(debouncedSymbol, horizonDays, 3, true, scenario.dte_min, scenario.dte_max) useOptionChainSlice(debouncedSymbol, horizonDays, 3, true, scenario.dte_min, scenario.dte_max)
const { data: ivForTrade } = useIvForTrade(debouncedSymbol) 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(() => { useEffect(() => {
setScenario(s => ({ ...s, symbol: debouncedSymbol, horizon_days: horizonDays })) 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 && <ScenarioGrid chain={chain} spot={chain.spot} scenario={scenario} setScenario={setScenario} />}
{chain && <VolSurfaceHeatmap chain={chain} spot={chain.spot} />} {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 && ( {chain && (
<div className="card space-y-3"> <div className="card space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">