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

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -26,15 +26,25 @@ class LegIn(BaseModel):
class ScenarioIn(BaseModel): class ScenarioIn(BaseModel):
symbol: str symbol: str
horizon_days: int = 8 horizon_days: int = 8 # scenario P&L evaluation date — NOT the expiry filter, see dte_min/dte_max
spot_shock_pct: float = 0.0 spot_shock_pct: float = 0.0
iv_level_shift: float = 0.0 iv_level_shift: float = 0.0 # parallel IV shift — applies to every strike/expiry uniformly
skew_tilt: float = 0.0 skew_tilt: float = 0.0
term_shift: float = 0.0 term_slope_shift: float = 0.0 # term-structure slope, per 30 days (0 at days=0)
rate_shock_bps: float = 0.0
manual_grid: Optional[List[Dict[str, Any]]] = None manual_grid: Optional[List[Dict[str, Any]]] = None
rate: float = 0.05 rate: float = 0.05
n_expiries: int = 3 n_expiries: int = 3
contract_size: float = DEFAULT_CONTRACT_SIZE contract_size: float = DEFAULT_CONTRACT_SIZE
# Which expiries the chain/optimizer may pick legs from — independent of horizon_days,
# so a short-horizon scenario (e.g. 8 days) can still be evaluated with longer-dated
# options (e.g. dte_min=20, dte_max=60) instead of horizon_days doing double duty.
dte_min: Optional[int] = None
dte_max: Optional[int] = None
@property
def shocked_rate(self) -> float:
return self.rate + self.rate_shock_bps / 10000.0
class PriceRequest(BaseModel): class PriceRequest(BaseModel):
@@ -50,9 +60,31 @@ class ConstraintsIn(BaseModel):
top_n: int = 20 top_n: int = 20
class GreekTargetIn(BaseModel):
"""One Greek's desired behavior — deliberately NOT a numeric slider (see project memory,
Strategy Builder Greeks plan): a qualitative state the optimizer resolves against the
actual candidate pool, so "strongly positive" means "top of what's achievable for this
instrument/scenario right now" rather than a guessed absolute number."""
state: str = "free" # "strong_negative"|"negative"|"neutral"|"positive"|"strong_positive"|"free"
tolerance: str = "normale" # "etroite"|"normale"|"large" — etroite hard-filters sign mismatches
weight: float = 50.0 # 0-100, importance relative to the base objective (net_pnl/return_on_risk/...)
class GreekProfileIn(BaseModel):
"""Layer B of the scenario/profile/constraints split: the behavior the user wants,
kept separate from the scenario (Layer A, what's anticipated) and from ConstraintsIn
(Layer C, hard construction limits)."""
delta: GreekTargetIn = GreekTargetIn()
gamma: GreekTargetIn = GreekTargetIn()
theta: GreekTargetIn = GreekTargetIn()
vega: GreekTargetIn = GreekTargetIn()
rho: GreekTargetIn = GreekTargetIn()
class OptimizeRequest(BaseModel): class OptimizeRequest(BaseModel):
scenario: ScenarioIn scenario: ScenarioIn
constraints: ConstraintsIn constraints: ConstraintsIn
greek_profile: Optional[GreekProfileIn] = None
class ScenarioSaveRequest(BaseModel): class ScenarioSaveRequest(BaseModel):
@@ -62,7 +94,10 @@ class ScenarioSaveRequest(BaseModel):
spot_shock_pct: float spot_shock_pct: float
iv_level_shift: float iv_level_shift: float
skew_tilt: float skew_tilt: float
term_shift: float term_slope_shift: float
rate_shock_bps: float = 0.0
dte_min: Optional[int] = None
dte_max: Optional[int] = None
manual_grid: Optional[List[Dict[str, Any]]] = None manual_grid: Optional[List[Dict[str, Any]]] = None
@@ -81,14 +116,17 @@ class StrategySaveRequest(BaseModel):
def _build_surfaces(scenario: ScenarioIn): def _build_surfaces(scenario: ScenarioIn):
chain_slice = get_chain_slice(scenario.symbol, scenario.horizon_days, scenario.n_expiries) chain_slice = get_chain_slice(
scenario.symbol, scenario.horizon_days, scenario.n_expiries,
dte_min=scenario.dte_min, dte_max=scenario.dte_max,
)
surface_now = build_surface(chain_slice) surface_now = build_surface(chain_slice)
surface_scenario = apply_scenario( surface_scenario = apply_scenario(
surface_now, surface_now,
spot_shock_pct=scenario.spot_shock_pct, spot_shock_pct=scenario.spot_shock_pct,
iv_level_shift=scenario.iv_level_shift, iv_level_shift=scenario.iv_level_shift,
skew_tilt=scenario.skew_tilt, skew_tilt=scenario.skew_tilt,
term_shift=scenario.term_shift, term_slope_shift=scenario.term_slope_shift,
manual_grid=scenario.manual_grid, manual_grid=scenario.manual_grid,
) )
return chain_slice, surface_now, surface_scenario return chain_slice, surface_now, surface_scenario
@@ -99,9 +137,11 @@ def chain(
symbol: str = Query(...), symbol: str = Query(...),
horizon_days: int = Query(8), horizon_days: int = Query(8),
n_expiries: int = Query(3), n_expiries: int = Query(3),
dte_min: Optional[int] = Query(None),
dte_max: Optional[int] = Query(None),
): ):
try: try:
return get_chain_slice(symbol, horizon_days, n_expiries) return get_chain_slice(symbol, horizon_days, n_expiries, dte_min=dte_min, dte_max=dte_max)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -121,7 +161,7 @@ def price(req: PriceRequest):
legs = [leg.model_dump() for leg in req.legs] legs = [leg.model_dump() for leg in req.legs]
result = payoff_curves( result = payoff_curves(
legs, chain_slice, surface_now, surface_scenario, legs, chain_slice, surface_now, surface_scenario,
req.scenario.horizon_days, req.scenario.rate, req.scenario.horizon_days, req.scenario.shocked_rate,
contract_size=req.scenario.contract_size, contract_size=req.scenario.contract_size,
) )
result["spot"] = chain_slice["spot"] result["spot"] = chain_slice["spot"]
@@ -130,10 +170,24 @@ def price(req: PriceRequest):
return result return result
@router.post("/suggested-profile")
def suggested_profile(scenario: ScenarioIn):
"""Mode 1 of the scenario/profile/constraints split: what Greek behavior this scenario
already implies on its own, before the user sets any explicit target — see
services.scenario_profile.infer_natural_greek_profile."""
from services.scenario_profile import infer_natural_greek_profile
return infer_natural_greek_profile(scenario.spot_shock_pct, scenario.iv_level_shift, scenario.horizon_days)
@router.post("/optimize") @router.post("/optimize")
def optimize(req: OptimizeRequest): def optimize(req: OptimizeRequest):
if req.constraints.max_legs > 4: if req.constraints.max_legs > 4:
raise HTTPException(status_code=400, detail="4 jambes maximum") raise HTTPException(status_code=400, detail="4 jambes maximum")
from services.scenario_profile import detect_greek_contradictions
warnings = detect_greek_contradictions(
req.greek_profile.model_dump() if req.greek_profile else None,
req.scenario.n_expiries, req.scenario.dte_min, req.scenario.dte_max,
)
try: try:
results = run_optimizer( results = run_optimizer(
symbol=req.scenario.symbol, symbol=req.scenario.symbol,
@@ -141,14 +195,18 @@ def optimize(req: OptimizeRequest):
spot_shock_pct=req.scenario.spot_shock_pct, spot_shock_pct=req.scenario.spot_shock_pct,
iv_level_shift=req.scenario.iv_level_shift, iv_level_shift=req.scenario.iv_level_shift,
skew_tilt=req.scenario.skew_tilt, skew_tilt=req.scenario.skew_tilt,
term_shift=req.scenario.term_shift, term_slope_shift=req.scenario.term_slope_shift,
manual_grid=req.scenario.manual_grid, manual_grid=req.scenario.manual_grid,
n_expiries=req.scenario.n_expiries, n_expiries=req.scenario.n_expiries,
rate=req.scenario.rate, rate=req.scenario.rate,
rate_shock_bps=req.scenario.rate_shock_bps,
dte_min=req.scenario.dte_min,
dte_max=req.scenario.dte_max,
constraints=req.constraints.model_dump(), constraints=req.constraints.model_dump(),
objective=req.constraints.objective, objective=req.constraints.objective,
top_n=req.constraints.top_n, top_n=req.constraints.top_n,
contract_size=req.scenario.contract_size, contract_size=req.scenario.contract_size,
greek_profile=req.greek_profile.model_dump() if req.greek_profile else None,
) )
except Exception as e: except Exception as e:
import traceback import traceback
@@ -161,7 +219,7 @@ def optimize(req: OptimizeRequest):
) )
status = 404 if isinstance(e, ValueError) else 500 status = 404 if isinstance(e, ValueError) else 500
raise HTTPException(status_code=status, detail=f"{e}") raise HTTPException(status_code=status, detail=f"{e}")
return results return {"candidates": results, "warnings": warnings}
@router.post("/scenarios") @router.post("/scenarios")

View File

@@ -94,7 +94,10 @@ def init_db():
spot_shock_pct REAL NOT NULL, spot_shock_pct REAL NOT NULL,
iv_level_shift REAL NOT NULL, iv_level_shift REAL NOT NULL,
skew_tilt 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, manual_grid TEXT,
created_at TEXT DEFAULT (datetime('now')) created_at TEXT DEFAULT (datetime('now'))
)""") )""")
@@ -324,6 +327,11 @@ def init_db():
profile_json TEXT, profile_json TEXT,
has_options_data INTEGER DEFAULT 0 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: try:
c.execute(_sql) 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()}" scenario_id = scenario.get("id") or f"SCN-{uuid.uuid4().hex[:8].upper()}"
conn = get_conn() conn = get_conn()
conn.execute("""INSERT INTO strategy_scenarios ( conn.execute("""INSERT INTO strategy_scenarios (
id, symbol, label, horizon_days, spot_shock_pct, iv_level_shift, skew_tilt, term_shift, manual_grid id, symbol, label, horizon_days, spot_shock_pct, iv_level_shift, skew_tilt, term_slope_shift,
) VALUES (?,?,?,?,?,?,?,?,?)""", ( rate_shock_bps, dte_min, dte_max, manual_grid
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", (
scenario_id, scenario_id,
scenario["symbol"], scenario["symbol"],
scenario.get("label", ""), scenario.get("label", ""),
@@ -6360,7 +6369,10 @@ def save_scenario(scenario: Dict[str, Any]) -> str:
scenario["spot_shock_pct"], scenario["spot_shock_pct"],
scenario["iv_level_shift"], scenario["iv_level_shift"],
scenario["skew_tilt"], 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 []), json.dumps(scenario.get("manual_grid") or []),
)) ))
conn.commit() conn.commit()

View File

@@ -10,13 +10,22 @@ from datetime import date, datetime
from typing import Any, Dict, List, Optional 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` Builds a chain slice from the latest accumulated Saxo snapshot rows for `symbol`
(services/database.get_latest_saxo_snapshot_rows). Returns the `n_expiries` (services/database.get_latest_saxo_snapshot_rows). Returns the `n_expiries`
expirations closest to target_days, each with calls/puts rows shaped expirations closest to target_days, each with calls/puts rows shaped
{strike, bid, ask, mid, last, iv, open_interest, volume} — same shape regardless {strike, bid, ask, mid, last, iv, open_interest, volume} — same shape regardless
of source, so vol_surface.py/strategy_engine.py need no changes. 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 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: def _days_to(expiry_date: str) -> int:
return (datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() - today).days 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]: def _row_shape(r: Dict[str, Any]) -> Dict[str, Any]:
bid = r.get("bid") or 0.0 bid = r.get("bid") or 0.0

View File

@@ -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]: 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) S = float(S or 100.0)
K = float(K or S) K = float(K or S)
T = float(T or 0.001) T = float(T or 0.001)
sigma = float(sigma or 0.25) sigma = float(sigma or 0.25)
if T <= 0 or sigma <= 0: if T <= 0 or sigma <= 0:
intrinsic = max(0, S - K) if option_type == "call" else max(0, K - S) 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)) sqrtT = math.sqrt(T)
d2 = d1 - sigma * 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": if option_type == "call":
price = S * norm.cdf(d1) - K * math.exp(-r * T) * norm.cdf(d2) 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 delta = norm.cdf(d1) - 1
rho = -K * T * math.exp(-r * T) * norm.cdf(-d2) / 100 rho = -K * T * math.exp(-r * T) * norm.cdf(-d2) / 100
gamma = norm.pdf(d1) / (S * sigma * math.sqrt(T)) gamma = phi_d1 / (S * sigma * sqrtT)
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 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 * norm.pdf(d1) * math.sqrt(T) / 100 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 { return {
"price": round(price, 4), "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), "theta": round(theta, 4),
"vega": round(vega, 4), "vega": round(vega, 4),
"rho": round(rho, 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),
} }

View 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

View File

@@ -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]: 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: for leg in legs:
remaining = max(leg["days_to_expiry"] - eval_days_from_now, 0.001) remaining = max(leg["days_to_expiry"] - eval_days_from_now, 0.001)
qty = leg.get("quantity", 1) 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"]) g = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"])
for k in net: for k in net:
net[k] += g[k] * qty * sign 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( def price_combo(
@@ -176,6 +206,9 @@ def price_combo(
"greeks_scenario": greeks_at(legs, spot_scenario, horizon_days, surface_scenario, r), "greeks_scenario": greeks_at(legs, spot_scenario, horizon_days, surface_scenario, r),
"net_delta_now": delta_now, "net_delta_now": delta_now,
"net_delta_scenario": delta_scenario, "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,
}) })

View File

@@ -17,6 +17,122 @@ MAX_SEEDS_FOR_RESIDUAL_SEARCH = 40
RESIDUAL_ITERATIONS_PER_SEED = 8 RESIDUAL_ITERATIONS_PER_SEED = 8
RESIDUAL_MAX_EVALS = 400 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]: 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": if objective == "net_pnl":
@@ -145,7 +261,7 @@ def optimize(
spot_shock_pct: float, spot_shock_pct: float,
iv_level_shift: float, iv_level_shift: float,
skew_tilt: float, skew_tilt: float,
term_shift: float, term_slope_shift: float,
manual_grid: Optional[List[Dict[str, Any]]], manual_grid: Optional[List[Dict[str, Any]]],
n_expiries: int, n_expiries: int,
rate: float, rate: float,
@@ -153,26 +269,31 @@ def optimize(
objective: str, objective: str,
top_n: int = 20, top_n: int = 20,
contract_size: float = DEFAULT_CONTRACT_SIZE, 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]]: ) -> 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_now = build_surface(chain_slice)
surface_scenario = apply_scenario( surface_scenario = apply_scenario(
surface_now, spot_shock_pct=spot_shock_pct, iv_level_shift=iv_level_shift, 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) candidates = generate_all(chain_slice)
scored: List[Dict[str, Any]] = [] scored: List[Dict[str, Any]] = []
for name, legs in candidates: 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: if evaluated:
scored.append(evaluated) scored.append(evaluated)
scored.sort(key=lambda c: c["score"], reverse=True) scored.sort(key=lambda c: c["score"], reverse=True)
seeds = scored[:MAX_SEEDS_FOR_RESIDUAL_SEARCH] 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.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)) return to_native(_dedup_top_n(scored, top_n))

View File

@@ -43,7 +43,13 @@ class Surface:
class ScenarioSurface: 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__( def __init__(
self, self,
@@ -51,14 +57,14 @@ class ScenarioSurface:
scenario_spot: float, scenario_spot: float,
iv_level_shift: float, iv_level_shift: float,
skew_tilt: float, skew_tilt: float,
term_shift: float, term_slope_shift: float,
manual_overrides: Optional[Dict[Tuple[int, float], float]] = None, manual_overrides: Optional[Dict[Tuple[int, float], float]] = None,
): ):
self.base = base self.base = base
self.spot = scenario_spot self.spot = scenario_spot
self.iv_level_shift = iv_level_shift self.iv_level_shift = iv_level_shift
self.skew_tilt = skew_tilt self.skew_tilt = skew_tilt
self.term_shift = term_shift self.term_slope_shift = term_slope_shift
self.manual_overrides = manual_overrides or {} self.manual_overrides = manual_overrides or {}
def iv_at(self, strike: float, days: float) -> float: def iv_at(self, strike: float, days: float) -> float:
@@ -71,7 +77,7 @@ class ScenarioSurface:
base_iv base_iv
+ self.iv_level_shift + self.iv_level_shift
+ self.skew_tilt * moneyness + self.skew_tilt * moneyness
+ self.term_shift * (days / 30.0) + self.term_slope_shift * (days / 30.0)
) )
return max(0.01, shocked) return max(0.01, shocked)
@@ -137,7 +143,7 @@ def apply_scenario(
spot_shock_pct: float = 0.0, spot_shock_pct: float = 0.0,
iv_level_shift: float = 0.0, iv_level_shift: float = 0.0,
skew_tilt: 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, manual_grid: Optional[List[Dict[str, Any]]] = None,
) -> ScenarioSurface: ) -> ScenarioSurface:
""" """
@@ -149,4 +155,4 @@ def apply_scenario(
for cell in (manual_grid or []): for cell in (manual_grid or []):
if cell.get("iv") is not None: if cell.get("iv") is not None:
overrides[(int(cell["days_to_expiry"]), float(cell["strike_pct"]))] = float(cell["iv"]) 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)

BIN
cockpit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

View File

@@ -1677,11 +1677,14 @@ export type StrategyScenario = {
spot_shock_pct: number spot_shock_pct: number
iv_level_shift: number iv_level_shift: number
skew_tilt: number skew_tilt: number
term_shift: number term_slope_shift: number
rate_shock_bps?: number
manual_grid?: ManualGridCell[] manual_grid?: ManualGridCell[]
rate?: number rate?: number
n_expiries?: number n_expiries?: number
contract_size?: number contract_size?: number
dte_min?: number | null
dte_max?: number | null
} }
export type StrategyLeg = { export type StrategyLeg = {
@@ -1693,7 +1696,14 @@ export type StrategyLeg = {
quantity: number quantity: number
} }
export type Greeks = { delta: number; gamma: number; theta: number; vega: number } export type Greeks = {
delta: number; gamma: number; theta: number; vega: number; rho: number
vanna: number; charm: number; vomma: number; veta: number; speed: number; color: number; zomma: number
}
export type VannaSimulation = {
spot_shock_pct: number; iv_shock_pts: number
delta_before: number; delta_after: number; delta_change: number
}
export type PayoffPoint = { underlying: number; pnl: number } export type PayoffPoint = { underlying: number; pnl: number }
export type PriceCombo = { export type PriceCombo = {
@@ -1710,6 +1720,7 @@ export type PriceCombo = {
greeks_scenario: Greeks greeks_scenario: Greeks
net_delta_now: number net_delta_now: number
net_delta_scenario: number net_delta_scenario: number
vanna_simulation: VannaSimulation | null
at_expiry: PayoffPoint[] at_expiry: PayoffPoint[]
at_scenario: PayoffPoint[] at_scenario: PayoffPoint[]
spot: number spot: number
@@ -1722,12 +1733,30 @@ export type StrategyCandidate = {
legs: StrategyLeg[] legs: StrategyLeg[]
score: number score: number
objective: string objective: string
greek_match_score?: number
final_rank_score?: number
} & PriceCombo } & PriceCombo
export const useOptionChainSlice = (symbol: string, horizonDays: number, nExpiries = 3, enabled = true) => export type GreekState = 'strong_negative' | 'negative' | 'neutral' | 'positive' | 'strong_positive' | 'free'
export type GreekTolerance = 'etroite' | 'normale' | 'large'
export type GreekTarget = { state: GreekState; tolerance: GreekTolerance; weight: number }
export type GreekProfile = { delta: GreekTarget; gamma: GreekTarget; theta: GreekTarget; vega: GreekTarget; rho: GreekTarget }
export const FREE_GREEK_TARGET: GreekTarget = { state: 'free', tolerance: 'normale', weight: 50 }
export const DEFAULT_GREEK_PROFILE: GreekProfile = {
delta: { ...FREE_GREEK_TARGET }, gamma: { ...FREE_GREEK_TARGET }, theta: { ...FREE_GREEK_TARGET },
vega: { ...FREE_GREEK_TARGET }, rho: { ...FREE_GREEK_TARGET },
}
export const useOptionChainSlice = (
symbol: string, horizonDays: number, nExpiries = 3, enabled = true,
dteMin?: number | null, dteMax?: number | null,
) =>
useQuery<ChainSlice>({ useQuery<ChainSlice>({
queryKey: ['strategy-builder-chain', symbol, horizonDays, nExpiries], queryKey: ['strategy-builder-chain', symbol, horizonDays, nExpiries, dteMin, dteMax],
queryFn: () => api.get('/strategy-builder/chain', { params: { symbol, horizon_days: horizonDays, n_expiries: nExpiries } }).then(r => r.data), queryFn: () => api.get('/strategy-builder/chain', {
params: { symbol, horizon_days: horizonDays, n_expiries: nExpiries, dte_min: dteMin ?? undefined, dte_max: dteMax ?? undefined },
}).then(r => r.data),
enabled: enabled && !!symbol, enabled: enabled && !!symbol,
staleTime: 30_000, staleTime: 30_000,
retry: 1, retry: 1,
@@ -1747,15 +1776,35 @@ export type OptimizeConstraints = {
top_n?: number top_n?: number
} }
export type OptimizeResponse = { candidates: StrategyCandidate[]; warnings: string[] }
export const useOptimizeStrategy = () => export const useOptimizeStrategy = () =>
useMutation({ useMutation({
mutationFn: (body: { scenario: StrategyScenario; constraints: OptimizeConstraints }) => mutationFn: (body: { scenario: StrategyScenario; constraints: OptimizeConstraints; greek_profile?: GreekProfile }) =>
api.post<StrategyCandidate[]>('/strategy-builder/optimize', body).then(r => r.data), api.post<OptimizeResponse>('/strategy-builder/optimize', body).then(r => r.data),
})
// Mode 1 of the scenario/profile/constraints split — what Greek behavior the scenario
// alone already implies, before the user sets any explicit target (project memory:
// Strategy Builder Greeks plan, Phase 4).
export type SuggestedProfile = {
delta: GreekState; gamma: GreekState; theta: GreekState; vega: GreekState; rho: GreekState
rationale: string[]
reading: { spot_direction: string; spot_speed: string; iv_bucket: string }
}
export const useSuggestedProfile = (scenario: StrategyScenario, enabled: boolean) =>
useQuery<SuggestedProfile>({
queryKey: ['strategy-suggested-profile', scenario.symbol, scenario.spot_shock_pct, scenario.iv_level_shift, scenario.horizon_days],
queryFn: () => api.post('/strategy-builder/suggested-profile', scenario).then(r => r.data),
enabled: enabled && !!scenario.symbol,
staleTime: 30_000,
}) })
export type SavedScenario = { export type SavedScenario = {
id: string; symbol: string; label: string; horizon_days: number id: string; symbol: string; label: string; horizon_days: number
spot_shock_pct: number; iv_level_shift: number; skew_tilt: number; term_shift: number spot_shock_pct: number; iv_level_shift: number; skew_tilt: number; term_slope_shift: number
rate_shock_bps: number; dte_min: number | null; dte_max: number | null
manual_grid: ManualGridCell[]; created_at: string manual_grid: ManualGridCell[]; created_at: string
} }

View File

@@ -5,12 +5,13 @@ import {
import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X } from 'lucide-react' import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useSuggestedProfile,
useScenarios, useSaveScenario, useDeleteScenario, useScenarios, useSaveScenario, useDeleteScenario,
useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy, useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
useWatchlistTickers, useSaxoCatalog, useIvForTrade, useWatchlistTickers, useSaxoCatalog, useIvForTrade,
type StrategyLeg, type StrategyScenario, type PriceCombo, type StrategyCandidate, type StrategyLeg, type StrategyScenario, type PriceCombo, type StrategyCandidate,
type OptimizeConstraints, type SavedScenario, type OptimizeConstraints, type SavedScenario,
type GreekProfile, type GreekTarget, type GreekState, type GreekTolerance, DEFAULT_GREEK_PROFILE,
} from '../hooks/useApi' } from '../hooks/useApi'
import { fmtPrice, fmtAsOf } from '../lib/format' import { fmtPrice, fmtAsOf } from '../lib/format'
@@ -87,14 +88,14 @@ function PayoffChart({ priced, spot, scenarioSpot }: { priced: PriceCombo; spot:
) )
} }
function GreeksTile({ label, now, scenario }: { label: string; now: number; scenario: number }) { function GreeksTile({ label, now, scenario, precision = 4, hint }: { label: string; now: number; scenario: number; precision?: number; hint?: string }) {
return ( return (
<div className="card-sm"> <div className="card-sm" title={hint}>
<div className="stat-label">{label}</div> <div className="stat-label">{label}</div>
<div className="flex items-baseline gap-2 mt-1"> <div className="flex items-baseline gap-2 mt-1">
<span className="text-lg font-bold text-white">{now.toFixed(4)}</span> <span className="text-lg font-bold text-white">{now.toFixed(precision)}</span>
<span className="text-xs text-slate-500"></span> <span className="text-xs text-slate-500"></span>
<span className={clsx('text-sm font-semibold', scenario >= now ? 'text-emerald-400' : 'text-red-400')}>{scenario.toFixed(4)}</span> <span className={clsx('text-sm font-semibold', scenario >= now ? 'text-emerald-400' : 'text-red-400')}>{scenario.toFixed(precision)}</span>
</div> </div>
</div> </div>
) )
@@ -111,16 +112,16 @@ function ScenarioPanel({
watchlistTickers: string[] watchlistTickers: string[]
}) { }) {
const slider = ( const slider = (
key: 'spot_shock_pct' | 'iv_level_shift' | 'skew_tilt' | 'term_shift', key: 'spot_shock_pct' | 'iv_level_shift' | 'skew_tilt' | 'term_slope_shift' | 'rate_shock_bps',
label: string, min: number, max: number, step: number, fmt: (v: number) => string, label: string, min: number, max: number, step: number, fmt: (v: number) => string,
) => ( ) => (
<div> <div>
<div className="flex items-center justify-between text-xs text-slate-400 mb-1"> <div className="flex items-center justify-between text-xs text-slate-400 mb-1">
<span>{label}</span> <span>{label}</span>
<span className="text-white font-semibold">{fmt(scenario[key])}</span> <span className="text-white font-semibold">{fmt(scenario[key] ?? 0)}</span>
</div> </div>
<input <input
type="range" min={min} max={max} step={step} value={scenario[key]} type="range" min={min} max={max} step={step} value={scenario[key] ?? 0}
onChange={(e) => setScenario({ ...scenario, [key]: parseFloat(e.target.value) })} onChange={(e) => setScenario({ ...scenario, [key]: parseFloat(e.target.value) })}
className="w-full accent-blue-500" className="w-full accent-blue-500"
/> />
@@ -152,20 +153,41 @@ function ScenarioPanel({
</datalist> </datalist>
</div> </div>
<div className="w-28"> <div className="w-28">
<label className="stat-label block mb-1">Horizon (j)</label> <label className="stat-label block mb-1" title="Date d'évaluation du P&L du scénario — indépendante des échéances utilisées (voir DTE min/max)">
Horizon (j)
</label>
<input <input
type="number" min={1} max={90} value={horizonDays} type="number" min={1} max={90} value={horizonDays}
onChange={(e) => setHorizonDays(parseInt(e.target.value) || 8)} onChange={(e) => setHorizonDays(parseInt(e.target.value) || 8)}
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white"
/> />
</div> </div>
<div className="w-24">
<label className="stat-label block mb-1" title="Échéances autorisées pour les jambes — laisser vide pour revenir au comportement par défaut (proche de l'horizon)">
DTE min
</label>
<input
type="number" min={0} placeholder="—" value={scenario.dte_min ?? ''}
onChange={(e) => setScenario({ ...scenario, dte_min: e.target.value === '' ? null : parseInt(e.target.value) })}
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white"
/>
</div>
<div className="w-24">
<label className="stat-label block mb-1">DTE max</label>
<input
type="number" min={0} placeholder="—" value={scenario.dte_max ?? ''}
onChange={(e) => setScenario({ ...scenario, dte_max: e.target.value === '' ? null : parseInt(e.target.value) })}
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white"
/>
</div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{slider('spot_shock_pct', 'Choc spot', -20, 20, 0.5, (v) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`)} {slider('spot_shock_pct', 'Choc spot', -20, 20, 0.5, (v) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`)}
{slider('iv_level_shift', 'Choc niveau IV', -0.15, 0.15, 0.005, (v) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}pts`)} {slider('iv_level_shift', 'Choc niveau IV', -0.15, 0.15, 0.005, (v) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}pts`)}
{slider('skew_tilt', 'Tilt skew', -0.1, 0.1, 0.005, (v) => v.toFixed(3))} {slider('skew_tilt', 'Tilt skew', -0.1, 0.1, 0.005, (v) => v.toFixed(3))}
{slider('term_shift', 'Choc terme (/30j)', -0.1, 0.1, 0.005, (v) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}pts`)} {slider('term_slope_shift', 'Pente du terme (/30j)', -0.1, 0.1, 0.005, (v) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}pts`)}
{slider('rate_shock_bps', 'Choc de taux', -200, 200, 5, (v) => `${v >= 0 ? '+' : ''}${v.toFixed(0)}bps`)}
</div> </div>
</div> </div>
) )
@@ -187,7 +209,7 @@ function ScenarioGrid({
const base = estimateBaseIv(chain, daysToExpiry, strikePct, spot) const base = estimateBaseIv(chain, daysToExpiry, strikePct, spot)
if (base == null) return null if (base == null) return null
const moneyness = Math.log(strikePct / 100) const moneyness = Math.log(strikePct / 100)
return Math.max(0.01, base + scenario.iv_level_shift + scenario.skew_tilt * moneyness + scenario.term_shift * (daysToExpiry / 30)) return Math.max(0.01, base + scenario.iv_level_shift + scenario.skew_tilt * moneyness + scenario.term_slope_shift * (daysToExpiry / 30))
} }
const setOverride = (daysToExpiry: number, strikePct: number, iv: number | null) => { const setOverride = (daysToExpiry: number, strikePct: number, iv: number | null) => {
@@ -468,6 +490,134 @@ function OptimizerPanel({
) )
} }
const GREEK_STATES: { value: GreekState; label: string }[] = [
{ value: 'strong_negative', label: 'Fortement négatif' },
{ value: 'negative', label: 'Négatif' },
{ value: 'neutral', label: 'Neutre' },
{ value: 'positive', label: 'Positif' },
{ value: 'strong_positive', label: 'Fortement positif' },
{ value: 'free', label: 'Libre / non contraint' },
]
const GREEK_TOLERANCES: { value: GreekTolerance; label: string }[] = [
{ value: 'etroite', label: 'Étroite' },
{ value: 'normale', label: 'Normale' },
{ value: 'large', label: 'Large' },
]
const GREEK_ROWS: { key: keyof GreekProfile; label: string; hint: string }[] = [
{ key: 'delta', label: 'Delta', hint: 'Exposition directionnelle immédiate' },
{ key: 'gamma', label: 'Gamma', hint: 'Le Delta saméliore-t-il avec le mouvement ?' },
{ key: 'theta', label: 'Theta', hint: 'Collecte de prime (+) ou achat de temps (-)' },
{ key: 'vega', label: 'Vega', hint: 'Acheteur (+) ou vendeur (-) de volatilité' },
{ key: 'rho', label: 'Rho', hint: 'Sensibilité au taux surtout utile en LEAPS/futures' },
]
const GREEK_STATE_LABEL: Record<GreekState, string> = {
strong_negative: 'Fortement négatif', negative: 'Négatif', neutral: 'Neutre',
positive: 'Positif', strong_positive: 'Fortement positif', free: 'Libre',
}
function SuggestedProfileCard({
scenario, enabled, onAdopt,
}: { scenario: StrategyScenario; enabled: boolean; onAdopt: (states: Record<'delta' | 'gamma' | 'theta' | 'vega' | 'rho', GreekState>) => void }) {
const { data } = useSuggestedProfile(scenario, enabled)
if (!data) return null
const rows: { key: 'delta' | 'gamma' | 'theta' | 'vega' | 'rho'; label: string }[] = [
{ key: 'delta', label: 'Delta' }, { key: 'gamma', label: 'Gamma' }, { key: 'theta', label: 'Theta' }, { key: 'vega', label: 'Vega' },
]
const active = rows.filter(r => data[r.key] !== 'free')
if (active.length === 0) return null
return (
<div className="card border-slate-700/50 bg-dark-700/20 space-y-2">
<div className="flex items-center justify-between">
<div className="stat-label">Profil suggéré par le scénario seul (avant vos propres cibles)</div>
<button
onClick={() => onAdopt({
delta: data.delta, gamma: data.gamma, theta: data.theta, vega: data.vega, rho: data.rho,
})}
className="text-[11px] bg-blue-600/80 hover:bg-blue-500 text-white px-2 py-1 rounded font-semibold"
>
Adopter ce profil
</button>
</div>
<div className="flex flex-wrap gap-2">
{active.map(r => (
<span key={r.key} className="text-[11px] bg-dark-700/60 border border-slate-700/40 rounded px-2 py-0.5 text-slate-200">
{r.label} : <span className="font-semibold">{GREEK_STATE_LABEL[data[r.key]]}</span>
</span>
))}
</div>
<ul className="text-[11px] text-slate-500 space-y-0.5 list-disc list-inside">
{data.rationale.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)
}
function GreekProfilePanel({
profile, setProfile,
}: { profile: GreekProfile; setProfile: (v: GreekProfile) => void }) {
const setTarget = (key: keyof GreekProfile, patch: Partial<GreekTarget>) =>
setProfile({ ...profile, [key]: { ...profile[key], ...patch } })
const anyActive = Object.values(profile).some(t => t.state !== 'free')
return (
<div className="card space-y-3">
<div className="flex items-center justify-between">
<div className="stat-label">Profil recherché — comportement Greeks, pas un nouveau scénario</div>
{anyActive && (
<button onClick={() => setProfile(DEFAULT_GREEK_PROFILE)} className="text-[10px] text-slate-500 hover:text-slate-300">
Réinitialiser
</button>
)}
</div>
<p className="text-[11px] text-slate-500">
Le scénario ci-dessus produit déjà naturellement certains Greeks — ces contrôles servent à favoriser,
tolérer ou interdire certaines expositions parmi les candidats trouvés, pas à décrire un second scénario.
</p>
<div className="space-y-2">
{GREEK_ROWS.map(({ key, label, hint }) => {
const t = profile[key]
const isFree = t.state === 'free'
return (
<div key={key} className={clsx('grid grid-cols-12 gap-2 items-center text-xs rounded px-2 py-1.5',
isFree ? 'bg-transparent' : 'bg-dark-700/40')}>
<div className="col-span-2">
<div className="text-slate-200 font-medium">{label}</div>
<div className="text-[9px] text-slate-600 leading-tight" title={hint}>{hint}</div>
</div>
<select
value={t.state}
onChange={(e) => setTarget(key, { state: e.target.value as GreekState })}
className="col-span-4 bg-dark-700 border border-slate-700/50 rounded px-2 py-1 text-slate-200"
>
{GREEK_STATES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
<select
value={t.tolerance}
disabled={isFree}
onChange={(e) => setTarget(key, { tolerance: e.target.value as GreekTolerance })}
className="col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1 text-slate-200 disabled:opacity-30"
>
{GREEK_TOLERANCES.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<input
type="range" min={0} max={100} step={5} value={t.weight} disabled={isFree}
onChange={(e) => setTarget(key, { weight: parseFloat(e.target.value) })}
className="col-span-3 accent-blue-500 disabled:opacity-30"
/>
<span className={clsx('col-span-1 text-right font-mono', isFree ? 'text-slate-700' : 'text-slate-300')}>
{isFree ? '' : `${t.weight.toFixed(0)}%`}
</span>
</div>
)
})}
</div>
</div>
)
}
function ResultsTable({ results, onSelect }: { results: StrategyCandidate[]; onSelect: (c: StrategyCandidate) => void }) { function ResultsTable({ results, onSelect }: { results: StrategyCandidate[]; onSelect: (c: StrategyCandidate) => void }) {
if (!results.length) return <div className="card-sm text-xs text-slate-500">Aucun candidat ne satisfait les contraintes — élargissez le seuil de delta ou le plafond de perte.</div> if (!results.length) return <div className="card-sm text-xs text-slate-500">Aucun candidat ne satisfait les contraintes — élargissez le seuil de delta ou le plafond de perte.</div>
return ( return (
@@ -483,6 +633,7 @@ function ResultsTable({ results, onSelect }: { results: StrategyCandidate[]; onS
<th className="py-1 pr-3 text-right" title="À l'échéance de la jambe la plus proche, sous la même vue de vol que le scénario. Approximatif (balayage rapide pour classer des centaines de candidats) se précise après «Charger».">Max gain</th> <th className="py-1 pr-3 text-right" title="À l'échéance de la jambe la plus proche, sous la même vue de vol que le scénario. Approximatif (balayage rapide pour classer des centaines de candidats) se précise après «Charger».">Max gain</th>
<th className="py-1 pr-3 text-right" title="À l'échéance de la jambe la plus proche, sous la même vue de vol que le scénario. Approximatif (balayage rapide pour classer des centaines de candidats) — se précise après «Charger».">Max perte</th> <th className="py-1 pr-3 text-right" title="À l'échéance de la jambe la plus proche, sous la même vue de vol que le scénario. Approximatif (balayage rapide pour classer des centaines de candidats) — se précise après «Charger».">Max perte</th>
<th className="py-1 pr-3 text-right">Δ net</th> <th className="py-1 pr-3 text-right">Δ net</th>
<th className="py-1 pr-3 text-right" title="Correspondance avec le profil de Greeks demandé (0-100%) — n'apparaît que si un profil est actif.">Profil</th>
<th className="py-1 pr-1"></th> <th className="py-1 pr-1"></th>
</tr> </tr>
</thead> </thead>
@@ -496,6 +647,9 @@ function ResultsTable({ results, onSelect }: { results: StrategyCandidate[]; onS
<td className="py-1.5 pr-3 text-right text-emerald-400">{r.max_gain != null ? fmtMoney(r.max_gain) : '∞'}</td> <td className="py-1.5 pr-3 text-right text-emerald-400">{r.max_gain != null ? fmtMoney(r.max_gain) : '∞'}</td>
<td className="py-1.5 pr-3 text-right text-red-400">{r.max_loss != null ? fmtMoney(r.max_loss) : '−∞'}</td> <td className="py-1.5 pr-3 text-right text-red-400">{r.max_loss != null ? fmtMoney(r.max_loss) : '−∞'}</td>
<td className="py-1.5 pr-3 text-right text-slate-400">{r.net_delta_now.toFixed(3)}</td> <td className="py-1.5 pr-3 text-right text-slate-400">{r.net_delta_now.toFixed(3)}</td>
<td className="py-1.5 pr-3 text-right text-slate-400">
{r.greek_match_score != null ? `${(r.greek_match_score * 100).toFixed(0)}%` : '—'}
</td>
<td className="py-1.5 pr-1 text-blue-400 text-right">Charger →</td> <td className="py-1.5 pr-1 text-blue-400 text-right">Charger →</td>
</tr> </tr>
))} ))}
@@ -585,7 +739,8 @@ export default function StrategyBuilder() {
const [debouncedSymbol, setDebouncedSymbol] = useState('') const [debouncedSymbol, setDebouncedSymbol] = useState('')
const [horizonDays, setHorizonDays] = useState(8) const [horizonDays, setHorizonDays] = useState(8)
const [scenario, setScenario] = useState<StrategyScenario>({ const [scenario, setScenario] = useState<StrategyScenario>({
symbol: '', horizon_days: 8, spot_shock_pct: 0, iv_level_shift: 0, skew_tilt: 0, term_shift: 0, manual_grid: [], symbol: '', horizon_days: 8, spot_shock_pct: 0, iv_level_shift: 0, skew_tilt: 0, term_slope_shift: 0,
rate_shock_bps: 0, dte_min: null, dte_max: null, manual_grid: [],
contract_size: 100_000, contract_size: 100_000,
}) })
@@ -599,6 +754,8 @@ export default function StrategyBuilder() {
const [constraints, setConstraints] = useState<OptimizeConstraints>({ const [constraints, setConstraints] = useState<OptimizeConstraints>({
max_legs: 4, delta_threshold: 0.15, max_loss_cap: null, objective: 'net_pnl', top_n: 20, max_legs: 4, delta_threshold: 0.15, max_loss_cap: null, objective: 'net_pnl', top_n: 20,
}) })
const [greekProfile, setGreekProfile] = useState<GreekProfile>(DEFAULT_GREEK_PROFILE)
const [showAdvancedGreeks, setShowAdvancedGreeks] = useState(false)
const [activeTemplate, setActiveTemplate] = useState<string | null>(null) const [activeTemplate, setActiveTemplate] = useState<string | null>(null)
const { data: watchlistData } = useWatchlistTickers() const { data: watchlistData } = useWatchlistTickers()
@@ -609,7 +766,7 @@ export default function StrategyBuilder() {
])).sort() ])).sort()
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) useOptionChainSlice(debouncedSymbol, horizonDays, 3, true, scenario.dte_min, scenario.dte_max)
const { data: ivForTrade } = useIvForTrade(debouncedSymbol) const { data: ivForTrade } = useIvForTrade(debouncedSymbol)
useEffect(() => { useEffect(() => {
@@ -655,7 +812,7 @@ export default function StrategyBuilder() {
const handleOptimize = () => { const handleOptimize = () => {
setActiveTemplate(null) setActiveTemplate(null)
optimizeMutation.mutate({ scenario, constraints }) optimizeMutation.mutate({ scenario, constraints, greek_profile: greekProfile })
} }
const handleSelectCandidate = (c: StrategyCandidate) => { const handleSelectCandidate = (c: StrategyCandidate) => {
@@ -668,7 +825,8 @@ export default function StrategyBuilder() {
setHorizonDays(s.horizon_days) setHorizonDays(s.horizon_days)
setScenario(prev => ({ setScenario(prev => ({
symbol: s.symbol, horizon_days: s.horizon_days, spot_shock_pct: s.spot_shock_pct, symbol: s.symbol, horizon_days: s.horizon_days, spot_shock_pct: s.spot_shock_pct,
iv_level_shift: s.iv_level_shift, skew_tilt: s.skew_tilt, term_shift: s.term_shift, iv_level_shift: s.iv_level_shift, skew_tilt: s.skew_tilt, term_slope_shift: s.term_slope_shift,
rate_shock_bps: s.rate_shock_bps, dte_min: s.dte_min, dte_max: s.dte_max,
manual_grid: s.manual_grid, contract_size: prev.contract_size, manual_grid: s.manual_grid, contract_size: prev.contract_size,
})) }))
} }
@@ -775,7 +933,20 @@ export default function StrategyBuilder() {
)} )}
{chain && ( {chain && (
<OptimizerPanel constraints={constraints} setConstraints={setConstraints} onRun={handleOptimize} isRunning={optimizeMutation.isPending} /> <>
<SuggestedProfileCard
scenario={scenario} enabled={!!chain}
onAdopt={(states) => setGreekProfile({
delta: { state: states.delta, tolerance: 'normale', weight: 60 },
gamma: { state: states.gamma, tolerance: 'normale', weight: 60 },
theta: { state: states.theta, tolerance: 'normale', weight: 60 },
vega: { state: states.vega, tolerance: 'normale', weight: 60 },
rho: { state: states.rho, tolerance: 'normale', weight: 60 },
})}
/>
<GreekProfilePanel profile={greekProfile} setProfile={setGreekProfile} />
<OptimizerPanel constraints={constraints} setConstraints={setConstraints} onRun={handleOptimize} isRunning={optimizeMutation.isPending} />
</>
)} )}
{optimizeMutation.isError && ( {optimizeMutation.isError && (
@@ -783,8 +954,18 @@ export default function StrategyBuilder() {
{(optimizeMutation.error as any)?.response?.data?.detail ?? "Erreur lors de l'optimisation."} {(optimizeMutation.error as any)?.response?.data?.detail ?? "Erreur lors de l'optimisation."}
</div> </div>
)} )}
{optimizeMutation.data && optimizeMutation.data.warnings.length > 0 && (
<div className="space-y-1.5">
{optimizeMutation.data.warnings.map((w, i) => (
<div key={i} className="flex items-start gap-2 px-3 py-2 rounded border border-amber-700/40 bg-amber-900/10 text-xs text-amber-300">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<span>{w}</span>
</div>
))}
</div>
)}
{optimizeMutation.data && ( {optimizeMutation.data && (
<ResultsTable results={optimizeMutation.data} onSelect={handleSelectCandidate} /> <ResultsTable results={optimizeMutation.data.candidates} onSelect={handleSelectCandidate} />
)} )}
{priceMutation.isPending && <div className="card-sm text-xs text-slate-500">Calcul en cours…</div>} {priceMutation.isPending && <div className="card-sm text-xs text-slate-500">Calcul en cours…</div>}
@@ -843,11 +1024,56 @@ export default function StrategyBuilder() {
</p> </p>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> <div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<GreeksTile label="Delta net" now={priced.greeks_now.delta} scenario={priced.greeks_scenario.delta} /> <GreeksTile label="Delta net" now={priced.greeks_now.delta} scenario={priced.greeks_scenario.delta} />
<GreeksTile label="Gamma net" now={priced.greeks_now.gamma} scenario={priced.greeks_scenario.gamma} /> <GreeksTile label="Gamma net" now={priced.greeks_now.gamma} scenario={priced.greeks_scenario.gamma} />
<GreeksTile label="Theta net" now={priced.greeks_now.theta} scenario={priced.greeks_scenario.theta} /> <GreeksTile label="Theta net" now={priced.greeks_now.theta} scenario={priced.greeks_scenario.theta} />
<GreeksTile label="Vega net" now={priced.greeks_now.vega} scenario={priced.greeks_scenario.vega} /> <GreeksTile label="Vega net" now={priced.greeks_now.vega} scenario={priced.greeks_scenario.vega} />
<GreeksTile label="Rho net" now={priced.greeks_now.rho} scenario={priced.greeks_scenario.rho} />
</div>
{priced.vanna_simulation && (
<div className="card border-blue-700/30 bg-blue-900/10">
<div className="stat-label mb-1">Simulation Vanna — pas juste un signe</div>
<p className="text-sm text-slate-200">
Spot <span className="font-semibold">{priced.vanna_simulation.spot_shock_pct}%</span>{' '}
et IV <span className="font-semibold">+{priced.vanna_simulation.iv_shock_pts}pts</span> →
{' '}Delta net passe de{' '}
<span className="font-mono font-semibold">{priced.vanna_simulation.delta_before.toFixed(4)}</span>
{' '}à{' '}
<span className="font-mono font-semibold">{priced.vanna_simulation.delta_after.toFixed(4)}</span>
<span className={clsx('ml-2 font-mono font-bold', priced.vanna_simulation.delta_change >= 0 ? 'text-emerald-400' : 'text-red-400')}>
({priced.vanna_simulation.delta_change >= 0 ? '+' : ''}{priced.vanna_simulation.delta_change.toFixed(4)})
</span>
</p>
<p className="text-[11px] text-slate-500 mt-1">
Repricing Black-Scholes réel sous ce choc conjoint — pas une approximation linéaire, valable même pour un choc large.
</p>
</div>
)}
<div className="card">
<button onClick={() => setShowAdvancedGreeks(v => !v)} className="stat-label flex items-center gap-1.5 w-full">
{showAdvancedGreeks ? '' : ''} Sensibilités avancées (second ordre)
</button>
{showAdvancedGreeks && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3">
<GreeksTile label="Vanna" precision={6} hint="∂Delta/∂IV — interaction spot/vol, par point d'IV"
now={priced.greeks_now.vanna} scenario={priced.greeks_scenario.vanna} />
<GreeksTile label="Charm" precision={6} hint="Delta/temps dérive du Delta par jour"
now={priced.greeks_now.charm} scenario={priced.greeks_scenario.charm} />
<GreeksTile label="Vomma" precision={6} hint="Vega/IV convexité de volatilité, par point d'IV"
now={priced.greeks_now.vomma} scenario={priced.greeks_scenario.vomma} />
<GreeksTile label="Veta" precision={6} hint="Vega/temps dérive du Vega par jour"
now={priced.greeks_now.veta} scenario={priced.greeks_scenario.veta} />
<GreeksTile label="Speed" precision={8} hint="Gamma/spot se déplace la convexité"
now={priced.greeks_now.speed} scenario={priced.greeks_scenario.speed} />
<GreeksTile label="Color" precision={8} hint="Gamma/temps dérive du Gamma par jour"
now={priced.greeks_now.color} scenario={priced.greeks_scenario.color} />
<GreeksTile label="Zomma" precision={6} hint="Gamma/IV le Gamma survit-il à un choc de vol ?"
now={priced.greeks_now.zomma} scenario={priced.greeks_scenario.zomma} />
</div>
)}
</div> </div>
</> </>
)} )}

BIN
macro_regime.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

BIN
option_lab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

BIN
strategy_builder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

BIN
var.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

BIN
wavelets.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB