feat: strategy builder
This commit is contained in:
558
OpenFin_Intelligence_Brochure.html
Normal file
558
OpenFin_Intelligence_Brochure.html
Normal file
File diff suppressed because one or more lines are too long
BIN
OpenFin_Intelligence_Brochure.pdf
Normal file
BIN
OpenFin_Intelligence_Brochure.pdf
Normal file
Binary file not shown.
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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]:
|
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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
BIN
cockpit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 639 KiB |
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 s’amé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 — où 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
BIN
macro_regime.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 357 KiB |
BIN
option_lab.png
Normal file
BIN
option_lab.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
BIN
strategy_builder.png
Normal file
BIN
strategy_builder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 317 KiB |
BIN
wavelets.png
Normal file
BIN
wavelets.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 419 KiB |
Reference in New Issue
Block a user