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
|
"""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)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user