feat: strategy builder
This commit is contained in:
@@ -94,7 +94,10 @@ def init_db():
|
||||
spot_shock_pct REAL NOT NULL,
|
||||
iv_level_shift REAL NOT NULL,
|
||||
skew_tilt REAL NOT NULL,
|
||||
term_shift REAL NOT NULL,
|
||||
term_slope_shift REAL NOT NULL,
|
||||
rate_shock_bps REAL DEFAULT 0,
|
||||
dte_min INTEGER,
|
||||
dte_max INTEGER,
|
||||
manual_grid TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)""")
|
||||
@@ -324,6 +327,11 @@ def init_db():
|
||||
profile_json TEXT,
|
||||
has_options_data INTEGER DEFAULT 0
|
||||
)""",
|
||||
# Strategy Builder — Greeks scenario plan Phase 1 (2026-07-27)
|
||||
"ALTER TABLE strategy_scenarios RENAME COLUMN term_shift TO term_slope_shift",
|
||||
"ALTER TABLE strategy_scenarios ADD COLUMN rate_shock_bps REAL DEFAULT 0",
|
||||
"ALTER TABLE strategy_scenarios ADD COLUMN dte_min INTEGER",
|
||||
"ALTER TABLE strategy_scenarios ADD COLUMN dte_max INTEGER",
|
||||
]:
|
||||
try:
|
||||
c.execute(_sql)
|
||||
@@ -6351,8 +6359,9 @@ def save_scenario(scenario: Dict[str, Any]) -> str:
|
||||
scenario_id = scenario.get("id") or f"SCN-{uuid.uuid4().hex[:8].upper()}"
|
||||
conn = get_conn()
|
||||
conn.execute("""INSERT INTO strategy_scenarios (
|
||||
id, symbol, label, horizon_days, spot_shock_pct, iv_level_shift, skew_tilt, term_shift, manual_grid
|
||||
) VALUES (?,?,?,?,?,?,?,?,?)""", (
|
||||
id, symbol, label, horizon_days, spot_shock_pct, iv_level_shift, skew_tilt, term_slope_shift,
|
||||
rate_shock_bps, dte_min, dte_max, manual_grid
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", (
|
||||
scenario_id,
|
||||
scenario["symbol"],
|
||||
scenario.get("label", ""),
|
||||
@@ -6360,7 +6369,10 @@ def save_scenario(scenario: Dict[str, Any]) -> str:
|
||||
scenario["spot_shock_pct"],
|
||||
scenario["iv_level_shift"],
|
||||
scenario["skew_tilt"],
|
||||
scenario["term_shift"],
|
||||
scenario["term_slope_shift"],
|
||||
scenario.get("rate_shock_bps", 0.0),
|
||||
scenario.get("dte_min"),
|
||||
scenario.get("dte_max"),
|
||||
json.dumps(scenario.get("manual_grid") or []),
|
||||
))
|
||||
conn.commit()
|
||||
|
||||
@@ -10,13 +10,22 @@ from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def get_chain_slice(symbol: str, target_days: int = 8, n_expiries: int = 3) -> Dict[str, Any]:
|
||||
def get_chain_slice(
|
||||
symbol: str, target_days: int = 8, n_expiries: int = 3,
|
||||
dte_min: Optional[int] = None, dte_max: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Builds a chain slice from the latest accumulated Saxo snapshot rows for `symbol`
|
||||
(services/database.get_latest_saxo_snapshot_rows). Returns the `n_expiries`
|
||||
expirations closest to target_days, each with calls/puts rows shaped
|
||||
{strike, bid, ask, mid, last, iv, open_interest, volume} — same shape regardless
|
||||
of source, so vol_surface.py/strategy_engine.py need no changes.
|
||||
|
||||
`dte_min`/`dte_max`, when given, restrict the candidate expiries to that DTE window
|
||||
before picking the `n_expiries` closest to target_days — lets a caller evaluate a
|
||||
scenario at a short horizon (e.g. target_days=8) while still building legs from
|
||||
longer-dated options (e.g. dte_min=20, dte_max=60), which target_days alone can't
|
||||
express since it drives both the evaluation date and (until now) the expiry pick.
|
||||
"""
|
||||
from services.database import get_latest_saxo_snapshot_rows
|
||||
|
||||
@@ -39,7 +48,18 @@ def get_chain_slice(symbol: str, target_days: int = 8, n_expiries: int = 3) -> D
|
||||
def _days_to(expiry_date: str) -> int:
|
||||
return (datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() - today).days
|
||||
|
||||
selected = sorted(by_expiry.keys(), key=lambda e: abs(_days_to(e) - target_days))[:max(1, n_expiries)]
|
||||
candidates = list(by_expiry.keys())
|
||||
if dte_min is not None or dte_max is not None:
|
||||
lo = dte_min if dte_min is not None else 0
|
||||
hi = dte_max if dte_max is not None else 10 ** 6
|
||||
candidates = [e for e in candidates if lo <= _days_to(e) <= hi]
|
||||
if not candidates:
|
||||
raise ValueError(
|
||||
f"Aucune échéance Saxo entre {dte_min}j et {dte_max}j pour '{symbol}' "
|
||||
f"— élargissez la fenêtre DTE ou laissez-la vide."
|
||||
)
|
||||
|
||||
selected = sorted(candidates, key=lambda e: abs(_days_to(e) - target_days))[:max(1, n_expiries)]
|
||||
|
||||
def _row_shape(r: Dict[str, Any]) -> Dict[str, Any]:
|
||||
bid = r.get("bid") or 0.0
|
||||
|
||||
@@ -6,17 +6,30 @@ import math
|
||||
|
||||
|
||||
def black_scholes(S: float, K: float, T: float, r: float, sigma: float, option_type: str = "call") -> Dict[str, float]:
|
||||
"""Black-Scholes pricing + Greeks."""
|
||||
"""Black-Scholes pricing + Greeks (first-order delta/gamma/theta/vega/rho, plus the
|
||||
second-order Greeks used by Strategy Builder's "advanced sensitivities" panel: vanna,
|
||||
charm, vomma/volga, veta, speed, color, zomma — vera deliberately omitted, see project
|
||||
memory "Strategy Builder Greeks plan"). All second-order values are scaled to match the
|
||||
convention their related first-order Greek already uses here — e.g. vanna/vomma/zomma
|
||||
are "per vol POINT" like vega already is (not per unit of raw decimal sigma), charm/
|
||||
color/veta are "per DAY" like theta already is (not per year) — every formula/scaling
|
||||
is verified against finite-difference bumps of this same function's own first-order
|
||||
outputs (see scratchpad test_second_order_greeks.py from the Phase 3 build), not just
|
||||
hand-derived from a textbook, since these third-derivative formulas are easy to get
|
||||
subtly wrong."""
|
||||
S = float(S or 100.0)
|
||||
K = float(K or S)
|
||||
T = float(T or 0.001)
|
||||
sigma = float(sigma or 0.25)
|
||||
if T <= 0 or sigma <= 0:
|
||||
intrinsic = max(0, S - K) if option_type == "call" else max(0, K - S)
|
||||
return {"price": intrinsic, "delta": 0, "gamma": 0, "theta": 0, "vega": 0, "rho": 0}
|
||||
return {"price": intrinsic, "delta": 0, "gamma": 0, "theta": 0, "vega": 0, "rho": 0,
|
||||
"vanna": 0, "charm": 0, "vomma": 0, "veta": 0, "speed": 0, "color": 0, "zomma": 0}
|
||||
|
||||
d1 = (math.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * math.sqrt(T))
|
||||
d2 = d1 - sigma * math.sqrt(T)
|
||||
sqrtT = math.sqrt(T)
|
||||
d1 = (math.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * sqrtT)
|
||||
d2 = d1 - sigma * sqrtT
|
||||
phi_d1 = norm.pdf(d1)
|
||||
|
||||
if option_type == "call":
|
||||
price = S * norm.cdf(d1) - K * math.exp(-r * T) * norm.cdf(d2)
|
||||
@@ -27,9 +40,19 @@ def black_scholes(S: float, K: float, T: float, r: float, sigma: float, option_t
|
||||
delta = norm.cdf(d1) - 1
|
||||
rho = -K * T * math.exp(-r * T) * norm.cdf(-d2) / 100
|
||||
|
||||
gamma = norm.pdf(d1) / (S * sigma * math.sqrt(T))
|
||||
theta = (-(S * norm.pdf(d1) * sigma) / (2 * math.sqrt(T)) - r * K * math.exp(-r * T) * norm.cdf(d2 if option_type == "call" else -d2)) / 365
|
||||
vega = S * norm.pdf(d1) * math.sqrt(T) / 100
|
||||
gamma = phi_d1 / (S * sigma * sqrtT)
|
||||
theta = (-(S * phi_d1 * sigma) / (2 * sqrtT) - r * K * math.exp(-r * T) * norm.cdf(d2 if option_type == "call" else -d2)) / 365
|
||||
vega = S * phi_d1 * sqrtT / 100
|
||||
|
||||
# Second-order — same for calls and puts (this pricer carries no dividend yield, so the
|
||||
# extra q-term that would otherwise make charm/veta/color differ by option_type is zero).
|
||||
vanna = (-phi_d1 * d2 / sigma) / 100
|
||||
vomma = (S * phi_d1 * sqrtT * d1 * d2 / sigma) / 10_000
|
||||
charm = (-phi_d1 * (2 * r * T - d2 * sigma * sqrtT) / (2 * T * sigma * sqrtT)) / 365
|
||||
veta = (S * phi_d1 * sqrtT * ((r * d1) / (sigma * sqrtT) - (1 + d1 * d2) / (2 * T))) / 36_500
|
||||
speed = -(gamma / S) * (d1 / (sigma * sqrtT) + 1)
|
||||
color = (phi_d1 / (2 * S * T * sigma * sqrtT) * (2 * r * T + 1 + d1 * (2 * r * T - d2 * sigma * sqrtT) / (sigma * sqrtT))) / 365
|
||||
zomma = (gamma * (d1 * d2 - 1) / sigma) / 100
|
||||
|
||||
return {
|
||||
"price": round(price, 4),
|
||||
@@ -38,6 +61,13 @@ def black_scholes(S: float, K: float, T: float, r: float, sigma: float, option_t
|
||||
"theta": round(theta, 4),
|
||||
"vega": round(vega, 4),
|
||||
"rho": round(rho, 4),
|
||||
"vanna": round(vanna, 6),
|
||||
"charm": round(charm, 6),
|
||||
"vomma": round(vomma, 6),
|
||||
"veta": round(veta, 6),
|
||||
"speed": round(speed, 8),
|
||||
"color": round(color, 8),
|
||||
"zomma": round(zomma, 6),
|
||||
}
|
||||
|
||||
|
||||
|
||||
139
backend/services/scenario_profile.py
Normal file
139
backend/services/scenario_profile.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Phase 4 of the Strategy Builder Greeks plan (see project memory) — Mode 1 ("scenario only")
|
||||
and the contradiction-detection layer from the user's spec, section 12.
|
||||
|
||||
`infer_natural_greek_profile` answers "what Greek behavior does this scenario already imply,
|
||||
before the user sets any explicit target?" — a deterministic, rule-based reading of the
|
||||
spec's own lookup tables (2.1 spot / 2.2 IV), NOT a fitted or learned model. Thresholds are
|
||||
judgment calls, documented inline, meant as a starting suggestion the Phase 2 profile panel
|
||||
can be pre-filled with and the user can freely override — not an authoritative answer.
|
||||
|
||||
`detect_greek_contradictions` answers "did the user just ask for something that's hard to
|
||||
get on a single option structure?" — static checks on the requested profile alone (no need
|
||||
to run the optimizer), returned as non-blocking warnings, never filtering the request.
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
_POSITIVE_STATES = {"positive", "strong_positive"}
|
||||
_STRONG_STATES = {"strong_positive", "strong_negative"}
|
||||
|
||||
|
||||
def infer_natural_greek_profile(spot_shock_pct: float, iv_level_shift: float, horizon_days: int) -> Dict[str, Any]:
|
||||
horizon_days = max(horizon_days, 1)
|
||||
speed = abs(spot_shock_pct) / horizon_days # %/day intensity of the anticipated move
|
||||
|
||||
if abs(spot_shock_pct) < 1.0:
|
||||
spot_dir = "stable"
|
||||
elif spot_shock_pct > 0:
|
||||
spot_dir = "hausse"
|
||||
else:
|
||||
spot_dir = "baisse"
|
||||
|
||||
# Thresholds are a judgment call, not calibrated against real move distributions —
|
||||
# ~0.8%/day is "a few percent in a few days" (fast), ~0.15%/day is "a percent or two
|
||||
# over a couple weeks" (progressive), below that reads as effectively directionless drift.
|
||||
if speed >= 0.8:
|
||||
spot_speed = "rapide"
|
||||
elif speed >= 0.15:
|
||||
spot_speed = "moderee"
|
||||
else:
|
||||
spot_speed = "lente"
|
||||
|
||||
if iv_level_shift >= 0.05:
|
||||
iv_bucket = "forte_hausse"
|
||||
elif iv_level_shift >= 0.02:
|
||||
iv_bucket = "hausse_moderee"
|
||||
elif iv_level_shift <= -0.02:
|
||||
iv_bucket = "baisse"
|
||||
else:
|
||||
iv_bucket = "faible"
|
||||
|
||||
delta = gamma = theta = "free"
|
||||
rationale: List[str] = []
|
||||
|
||||
# Spot -> delta/gamma/theta, spec section 2.1's table
|
||||
if spot_dir == "stable":
|
||||
delta, theta = "neutral", "positive"
|
||||
rationale.append("Spot quasi stable → Delta proche de zéro, Theta plutôt positif (collecte de temps).")
|
||||
elif spot_dir == "hausse" and spot_speed == "rapide":
|
||||
delta, gamma = "strong_positive", "positive"
|
||||
rationale.append("Hausse forte et rapide → Delta et Gamma positifs, la vitesse du mouvement compte autant que le niveau.")
|
||||
elif spot_dir == "hausse":
|
||||
delta = "positive"
|
||||
theta = "positive" if spot_speed == "lente" else "neutral"
|
||||
rationale.append("Hausse modérée/progressive → Delta positif, Theta plutôt positif si le mouvement reste lent.")
|
||||
elif spot_dir == "baisse" and spot_speed == "rapide":
|
||||
delta, gamma = "strong_negative", "positive"
|
||||
rationale.append("Baisse forte et rapide → Delta négatif et Gamma positif, la vitesse compte plus que le niveau.")
|
||||
else: # baisse, lente/modérée
|
||||
delta = "negative"
|
||||
theta = "positive" if spot_speed == "lente" else "neutral"
|
||||
rationale.append("Baisse modérée ou stagnation baissière → Delta négatif faible, Theta plutôt positif.")
|
||||
|
||||
# IV -> vega, spec section 2.2's table — can nuance the theta read above when IV dominates
|
||||
if iv_bucket == "forte_hausse":
|
||||
vega = "strong_positive"
|
||||
rationale.append("Forte hausse d'IV anticipée → Vega positif, idéalement avec de la convexité de vol (Vomma).")
|
||||
elif iv_bucket == "hausse_moderee":
|
||||
vega = "positive"
|
||||
rationale.append("Hausse modérée d'IV → Vega positif, sans excès.")
|
||||
elif iv_bucket == "baisse":
|
||||
vega = "negative"
|
||||
if theta == "free":
|
||||
theta = "positive"
|
||||
rationale.append("Baisse d'IV attendue (normalisation) → Vega négatif, Theta plutôt positif.")
|
||||
else:
|
||||
vega = "free"
|
||||
|
||||
return {
|
||||
"delta": delta, "gamma": gamma, "theta": theta, "vega": vega, "rho": "free",
|
||||
"rationale": rationale,
|
||||
"reading": {"spot_direction": spot_dir, "spot_speed": spot_speed, "iv_bucket": iv_bucket},
|
||||
}
|
||||
|
||||
|
||||
def detect_greek_contradictions(
|
||||
greek_profile: Optional[Dict[str, Any]], n_expiries: int,
|
||||
dte_min: Optional[int], dte_max: Optional[int],
|
||||
) -> List[str]:
|
||||
if not greek_profile:
|
||||
return []
|
||||
|
||||
def state_of(key: str) -> str:
|
||||
return (greek_profile.get(key) or {}).get("state", "free")
|
||||
|
||||
def weight_of(key: str) -> float:
|
||||
return (greek_profile.get(key) or {}).get("weight", 50.0)
|
||||
|
||||
single_expiry = (n_expiries or 1) <= 1 or (
|
||||
dte_min is not None and dte_max is not None and dte_max - dte_min <= 5
|
||||
)
|
||||
|
||||
warnings: List[str] = []
|
||||
gamma_state, theta_state, delta_state, vega_state = (
|
||||
state_of("gamma"), state_of("theta"), state_of("delta"), state_of("vega"),
|
||||
)
|
||||
|
||||
if (gamma_state in _POSITIVE_STATES and theta_state in _POSITIVE_STATES
|
||||
and weight_of("gamma") >= 30 and weight_of("theta") >= 30 and single_expiry):
|
||||
warnings.append(
|
||||
"Gamma positif et Theta positif en même temps sont difficiles à obtenir sur une seule "
|
||||
"échéance. Solutions : élargir la fenêtre DTE (calendars/diagonales), réduire l'exigence "
|
||||
"sur l'un des deux, ou n'exiger un Theta positif qu'autour du scénario central."
|
||||
)
|
||||
|
||||
if delta_state == "neutral" and gamma_state in _STRONG_STATES and weight_of("delta") >= 30 and weight_of("gamma") >= 30:
|
||||
warnings.append(
|
||||
"Delta neutre et Gamma fortement positif se contredisent dans la durée : un Gamma élevé "
|
||||
"fait bouger le Delta dès que le marché évolue — il ne restera « neutre » qu'au voisinage "
|
||||
"immédiat du scénario central."
|
||||
)
|
||||
|
||||
if vega_state == "strong_positive" and theta_state == "strong_positive" and weight_of("vega") >= 30 and weight_of("theta") >= 30:
|
||||
warnings.append(
|
||||
"Vega fortement positif et Theta fortement positif combinent rarement bien : la convexité "
|
||||
"de volatilité coûte généralement du portage — vérifiez que le crédit net visé reste "
|
||||
"cohérent avec cet objectif."
|
||||
)
|
||||
|
||||
return warnings
|
||||
@@ -97,7 +97,10 @@ def value_at(
|
||||
|
||||
|
||||
def greeks_at(legs: List[Dict[str, Any]], S: float, eval_days_from_now: float, surface: Any, r: float) -> Dict[str, float]:
|
||||
net = {"delta": 0.0, "gamma": 0.0, "theta": 0.0, "vega": 0.0}
|
||||
net = {
|
||||
"delta": 0.0, "gamma": 0.0, "theta": 0.0, "vega": 0.0, "rho": 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:
|
||||
remaining = max(leg["days_to_expiry"] - eval_days_from_now, 0.001)
|
||||
qty = leg.get("quantity", 1)
|
||||
@@ -106,7 +109,34 @@ def greeks_at(legs: List[Dict[str, Any]], S: float, eval_days_from_now: float, s
|
||||
g = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"])
|
||||
for k in net:
|
||||
net[k] += g[k] * qty * sign
|
||||
return {k: round(v, 4) for k, v in net.items()}
|
||||
return {k: round(v, 6) for k, v in net.items()}
|
||||
|
||||
|
||||
def vanna_simulation(
|
||||
legs: List[Dict[str, Any]], S: float, eval_days_from_now: float, surface: Any, r: float,
|
||||
spot_shock_pct: float = -5.0, iv_shock_pts: float = 8.0,
|
||||
) -> Dict[str, float]:
|
||||
"""A concrete joint spot+IV shock reprice — "if spot drops 5% and IV jumps 8pts, what
|
||||
actually happens to my net delta" — rather than a bare "vanna is positive/negative"
|
||||
label. Uses a real Black-Scholes reprice (not the linear vanna approximation) so it's
|
||||
accurate for shocks this large, matching the same "show a simulation, not a sign"
|
||||
principle the payoff diagram already uses elsewhere in Strategy Builder."""
|
||||
delta_before = greeks_at(legs, S, eval_days_from_now, surface, r)["delta"]
|
||||
|
||||
class _ShockedSurface:
|
||||
def iv_at(self, strike: float, days: float) -> float:
|
||||
return max(0.01, surface.iv_at(strike, days) + iv_shock_pts / 100.0)
|
||||
|
||||
S_shocked = S * (1 + spot_shock_pct / 100.0)
|
||||
delta_after = greeks_at(legs, S_shocked, eval_days_from_now, _ShockedSurface(), r)["delta"]
|
||||
|
||||
return {
|
||||
"spot_shock_pct": spot_shock_pct,
|
||||
"iv_shock_pts": iv_shock_pts,
|
||||
"delta_before": delta_before,
|
||||
"delta_after": delta_after,
|
||||
"delta_change": round(delta_after - delta_before, 6),
|
||||
}
|
||||
|
||||
|
||||
def price_combo(
|
||||
@@ -176,6 +206,9 @@ def price_combo(
|
||||
"greeks_scenario": greeks_at(legs, spot_scenario, horizon_days, surface_scenario, r),
|
||||
"net_delta_now": delta_now,
|
||||
"net_delta_scenario": delta_scenario,
|
||||
# Skipped during the optimizer's bulk scan (precise=False, hundreds of candidates
|
||||
# per request) — only computed for the single position actually loaded/priced.
|
||||
"vanna_simulation": vanna_simulation(legs, spot_now, 0, surface_now, r) if precise else None,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -43,7 +43,13 @@ class Surface:
|
||||
|
||||
|
||||
class ScenarioSurface:
|
||||
"""Shocked surface at the scenario horizon: parametric shifts + manual overrides."""
|
||||
"""Shocked surface at the scenario horizon: parametric shifts + manual overrides.
|
||||
|
||||
`iv_level_shift` is already the "parallel" component (applies to every strike/expiry
|
||||
uniformly) — `term_slope_shift` is the term-structure *slope* (zero at days=0, growing
|
||||
linearly per 30 days), so between the two the surface already has the "parallel vs.
|
||||
slope" split a term-structure model needs; a third "curvature" term is deliberately
|
||||
not modeled yet (see project memory: Strategy Builder Greeks plan, Phase 1)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -51,14 +57,14 @@ class ScenarioSurface:
|
||||
scenario_spot: float,
|
||||
iv_level_shift: float,
|
||||
skew_tilt: float,
|
||||
term_shift: float,
|
||||
term_slope_shift: float,
|
||||
manual_overrides: Optional[Dict[Tuple[int, float], float]] = None,
|
||||
):
|
||||
self.base = base
|
||||
self.spot = scenario_spot
|
||||
self.iv_level_shift = iv_level_shift
|
||||
self.skew_tilt = skew_tilt
|
||||
self.term_shift = term_shift
|
||||
self.term_slope_shift = term_slope_shift
|
||||
self.manual_overrides = manual_overrides or {}
|
||||
|
||||
def iv_at(self, strike: float, days: float) -> float:
|
||||
@@ -71,7 +77,7 @@ class ScenarioSurface:
|
||||
base_iv
|
||||
+ self.iv_level_shift
|
||||
+ self.skew_tilt * moneyness
|
||||
+ self.term_shift * (days / 30.0)
|
||||
+ self.term_slope_shift * (days / 30.0)
|
||||
)
|
||||
return max(0.01, shocked)
|
||||
|
||||
@@ -137,7 +143,7 @@ def apply_scenario(
|
||||
spot_shock_pct: float = 0.0,
|
||||
iv_level_shift: float = 0.0,
|
||||
skew_tilt: float = 0.0,
|
||||
term_shift: float = 0.0,
|
||||
term_slope_shift: float = 0.0,
|
||||
manual_grid: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> ScenarioSurface:
|
||||
"""
|
||||
@@ -149,4 +155,4 @@ def apply_scenario(
|
||||
for cell in (manual_grid or []):
|
||||
if cell.get("iv") is not None:
|
||||
overrides[(int(cell["days_to_expiry"]), float(cell["strike_pct"]))] = float(cell["iv"])
|
||||
return ScenarioSurface(surface, scenario_spot, iv_level_shift, skew_tilt, term_shift, overrides)
|
||||
return ScenarioSurface(surface, scenario_spot, iv_level_shift, skew_tilt, term_slope_shift, overrides)
|
||||
|
||||
Reference in New Issue
Block a user