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