feat: strategy builder

This commit is contained in:
OpenSquared
2026-07-27 18:58:02 +02:00
parent ce09159bfb
commit 568414ca0c
18 changed files with 1315 additions and 63 deletions

View File

@@ -17,6 +17,122 @@ 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":
@@ -145,7 +261,7 @@ def optimize(
spot_shock_pct: float,
iv_level_shift: float,
skew_tilt: float,
term_shift: float,
term_slope_shift: float,
manual_grid: Optional[List[Dict[str, Any]]],
n_expiries: int,
rate: float,
@@ -153,26 +269,31 @@ def optimize(
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,
) -> List[Dict[str, Any]]:
chain_slice = get_chain_slice(symbol, horizon_days, n_expiries)
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)
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,
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, rate, constraints, objective, contract_size)
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, rate, constraints, objective, contract_size)
refined = _residual_search(seeds, chain_slice, surface_now, surface_scenario, horizon_days, r, constraints, objective, contract_size)
scored.extend(refined)
scored.sort(key=lambda c: c["score"], reverse=True)
scored = _apply_greek_profile(scored, greek_profile)
return to_native(_dedup_top_n(scored, top_n))