feat: strategy builder
This commit is contained in:
@@ -5,7 +5,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from services.option_chain import get_chain_slice
|
from services.option_chain import get_chain_slice
|
||||||
from services.vol_surface import build_surface, apply_scenario
|
from services.vol_surface import build_surface, apply_scenario
|
||||||
from services.strategy_engine import payoff_curves
|
from services.strategy_engine import payoff_curves, DEFAULT_CONTRACT_SIZE
|
||||||
from services.strategy_optimizer import optimize as run_optimizer
|
from services.strategy_optimizer import optimize as run_optimizer
|
||||||
from services.database import (
|
from services.database import (
|
||||||
save_scenario, get_scenarios, delete_scenario,
|
save_scenario, get_scenarios, delete_scenario,
|
||||||
@@ -34,6 +34,7 @@ class ScenarioIn(BaseModel):
|
|||||||
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
|
||||||
|
|
||||||
|
|
||||||
class PriceRequest(BaseModel):
|
class PriceRequest(BaseModel):
|
||||||
@@ -121,6 +122,7 @@ def price(req: PriceRequest):
|
|||||||
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.rate,
|
||||||
|
contract_size=req.scenario.contract_size,
|
||||||
)
|
)
|
||||||
result["spot"] = chain_slice["spot"]
|
result["spot"] = chain_slice["spot"]
|
||||||
result["scenario_spot"] = surface_scenario.spot
|
result["scenario_spot"] = surface_scenario.spot
|
||||||
@@ -146,6 +148,7 @@ def optimize(req: OptimizeRequest):
|
|||||||
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,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import httpx
|
|||||||
|
|
||||||
from services.options_pricer import black_scholes
|
from services.options_pricer import black_scholes
|
||||||
from services.saxo_auth import SAXO_API_BASE_URL, get_valid_access_token
|
from services.saxo_auth import SAXO_API_BASE_URL, get_valid_access_token
|
||||||
|
from services.vol_surface import Surface
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -278,8 +279,12 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str,
|
|||||||
Returns normalized rows ready for services/database.save_saxo_snapshot_rows:
|
Returns normalized rows ready for services/database.save_saxo_snapshot_rows:
|
||||||
{symbol, snapshot_date, spot, expiry_date, strike, option_type, bid, ask, mid,
|
{symbol, snapshot_date, spot, expiry_date, strike, option_type, bid, ask, mid,
|
||||||
volatility_pct, delta, gamma, theta, vega, is_synthetic}. bid/ask/mid are
|
volatility_pct, delta, gamma, theta, vega, is_synthetic}. bid/ask/mid are
|
||||||
Black-Scholes-synthesized from IV (is_synthetic=True) whenever Saxo returns no live
|
Black-Scholes-synthesized (is_synthetic=True) whenever Saxo returns no live Bid/Ask for
|
||||||
Bid/Ask for that contract (e.g. FX options outside market hours).
|
that contract (e.g. FX options outside market hours) — using that contract's own IV
|
||||||
|
when Saxo quoted it, or otherwise an IV borrowed from a smile built across whatever
|
||||||
|
strikes/expiries in this same snapshot DID carry a live MidVolatility (Saxo's "active
|
||||||
|
quoting window" is often just the near-the-money strikes on the nearest expiry; the
|
||||||
|
rest of the chain has no Greeks/MidVolatility at all, not just no Bid/Ask).
|
||||||
"""
|
"""
|
||||||
instrument = resolve_instrument(symbol)
|
instrument = resolve_instrument(symbol)
|
||||||
root_uic = instrument["uic"]
|
root_uic = instrument["uic"]
|
||||||
@@ -295,10 +300,15 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str,
|
|||||||
# real payload) — MidStrikePrice on the nearest expiry is the best available proxy.
|
# real payload) — MidStrikePrice on the nearest expiry is the best available proxy.
|
||||||
spot = next((eb.get("MidStrikePrice") for eb in expiry_blocks if eb.get("MidStrikePrice") is not None), None)
|
spot = next((eb.get("MidStrikePrice") for eb in expiry_blocks if eb.get("MidStrikePrice") is not None), None)
|
||||||
|
|
||||||
rows: List[Dict[str, Any]] = []
|
# First pass: take exactly what Saxo quoted, no synthesis yet.
|
||||||
|
raw: List[Dict[str, Any]] = []
|
||||||
for expiry_block in expiry_blocks:
|
for expiry_block in expiry_blocks:
|
||||||
expiry_date = (expiry_block.get("Expiry") or "")[:10] or None
|
expiry_date = (expiry_block.get("Expiry") or "")[:10] or None
|
||||||
for strike_block in (strike_block for strike_block in (expiry_block.get("Strikes") or [])):
|
try:
|
||||||
|
days_to_expiry = (date.fromisoformat(expiry_date) - date.fromisoformat(snapshot_date)).days if expiry_date else None
|
||||||
|
except ValueError:
|
||||||
|
days_to_expiry = None
|
||||||
|
for strike_block in (expiry_block.get("Strikes") or []):
|
||||||
strike = strike_block.get("Strike")
|
strike = strike_block.get("Strike")
|
||||||
for side_key in ("Call", "Put"):
|
for side_key in ("Call", "Put"):
|
||||||
side = strike_block.get(side_key)
|
side = strike_block.get(side_key)
|
||||||
@@ -307,38 +317,68 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str,
|
|||||||
greeks = side.get("Greeks") or {}
|
greeks = side.get("Greeks") or {}
|
||||||
bid, ask = side.get("Bid"), side.get("Ask")
|
bid, ask = side.get("Bid"), side.get("Ask")
|
||||||
mid_vol = greeks.get("MidVolatility")
|
mid_vol = greeks.get("MidVolatility")
|
||||||
option_type = "put" if side_key == "Put" else "call"
|
raw.append({
|
||||||
vol_pct = round(mid_vol * 100, 4) if mid_vol is not None else None
|
|
||||||
mid = round((bid + ask) / 2, 6) if (bid is not None and ask is not None) else None
|
|
||||||
|
|
||||||
is_synthetic = False
|
|
||||||
if not bid and not ask:
|
|
||||||
syn_bid, syn_ask, syn_mid = _synthesize_quote(
|
|
||||||
spot, strike, expiry_date, snapshot_date, vol_pct, option_type,
|
|
||||||
)
|
|
||||||
if syn_bid is not None:
|
|
||||||
bid, ask, mid, is_synthetic = syn_bid, syn_ask, syn_mid, True
|
|
||||||
|
|
||||||
rows.append({
|
|
||||||
"symbol": symbol.upper(),
|
"symbol": symbol.upper(),
|
||||||
"snapshot_date": snapshot_date,
|
"snapshot_date": snapshot_date,
|
||||||
"spot": float(spot) if spot is not None else None,
|
"spot": float(spot) if spot is not None else None,
|
||||||
"expiry_date": expiry_date,
|
"expiry_date": expiry_date,
|
||||||
|
"days_to_expiry": days_to_expiry,
|
||||||
"strike": float(strike) if strike is not None else None,
|
"strike": float(strike) if strike is not None else None,
|
||||||
"option_type": option_type,
|
"option_type": "put" if side_key == "Put" else "call",
|
||||||
"bid": bid,
|
"bid": bid,
|
||||||
"ask": ask,
|
"ask": ask,
|
||||||
"mid": mid,
|
"mid": round((bid + ask) / 2, 6) if (bid is not None and ask is not None) else None,
|
||||||
# MidVolatility comes back as a decimal fraction (0.05 = 5%) — store as an
|
# MidVolatility comes back as a decimal fraction (0.05 = 5%) — store as an
|
||||||
# actual percentage to match the volatility_pct column's name/convention.
|
# actual percentage to match the volatility_pct column's name/convention.
|
||||||
"volatility_pct": vol_pct,
|
"volatility_pct": round(mid_vol * 100, 4) if mid_vol is not None else None,
|
||||||
"delta": greeks.get("Delta"),
|
"delta": greeks.get("Delta"),
|
||||||
"gamma": greeks.get("Gamma"),
|
"gamma": greeks.get("Gamma"),
|
||||||
"theta": greeks.get("Theta"),
|
"theta": greeks.get("Theta"),
|
||||||
"vega": greeks.get("Vega"),
|
"vega": greeks.get("Vega"),
|
||||||
"is_synthetic": is_synthetic,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if not rows:
|
if not raw:
|
||||||
raise ValueError(f"Snapshot Saxo vide pour '{symbol}' (clés reçues: {list(snapshot.keys())})")
|
raise ValueError(f"Snapshot Saxo vide pour '{symbol}' (clés reçues: {list(snapshot.keys())})")
|
||||||
|
|
||||||
|
fallback_surface = _build_fallback_surface(spot, raw)
|
||||||
|
|
||||||
|
rows: List[Dict[str, Any]] = []
|
||||||
|
for r in raw:
|
||||||
|
bid, ask, mid, vol_pct = r["bid"], r["ask"], r["mid"], r["volatility_pct"]
|
||||||
|
is_synthetic = False
|
||||||
|
if not bid and not ask:
|
||||||
|
iv_for_synth = vol_pct
|
||||||
|
if iv_for_synth is None and fallback_surface is not None and r["strike"] and r["days_to_expiry"]:
|
||||||
|
iv_for_synth = round(fallback_surface.iv_at(r["strike"], max(r["days_to_expiry"], 1)) * 100, 4)
|
||||||
|
syn_bid, syn_ask, syn_mid = _synthesize_quote(
|
||||||
|
r["spot"], r["strike"], r["expiry_date"], snapshot_date, iv_for_synth, r["option_type"],
|
||||||
|
)
|
||||||
|
if syn_bid is not None:
|
||||||
|
bid, ask, mid, is_synthetic = syn_bid, syn_ask, syn_mid, True
|
||||||
|
if vol_pct is None:
|
||||||
|
vol_pct = iv_for_synth
|
||||||
|
|
||||||
|
rows.append({
|
||||||
|
**{k: v for k, v in r.items() if k != "days_to_expiry"},
|
||||||
|
"bid": bid, "ask": ask, "mid": mid, "volatility_pct": vol_pct,
|
||||||
|
"is_synthetic": is_synthetic,
|
||||||
|
})
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _build_fallback_surface(spot: Optional[float], raw_rows: List[Dict[str, Any]]) -> Optional[Surface]:
|
||||||
|
"""A smile built only from strikes/expiries that carried a live MidVolatility in this
|
||||||
|
same snapshot — used to borrow a plausible IV for contracts Saxo didn't quote at all."""
|
||||||
|
if not spot:
|
||||||
|
return None
|
||||||
|
by_days: Dict[float, Dict[str, Any]] = {}
|
||||||
|
for r in raw_rows:
|
||||||
|
if r["volatility_pct"] is None or r["days_to_expiry"] is None or r["strike"] is None:
|
||||||
|
continue
|
||||||
|
exp = by_days.setdefault(r["days_to_expiry"], {"days_to_expiry": r["days_to_expiry"], "calls": [], "puts": []})
|
||||||
|
entry = {"strike": r["strike"], "iv": r["volatility_pct"] / 100.0}
|
||||||
|
(exp["calls"] if r["option_type"] == "call" else exp["puts"]).append(entry)
|
||||||
|
if not by_days:
|
||||||
|
return None
|
||||||
|
return Surface(spot, list(by_days.values()))
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from services.option_chain import find_quote
|
|||||||
from services.vol_surface import Surface, ScenarioSurface
|
from services.vol_surface import Surface, ScenarioSurface
|
||||||
|
|
||||||
DEFAULT_SPREAD_PCT = 0.05 # fallback relative bid/ask spread when no live quote is found
|
DEFAULT_SPREAD_PCT = 0.05 # fallback relative bid/ask spread when no live quote is found
|
||||||
|
DEFAULT_CONTRACT_SIZE = 100_000 # notional per 1 contract/lot (e.g. a standard FX lot); "quantity" on a leg is the number of these
|
||||||
|
|
||||||
|
|
||||||
def to_native(obj: Any) -> Any:
|
def to_native(obj: Any) -> Any:
|
||||||
@@ -77,6 +78,7 @@ def value_at(
|
|||||||
eval_days_from_now: float,
|
eval_days_from_now: float,
|
||||||
surface: Any,
|
surface: Any,
|
||||||
r: float,
|
r: float,
|
||||||
|
contract_size: float = DEFAULT_CONTRACT_SIZE,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Signed portfolio value (BS reprice for unexpired legs, intrinsic for expired ones)."""
|
"""Signed portfolio value (BS reprice for unexpired legs, intrinsic for expired ones)."""
|
||||||
total = 0.0
|
total = 0.0
|
||||||
@@ -89,7 +91,7 @@ def value_at(
|
|||||||
else:
|
else:
|
||||||
sigma = surface.iv_at(leg["strike"], remaining)
|
sigma = surface.iv_at(leg["strike"], remaining)
|
||||||
price = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"])["price"]
|
price = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"])["price"]
|
||||||
total += sign * price * qty * 100
|
total += sign * price * qty * contract_size
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
@@ -113,6 +115,7 @@ def price_combo(
|
|||||||
surface_scenario: ScenarioSurface,
|
surface_scenario: ScenarioSurface,
|
||||||
horizon_days: int,
|
horizon_days: int,
|
||||||
r: float = 0.05,
|
r: float = 0.05,
|
||||||
|
contract_size: float = DEFAULT_CONTRACT_SIZE,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
spot_now = chain_slice["spot"]
|
spot_now = chain_slice["spot"]
|
||||||
spot_scenario = surface_scenario.spot
|
spot_scenario = surface_scenario.spot
|
||||||
@@ -123,8 +126,8 @@ def price_combo(
|
|||||||
ep = entry_price(leg, chain_slice, surface_now, r)
|
ep = entry_price(leg, chain_slice, surface_now, r)
|
||||||
sign = _sign(leg)
|
sign = _sign(leg)
|
||||||
qty = leg.get("quantity", 1)
|
qty = leg.get("quantity", 1)
|
||||||
entry_ref += sign * ep["exec_price"] * qty * 100
|
entry_ref += sign * ep["exec_price"] * qty * contract_size
|
||||||
entry_ref_mid += sign * ep["mid"] * qty * 100
|
entry_ref_mid += sign * ep["mid"] * qty * contract_size
|
||||||
|
|
||||||
# Scenario exit: apply each leg's own bid/ask spread (est. from entry quote) to the
|
# Scenario exit: apply each leg's own bid/ask spread (est. from entry quote) to the
|
||||||
# theoretical scenario value, since we don't have a live quote for the future date.
|
# theoretical scenario value, since we don't have a live quote for the future date.
|
||||||
@@ -139,8 +142,8 @@ def price_combo(
|
|||||||
quote = find_quote(chain_slice, leg["expiry_date"], leg["strike"], leg["option_type"])
|
quote = find_quote(chain_slice, leg["expiry_date"], leg["strike"], leg["option_type"])
|
||||||
spread_pct = _quote_spread_pct(quote)
|
spread_pct = _quote_spread_pct(quote)
|
||||||
exec_price = theo * (1 - spread_pct / 2) if leg.get("position", "long") == "long" else theo * (1 + spread_pct / 2)
|
exec_price = theo * (1 - spread_pct / 2) if leg.get("position", "long") == "long" else theo * (1 + spread_pct / 2)
|
||||||
scenario_mid += sign * theo * qty * 100
|
scenario_mid += sign * theo * qty * contract_size
|
||||||
scenario_exec += sign * exec_price * qty * 100
|
scenario_exec += sign * exec_price * qty * contract_size
|
||||||
|
|
||||||
net_pnl = scenario_exec - entry_ref
|
net_pnl = scenario_exec - entry_ref
|
||||||
broker_cost = (entry_ref - entry_ref_mid) + (scenario_mid - scenario_exec)
|
broker_cost = (entry_ref - entry_ref_mid) + (scenario_mid - scenario_exec)
|
||||||
@@ -153,7 +156,7 @@ def price_combo(
|
|||||||
# "today's vol" into a number sitting next to net_pnl (which uses the scenario's
|
# "today's vol" into a number sitting next to net_pnl (which uses the scenario's
|
||||||
# shocked vol), producing a max_gain that could be below net_pnl. Pricing both with
|
# shocked vol), producing a max_gain that could be below net_pnl. Pricing both with
|
||||||
# the same scenario vol view keeps them consistent.
|
# the same scenario vol view keeps them consistent.
|
||||||
bounded = check_bounded_risk(legs, entry_ref, surface_scenario, spot_now, r)
|
bounded = check_bounded_risk(legs, entry_ref, surface_scenario, spot_now, r, contract_size)
|
||||||
delta_now = greeks_at(legs, spot_now, 0, surface_now, r)["delta"]
|
delta_now = greeks_at(legs, spot_now, 0, surface_now, r)["delta"]
|
||||||
delta_scenario = greeks_at(legs, spot_scenario, horizon_days, surface_scenario, r)["delta"]
|
delta_scenario = greeks_at(legs, spot_scenario, horizon_days, surface_scenario, r)["delta"]
|
||||||
|
|
||||||
@@ -174,7 +177,7 @@ def price_combo(
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def check_bounded_risk(legs: List[Dict[str, Any]], entry_ref: float, surface: Any, spot: float, r: float = 0.05) -> Dict[str, Any]:
|
def check_bounded_risk(legs: List[Dict[str, Any]], entry_ref: float, surface: Any, spot: float, r: float = 0.05, contract_size: float = DEFAULT_CONTRACT_SIZE) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Scan a wide log-spaced spot range at expiry and inspect both tails independently for LOSS
|
Scan a wide log-spaced spot range at expiry and inspect both tails independently for LOSS
|
||||||
vs GAIN direction. "Bounded risk" only requires the loss side to be capped — a long
|
vs GAIN direction. "Bounded risk" only requires the loss side to be capped — a long
|
||||||
@@ -190,29 +193,45 @@ def check_bounded_risk(legs: List[Dict[str, Any]], entry_ref: float, surface: An
|
|||||||
`surface` to be priced, so max_gain/max_loss there is only as good as that vol input.
|
`surface` to be priced, so max_gain/max_loss there is only as good as that vol input.
|
||||||
"""
|
"""
|
||||||
eval_days = min(l["days_to_expiry"] for l in legs)
|
eval_days = min(l["days_to_expiry"] for l in legs)
|
||||||
grid = np.geomspace(spot * 0.05, spot * 20, 300)
|
|
||||||
values = [value_at(legs, float(p), eval_days, surface, r) - entry_ref for p in grid]
|
|
||||||
|
|
||||||
tail_n = max(3, len(values) // 30)
|
# Wide, log-spaced tail grid — used only to detect whether the payoff flattens out
|
||||||
|
# (bounded) toward either extreme, or keeps moving further away.
|
||||||
|
tail_grid = np.geomspace(spot * 0.05, spot * 20, 300)
|
||||||
|
tail_values = [value_at(legs, float(p), eval_days, surface, r, contract_size) - entry_ref for p in tail_grid]
|
||||||
|
|
||||||
|
tail_n = max(3, len(tail_values) // 30)
|
||||||
tol = max(abs(entry_ref), 1.0) * 0.01
|
tol = max(abs(entry_ref), 1.0) * 0.01
|
||||||
lo_edge, lo_in = values[0], values[tail_n]
|
lo_edge, lo_in = tail_values[0], tail_values[tail_n]
|
||||||
hi_edge, hi_in = values[-1], values[-1 - tail_n]
|
hi_edge, hi_in = tail_values[-1], tail_values[-1 - tail_n]
|
||||||
|
|
||||||
loss_bounded = (lo_edge >= lo_in - tol) and (hi_edge >= hi_in - tol)
|
loss_bounded = (lo_edge >= lo_in - tol) and (hi_edge >= hi_in - tol)
|
||||||
gain_bounded = (lo_edge <= lo_in + tol) and (hi_edge <= hi_in + tol)
|
gain_bounded = (lo_edge <= lo_in + tol) and (hi_edge <= hi_in + tol)
|
||||||
|
|
||||||
|
# A calendar spread's (or ratio spread's) real best/worst case is a sharp peak right at
|
||||||
|
# a strike, not out in the tails — over a log-spaced 0.05x-20x sweep the two nearest
|
||||||
|
# samples can straddle right over it, missing the true extremum entirely (confirmed:
|
||||||
|
# for a real calendar spread the tail grid reported max_gain=-0.05 while the payoff at
|
||||||
|
# the strike itself was +0.22 — the grid simply never sampled that point). Add a dense
|
||||||
|
# linear sweep across the legs' own strikes to capture it.
|
||||||
|
strikes = [l["strike"] for l in legs]
|
||||||
|
lo_k, hi_k = min(strikes) * 0.7, max(strikes) * 1.3
|
||||||
|
near_grid = np.linspace(max(lo_k, spot * 0.05), min(hi_k, spot * 20), 400)
|
||||||
|
near_values = [value_at(legs, float(p), eval_days, surface, r, contract_size) - entry_ref for p in near_grid]
|
||||||
|
|
||||||
|
all_values = tail_values + near_values
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"bounded": loss_bounded,
|
"bounded": loss_bounded,
|
||||||
"max_loss": round(min(values), 2) if loss_bounded else None,
|
"max_loss": round(min(all_values), 2) if loss_bounded else None,
|
||||||
"max_gain": round(max(values), 2) if gain_bounded else None,
|
"max_gain": round(max(all_values), 2) if gain_bounded else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def payoff_curve_expiry(legs: List[Dict[str, Any]], surface_now: Surface, spot: float, n: int = 100) -> List[Dict[str, float]]:
|
def payoff_curve_expiry(legs: List[Dict[str, Any]], surface_now: Surface, spot: float, n: int = 100, contract_size: float = DEFAULT_CONTRACT_SIZE) -> List[Dict[str, float]]:
|
||||||
eval_days = min(l["days_to_expiry"] for l in legs)
|
eval_days = min(l["days_to_expiry"] for l in legs)
|
||||||
prices = np.linspace(spot * 0.5, spot * 1.5, n)
|
prices = np.linspace(spot * 0.5, spot * 1.5, n)
|
||||||
return [
|
return [
|
||||||
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days, surface_now, 0.05)), 2)}
|
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days, surface_now, 0.05, contract_size)), 2)}
|
||||||
for p in prices
|
for p in prices
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -224,6 +243,7 @@ def expected_pnl_scenario(
|
|||||||
r: float,
|
r: float,
|
||||||
entry_ref: float,
|
entry_ref: float,
|
||||||
n: int = 200,
|
n: int = 200,
|
||||||
|
contract_size: float = DEFAULT_CONTRACT_SIZE,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""
|
"""
|
||||||
Probability-weighted expected P&L at the scenario date: integrates the payoff over a
|
Probability-weighted expected P&L at the scenario date: integrates the payoff over a
|
||||||
@@ -237,13 +257,13 @@ def expected_pnl_scenario(
|
|||||||
T = remaining / 365
|
T = remaining / 365
|
||||||
sd = sigma * math.sqrt(T)
|
sd = sigma * math.sqrt(T)
|
||||||
if sd <= 1e-6:
|
if sd <= 1e-6:
|
||||||
return value_at(legs, spot_scenario, horizon_days, surface_scenario, r) - entry_ref
|
return value_at(legs, spot_scenario, horizon_days, surface_scenario, r, contract_size) - entry_ref
|
||||||
|
|
||||||
mu = math.log(spot_scenario) + (r - 0.5 * sigma ** 2) * T
|
mu = math.log(spot_scenario) + (r - 0.5 * sigma ** 2) * T
|
||||||
grid = np.geomspace(spot_scenario * 0.15, spot_scenario * 4, n)
|
grid = np.geomspace(spot_scenario * 0.15, spot_scenario * 4, n)
|
||||||
log_grid = np.log(grid)
|
log_grid = np.log(grid)
|
||||||
density = np.exp(-0.5 * ((log_grid - mu) / sd) ** 2) / (grid * sd * math.sqrt(2 * math.pi))
|
density = np.exp(-0.5 * ((log_grid - mu) / sd) ** 2) / (grid * sd * math.sqrt(2 * math.pi))
|
||||||
pnl = np.array([value_at(legs, float(s), horizon_days, surface_scenario, r) - entry_ref for s in grid])
|
pnl = np.array([value_at(legs, float(s), horizon_days, surface_scenario, r, contract_size) - entry_ref for s in grid])
|
||||||
|
|
||||||
numerator = np.trapz(pnl * density, grid)
|
numerator = np.trapz(pnl * density, grid)
|
||||||
denominator = np.trapz(density, grid)
|
denominator = np.trapz(density, grid)
|
||||||
@@ -258,9 +278,10 @@ def payoff_curves(
|
|||||||
horizon_days: int,
|
horizon_days: int,
|
||||||
r: float = 0.05,
|
r: float = 0.05,
|
||||||
n: int = 100,
|
n: int = 100,
|
||||||
|
contract_size: float = DEFAULT_CONTRACT_SIZE,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
spot = chain_slice["spot"]
|
spot = chain_slice["spot"]
|
||||||
priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r)
|
priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r, contract_size)
|
||||||
entry_ref = priced["entry_cost"]
|
entry_ref = priced["entry_cost"]
|
||||||
|
|
||||||
lo, hi = spot * 0.6, spot * 1.4
|
lo, hi = spot * 0.6, spot * 1.4
|
||||||
@@ -268,11 +289,11 @@ def payoff_curves(
|
|||||||
eval_days_expiry = min(l["days_to_expiry"] for l in legs)
|
eval_days_expiry = min(l["days_to_expiry"] for l in legs)
|
||||||
|
|
||||||
at_expiry = [
|
at_expiry = [
|
||||||
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days_expiry, surface_now, r) - entry_ref), 2)}
|
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days_expiry, surface_now, r, contract_size) - entry_ref), 2)}
|
||||||
for p in prices
|
for p in prices
|
||||||
]
|
]
|
||||||
at_scenario = [
|
at_scenario = [
|
||||||
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), horizon_days, surface_scenario, r) - entry_ref), 2)}
|
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), horizon_days, surface_scenario, r, contract_size) - entry_ref), 2)}
|
||||||
for p in prices
|
for p in prices
|
||||||
]
|
]
|
||||||
return {"at_expiry": at_expiry, "at_scenario": at_scenario, **priced}
|
return {"at_expiry": at_expiry, "at_scenario": at_scenario, **priced}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
from services.option_chain import get_chain_slice
|
from services.option_chain import get_chain_slice
|
||||||
from services.vol_surface import Surface, ScenarioSurface, build_surface, apply_scenario
|
from services.vol_surface import Surface, ScenarioSurface, build_surface, apply_scenario
|
||||||
from services.strategy_engine import price_combo, expected_pnl_scenario, to_native
|
from services.strategy_engine import price_combo, expected_pnl_scenario, to_native, DEFAULT_CONTRACT_SIZE
|
||||||
from services.strategy_templates import generate_all, strikes_for
|
from services.strategy_templates import generate_all, strikes_for
|
||||||
|
|
||||||
MAX_SEEDS_FOR_RESIDUAL_SEARCH = 40
|
MAX_SEEDS_FOR_RESIDUAL_SEARCH = 40
|
||||||
@@ -18,7 +18,7 @@ RESIDUAL_ITERATIONS_PER_SEED = 8
|
|||||||
RESIDUAL_MAX_EVALS = 400
|
RESIDUAL_MAX_EVALS = 400
|
||||||
|
|
||||||
|
|
||||||
def _score(priced: Dict[str, Any], legs: List[Dict[str, Any]], objective: str, surface_scenario: ScenarioSurface, horizon_days: int, r: 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":
|
||||||
return priced["net_pnl"]
|
return priced["net_pnl"]
|
||||||
if objective == "return_on_risk":
|
if objective == "return_on_risk":
|
||||||
@@ -26,7 +26,7 @@ def _score(priced: Dict[str, Any], legs: List[Dict[str, Any]], objective: str, s
|
|||||||
return None
|
return None
|
||||||
return priced["net_pnl"] / abs(priced["max_loss"])
|
return priced["net_pnl"] / abs(priced["max_loss"])
|
||||||
if objective == "prob_weighted":
|
if objective == "prob_weighted":
|
||||||
return expected_pnl_scenario(legs, surface_scenario, horizon_days, r, priced["entry_cost"])
|
return expected_pnl_scenario(legs, surface_scenario, horizon_days, r, priced["entry_cost"], contract_size=contract_size)
|
||||||
raise ValueError(f"Objectif inconnu: {objective}")
|
raise ValueError(f"Objectif inconnu: {objective}")
|
||||||
|
|
||||||
|
|
||||||
@@ -46,16 +46,17 @@ def _passes_constraints(legs: List[Dict[str, Any]], priced: Dict[str, Any], cons
|
|||||||
def _evaluate(
|
def _evaluate(
|
||||||
name: str, legs: List[Dict[str, Any]], chain_slice: Dict[str, Any], surface_now: Surface,
|
name: str, legs: List[Dict[str, Any]], chain_slice: Dict[str, Any], surface_now: Surface,
|
||||||
surface_scenario: ScenarioSurface, horizon_days: int, r: float, constraints: Dict[str, Any], objective: str,
|
surface_scenario: ScenarioSurface, horizon_days: int, r: float, constraints: Dict[str, Any], objective: str,
|
||||||
|
contract_size: float = DEFAULT_CONTRACT_SIZE,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
if len(legs) > constraints["max_legs"] or len(legs) == 0:
|
if len(legs) > constraints["max_legs"] or len(legs) == 0:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r)
|
priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r, contract_size)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
if not _passes_constraints(legs, priced, constraints):
|
if not _passes_constraints(legs, priced, constraints):
|
||||||
return None
|
return None
|
||||||
score = _score(priced, legs, objective, surface_scenario, horizon_days, r)
|
score = _score(priced, legs, objective, surface_scenario, horizon_days, r, contract_size)
|
||||||
if score is None:
|
if score is None:
|
||||||
return None
|
return None
|
||||||
return {"template_name": name, "legs": legs, "score": round(score, 2), "objective": objective, **priced}
|
return {"template_name": name, "legs": legs, "score": round(score, 2), "objective": objective, **priced}
|
||||||
@@ -87,6 +88,7 @@ def _perturb(legs: List[Dict[str, Any]], strikes_by_expiry: Dict[Any, List[float
|
|||||||
def _residual_search(
|
def _residual_search(
|
||||||
seeds: List[Dict[str, Any]], chain_slice: Dict[str, Any], surface_now: Surface, surface_scenario: ScenarioSurface,
|
seeds: List[Dict[str, Any]], chain_slice: Dict[str, Any], surface_now: Surface, surface_scenario: ScenarioSurface,
|
||||||
horizon_days: int, r: float, constraints: Dict[str, Any], objective: str,
|
horizon_days: int, r: float, constraints: Dict[str, Any], objective: str,
|
||||||
|
contract_size: float = DEFAULT_CONTRACT_SIZE,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
strikes_by_expiry = {
|
strikes_by_expiry = {
|
||||||
(exp["expiry_date"], opt_type): strikes_for(exp, opt_type)
|
(exp["expiry_date"], opt_type): strikes_for(exp, opt_type)
|
||||||
@@ -104,7 +106,7 @@ def _residual_search(
|
|||||||
evals += 1
|
evals += 1
|
||||||
evaluated = _evaluate(
|
evaluated = _evaluate(
|
||||||
f"{seed['template_name']} (variante)", candidate_legs, chain_slice, surface_now,
|
f"{seed['template_name']} (variante)", candidate_legs, chain_slice, surface_now,
|
||||||
surface_scenario, horizon_days, r, constraints, objective,
|
surface_scenario, horizon_days, r, constraints, objective, contract_size,
|
||||||
)
|
)
|
||||||
if evaluated and evaluated["score"] > current["score"]:
|
if evaluated and evaluated["score"] > current["score"]:
|
||||||
current = evaluated
|
current = evaluated
|
||||||
@@ -146,6 +148,7 @@ def optimize(
|
|||||||
constraints: Dict[str, Any],
|
constraints: Dict[str, Any],
|
||||||
objective: str,
|
objective: str,
|
||||||
top_n: int = 20,
|
top_n: int = 20,
|
||||||
|
contract_size: float = DEFAULT_CONTRACT_SIZE,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
chain_slice = get_chain_slice(symbol, horizon_days, n_expiries)
|
chain_slice = get_chain_slice(symbol, horizon_days, n_expiries)
|
||||||
surface_now = build_surface(chain_slice)
|
surface_now = build_surface(chain_slice)
|
||||||
@@ -157,14 +160,14 @@ def optimize(
|
|||||||
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)
|
evaluated = _evaluate(name, legs, chain_slice, surface_now, surface_scenario, horizon_days, rate, 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)
|
refined = _residual_search(seeds, chain_slice, surface_now, surface_scenario, horizon_days, rate, constraints, objective, contract_size)
|
||||||
scored.extend(refined)
|
scored.extend(refined)
|
||||||
|
|
||||||
scored.sort(key=lambda c: c["score"], reverse=True)
|
scored.sort(key=lambda c: c["score"], reverse=True)
|
||||||
|
|||||||
@@ -1534,6 +1534,7 @@ export type StrategyScenario = {
|
|||||||
manual_grid?: ManualGridCell[]
|
manual_grid?: ManualGridCell[]
|
||||||
rate?: number
|
rate?: number
|
||||||
n_expiries?: number
|
n_expiries?: number
|
||||||
|
contract_size?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StrategyLeg = {
|
export type StrategyLeg = {
|
||||||
|
|||||||
@@ -584,6 +584,7 @@ export default function StrategyBuilder() {
|
|||||||
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_shift: 0, manual_grid: [],
|
||||||
|
contract_size: 100_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Chain lookup only commits on blur/Enter/datalist-pick, never mid-keystroke — typing
|
// Chain lookup only commits on blur/Enter/datalist-pick, never mid-keystroke — typing
|
||||||
@@ -662,11 +663,11 @@ export default function StrategyBuilder() {
|
|||||||
const handleLoadScenario = (s: SavedScenario) => {
|
const handleLoadScenario = (s: SavedScenario) => {
|
||||||
setSymbol(s.symbol)
|
setSymbol(s.symbol)
|
||||||
setHorizonDays(s.horizon_days)
|
setHorizonDays(s.horizon_days)
|
||||||
setScenario({
|
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_shift: s.term_shift,
|
||||||
manual_grid: s.manual_grid,
|
manual_grid: s.manual_grid, contract_size: prev.contract_size,
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveStrategy = () => {
|
const handleSaveStrategy = () => {
|
||||||
@@ -721,13 +722,24 @@ export default function StrategyBuilder() {
|
|||||||
<div className="card space-y-3">
|
<div className="card space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="stat-label">Jambes (1-4) — Spot {chain.spot}</div>
|
<div className="stat-label">Jambes (1-4) — Spot {chain.spot}</div>
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
onClick={addLeg}
|
<label className="flex items-center gap-1.5 text-xs text-slate-400" title="Notionnel par contrat (ex. 100 000 = 1 lot standard EURUSD). S'applique à chaque jambe, multiplié par sa quantité.">
|
||||||
disabled={legs.length >= 4}
|
Nominal (USD)
|
||||||
className="flex items-center gap-1 text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-2.5 py-1 rounded"
|
<input
|
||||||
>
|
type="number" step={1000} min={1}
|
||||||
<Plus className="w-3.5 h-3.5" /> Ajouter une jambe
|
value={scenario.contract_size ?? 100_000}
|
||||||
</button>
|
onChange={(e) => setScenario(s => ({ ...s, contract_size: parseFloat(e.target.value) || 100_000 }))}
|
||||||
|
className="w-28 bg-dark-700 border border-slate-700/50 rounded px-2 py-1 text-slate-200"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={addLeg}
|
||||||
|
disabled={legs.length >= 4}
|
||||||
|
className="flex items-center gap-1 text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-2.5 py-1 rounded"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Ajouter une jambe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{legs.map((leg, i) => (
|
{legs.map((leg, i) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user