301 lines
13 KiB
Python
301 lines
13 KiB
Python
"""
|
|
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, to_native, DEFAULT_CONTRACT_SIZE
|
|
from services.strategy_templates import generate_all, strikes_for
|
|
|
|
MAX_SEEDS_FOR_RESIDUAL_SEARCH = 40
|
|
RESIDUAL_ITERATIONS_PER_SEED = 8
|
|
RESIDUAL_MAX_EVALS = 400
|
|
|
|
GREEK_KEYS = ("delta", "gamma", "theta", "vega", "rho")
|
|
|
|
# Neutral band and "strong" percentile threshold, both self-calibrated against the actual
|
|
# candidate pool (see _greek_target_match) rather than a hardcoded absolute number — there's
|
|
# no single "big gamma" that means the same thing for EURUSD and for GOLD, but "top third of
|
|
# what's achievable for this instrument under this scenario" means the same thing for both.
|
|
_TOLERANCE_NEUTRAL_FRACTION = {"etroite": 0.05, "normale": 0.15, "large": 0.30}
|
|
_TOLERANCE_STRONG_PERCENTILE = {"etroite": 0.75, "normale": 0.66, "large": 0.50}
|
|
|
|
|
|
def _percentile_rank(sorted_vals: List[float], v: float) -> float:
|
|
if not sorted_vals:
|
|
return 0.0
|
|
import bisect
|
|
return bisect.bisect_left(sorted_vals, v) / len(sorted_vals)
|
|
|
|
|
|
def _greek_target_match(value: float, state: str, tolerance: str, sorted_abs_pool: List[float]):
|
|
"""Returns (match_score in [0,1], hard_fail: bool) for one candidate's Greek value
|
|
against one target. hard_fail only ever fires under "etroite" tolerance on a sign/neutral
|
|
violation — everything else is a soft score, blended in by optimize()."""
|
|
if state == "free":
|
|
return 1.0, False
|
|
max_abs = sorted_abs_pool[-1] if sorted_abs_pool else 0.0
|
|
neutral_band = max_abs * _TOLERANCE_NEUTRAL_FRACTION.get(tolerance, 0.15)
|
|
if state == "neutral":
|
|
ok = abs(value) <= max(neutral_band, 1e-9)
|
|
return (1.0 if ok else 0.0), (tolerance == "etroite" and not ok)
|
|
desired_sign = -1 if "negative" in state else 1
|
|
actual_sign = 1 if value > 1e-9 else (-1 if value < -1e-9 else 0)
|
|
if actual_sign != desired_sign:
|
|
return 0.0, (tolerance == "etroite")
|
|
if state in ("strong_negative", "strong_positive"):
|
|
pct = _percentile_rank(sorted_abs_pool, abs(value))
|
|
threshold = _TOLERANCE_STRONG_PERCENTILE.get(tolerance, 0.66)
|
|
return (1.0 if pct >= threshold else 0.55), False
|
|
return 1.0, False # plain "positive"/"negative": correct sign is enough
|
|
|
|
|
|
def _apply_greek_profile(scored: List[Dict[str, Any]], greek_profile: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""Post-hoc re-ranking of an already-evaluated candidate pool against the requested
|
|
Greek behavior profile — see routers/strategy_builder.GreekProfileIn and project memory
|
|
(Strategy Builder Greeks plan, Phase 2). Scoped to entry-state greeks_now (a candidate's
|
|
immediate nature), not the residual search's own hill-climbing objective — the search
|
|
still climbs toward the base objective (net_pnl/return_on_risk/prob_weighted); this only
|
|
re-orders the resulting pool, it doesn't steer the search itself (a Phase 2.x refinement
|
|
if the un-guided pool turns out too shallow in practice)."""
|
|
if not scored:
|
|
return scored
|
|
active = {
|
|
k: t for k, t in (greek_profile or {}).items()
|
|
if k in GREEK_KEYS and t.get("state", "free") != "free"
|
|
}
|
|
if not active:
|
|
scored.sort(key=lambda c: c["score"], reverse=True)
|
|
return scored
|
|
|
|
# For "strong" states, the percentile pool must be restricted to candidates that already
|
|
# share the desired sign — otherwise a large WRONG-signed value (e.g. a deep-negative
|
|
# delta candidate) inflates the pool's max and makes a genuinely strong correctly-signed
|
|
# candidate look merely average by comparison.
|
|
def _sign_of(v: float) -> int:
|
|
return 1 if v > 1e-9 else (-1 if v < -1e-9 else 0)
|
|
|
|
pool_abs: Dict[str, List[float]] = {}
|
|
for k, t in active.items():
|
|
state = t.get("state", "free")
|
|
if state in ("strong_negative", "strong_positive"):
|
|
desired_sign = -1 if "negative" in state else 1
|
|
pool_abs[k] = sorted(
|
|
abs(c["greeks_now"][k]) for c in scored if _sign_of(c["greeks_now"][k]) == desired_sign
|
|
)
|
|
else:
|
|
pool_abs[k] = sorted(abs(c["greeks_now"][k]) for c in scored)
|
|
kept: List[Dict[str, Any]] = []
|
|
for c in scored:
|
|
hard_fail = False
|
|
match_scores, weights = [], []
|
|
for k, t in active.items():
|
|
v = c["greeks_now"].get(k, 0.0)
|
|
m, fail = _greek_target_match(v, t.get("state", "free"), t.get("tolerance", "normale"), pool_abs[k])
|
|
if fail:
|
|
hard_fail = True
|
|
break
|
|
match_scores.append(m)
|
|
weights.append(max(0.0, min(100.0, t.get("weight", 50.0))) / 100.0)
|
|
if hard_fail:
|
|
continue
|
|
avg_weight = sum(weights) / len(weights) if weights else 0.0
|
|
greek_match = sum(m * w for m, w in zip(match_scores, weights)) / sum(weights) if weights else 1.0
|
|
c["greek_match_score"] = round(greek_match, 3)
|
|
c["greek_weight"] = round(avg_weight, 3)
|
|
kept.append(c)
|
|
|
|
if not kept:
|
|
# Every candidate hard-failed an "étroite" target — fall back to the un-filtered
|
|
# pool (ranked on the base objective alone) rather than returning nothing, since an
|
|
# empty result reads as "no strategies exist" instead of "no strategy matches this
|
|
# strict a Greek target".
|
|
scored.sort(key=lambda c: c["score"], reverse=True)
|
|
for c in scored:
|
|
c["greek_match_score"] = 0.0
|
|
c["greek_weight"] = 0.0
|
|
return scored
|
|
|
|
# Linear blend of the two percentile ranks, NOT a product — a candidate with a perfect
|
|
# Greek match but the pool's worst raw score has base_pct=0, and multiplying would zero
|
|
# it out regardless of how much weight the user put on the Greek profile.
|
|
base_scores = sorted(c["score"] for c in kept)
|
|
for c in kept:
|
|
base_pct = _percentile_rank(base_scores, c["score"])
|
|
aw, gm = c["greek_weight"], c["greek_match_score"]
|
|
c["final_rank_score"] = round((1 - aw) * base_pct + aw * gm, 4)
|
|
kept.sort(key=lambda c: c["final_rank_score"], reverse=True)
|
|
return kept
|
|
|
|
|
|
def _score(priced: Dict[str, Any], legs: List[Dict[str, Any]], objective: str, surface_scenario: ScenarioSurface, horizon_days: int, r: float, contract_size: 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"], contract_size=contract_size)
|
|
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
|
|
delta_threshold = constraints.get("delta_threshold")
|
|
if delta_threshold is not None and abs(priced["net_delta_now"]) > 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,
|
|
contract_size: float = DEFAULT_CONTRACT_SIZE,
|
|
) -> Optional[Dict[str, Any]]:
|
|
if len(legs) > constraints["max_legs"] or len(legs) == 0:
|
|
return None
|
|
try:
|
|
# precise=False: skips the dense near-strike refinement (see check_bounded_risk) —
|
|
# ranking/filtering hundreds of candidates only needs relative ordering, not the
|
|
# exact peak height. The single loaded candidate gets refined precision via /price.
|
|
priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r, contract_size, precise=False)
|
|
except Exception:
|
|
return None
|
|
if not _passes_constraints(legs, priced, constraints):
|
|
return None
|
|
score = _score(priced, legs, objective, surface_scenario, horizon_days, r, contract_size)
|
|
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,
|
|
contract_size: float = DEFAULT_CONTRACT_SIZE,
|
|
) -> 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, contract_size,
|
|
)
|
|
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_slope_shift: float,
|
|
manual_grid: Optional[List[Dict[str, Any]]],
|
|
n_expiries: int,
|
|
rate: float,
|
|
constraints: Dict[str, Any],
|
|
objective: str,
|
|
top_n: int = 20,
|
|
contract_size: float = DEFAULT_CONTRACT_SIZE,
|
|
rate_shock_bps: float = 0.0,
|
|
dte_min: Optional[int] = None,
|
|
dte_max: Optional[int] = None,
|
|
greek_profile: Optional[Dict[str, Any]] = None,
|
|
as_of: Optional[str] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
r = rate + rate_shock_bps / 10000.0
|
|
chain_slice = get_chain_slice(symbol, horizon_days, n_expiries, dte_min=dte_min, dte_max=dte_max, as_of=as_of)
|
|
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_slope_shift=term_slope_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, r, constraints, objective, contract_size)
|
|
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, r, constraints, objective, contract_size)
|
|
scored.extend(refined)
|
|
|
|
scored = _apply_greek_profile(scored, greek_profile)
|
|
return to_native(_dedup_top_n(scored, top_n))
|