feat: strategy builder
This commit is contained in:
171
backend/services/strategy_optimizer.py
Normal file
171
backend/services/strategy_optimizer.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Optimizer: template generation + bounded hill-climbing residual search + scoring/filtering.
|
||||
|
||||
Scans hundreds-to-thousands of candidate 1-4 leg structures (templates.generate_all,
|
||||
plus off-template perturbations) and returns the top-N ranked by the user's chosen
|
||||
objective, restricted to non-directional / bounded-risk candidates.
|
||||
"""
|
||||
import random
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from services.option_chain import get_chain_slice
|
||||
from services.vol_surface import Surface, ScenarioSurface, build_surface, apply_scenario
|
||||
from services.strategy_engine import price_combo, expected_pnl_scenario
|
||||
from services.strategy_templates import generate_all, strikes_for
|
||||
|
||||
MAX_SEEDS_FOR_RESIDUAL_SEARCH = 40
|
||||
RESIDUAL_ITERATIONS_PER_SEED = 8
|
||||
RESIDUAL_MAX_EVALS = 400
|
||||
|
||||
|
||||
def _score(priced: Dict[str, Any], legs: List[Dict[str, Any]], objective: str, surface_scenario: ScenarioSurface, horizon_days: int, r: float) -> Optional[float]:
|
||||
if objective == "net_pnl":
|
||||
return priced["net_pnl"]
|
||||
if objective == "return_on_risk":
|
||||
if not priced["max_loss"]:
|
||||
return None
|
||||
return priced["net_pnl"] / abs(priced["max_loss"])
|
||||
if objective == "prob_weighted":
|
||||
return expected_pnl_scenario(legs, surface_scenario, horizon_days, r, priced["entry_cost"])
|
||||
raise ValueError(f"Objectif inconnu: {objective}")
|
||||
|
||||
|
||||
def _passes_constraints(legs: List[Dict[str, Any]], priced: Dict[str, Any], constraints: Dict[str, Any]) -> bool:
|
||||
if len(legs) > constraints["max_legs"]:
|
||||
return False
|
||||
if abs(priced["net_delta_now"]) > constraints["delta_threshold"]:
|
||||
return False
|
||||
if not priced["bounded_risk"]:
|
||||
return False
|
||||
cap = constraints.get("max_loss_cap")
|
||||
if cap is not None and priced["max_loss"] is not None and abs(priced["max_loss"]) > cap:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _evaluate(
|
||||
name: str, legs: List[Dict[str, Any]], chain_slice: Dict[str, Any], surface_now: Surface,
|
||||
surface_scenario: ScenarioSurface, horizon_days: int, r: float, constraints: Dict[str, Any], objective: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if len(legs) > constraints["max_legs"] or len(legs) == 0:
|
||||
return None
|
||||
try:
|
||||
priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r)
|
||||
except Exception:
|
||||
return None
|
||||
if not _passes_constraints(legs, priced, constraints):
|
||||
return None
|
||||
score = _score(priced, legs, objective, surface_scenario, horizon_days, r)
|
||||
if score is None:
|
||||
return None
|
||||
return {"template_name": name, "legs": legs, "score": round(score, 2), "objective": objective, **priced}
|
||||
|
||||
|
||||
def _perturb(legs: List[Dict[str, Any]], strikes_by_expiry: Dict[Any, List[float]]) -> List[Dict[str, Any]]:
|
||||
new_legs = [dict(l) for l in legs]
|
||||
idx = random.randrange(len(new_legs))
|
||||
leg = new_legs[idx]
|
||||
kind = random.choice(["strike", "strike", "quantity"])
|
||||
|
||||
if kind == "strike":
|
||||
strikes = strikes_by_expiry.get((leg["expiry_date"], leg["option_type"]), [])
|
||||
if not strikes:
|
||||
return new_legs
|
||||
try:
|
||||
cur_idx = strikes.index(leg["strike"])
|
||||
except ValueError:
|
||||
cur_idx = min(range(len(strikes)), key=lambda i: abs(strikes[i] - leg["strike"]))
|
||||
step = random.choice([-2, -1, 1, 2])
|
||||
new_idx = max(0, min(len(strikes) - 1, cur_idx + step))
|
||||
leg["strike"] = strikes[new_idx]
|
||||
else:
|
||||
leg["quantity"] = max(1, min(3, leg["quantity"] + random.choice([-1, 1])))
|
||||
|
||||
return new_legs
|
||||
|
||||
|
||||
def _residual_search(
|
||||
seeds: List[Dict[str, Any]], chain_slice: Dict[str, Any], surface_now: Surface, surface_scenario: ScenarioSurface,
|
||||
horizon_days: int, r: float, constraints: Dict[str, Any], objective: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
strikes_by_expiry = {
|
||||
(exp["expiry_date"], opt_type): strikes_for(exp, opt_type)
|
||||
for exp in chain_slice["expiries"] for opt_type in ("call", "put")
|
||||
}
|
||||
found: List[Dict[str, Any]] = []
|
||||
evals = 0
|
||||
|
||||
for seed in seeds:
|
||||
current = seed
|
||||
for _ in range(RESIDUAL_ITERATIONS_PER_SEED):
|
||||
if evals >= RESIDUAL_MAX_EVALS:
|
||||
break
|
||||
candidate_legs = _perturb(current["legs"], strikes_by_expiry)
|
||||
evals += 1
|
||||
evaluated = _evaluate(
|
||||
f"{seed['template_name']} (variante)", candidate_legs, chain_slice, surface_now,
|
||||
surface_scenario, horizon_days, r, constraints, objective,
|
||||
)
|
||||
if evaluated and evaluated["score"] > current["score"]:
|
||||
current = evaluated
|
||||
found.append(evaluated)
|
||||
if evals >= RESIDUAL_MAX_EVALS:
|
||||
break
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def _dedup_top_n(scored: List[Dict[str, Any]], top_n: int) -> List[Dict[str, Any]]:
|
||||
seen = set()
|
||||
out = []
|
||||
for c in scored:
|
||||
sig = (
|
||||
c["template_name"].replace(" (variante)", ""),
|
||||
tuple(sorted(round(l["strike"]) for l in c["legs"])),
|
||||
tuple(sorted(l["expiry_date"] for l in c["legs"])),
|
||||
)
|
||||
if sig in seen:
|
||||
continue
|
||||
seen.add(sig)
|
||||
out.append(c)
|
||||
if len(out) >= top_n:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def optimize(
|
||||
symbol: str,
|
||||
horizon_days: int,
|
||||
spot_shock_pct: float,
|
||||
iv_level_shift: float,
|
||||
skew_tilt: float,
|
||||
term_shift: float,
|
||||
manual_grid: Optional[List[Dict[str, Any]]],
|
||||
n_expiries: int,
|
||||
rate: float,
|
||||
constraints: Dict[str, Any],
|
||||
objective: str,
|
||||
top_n: int = 20,
|
||||
) -> List[Dict[str, Any]]:
|
||||
chain_slice = get_chain_slice(symbol, horizon_days, n_expiries)
|
||||
surface_now = build_surface(chain_slice)
|
||||
surface_scenario = apply_scenario(
|
||||
surface_now, spot_shock_pct=spot_shock_pct, iv_level_shift=iv_level_shift,
|
||||
skew_tilt=skew_tilt, term_shift=term_shift, manual_grid=manual_grid,
|
||||
)
|
||||
|
||||
candidates = generate_all(chain_slice)
|
||||
scored: List[Dict[str, Any]] = []
|
||||
for name, legs in candidates:
|
||||
evaluated = _evaluate(name, legs, chain_slice, surface_now, surface_scenario, horizon_days, rate, constraints, objective)
|
||||
if evaluated:
|
||||
scored.append(evaluated)
|
||||
|
||||
scored.sort(key=lambda c: c["score"], reverse=True)
|
||||
seeds = scored[:MAX_SEEDS_FOR_RESIDUAL_SEARCH]
|
||||
|
||||
refined = _residual_search(seeds, chain_slice, surface_now, surface_scenario, horizon_days, rate, constraints, objective)
|
||||
scored.extend(refined)
|
||||
|
||||
scored.sort(key=lambda c: c["score"], reverse=True)
|
||||
return _dedup_top_n(scored, top_n)
|
||||
Reference in New Issue
Block a user