feat: strategy builder
This commit is contained in:
@@ -20,6 +20,7 @@ from routers import instrument_models as instrument_models_router
|
|||||||
from routers import instruments_watchlist as instruments_watchlist_router
|
from routers import instruments_watchlist as instruments_watchlist_router
|
||||||
from routers import wavelet as wavelet_router
|
from routers import wavelet as wavelet_router
|
||||||
from routers import ai_chat as ai_chat_router
|
from routers import ai_chat as ai_chat_router
|
||||||
|
from routers import strategy_builder as strategy_builder_router
|
||||||
from services.database import init_db, get_config, cleanup_stale_running_cycles
|
from services.database import init_db, get_config, cleanup_stale_running_cycles
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@@ -247,6 +248,7 @@ app.include_router(instrument_models_router.router)
|
|||||||
app.include_router(instruments_watchlist_router.router)
|
app.include_router(instruments_watchlist_router.router)
|
||||||
app.include_router(wavelet_router.router)
|
app.include_router(wavelet_router.router)
|
||||||
app.include_router(ai_chat_router.router)
|
app.include_router(ai_chat_router.router)
|
||||||
|
app.include_router(strategy_builder_router.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
190
backend/routers/strategy_builder.py
Normal file
190
backend/routers/strategy_builder.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from services.option_chain import get_chain_slice
|
||||||
|
from services.vol_surface import build_surface, apply_scenario
|
||||||
|
from services.strategy_engine import payoff_curves
|
||||||
|
from services.strategy_optimizer import optimize as run_optimizer
|
||||||
|
from services.database import (
|
||||||
|
save_scenario, get_scenarios, delete_scenario,
|
||||||
|
save_strategy, get_saved_strategies, delete_saved_strategy,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/strategy-builder", tags=["strategy-builder"])
|
||||||
|
|
||||||
|
|
||||||
|
class LegIn(BaseModel):
|
||||||
|
expiry_date: str
|
||||||
|
days_to_expiry: int
|
||||||
|
strike: float
|
||||||
|
option_type: str # "call" | "put"
|
||||||
|
position: str # "long" | "short"
|
||||||
|
quantity: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioIn(BaseModel):
|
||||||
|
symbol: str
|
||||||
|
horizon_days: int = 8
|
||||||
|
spot_shock_pct: float = 0.0
|
||||||
|
iv_level_shift: float = 0.0
|
||||||
|
skew_tilt: float = 0.0
|
||||||
|
term_shift: float = 0.0
|
||||||
|
manual_grid: Optional[List[Dict[str, Any]]] = None
|
||||||
|
rate: float = 0.05
|
||||||
|
n_expiries: int = 3
|
||||||
|
|
||||||
|
|
||||||
|
class PriceRequest(BaseModel):
|
||||||
|
scenario: ScenarioIn
|
||||||
|
legs: List[LegIn]
|
||||||
|
|
||||||
|
|
||||||
|
class ConstraintsIn(BaseModel):
|
||||||
|
max_legs: int = 4
|
||||||
|
delta_threshold: float = 0.15
|
||||||
|
max_loss_cap: Optional[float] = None
|
||||||
|
objective: str = "net_pnl" # "net_pnl" | "return_on_risk" | "prob_weighted"
|
||||||
|
top_n: int = 20
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizeRequest(BaseModel):
|
||||||
|
scenario: ScenarioIn
|
||||||
|
constraints: ConstraintsIn
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioSaveRequest(BaseModel):
|
||||||
|
symbol: str
|
||||||
|
label: Optional[str] = ""
|
||||||
|
horizon_days: int
|
||||||
|
spot_shock_pct: float
|
||||||
|
iv_level_shift: float
|
||||||
|
skew_tilt: float
|
||||||
|
term_shift: float
|
||||||
|
manual_grid: Optional[List[Dict[str, Any]]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StrategySaveRequest(BaseModel):
|
||||||
|
scenario_id: Optional[str] = None
|
||||||
|
symbol: str
|
||||||
|
template_name: Optional[str] = ""
|
||||||
|
objective: Optional[str] = ""
|
||||||
|
legs: List[LegIn]
|
||||||
|
entry_cost: Optional[float] = None
|
||||||
|
max_gain: Optional[float] = None
|
||||||
|
max_loss: Optional[float] = None
|
||||||
|
net_pnl_scenario: Optional[float] = None
|
||||||
|
net_delta: Optional[float] = None
|
||||||
|
notes: Optional[str] = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_surfaces(scenario: ScenarioIn):
|
||||||
|
chain_slice = get_chain_slice(scenario.symbol, scenario.horizon_days, scenario.n_expiries)
|
||||||
|
surface_now = build_surface(chain_slice)
|
||||||
|
surface_scenario = apply_scenario(
|
||||||
|
surface_now,
|
||||||
|
spot_shock_pct=scenario.spot_shock_pct,
|
||||||
|
iv_level_shift=scenario.iv_level_shift,
|
||||||
|
skew_tilt=scenario.skew_tilt,
|
||||||
|
term_shift=scenario.term_shift,
|
||||||
|
manual_grid=scenario.manual_grid,
|
||||||
|
)
|
||||||
|
return chain_slice, surface_now, surface_scenario
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chain")
|
||||||
|
def chain(
|
||||||
|
symbol: str = Query(...),
|
||||||
|
horizon_days: int = Query(8),
|
||||||
|
n_expiries: int = Query(3),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return get_chain_slice(symbol, horizon_days, n_expiries)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/price")
|
||||||
|
def price(req: PriceRequest):
|
||||||
|
if not req.legs:
|
||||||
|
raise HTTPException(status_code=400, detail="Au moins une jambe est requise")
|
||||||
|
if len(req.legs) > 4:
|
||||||
|
raise HTTPException(status_code=400, detail="4 jambes maximum")
|
||||||
|
|
||||||
|
try:
|
||||||
|
chain_slice, surface_now, surface_scenario = _build_surfaces(req.scenario)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
legs = [leg.model_dump() for leg in req.legs]
|
||||||
|
result = payoff_curves(
|
||||||
|
legs, chain_slice, surface_now, surface_scenario,
|
||||||
|
req.scenario.horizon_days, req.scenario.rate,
|
||||||
|
)
|
||||||
|
result["spot"] = chain_slice["spot"]
|
||||||
|
result["scenario_spot"] = surface_scenario.spot
|
||||||
|
result["proxy"] = chain_slice["proxy"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/optimize")
|
||||||
|
def optimize(req: OptimizeRequest):
|
||||||
|
if req.constraints.max_legs > 4:
|
||||||
|
raise HTTPException(status_code=400, detail="4 jambes maximum")
|
||||||
|
try:
|
||||||
|
results = run_optimizer(
|
||||||
|
symbol=req.scenario.symbol,
|
||||||
|
horizon_days=req.scenario.horizon_days,
|
||||||
|
spot_shock_pct=req.scenario.spot_shock_pct,
|
||||||
|
iv_level_shift=req.scenario.iv_level_shift,
|
||||||
|
skew_tilt=req.scenario.skew_tilt,
|
||||||
|
term_shift=req.scenario.term_shift,
|
||||||
|
manual_grid=req.scenario.manual_grid,
|
||||||
|
n_expiries=req.scenario.n_expiries,
|
||||||
|
rate=req.scenario.rate,
|
||||||
|
constraints=req.constraints.model_dump(),
|
||||||
|
objective=req.constraints.objective,
|
||||||
|
top_n=req.constraints.top_n,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scenarios")
|
||||||
|
def create_scenario(req: ScenarioSaveRequest):
|
||||||
|
scenario_id = save_scenario(req.model_dump())
|
||||||
|
return {"id": scenario_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scenarios")
|
||||||
|
def list_scenarios(symbol: Optional[str] = Query(None)):
|
||||||
|
return get_scenarios(symbol)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/scenarios/{scenario_id}")
|
||||||
|
def remove_scenario(scenario_id: str):
|
||||||
|
if not delete_scenario(scenario_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Scénario non trouvé")
|
||||||
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/saved")
|
||||||
|
def create_saved_strategy(req: StrategySaveRequest):
|
||||||
|
payload = req.model_dump()
|
||||||
|
payload["legs"] = [leg for leg in payload["legs"]]
|
||||||
|
strategy_id = save_strategy(payload)
|
||||||
|
return {"id": strategy_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/saved")
|
||||||
|
def list_saved_strategies(symbol: Optional[str] = Query(None)):
|
||||||
|
return get_saved_strategies(symbol)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/saved/{strategy_id}")
|
||||||
|
def remove_saved_strategy(strategy_id: str):
|
||||||
|
if not delete_saved_strategy(strategy_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Stratégie non trouvée")
|
||||||
|
return {"deleted": True}
|
||||||
@@ -44,6 +44,36 @@ def init_db():
|
|||||||
created_at TEXT DEFAULT (datetime('now'))
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
)""")
|
)""")
|
||||||
|
|
||||||
|
c.execute("""CREATE TABLE IF NOT EXISTS strategy_scenarios (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
horizon_days INTEGER NOT NULL,
|
||||||
|
spot_shock_pct REAL NOT NULL,
|
||||||
|
iv_level_shift REAL NOT NULL,
|
||||||
|
skew_tilt REAL NOT NULL,
|
||||||
|
term_shift REAL NOT NULL,
|
||||||
|
manual_grid TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)""")
|
||||||
|
|
||||||
|
c.execute("""CREATE TABLE IF NOT EXISTS saved_strategies (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
scenario_id TEXT,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
template_name TEXT,
|
||||||
|
objective TEXT,
|
||||||
|
legs TEXT NOT NULL,
|
||||||
|
entry_cost REAL,
|
||||||
|
max_gain REAL,
|
||||||
|
max_loss REAL,
|
||||||
|
net_pnl_scenario REAL,
|
||||||
|
net_delta REAL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (scenario_id) REFERENCES strategy_scenarios(id)
|
||||||
|
)""")
|
||||||
|
|
||||||
c.execute("""CREATE TABLE IF NOT EXISTS custom_patterns (
|
c.execute("""CREATE TABLE IF NOT EXISTS custom_patterns (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -5885,3 +5915,105 @@ def get_market_events_near_date(date_str: str, days: int = 2,
|
|||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Strategy Builder: scenarios & saved strategies ───────────────────────────
|
||||||
|
|
||||||
|
def save_scenario(scenario: Dict[str, Any]) -> str:
|
||||||
|
import uuid
|
||||||
|
scenario_id = scenario.get("id") or f"SCN-{uuid.uuid4().hex[:8].upper()}"
|
||||||
|
conn = get_conn()
|
||||||
|
conn.execute("""INSERT INTO strategy_scenarios (
|
||||||
|
id, symbol, label, horizon_days, spot_shock_pct, iv_level_shift, skew_tilt, term_shift, manual_grid
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?)""", (
|
||||||
|
scenario_id,
|
||||||
|
scenario["symbol"],
|
||||||
|
scenario.get("label", ""),
|
||||||
|
scenario["horizon_days"],
|
||||||
|
scenario["spot_shock_pct"],
|
||||||
|
scenario["iv_level_shift"],
|
||||||
|
scenario["skew_tilt"],
|
||||||
|
scenario["term_shift"],
|
||||||
|
json.dumps(scenario.get("manual_grid") or []),
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return scenario_id
|
||||||
|
|
||||||
|
|
||||||
|
def get_scenarios(symbol: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
conn = get_conn()
|
||||||
|
if symbol:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM strategy_scenarios WHERE symbol=? ORDER BY created_at DESC", (symbol,)
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute("SELECT * FROM strategy_scenarios ORDER BY created_at DESC").fetchall()
|
||||||
|
conn.close()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
d["manual_grid"] = json.loads(d.get("manual_grid") or "[]")
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def delete_scenario(scenario_id: str) -> bool:
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.execute("DELETE FROM strategy_scenarios WHERE id=?", (scenario_id,))
|
||||||
|
conn.commit()
|
||||||
|
deleted = cur.rowcount > 0
|
||||||
|
conn.close()
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
def save_strategy(strategy: Dict[str, Any]) -> str:
|
||||||
|
import uuid
|
||||||
|
strategy_id = strategy.get("id") or f"STR-{uuid.uuid4().hex[:8].upper()}"
|
||||||
|
conn = get_conn()
|
||||||
|
conn.execute("""INSERT INTO saved_strategies (
|
||||||
|
id, scenario_id, symbol, template_name, objective, legs,
|
||||||
|
entry_cost, max_gain, max_loss, net_pnl_scenario, net_delta, notes
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", (
|
||||||
|
strategy_id,
|
||||||
|
strategy.get("scenario_id"),
|
||||||
|
strategy["symbol"],
|
||||||
|
strategy.get("template_name", ""),
|
||||||
|
strategy.get("objective", ""),
|
||||||
|
json.dumps(strategy.get("legs", [])),
|
||||||
|
strategy.get("entry_cost"),
|
||||||
|
strategy.get("max_gain"),
|
||||||
|
strategy.get("max_loss"),
|
||||||
|
strategy.get("net_pnl_scenario"),
|
||||||
|
strategy.get("net_delta"),
|
||||||
|
strategy.get("notes", ""),
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return strategy_id
|
||||||
|
|
||||||
|
|
||||||
|
def get_saved_strategies(symbol: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
conn = get_conn()
|
||||||
|
if symbol:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM saved_strategies WHERE symbol=? ORDER BY created_at DESC", (symbol,)
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute("SELECT * FROM saved_strategies ORDER BY created_at DESC").fetchall()
|
||||||
|
conn.close()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
d["legs"] = json.loads(d.get("legs") or "[]")
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def delete_saved_strategy(strategy_id: str) -> bool:
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.execute("DELETE FROM saved_strategies WHERE id=?", (strategy_id,))
|
||||||
|
conn.commit()
|
||||||
|
deleted = cur.rowcount > 0
|
||||||
|
conn.close()
|
||||||
|
return deleted
|
||||||
|
|||||||
99
backend/services/option_chain.py
Normal file
99
backend/services/option_chain.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Real option chain fetcher for the Strategy Builder — reuses the same yfinance
|
||||||
|
proxy/resolution logic as iv_engine.py (futures/indices → optionable ETFs).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import yfinance as yf
|
||||||
|
|
||||||
|
from services.iv_engine import _resolve_ticker, _get_current_price
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _num(v: Any, default: float = 0.0) -> float:
|
||||||
|
try:
|
||||||
|
f = float(v)
|
||||||
|
return default if math.isnan(f) else f
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _rows_from_df(df) -> List[Dict[str, Any]]:
|
||||||
|
rows = []
|
||||||
|
for _, r in df.iterrows():
|
||||||
|
bid = _num(r.get("bid"))
|
||||||
|
ask = _num(r.get("ask"))
|
||||||
|
rows.append({
|
||||||
|
"strike": _num(r.get("strike")),
|
||||||
|
"bid": bid,
|
||||||
|
"ask": ask,
|
||||||
|
"mid": round((bid + ask) / 2, 4) if (bid > 0 and ask > 0) else _num(r.get("lastPrice")),
|
||||||
|
"last": _num(r.get("lastPrice")),
|
||||||
|
"iv": _num(r.get("impliedVolatility")),
|
||||||
|
"open_interest": int(_num(r.get("openInterest"))),
|
||||||
|
"volume": int(_num(r.get("volume"))),
|
||||||
|
})
|
||||||
|
return sorted(rows, key=lambda x: x["strike"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_chain_slice(symbol: str, target_days: int = 8, n_expiries: int = 3) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Fetch the real option chain for `symbol` around a target horizon (days).
|
||||||
|
Returns the `n_expiries` expirations closest to target_days, each with
|
||||||
|
normalized calls/puts rows (strike, bid, ask, mid, last, iv, open_interest, volume).
|
||||||
|
"""
|
||||||
|
proxy = _resolve_ticker(symbol)
|
||||||
|
t = yf.Ticker(proxy)
|
||||||
|
spot = _get_current_price(t)
|
||||||
|
if not spot:
|
||||||
|
raise ValueError(f"Impossible d'obtenir le prix spot pour {symbol} ({proxy})")
|
||||||
|
|
||||||
|
expirations = t.options
|
||||||
|
if not expirations:
|
||||||
|
raise ValueError(f"Aucune chaîne d'options disponible pour {symbol} ({proxy})")
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
dated = sorted(
|
||||||
|
expirations,
|
||||||
|
key=lambda e: abs((datetime.strptime(e, "%Y-%m-%d").date() - today).days - target_days),
|
||||||
|
)[:max(1, n_expiries)]
|
||||||
|
|
||||||
|
expiries_out = []
|
||||||
|
for exp in dated:
|
||||||
|
try:
|
||||||
|
chain = t.option_chain(exp)
|
||||||
|
days_to_expiry = (datetime.strptime(exp, "%Y-%m-%d").date() - today).days
|
||||||
|
expiries_out.append({
|
||||||
|
"expiry_date": exp,
|
||||||
|
"days_to_expiry": days_to_expiry,
|
||||||
|
"calls": _rows_from_df(chain.calls),
|
||||||
|
"puts": _rows_from_df(chain.puts),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[OptionChain] {proxy} {exp}: {e}")
|
||||||
|
|
||||||
|
if not expiries_out:
|
||||||
|
raise ValueError(f"Aucune chaîne exploitable pour {symbol} ({proxy})")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"symbol": symbol.upper(),
|
||||||
|
"proxy": proxy,
|
||||||
|
"spot": round(float(spot), 4),
|
||||||
|
"expiries": expiries_out,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_quote(chain_slice: Dict[str, Any], expiry_date: str, strike: float, option_type: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Look up a single contract's quote row within a previously fetched chain slice."""
|
||||||
|
for exp in chain_slice["expiries"]:
|
||||||
|
if exp["expiry_date"] != expiry_date:
|
||||||
|
continue
|
||||||
|
rows = exp["calls"] if option_type == "call" else exp["puts"]
|
||||||
|
for row in rows:
|
||||||
|
if abs(row["strike"] - strike) < 1e-6:
|
||||||
|
return row
|
||||||
|
return None
|
||||||
247
backend/services/strategy_engine.py
Normal file
247
backend/services/strategy_engine.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"""
|
||||||
|
Generic N-leg (1-4) option strategy pricer: entry cost with real broker spread,
|
||||||
|
scenario repricing at a future horizon on a shocked vol surface, payoff curves,
|
||||||
|
greeks, and bounded-risk / non-directional checks.
|
||||||
|
|
||||||
|
A "leg" dict: {expiry_date, days_to_expiry, strike, option_type ("call"/"put"),
|
||||||
|
position ("long"/"short"), quantity}
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from services.options_pricer import black_scholes
|
||||||
|
from services.option_chain import find_quote
|
||||||
|
from services.vol_surface import Surface, ScenarioSurface
|
||||||
|
|
||||||
|
DEFAULT_SPREAD_PCT = 0.05 # fallback relative bid/ask spread when no live quote is found
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(leg: Dict[str, Any]) -> int:
|
||||||
|
return 1 if leg.get("position", "long") == "long" else -1
|
||||||
|
|
||||||
|
|
||||||
|
def _intrinsic(S: float, K: float, option_type: str) -> float:
|
||||||
|
return max(0.0, S - K) if option_type == "call" else max(0.0, K - S)
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_spread_pct(quote: Optional[Dict[str, Any]]) -> float:
|
||||||
|
if not quote or quote["bid"] <= 0 or quote["ask"] <= 0:
|
||||||
|
return DEFAULT_SPREAD_PCT
|
||||||
|
mid = (quote["bid"] + quote["ask"]) / 2
|
||||||
|
if mid <= 0:
|
||||||
|
return DEFAULT_SPREAD_PCT
|
||||||
|
return (quote["ask"] - quote["bid"]) / mid
|
||||||
|
|
||||||
|
|
||||||
|
def entry_price(leg: Dict[str, Any], chain_slice: Dict[str, Any], surface_now: Surface, r: float) -> Dict[str, float]:
|
||||||
|
"""Real execution price (crossing the spread) + theoretical mid, for one leg today."""
|
||||||
|
quote = find_quote(chain_slice, leg["expiry_date"], leg["strike"], leg["option_type"])
|
||||||
|
T = max(leg["days_to_expiry"], 0.001) / 365
|
||||||
|
sigma = surface_now.iv_at(leg["strike"], leg["days_to_expiry"])
|
||||||
|
theo_mid = black_scholes(chain_slice["spot"], leg["strike"], T, r, sigma, leg["option_type"])["price"]
|
||||||
|
|
||||||
|
if quote and quote["bid"] > 0 and quote["ask"] > 0:
|
||||||
|
exec_price = quote["ask"] if leg.get("position", "long") == "long" else quote["bid"]
|
||||||
|
mid = quote["mid"] or theo_mid
|
||||||
|
else:
|
||||||
|
spread = theo_mid * DEFAULT_SPREAD_PCT
|
||||||
|
exec_price = theo_mid + spread / 2 if leg.get("position", "long") == "long" else max(0.0, theo_mid - spread / 2)
|
||||||
|
mid = theo_mid
|
||||||
|
|
||||||
|
return {"exec_price": exec_price, "mid": mid, "spread_pct": _quote_spread_pct(quote)}
|
||||||
|
|
||||||
|
|
||||||
|
def value_at(
|
||||||
|
legs: List[Dict[str, Any]],
|
||||||
|
S: float,
|
||||||
|
eval_days_from_now: float,
|
||||||
|
surface: Any,
|
||||||
|
r: float,
|
||||||
|
) -> float:
|
||||||
|
"""Signed portfolio value (BS reprice for unexpired legs, intrinsic for expired ones)."""
|
||||||
|
total = 0.0
|
||||||
|
for leg in legs:
|
||||||
|
remaining = leg["days_to_expiry"] - eval_days_from_now
|
||||||
|
qty = leg.get("quantity", 1)
|
||||||
|
sign = _sign(leg)
|
||||||
|
if remaining <= 0:
|
||||||
|
price = _intrinsic(S, leg["strike"], leg["option_type"])
|
||||||
|
else:
|
||||||
|
sigma = surface.iv_at(leg["strike"], remaining)
|
||||||
|
price = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"])["price"]
|
||||||
|
total += sign * price * qty * 100
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
|
for leg in legs:
|
||||||
|
remaining = max(leg["days_to_expiry"] - eval_days_from_now, 0.001)
|
||||||
|
qty = leg.get("quantity", 1)
|
||||||
|
sign = _sign(leg)
|
||||||
|
sigma = surface.iv_at(leg["strike"], remaining)
|
||||||
|
g = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"])
|
||||||
|
for k in net:
|
||||||
|
net[k] += g[k] * qty * sign
|
||||||
|
return {k: round(v, 4) for k, v in net.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def price_combo(
|
||||||
|
legs: List[Dict[str, Any]],
|
||||||
|
chain_slice: Dict[str, Any],
|
||||||
|
surface_now: Surface,
|
||||||
|
surface_scenario: ScenarioSurface,
|
||||||
|
horizon_days: int,
|
||||||
|
r: float = 0.05,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
spot_now = chain_slice["spot"]
|
||||||
|
spot_scenario = surface_scenario.spot
|
||||||
|
|
||||||
|
entry_ref = 0.0
|
||||||
|
entry_ref_mid = 0.0
|
||||||
|
for leg in legs:
|
||||||
|
ep = entry_price(leg, chain_slice, surface_now, r)
|
||||||
|
sign = _sign(leg)
|
||||||
|
qty = leg.get("quantity", 1)
|
||||||
|
entry_ref += sign * ep["exec_price"] * qty * 100
|
||||||
|
entry_ref_mid += sign * ep["mid"] * qty * 100
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
scenario_mid = 0.0
|
||||||
|
scenario_exec = 0.0
|
||||||
|
for leg in legs:
|
||||||
|
remaining = max(leg["days_to_expiry"] - horizon_days, 0.001)
|
||||||
|
qty = leg.get("quantity", 1)
|
||||||
|
sign = _sign(leg)
|
||||||
|
sigma = surface_scenario.iv_at(leg["strike"], remaining)
|
||||||
|
theo = black_scholes(spot_scenario, leg["strike"], remaining / 365, r, sigma, leg["option_type"])["price"]
|
||||||
|
quote = find_quote(chain_slice, leg["expiry_date"], leg["strike"], leg["option_type"])
|
||||||
|
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)
|
||||||
|
scenario_mid += sign * theo * qty * 100
|
||||||
|
scenario_exec += sign * exec_price * qty * 100
|
||||||
|
|
||||||
|
net_pnl = scenario_exec - entry_ref
|
||||||
|
broker_cost = (entry_ref - entry_ref_mid) + (scenario_mid - scenario_exec)
|
||||||
|
|
||||||
|
bounded = check_bounded_risk(legs, entry_ref, surface_now, spot_now)
|
||||||
|
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"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entry_cost": round(entry_ref, 2),
|
||||||
|
"entry_cost_mid": round(entry_ref_mid, 2),
|
||||||
|
"scenario_value": round(scenario_exec, 2),
|
||||||
|
"scenario_value_mid": round(scenario_mid, 2),
|
||||||
|
"net_pnl": round(net_pnl, 2),
|
||||||
|
"broker_spread_cost": round(broker_cost, 2),
|
||||||
|
"max_gain": bounded["max_gain"],
|
||||||
|
"max_loss": bounded["max_loss"],
|
||||||
|
"bounded_risk": bounded["bounded"],
|
||||||
|
"greeks_now": greeks_at(legs, spot_now, 0, surface_now, r),
|
||||||
|
"greeks_scenario": greeks_at(legs, spot_scenario, horizon_days, surface_scenario, r),
|
||||||
|
"net_delta_now": delta_now,
|
||||||
|
"net_delta_scenario": delta_scenario,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_bounded_risk(legs: List[Dict[str, Any]], entry_ref: float, surface: Any, spot: float) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
straddle (loss capped at the premium, gain uncapped) is a textbook risk-bounded,
|
||||||
|
non-directional trade and must not be excluded just because its gain is open-ended.
|
||||||
|
|
||||||
|
A tail is loss-bounded if moving further to that extreme does not make the P&L any worse
|
||||||
|
than a point already well into that tail; symmetrically for gain-bounded.
|
||||||
|
"""
|
||||||
|
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, 0.05) - entry_ref for p in grid]
|
||||||
|
|
||||||
|
tail_n = max(3, len(values) // 30)
|
||||||
|
tol = max(abs(entry_ref), 1.0) * 0.01
|
||||||
|
lo_edge, lo_in = values[0], values[tail_n]
|
||||||
|
hi_edge, hi_in = values[-1], values[-1 - tail_n]
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"bounded": loss_bounded,
|
||||||
|
"max_loss": round(min(values), 2) if loss_bounded else None,
|
||||||
|
"max_gain": round(max(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]]:
|
||||||
|
eval_days = min(l["days_to_expiry"] for l in legs)
|
||||||
|
prices = np.linspace(spot * 0.5, spot * 1.5, n)
|
||||||
|
return [
|
||||||
|
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days, surface_now, 0.05)), 2)}
|
||||||
|
for p in prices
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def expected_pnl_scenario(
|
||||||
|
legs: List[Dict[str, Any]],
|
||||||
|
surface_scenario: ScenarioSurface,
|
||||||
|
horizon_days: int,
|
||||||
|
r: float,
|
||||||
|
entry_ref: float,
|
||||||
|
n: int = 200,
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Probability-weighted expected P&L at the scenario date: integrates the payoff over a
|
||||||
|
risk-neutral lognormal density for the underlying, centered on the scenario spot with a
|
||||||
|
variance driven by the scenario surface's ATM IV over the residual time to the nearest
|
||||||
|
leg's expiry (the remaining uncertainty once the scenario date is reached).
|
||||||
|
"""
|
||||||
|
spot_scenario = surface_scenario.spot
|
||||||
|
remaining = max(min(l["days_to_expiry"] for l in legs) - horizon_days, 1)
|
||||||
|
sigma = surface_scenario.iv_at(spot_scenario, remaining)
|
||||||
|
T = remaining / 365
|
||||||
|
sd = sigma * math.sqrt(T)
|
||||||
|
if sd <= 1e-6:
|
||||||
|
return value_at(legs, spot_scenario, horizon_days, surface_scenario, r) - entry_ref
|
||||||
|
|
||||||
|
mu = math.log(spot_scenario) + (r - 0.5 * sigma ** 2) * T
|
||||||
|
grid = np.geomspace(spot_scenario * 0.15, spot_scenario * 4, n)
|
||||||
|
log_grid = np.log(grid)
|
||||||
|
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])
|
||||||
|
|
||||||
|
numerator = np.trapz(pnl * density, grid)
|
||||||
|
denominator = np.trapz(density, grid)
|
||||||
|
return float(numerator / denominator) if denominator > 1e-12 else float(pnl.mean())
|
||||||
|
|
||||||
|
|
||||||
|
def payoff_curves(
|
||||||
|
legs: List[Dict[str, Any]],
|
||||||
|
chain_slice: Dict[str, Any],
|
||||||
|
surface_now: Surface,
|
||||||
|
surface_scenario: ScenarioSurface,
|
||||||
|
horizon_days: int,
|
||||||
|
r: float = 0.05,
|
||||||
|
n: int = 100,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
spot = chain_slice["spot"]
|
||||||
|
priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r)
|
||||||
|
entry_ref = priced["entry_cost"]
|
||||||
|
|
||||||
|
lo, hi = spot * 0.6, spot * 1.4
|
||||||
|
prices = np.linspace(lo, hi, n)
|
||||||
|
eval_days_expiry = min(l["days_to_expiry"] for l in legs)
|
||||||
|
|
||||||
|
at_expiry = [
|
||||||
|
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days_expiry, surface_now, r) - entry_ref), 2)}
|
||||||
|
for p in prices
|
||||||
|
]
|
||||||
|
at_scenario = [
|
||||||
|
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), horizon_days, surface_scenario, r) - entry_ref), 2)}
|
||||||
|
for p in prices
|
||||||
|
]
|
||||||
|
return {"at_expiry": at_expiry, "at_scenario": at_scenario, **priced}
|
||||||
171
backend/services/strategy_optimizer.py
Normal file
171
backend/services/strategy_optimizer.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"""
|
||||||
|
Optimizer: template generation + bounded hill-climbing residual search + scoring/filtering.
|
||||||
|
|
||||||
|
Scans hundreds-to-thousands of candidate 1-4 leg structures (templates.generate_all,
|
||||||
|
plus off-template perturbations) and returns the top-N ranked by the user's chosen
|
||||||
|
objective, restricted to non-directional / bounded-risk candidates.
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from services.option_chain import get_chain_slice
|
||||||
|
from services.vol_surface import Surface, ScenarioSurface, build_surface, apply_scenario
|
||||||
|
from services.strategy_engine import price_combo, expected_pnl_scenario
|
||||||
|
from services.strategy_templates import generate_all, strikes_for
|
||||||
|
|
||||||
|
MAX_SEEDS_FOR_RESIDUAL_SEARCH = 40
|
||||||
|
RESIDUAL_ITERATIONS_PER_SEED = 8
|
||||||
|
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]:
|
||||||
|
if objective == "net_pnl":
|
||||||
|
return priced["net_pnl"]
|
||||||
|
if objective == "return_on_risk":
|
||||||
|
if not priced["max_loss"]:
|
||||||
|
return None
|
||||||
|
return priced["net_pnl"] / abs(priced["max_loss"])
|
||||||
|
if objective == "prob_weighted":
|
||||||
|
return expected_pnl_scenario(legs, surface_scenario, horizon_days, r, priced["entry_cost"])
|
||||||
|
raise ValueError(f"Objectif inconnu: {objective}")
|
||||||
|
|
||||||
|
|
||||||
|
def _passes_constraints(legs: List[Dict[str, Any]], priced: Dict[str, Any], constraints: Dict[str, Any]) -> bool:
|
||||||
|
if len(legs) > constraints["max_legs"]:
|
||||||
|
return False
|
||||||
|
if abs(priced["net_delta_now"]) > constraints["delta_threshold"]:
|
||||||
|
return False
|
||||||
|
if not priced["bounded_risk"]:
|
||||||
|
return False
|
||||||
|
cap = constraints.get("max_loss_cap")
|
||||||
|
if cap is not None and priced["max_loss"] is not None and abs(priced["max_loss"]) > cap:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate(
|
||||||
|
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,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
if len(legs) > constraints["max_legs"] or len(legs) == 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not _passes_constraints(legs, priced, constraints):
|
||||||
|
return None
|
||||||
|
score = _score(priced, legs, objective, surface_scenario, horizon_days, r)
|
||||||
|
if score is None:
|
||||||
|
return None
|
||||||
|
return {"template_name": name, "legs": legs, "score": round(score, 2), "objective": objective, **priced}
|
||||||
|
|
||||||
|
|
||||||
|
def _perturb(legs: List[Dict[str, Any]], strikes_by_expiry: Dict[Any, List[float]]) -> List[Dict[str, Any]]:
|
||||||
|
new_legs = [dict(l) for l in legs]
|
||||||
|
idx = random.randrange(len(new_legs))
|
||||||
|
leg = new_legs[idx]
|
||||||
|
kind = random.choice(["strike", "strike", "quantity"])
|
||||||
|
|
||||||
|
if kind == "strike":
|
||||||
|
strikes = strikes_by_expiry.get((leg["expiry_date"], leg["option_type"]), [])
|
||||||
|
if not strikes:
|
||||||
|
return new_legs
|
||||||
|
try:
|
||||||
|
cur_idx = strikes.index(leg["strike"])
|
||||||
|
except ValueError:
|
||||||
|
cur_idx = min(range(len(strikes)), key=lambda i: abs(strikes[i] - leg["strike"]))
|
||||||
|
step = random.choice([-2, -1, 1, 2])
|
||||||
|
new_idx = max(0, min(len(strikes) - 1, cur_idx + step))
|
||||||
|
leg["strike"] = strikes[new_idx]
|
||||||
|
else:
|
||||||
|
leg["quantity"] = max(1, min(3, leg["quantity"] + random.choice([-1, 1])))
|
||||||
|
|
||||||
|
return new_legs
|
||||||
|
|
||||||
|
|
||||||
|
def _residual_search(
|
||||||
|
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,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
strikes_by_expiry = {
|
||||||
|
(exp["expiry_date"], opt_type): strikes_for(exp, opt_type)
|
||||||
|
for exp in chain_slice["expiries"] for opt_type in ("call", "put")
|
||||||
|
}
|
||||||
|
found: List[Dict[str, Any]] = []
|
||||||
|
evals = 0
|
||||||
|
|
||||||
|
for seed in seeds:
|
||||||
|
current = seed
|
||||||
|
for _ in range(RESIDUAL_ITERATIONS_PER_SEED):
|
||||||
|
if evals >= RESIDUAL_MAX_EVALS:
|
||||||
|
break
|
||||||
|
candidate_legs = _perturb(current["legs"], strikes_by_expiry)
|
||||||
|
evals += 1
|
||||||
|
evaluated = _evaluate(
|
||||||
|
f"{seed['template_name']} (variante)", candidate_legs, chain_slice, surface_now,
|
||||||
|
surface_scenario, horizon_days, r, constraints, objective,
|
||||||
|
)
|
||||||
|
if evaluated and evaluated["score"] > current["score"]:
|
||||||
|
current = evaluated
|
||||||
|
found.append(evaluated)
|
||||||
|
if evals >= RESIDUAL_MAX_EVALS:
|
||||||
|
break
|
||||||
|
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _dedup_top_n(scored: List[Dict[str, Any]], top_n: int) -> List[Dict[str, Any]]:
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for c in scored:
|
||||||
|
sig = (
|
||||||
|
c["template_name"].replace(" (variante)", ""),
|
||||||
|
tuple(sorted(round(l["strike"]) for l in c["legs"])),
|
||||||
|
tuple(sorted(l["expiry_date"] for l in c["legs"])),
|
||||||
|
)
|
||||||
|
if sig in seen:
|
||||||
|
continue
|
||||||
|
seen.add(sig)
|
||||||
|
out.append(c)
|
||||||
|
if len(out) >= top_n:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def optimize(
|
||||||
|
symbol: str,
|
||||||
|
horizon_days: int,
|
||||||
|
spot_shock_pct: float,
|
||||||
|
iv_level_shift: float,
|
||||||
|
skew_tilt: float,
|
||||||
|
term_shift: float,
|
||||||
|
manual_grid: Optional[List[Dict[str, Any]]],
|
||||||
|
n_expiries: int,
|
||||||
|
rate: float,
|
||||||
|
constraints: Dict[str, Any],
|
||||||
|
objective: str,
|
||||||
|
top_n: int = 20,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
chain_slice = get_chain_slice(symbol, horizon_days, n_expiries)
|
||||||
|
surface_now = build_surface(chain_slice)
|
||||||
|
surface_scenario = apply_scenario(
|
||||||
|
surface_now, spot_shock_pct=spot_shock_pct, iv_level_shift=iv_level_shift,
|
||||||
|
skew_tilt=skew_tilt, term_shift=term_shift, manual_grid=manual_grid,
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates = generate_all(chain_slice)
|
||||||
|
scored: List[Dict[str, Any]] = []
|
||||||
|
for name, legs in candidates:
|
||||||
|
evaluated = _evaluate(name, legs, chain_slice, surface_now, surface_scenario, horizon_days, rate, constraints, objective)
|
||||||
|
if evaluated:
|
||||||
|
scored.append(evaluated)
|
||||||
|
|
||||||
|
scored.sort(key=lambda c: c["score"], reverse=True)
|
||||||
|
seeds = scored[:MAX_SEEDS_FOR_RESIDUAL_SEARCH]
|
||||||
|
|
||||||
|
refined = _residual_search(seeds, chain_slice, surface_now, surface_scenario, horizon_days, rate, constraints, objective)
|
||||||
|
scored.extend(refined)
|
||||||
|
|
||||||
|
scored.sort(key=lambda c: c["score"], reverse=True)
|
||||||
|
return _dedup_top_n(scored, top_n)
|
||||||
222
backend/services/strategy_templates.py
Normal file
222
backend/services/strategy_templates.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"""
|
||||||
|
Parametric generators for canonical non-directional / defined-risk option structures.
|
||||||
|
|
||||||
|
Each generator sweeps a small, bounded grid of strike offsets from ATM (not raw brute
|
||||||
|
force over every strike) so the candidate count stays in the hundreds-to-low-thousands
|
||||||
|
per expiry, not a combinatorial explosion. Every generator yields (template_name, legs).
|
||||||
|
|
||||||
|
Real chains are often asymmetric — a strike can be listed for puts but not for calls
|
||||||
|
(illiquid/untraded contract). Every generator therefore draws strikes from the
|
||||||
|
type-specific list (calls_strikes/put_strikes) for whichever leg it's building, never
|
||||||
|
from a call+put union — picking an unlisted strike would silently fall back to a
|
||||||
|
theoretical smile price instead of a real tradeable quote.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, Iterator, List, Tuple
|
||||||
|
|
||||||
|
Leg = Dict[str, Any]
|
||||||
|
|
||||||
|
OFFSETS = [1, 2, 3, 4, 5, 6]
|
||||||
|
WIDTHS = [1, 2, 3, 4]
|
||||||
|
|
||||||
|
|
||||||
|
def call_strikes(expiry: Dict[str, Any]) -> List[float]:
|
||||||
|
return sorted({r["strike"] for r in expiry["calls"]})
|
||||||
|
|
||||||
|
|
||||||
|
def put_strikes(expiry: Dict[str, Any]) -> List[float]:
|
||||||
|
return sorted({r["strike"] for r in expiry["puts"]})
|
||||||
|
|
||||||
|
|
||||||
|
def strikes_for(expiry: Dict[str, Any], option_type: str) -> List[float]:
|
||||||
|
return call_strikes(expiry) if option_type == "call" else put_strikes(expiry)
|
||||||
|
|
||||||
|
|
||||||
|
def _atm_index(strikes: List[float], spot: float) -> int:
|
||||||
|
return min(range(len(strikes)), key=lambda i: abs(strikes[i] - spot))
|
||||||
|
|
||||||
|
|
||||||
|
def _leg(expiry: Dict[str, Any], strike: float, option_type: str, position: str, quantity: int = 1) -> Leg:
|
||||||
|
return {
|
||||||
|
"expiry_date": expiry["expiry_date"],
|
||||||
|
"days_to_expiry": expiry["days_to_expiry"],
|
||||||
|
"strike": strike,
|
||||||
|
"option_type": option_type,
|
||||||
|
"position": position,
|
||||||
|
"quantity": quantity,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _at(strikes: List[float], idx: int) -> float | None:
|
||||||
|
return strikes[idx] if 0 <= idx < len(strikes) else None
|
||||||
|
|
||||||
|
|
||||||
|
def iron_condor(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
|
||||||
|
puts, calls = put_strikes(expiry), call_strikes(expiry)
|
||||||
|
if not puts or not calls:
|
||||||
|
return
|
||||||
|
atm_p, atm_c = _atm_index(puts, spot), _atm_index(calls, spot)
|
||||||
|
for po in OFFSETS[1:]:
|
||||||
|
for co in OFFSETS[1:]:
|
||||||
|
for w in WIDTHS[:3]:
|
||||||
|
sp, lp = _at(puts, atm_p - po), _at(puts, atm_p - po - w)
|
||||||
|
sc, lc = _at(calls, atm_c + co), _at(calls, atm_c + co + w)
|
||||||
|
if None in (sp, lp, sc, lc):
|
||||||
|
continue
|
||||||
|
yield "Iron Condor", [
|
||||||
|
_leg(expiry, sp, "put", "short"), _leg(expiry, lp, "put", "long"),
|
||||||
|
_leg(expiry, sc, "call", "short"), _leg(expiry, lc, "call", "long"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def iron_butterfly(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
|
||||||
|
puts, calls = put_strikes(expiry), call_strikes(expiry)
|
||||||
|
common = sorted(set(puts) & set(calls))
|
||||||
|
if not common:
|
||||||
|
return
|
||||||
|
atm = _atm_index(common, spot)
|
||||||
|
for center in (0, 1):
|
||||||
|
center_strike = _at(common, atm + center)
|
||||||
|
if center_strike is None:
|
||||||
|
continue
|
||||||
|
p_idx, c_idx = puts.index(center_strike), calls.index(center_strike)
|
||||||
|
for w in OFFSETS:
|
||||||
|
lp, lc = _at(puts, p_idx - w), _at(calls, c_idx + w)
|
||||||
|
if None in (lp, lc):
|
||||||
|
continue
|
||||||
|
yield "Iron Butterfly", [
|
||||||
|
_leg(expiry, center_strike, "put", "short"), _leg(expiry, center_strike, "call", "short"),
|
||||||
|
_leg(expiry, lp, "put", "long"), _leg(expiry, lc, "call", "long"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def butterfly(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
|
||||||
|
"""Call or put butterfly: long 1 low, short 2 mid, long 1 high (all same type, debit)."""
|
||||||
|
for opt_type in ("call", "put"):
|
||||||
|
strikes = strikes_for(expiry, opt_type)
|
||||||
|
if not strikes:
|
||||||
|
continue
|
||||||
|
atm = _atm_index(strikes, spot)
|
||||||
|
for center in (-1, 0, 1):
|
||||||
|
for w in OFFSETS:
|
||||||
|
mid, lo, hi = _at(strikes, atm + center), _at(strikes, atm + center - w), _at(strikes, atm + center + w)
|
||||||
|
if None in (mid, lo, hi):
|
||||||
|
continue
|
||||||
|
yield f"{opt_type.capitalize()} Butterfly", [
|
||||||
|
_leg(expiry, lo, opt_type, "long"), _leg(expiry, mid, opt_type, "short", 2),
|
||||||
|
_leg(expiry, hi, opt_type, "long"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def condor(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
|
||||||
|
"""Call or put condor: long low, short mid-low, short mid-high, long high (same type)."""
|
||||||
|
for opt_type in ("call", "put"):
|
||||||
|
strikes = strikes_for(expiry, opt_type)
|
||||||
|
if not strikes:
|
||||||
|
continue
|
||||||
|
atm = _atm_index(strikes, spot)
|
||||||
|
for inner in (1, 2, 3):
|
||||||
|
for w in WIDTHS:
|
||||||
|
lo, mid_lo = _at(strikes, atm - inner - w), _at(strikes, atm - inner)
|
||||||
|
mid_hi, hi = _at(strikes, atm + inner), _at(strikes, atm + inner + w)
|
||||||
|
if None in (lo, mid_lo, mid_hi, hi):
|
||||||
|
continue
|
||||||
|
yield f"{opt_type.capitalize()} Condor", [
|
||||||
|
_leg(expiry, lo, opt_type, "long"), _leg(expiry, mid_lo, opt_type, "short"),
|
||||||
|
_leg(expiry, mid_hi, opt_type, "short"), _leg(expiry, hi, opt_type, "long"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def straddle_strangle(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
|
||||||
|
puts, calls = put_strikes(expiry), call_strikes(expiry)
|
||||||
|
common = sorted(set(puts) & set(calls))
|
||||||
|
if common:
|
||||||
|
atm_strike = _at(common, _atm_index(common, spot))
|
||||||
|
if atm_strike is not None:
|
||||||
|
yield "Long Straddle", [_leg(expiry, atm_strike, "call", "long"), _leg(expiry, atm_strike, "put", "long")]
|
||||||
|
yield "Short Straddle", [_leg(expiry, atm_strike, "call", "short"), _leg(expiry, atm_strike, "put", "short")]
|
||||||
|
|
||||||
|
if not puts or not calls:
|
||||||
|
return
|
||||||
|
atm_p, atm_c = _atm_index(puts, spot), _atm_index(calls, spot)
|
||||||
|
for w in OFFSETS:
|
||||||
|
put_k, call_k = _at(puts, atm_p - w), _at(calls, atm_c + w)
|
||||||
|
if None in (put_k, call_k):
|
||||||
|
continue
|
||||||
|
yield "Long Strangle", [_leg(expiry, call_k, "call", "long"), _leg(expiry, put_k, "put", "long")]
|
||||||
|
yield "Short Strangle", [_leg(expiry, call_k, "call", "short"), _leg(expiry, put_k, "put", "short")]
|
||||||
|
|
||||||
|
|
||||||
|
def ratio_spread(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
|
||||||
|
for opt_type in ("call", "put"):
|
||||||
|
strikes = strikes_for(expiry, opt_type)
|
||||||
|
if not strikes:
|
||||||
|
continue
|
||||||
|
atm = _atm_index(strikes, spot)
|
||||||
|
sign = 1 if opt_type == "call" else -1
|
||||||
|
for near in (1, 2, 3):
|
||||||
|
for far in (2, 3, 4, 5):
|
||||||
|
if far <= near:
|
||||||
|
continue
|
||||||
|
near_k = _at(strikes, atm + sign * near)
|
||||||
|
far_k = _at(strikes, atm + sign * far)
|
||||||
|
if None in (near_k, far_k):
|
||||||
|
continue
|
||||||
|
yield f"{opt_type.capitalize()} Ratio Spread", [
|
||||||
|
_leg(expiry, near_k, opt_type, "long"), _leg(expiry, far_k, opt_type, "short", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def calendar_spread(near_expiry: Dict[str, Any], far_expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
|
||||||
|
for opt_type in ("call", "put"):
|
||||||
|
near_strikes = strikes_for(near_expiry, opt_type)
|
||||||
|
far_set = set(strikes_for(far_expiry, opt_type))
|
||||||
|
if not near_strikes or not far_set:
|
||||||
|
continue
|
||||||
|
atm = _atm_index(near_strikes, spot)
|
||||||
|
for offset in (-1, 0, 1):
|
||||||
|
k = _at(near_strikes, atm + offset)
|
||||||
|
if k is None or k not in far_set:
|
||||||
|
continue
|
||||||
|
yield "Calendar Spread", [
|
||||||
|
_leg(near_expiry, k, opt_type, "short"), _leg(far_expiry, k, opt_type, "long"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def diagonal_spread(near_expiry: Dict[str, Any], far_expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
|
||||||
|
for opt_type in ("call", "put"):
|
||||||
|
near_strikes = strikes_for(near_expiry, opt_type)
|
||||||
|
far_strikes = strikes_for(far_expiry, opt_type)
|
||||||
|
if not near_strikes or not far_strikes:
|
||||||
|
continue
|
||||||
|
atm_near, atm_far = _atm_index(near_strikes, spot), _atm_index(far_strikes, spot)
|
||||||
|
sign = 1 if opt_type == "call" else -1
|
||||||
|
for near_off in (1, 2, 3):
|
||||||
|
for far_off in (0, 1, 2):
|
||||||
|
near_k = _at(near_strikes, atm_near + sign * near_off)
|
||||||
|
far_k = _at(far_strikes, atm_far + sign * far_off)
|
||||||
|
if None in (near_k, far_k) or near_k == far_k:
|
||||||
|
continue
|
||||||
|
yield "Diagonal Spread", [
|
||||||
|
_leg(near_expiry, near_k, opt_type, "short"), _leg(far_expiry, far_k, opt_type, "long"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_all(chain_slice: Dict[str, Any]) -> List[Tuple[str, List[Leg]]]:
|
||||||
|
"""All template candidates across the fetched expiries. Single-expiry templates run per
|
||||||
|
expiry; calendar/diagonal templates pair the two nearest expiries."""
|
||||||
|
spot = chain_slice["spot"]
|
||||||
|
expiries = chain_slice["expiries"]
|
||||||
|
candidates: List[Tuple[str, List[Leg]]] = []
|
||||||
|
|
||||||
|
for exp in expiries:
|
||||||
|
for gen in (iron_condor, iron_butterfly, butterfly, condor, straddle_strangle, ratio_spread):
|
||||||
|
candidates.extend(gen(exp, spot))
|
||||||
|
|
||||||
|
by_date = sorted(expiries, key=lambda e: e["days_to_expiry"])
|
||||||
|
if len(by_date) >= 2:
|
||||||
|
near, far = by_date[0], by_date[1]
|
||||||
|
if far["days_to_expiry"] > near["days_to_expiry"]:
|
||||||
|
candidates.extend(calendar_spread(near, far, spot))
|
||||||
|
candidates.extend(diagonal_spread(near, far, spot))
|
||||||
|
|
||||||
|
return candidates
|
||||||
152
backend/services/vol_surface.py
Normal file
152
backend/services/vol_surface.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
Vol surface model for the Strategy Builder.
|
||||||
|
|
||||||
|
Builds a smile-by-expiry model from a real option chain slice (option_chain.py)
|
||||||
|
and projects a shocked scenario surface (spot/IV-level/skew/term shocks +
|
||||||
|
optional manual per-cell overrides from the frontend grid).
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class Surface:
|
||||||
|
"""Current-market smile, interpolated per expiry over log-moneyness."""
|
||||||
|
|
||||||
|
def __init__(self, spot: float, expiries: List[Dict[str, Any]]):
|
||||||
|
self.spot = spot
|
||||||
|
self._tenors: List[Tuple[float, Callable[[float], float]]] = [] # (days, smile_fn)
|
||||||
|
for exp in expiries:
|
||||||
|
points = _smile_points(exp, spot)
|
||||||
|
if not points:
|
||||||
|
continue
|
||||||
|
self._tenors.append((exp["days_to_expiry"], _build_smile_fn(points)))
|
||||||
|
self._tenors.sort(key=lambda x: x[0])
|
||||||
|
|
||||||
|
def iv_at(self, strike: float, days: float) -> float:
|
||||||
|
if not self._tenors:
|
||||||
|
return 0.20
|
||||||
|
moneyness = math.log(max(strike, 1e-6) / max(self.spot, 1e-6))
|
||||||
|
|
||||||
|
if days <= self._tenors[0][0]:
|
||||||
|
return self._tenors[0][1](moneyness)
|
||||||
|
if days >= self._tenors[-1][0]:
|
||||||
|
return self._tenors[-1][1](moneyness)
|
||||||
|
|
||||||
|
for (d0, f0), (d1, f1) in zip(self._tenors, self._tenors[1:]):
|
||||||
|
if d0 <= days <= d1:
|
||||||
|
iv0, iv1 = f0(moneyness), f1(moneyness)
|
||||||
|
w = (days - d0) / max(d1 - d0, 1e-6)
|
||||||
|
return iv0 + (iv1 - iv0) * w
|
||||||
|
return self._tenors[-1][1](moneyness)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioSurface:
|
||||||
|
"""Shocked surface at the scenario horizon: parametric shifts + manual overrides."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base: Surface,
|
||||||
|
scenario_spot: float,
|
||||||
|
iv_level_shift: float,
|
||||||
|
skew_tilt: float,
|
||||||
|
term_shift: float,
|
||||||
|
manual_overrides: Optional[Dict[Tuple[int, float], float]] = None,
|
||||||
|
):
|
||||||
|
self.base = base
|
||||||
|
self.spot = scenario_spot
|
||||||
|
self.iv_level_shift = iv_level_shift
|
||||||
|
self.skew_tilt = skew_tilt
|
||||||
|
self.term_shift = term_shift
|
||||||
|
self.manual_overrides = manual_overrides or {}
|
||||||
|
|
||||||
|
def iv_at(self, strike: float, days: float) -> float:
|
||||||
|
override = self._match_override(strike, days)
|
||||||
|
if override is not None:
|
||||||
|
return override
|
||||||
|
base_iv = self.base.iv_at(strike, days)
|
||||||
|
moneyness = math.log(max(strike, 1e-6) / max(self.spot, 1e-6))
|
||||||
|
shocked = (
|
||||||
|
base_iv
|
||||||
|
+ self.iv_level_shift
|
||||||
|
+ self.skew_tilt * moneyness
|
||||||
|
+ self.term_shift * (days / 30.0)
|
||||||
|
)
|
||||||
|
return max(0.01, shocked)
|
||||||
|
|
||||||
|
def _match_override(self, strike: float, days: float) -> Optional[float]:
|
||||||
|
if not self.manual_overrides:
|
||||||
|
return None
|
||||||
|
strike_pct = strike / max(self.spot, 1e-6)
|
||||||
|
best = None
|
||||||
|
best_dist = None
|
||||||
|
for (o_days, o_pct), iv in self.manual_overrides.items():
|
||||||
|
dist = abs(o_days - days) / 30.0 + abs(o_pct - strike_pct)
|
||||||
|
if best_dist is None or dist < best_dist:
|
||||||
|
best_dist, best = dist, iv
|
||||||
|
# Only snap to an override if it's reasonably close to the requested cell
|
||||||
|
if best_dist is not None and best_dist < 0.08:
|
||||||
|
return best
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _smile_points(expiry: Dict[str, Any], spot: float) -> List[Tuple[float, float]]:
|
||||||
|
"""Average call/put IV per strike (filtering stale/zero quotes) -> [(log-moneyness, iv), ...]."""
|
||||||
|
by_strike: Dict[float, List[float]] = {}
|
||||||
|
for row in expiry.get("calls", []) + expiry.get("puts", []):
|
||||||
|
if row["iv"] and row["iv"] > 0.01:
|
||||||
|
by_strike.setdefault(row["strike"], []).append(row["iv"])
|
||||||
|
if not by_strike:
|
||||||
|
return []
|
||||||
|
points = [
|
||||||
|
(math.log(k / spot), sum(v) / len(v))
|
||||||
|
for k, v in sorted(by_strike.items())
|
||||||
|
]
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def _build_smile_fn(points: List[Tuple[float, float]]) -> Callable[[float], float]:
|
||||||
|
xs = np.array([p[0] for p in points])
|
||||||
|
ys = np.array([p[1] for p in points])
|
||||||
|
|
||||||
|
if len(xs) >= 4:
|
||||||
|
from scipy.interpolate import CubicSpline
|
||||||
|
spline = CubicSpline(xs, ys, extrapolate=False)
|
||||||
|
|
||||||
|
def fn(x: float) -> float:
|
||||||
|
if x <= xs[0]:
|
||||||
|
return float(ys[0])
|
||||||
|
if x >= xs[-1]:
|
||||||
|
return float(ys[-1])
|
||||||
|
v = spline(x)
|
||||||
|
return float(max(0.01, v))
|
||||||
|
return fn
|
||||||
|
|
||||||
|
def fn_linear(x: float) -> float:
|
||||||
|
return float(max(0.01, np.interp(x, xs, ys)))
|
||||||
|
return fn_linear
|
||||||
|
|
||||||
|
|
||||||
|
def build_surface(chain_slice: Dict[str, Any]) -> Surface:
|
||||||
|
return Surface(chain_slice["spot"], chain_slice["expiries"])
|
||||||
|
|
||||||
|
|
||||||
|
def apply_scenario(
|
||||||
|
surface: Surface,
|
||||||
|
spot_shock_pct: float = 0.0,
|
||||||
|
iv_level_shift: float = 0.0,
|
||||||
|
skew_tilt: float = 0.0,
|
||||||
|
term_shift: float = 0.0,
|
||||||
|
manual_grid: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
) -> ScenarioSurface:
|
||||||
|
"""
|
||||||
|
manual_grid: list of {days_to_expiry, strike_pct, iv} cells edited by the user in the
|
||||||
|
frontend grid — takes precedence over the parametric shock at nearby (days, strike_pct).
|
||||||
|
"""
|
||||||
|
scenario_spot = surface.spot * (1 + spot_shock_pct / 100.0)
|
||||||
|
overrides: Dict[Tuple[int, float], float] = {}
|
||||||
|
for cell in (manual_grid or []):
|
||||||
|
if cell.get("iv") is not None:
|
||||||
|
overrides[(int(cell["days_to_expiry"]), float(cell["strike_pct"]))] = float(cell["iv"])
|
||||||
|
return ScenarioSurface(surface, scenario_spot, iv_level_shift, skew_tilt, term_shift, overrides)
|
||||||
@@ -9,6 +9,7 @@ import GeoRadar from './pages/GeoRadar'
|
|||||||
import Markets from './pages/Markets'
|
import Markets from './pages/Markets'
|
||||||
import MacroRegime from './pages/MacroRegime'
|
import MacroRegime from './pages/MacroRegime'
|
||||||
import OptionsLab from './pages/OptionsLab'
|
import OptionsLab from './pages/OptionsLab'
|
||||||
|
import StrategyBuilder from './pages/StrategyBuilder'
|
||||||
import WaveletsSimulation from './pages/WaveletsSimulation'
|
import WaveletsSimulation from './pages/WaveletsSimulation'
|
||||||
import Backtest from './pages/Backtest'
|
import Backtest from './pages/Backtest'
|
||||||
import CalendarPage from './pages/CalendarPage'
|
import CalendarPage from './pages/CalendarPage'
|
||||||
@@ -50,6 +51,7 @@ const KEEP_ALIVE_DEFS: { path: string; component: ComponentType }[] = [
|
|||||||
{ path: '/markets', component: Markets },
|
{ path: '/markets', component: Markets },
|
||||||
{ path: '/macro', component: MacroRegime },
|
{ path: '/macro', component: MacroRegime },
|
||||||
{ path: '/options', component: OptionsLab },
|
{ path: '/options', component: OptionsLab },
|
||||||
|
{ path: '/strategy-builder', component: StrategyBuilder },
|
||||||
{ path: '/wavelets-simulation', component: WaveletsSimulation },
|
{ path: '/wavelets-simulation', component: WaveletsSimulation },
|
||||||
{ path: '/patterns', component: PatternExplorer },
|
{ path: '/patterns', component: PatternExplorer },
|
||||||
{ path: '/pattern-lab', component: PatternLab },
|
{ path: '/pattern-lab', component: PatternLab },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Globe, BarChart2, FlaskConical,
|
LayoutDashboard, Globe, BarChart2, FlaskConical,
|
||||||
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio, Bot, Sliders, Waves
|
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio, Bot, Sliders, Waves, Layers
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@@ -12,6 +12,7 @@ const nav = [
|
|||||||
{ to: '/markets', icon: BarChart2, label: 'Markets & Prices' },
|
{ to: '/markets', icon: BarChart2, label: 'Markets & Prices' },
|
||||||
{ to: '/macro', icon: Activity, label: 'Macro Regime' },
|
{ to: '/macro', icon: Activity, label: 'Macro Regime' },
|
||||||
{ to: '/options', icon: TrendingUp, label: 'Options Lab' },
|
{ to: '/options', icon: TrendingUp, label: 'Options Lab' },
|
||||||
|
{ to: '/strategy-builder', icon: Layers, label: 'Strategy Builder' },
|
||||||
{ to: '/wavelets-simulation', icon: Waves, label: 'Wavelets Simulation' },
|
{ to: '/wavelets-simulation', icon: Waves, label: 'Wavelets Simulation' },
|
||||||
{ to: '/patterns', icon: Zap, label: 'Patterns' },
|
{ to: '/patterns', icon: Zap, label: 'Patterns' },
|
||||||
{ to: '/pattern-lab', icon: FlaskConical, label: 'Pattern Lab' },
|
{ to: '/pattern-lab', icon: FlaskConical, label: 'Pattern Lab' },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
LayoutDashboard, Globe, BarChart2, Activity, TrendingUp, Zap, FlaskConical,
|
LayoutDashboard, Globe, BarChart2, Activity, TrendingUp, Zap, FlaskConical,
|
||||||
DollarSign, BookOpen, FileBarChart, Brain, Microscope, ShieldAlert, Gauge,
|
DollarSign, BookOpen, FileBarChart, Brain, Microscope, ShieldAlert, Gauge,
|
||||||
GitCompare, History, Calendar, Sliders, TrendingUp as MacroSeriesIcon,
|
GitCompare, History, Calendar, Sliders, TrendingUp as MacroSeriesIcon,
|
||||||
Building2, Users, ScanEye, Radio, Bot, PlayCircle, ScrollText, Settings, Waves,
|
Building2, Users, ScanEye, Radio, Bot, PlayCircle, ScrollText, Settings, Waves, Layers,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ export const KEEP_ALIVE_META: KeepAliveMeta[] = [
|
|||||||
{ path: '/markets', label: 'Markets', icon: BarChart2 },
|
{ path: '/markets', label: 'Markets', icon: BarChart2 },
|
||||||
{ path: '/macro', label: 'Macro Regime', icon: Activity },
|
{ path: '/macro', label: 'Macro Regime', icon: Activity },
|
||||||
{ path: '/options', label: 'Options Lab', icon: TrendingUp },
|
{ path: '/options', label: 'Options Lab', icon: TrendingUp },
|
||||||
|
{ path: '/strategy-builder', label: 'Strategy Builder', icon: Layers },
|
||||||
{ path: '/wavelets-simulation', label: 'Wavelets Sim.', icon: Waves },
|
{ path: '/wavelets-simulation', label: 'Wavelets Sim.', icon: Waves },
|
||||||
{ path: '/patterns', label: 'Patterns', icon: Zap },
|
{ path: '/patterns', label: 'Patterns', icon: Zap },
|
||||||
{ path: '/pattern-lab', label: 'Pattern Lab', icon: FlaskConical },
|
{ path: '/pattern-lab', label: 'Pattern Lab', icon: FlaskConical },
|
||||||
|
|||||||
@@ -1515,3 +1515,152 @@ export const useRejectAiTradeProposal = () => {
|
|||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ai-trade-proposals'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['ai-trade-proposals'] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Strategy Builder ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ChainRow = { strike: number; bid: number; ask: number; mid: number; last: number; iv: number; open_interest: number; volume: number }
|
||||||
|
export type ChainExpiry = { expiry_date: string; days_to_expiry: number; calls: ChainRow[]; puts: ChainRow[] }
|
||||||
|
export type ChainSlice = { symbol: string; proxy: string; spot: number; expiries: ChainExpiry[] }
|
||||||
|
|
||||||
|
export type ManualGridCell = { days_to_expiry: number; strike_pct: number; iv: number | null }
|
||||||
|
|
||||||
|
export type StrategyScenario = {
|
||||||
|
symbol: string
|
||||||
|
horizon_days: number
|
||||||
|
spot_shock_pct: number
|
||||||
|
iv_level_shift: number
|
||||||
|
skew_tilt: number
|
||||||
|
term_shift: number
|
||||||
|
manual_grid?: ManualGridCell[]
|
||||||
|
rate?: number
|
||||||
|
n_expiries?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StrategyLeg = {
|
||||||
|
expiry_date: string
|
||||||
|
days_to_expiry: number
|
||||||
|
strike: number
|
||||||
|
option_type: 'call' | 'put'
|
||||||
|
position: 'long' | 'short'
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Greeks = { delta: number; gamma: number; theta: number; vega: number }
|
||||||
|
export type PayoffPoint = { underlying: number; pnl: number }
|
||||||
|
|
||||||
|
export type PriceCombo = {
|
||||||
|
entry_cost: number
|
||||||
|
entry_cost_mid: number
|
||||||
|
scenario_value: number
|
||||||
|
scenario_value_mid: number
|
||||||
|
net_pnl: number
|
||||||
|
broker_spread_cost: number
|
||||||
|
max_gain: number | null
|
||||||
|
max_loss: number | null
|
||||||
|
bounded_risk: boolean
|
||||||
|
greeks_now: Greeks
|
||||||
|
greeks_scenario: Greeks
|
||||||
|
net_delta_now: number
|
||||||
|
net_delta_scenario: number
|
||||||
|
at_expiry: PayoffPoint[]
|
||||||
|
at_scenario: PayoffPoint[]
|
||||||
|
spot: number
|
||||||
|
scenario_spot: number
|
||||||
|
proxy: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StrategyCandidate = {
|
||||||
|
template_name: string
|
||||||
|
legs: StrategyLeg[]
|
||||||
|
score: number
|
||||||
|
objective: string
|
||||||
|
} & PriceCombo
|
||||||
|
|
||||||
|
export const useOptionChainSlice = (symbol: string, horizonDays: number, nExpiries = 3, enabled = true) =>
|
||||||
|
useQuery<ChainSlice>({
|
||||||
|
queryKey: ['strategy-builder-chain', symbol, horizonDays, nExpiries],
|
||||||
|
queryFn: () => api.get('/strategy-builder/chain', { params: { symbol, horizon_days: horizonDays, n_expiries: nExpiries } }).then(r => r.data),
|
||||||
|
enabled: enabled && !!symbol,
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const usePriceStrategy = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: (body: { scenario: StrategyScenario; legs: StrategyLeg[] }) =>
|
||||||
|
api.post<PriceCombo>('/strategy-builder/price', body).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type OptimizeConstraints = {
|
||||||
|
max_legs: number
|
||||||
|
delta_threshold: number
|
||||||
|
max_loss_cap?: number | null
|
||||||
|
objective: 'net_pnl' | 'return_on_risk' | 'prob_weighted'
|
||||||
|
top_n?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOptimizeStrategy = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: (body: { scenario: StrategyScenario; constraints: OptimizeConstraints }) =>
|
||||||
|
api.post<StrategyCandidate[]>('/strategy-builder/optimize', body).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SavedScenario = {
|
||||||
|
id: string; symbol: string; label: string; horizon_days: number
|
||||||
|
spot_shock_pct: number; iv_level_shift: number; skew_tilt: number; term_shift: number
|
||||||
|
manual_grid: ManualGridCell[]; created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SavedStrategyRecord = {
|
||||||
|
id: string; scenario_id: string | null; symbol: string; template_name: string; objective: string
|
||||||
|
legs: StrategyLeg[]; entry_cost: number | null; max_gain: number | null; max_loss: number | null
|
||||||
|
net_pnl_scenario: number | null; net_delta: number | null; notes: string; created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useScenarios = (symbol?: string) =>
|
||||||
|
useQuery<SavedScenario[]>({
|
||||||
|
queryKey: ['strategy-scenarios', symbol],
|
||||||
|
queryFn: () => api.get('/strategy-builder/scenarios', { params: symbol ? { symbol } : {} }).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useSaveScenario = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: StrategyScenario & { label?: string }) => api.post('/strategy-builder/scenarios', body).then(r => r.data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['strategy-scenarios'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeleteScenario = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/strategy-builder/scenarios/${id}`).then(r => r.data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['strategy-scenarios'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSavedStrategies = (symbol?: string) =>
|
||||||
|
useQuery<SavedStrategyRecord[]>({
|
||||||
|
queryKey: ['saved-strategies', symbol],
|
||||||
|
queryFn: () => api.get('/strategy-builder/saved', { params: symbol ? { symbol } : {} }).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useSaveStrategyRecord = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: {
|
||||||
|
scenario_id?: string | null; symbol: string; template_name?: string; objective?: string
|
||||||
|
legs: StrategyLeg[]; entry_cost?: number | null; max_gain?: number | null; max_loss?: number | null
|
||||||
|
net_pnl_scenario?: number | null; net_delta?: number | null; notes?: string
|
||||||
|
}) => api.post('/strategy-builder/saved', body).then(r => r.data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-strategies'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeleteSavedStrategy = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/strategy-builder/saved/${id}`).then(r => r.data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-strategies'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
697
frontend/src/pages/StrategyBuilder.tsx
Normal file
697
frontend/src/pages/StrategyBuilder.tsx
Normal file
@@ -0,0 +1,697 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import {
|
||||||
|
useOptionChainSlice, usePriceStrategy, useOptimizeStrategy,
|
||||||
|
useScenarios, useSaveScenario, useDeleteScenario,
|
||||||
|
useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
|
||||||
|
type StrategyLeg, type StrategyScenario, type PriceCombo, type StrategyCandidate,
|
||||||
|
type OptimizeConstraints, type SavedScenario,
|
||||||
|
} from '../hooks/useApi'
|
||||||
|
|
||||||
|
const STRIKE_PCTS = [80, 85, 90, 95, 100, 105, 110, 115, 120]
|
||||||
|
const DELTA_NEUTRAL_THRESHOLD = 0.15
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function emptyLeg(expiryDate: string, daysToExpiry: number, strike: number): StrategyLeg {
|
||||||
|
return { expiry_date: expiryDate, days_to_expiry: daysToExpiry, strike, option_type: 'call', position: 'long', quantity: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMoney(v: number | null | undefined) {
|
||||||
|
if (v == null) return '—'
|
||||||
|
return `${v >= 0 ? '+' : ''}${v.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function pnlColor(v: number | null | undefined) {
|
||||||
|
if (v == null) return 'text-slate-400'
|
||||||
|
return v >= 0 ? 'text-emerald-400' : 'text-red-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rough client-side smile preview (avg call/put IV at nearest strike) — mirrors the
|
||||||
|
* cubic-spline surface computed server-side closely enough to preview shocks live. */
|
||||||
|
function estimateBaseIv(chain: any, daysToExpiry: number, strikePct: number, spot: number): number | null {
|
||||||
|
if (!chain) return null
|
||||||
|
const exp = chain.expiries.reduce((best: any, e: any) =>
|
||||||
|
Math.abs(e.days_to_expiry - daysToExpiry) < Math.abs((best?.days_to_expiry ?? Infinity) - daysToExpiry) ? e : best, null)
|
||||||
|
if (!exp) return null
|
||||||
|
const targetStrike = spot * (strikePct / 100)
|
||||||
|
const rows = [...exp.calls, ...exp.puts].filter((r: any) => r.iv > 0.01)
|
||||||
|
if (!rows.length) return null
|
||||||
|
const nearest = rows.reduce((best: any, r: any) =>
|
||||||
|
Math.abs(r.strike - targetStrike) < Math.abs(best.strike - targetStrike) ? r : best)
|
||||||
|
return nearest.iv
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payoff chart ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PayoffChart({ priced, spot, scenarioSpot }: { priced: PriceCombo; spot: number; scenarioSpot: number }) {
|
||||||
|
const data = priced.at_expiry.map((p, i) => ({
|
||||||
|
underlying: p.underlying,
|
||||||
|
expiry: p.pnl,
|
||||||
|
scenario: priced.at_scenario[i]?.pnl,
|
||||||
|
}))
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<LineChart data={data} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
|
||||||
|
<XAxis dataKey="underlying" tick={{ fill: '#475569', fontSize: 10 }} tickLine={false}
|
||||||
|
tickFormatter={(v) => v.toFixed(0)} />
|
||||||
|
<YAxis tick={{ fill: '#475569', fontSize: 10 }} tickLine={false} axisLine={false}
|
||||||
|
tickFormatter={(v) => `${v}`} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 11 }}
|
||||||
|
labelFormatter={(v) => `Sous-jacent: ${Number(v).toFixed(2)}`}
|
||||||
|
formatter={(v: number, name: string) => [fmtMoney(v), name]}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 4" />
|
||||||
|
<ReferenceLine x={spot} stroke="#3b82f6" strokeDasharray="2 2" label={{ value: 'Spot', fill: '#3b82f6', fontSize: 9, position: 'top' }} />
|
||||||
|
<ReferenceLine x={scenarioSpot} stroke="#f59e0b" strokeDasharray="2 2" label={{ value: 'Scénario J+8', fill: '#f59e0b', fontSize: 9, position: 'insideTopRight' }} />
|
||||||
|
<Line type="monotone" dataKey="expiry" name="À échéance" stroke="#3b82f6" strokeWidth={2} dot={false} />
|
||||||
|
<Line type="monotone" dataKey="scenario" name="À J+8 (scénario)" stroke="#f59e0b" strokeWidth={2} dot={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GreeksTile({ label, now, scenario }: { label: string; now: number; scenario: number }) {
|
||||||
|
return (
|
||||||
|
<div className="card-sm">
|
||||||
|
<div className="stat-label">{label}</div>
|
||||||
|
<div className="flex items-baseline gap-2 mt-1">
|
||||||
|
<span className="text-lg font-bold text-white">{now.toFixed(4)}</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenario panel ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ScenarioPanel({
|
||||||
|
symbol, setSymbol, horizonDays, setHorizonDays, scenario, setScenario,
|
||||||
|
}: {
|
||||||
|
symbol: string; setSymbol: (v: string) => void
|
||||||
|
horizonDays: number; setHorizonDays: (v: number) => void
|
||||||
|
scenario: StrategyScenario; setScenario: (v: StrategyScenario) => void
|
||||||
|
}) {
|
||||||
|
const slider = (
|
||||||
|
key: 'spot_shock_pct' | 'iv_level_shift' | 'skew_tilt' | 'term_shift',
|
||||||
|
label: string, min: number, max: number, step: number, fmt: (v: number) => string,
|
||||||
|
) => (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400 mb-1">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="text-white font-semibold">{fmt(scenario[key])}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range" min={min} max={max} step={step} value={scenario[key]}
|
||||||
|
onChange={(e) => setScenario({ ...scenario, [key]: parseFloat(e.target.value) })}
|
||||||
|
className="w-full accent-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="stat-label block mb-1">Symbole</label>
|
||||||
|
<input
|
||||||
|
value={symbol}
|
||||||
|
onChange={(e) => setSymbol(e.target.value.toUpperCase())}
|
||||||
|
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white"
|
||||||
|
placeholder="SPY, QQQ, GLD…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-28">
|
||||||
|
<label className="stat-label block mb-1">Horizon (j)</label>
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={90} value={horizonDays}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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('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('term_shift', 'Choc terme (/30j)', -0.1, 0.1, 0.005, (v) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}pts`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manual grid override ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ScenarioGrid({
|
||||||
|
chain, spot, scenario, setScenario,
|
||||||
|
}: {
|
||||||
|
chain: any; spot: number; scenario: StrategyScenario; setScenario: (v: StrategyScenario) => void
|
||||||
|
}) {
|
||||||
|
if (!chain) return null
|
||||||
|
const overrides = scenario.manual_grid || []
|
||||||
|
|
||||||
|
const cellValue = (daysToExpiry: number, strikePct: number): number | null => {
|
||||||
|
const hit = overrides.find(o => o.days_to_expiry === daysToExpiry && o.strike_pct === strikePct)
|
||||||
|
if (hit) return hit.iv
|
||||||
|
const base = estimateBaseIv(chain, daysToExpiry, strikePct, spot)
|
||||||
|
if (base == null) return null
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setOverride = (daysToExpiry: number, strikePct: number, iv: number | null) => {
|
||||||
|
const next = overrides.filter(o => !(o.days_to_expiry === daysToExpiry && o.strike_pct === strikePct))
|
||||||
|
if (iv != null) next.push({ days_to_expiry: daysToExpiry, strike_pct: strikePct, iv })
|
||||||
|
setScenario({ ...scenario, manual_grid: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOverridden = (daysToExpiry: number, strikePct: number) =>
|
||||||
|
overrides.some(o => o.days_to_expiry === daysToExpiry && o.strike_pct === strikePct)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="stat-label">Grille IV scénario (éditable — clic sur une cellule)</div>
|
||||||
|
{overrides.length > 0 && (
|
||||||
|
<button onClick={() => setScenario({ ...scenario, manual_grid: [] })} className="text-xs text-slate-500 hover:text-slate-300">
|
||||||
|
Réinitialiser overrides
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-slate-500">
|
||||||
|
<th className="text-left py-1 pr-3">Expiry / Strike%</th>
|
||||||
|
{STRIKE_PCTS.map(p => <th key={p} className="px-2 py-1 text-right">{p}%</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{chain.expiries.map((exp: any) => (
|
||||||
|
<tr key={exp.expiry_date} className="border-t border-slate-700/30">
|
||||||
|
<td className="py-1 pr-3 text-slate-400 whitespace-nowrap">{exp.expiry_date} ({exp.days_to_expiry}j)</td>
|
||||||
|
{STRIKE_PCTS.map(pct => {
|
||||||
|
const v = cellValue(exp.days_to_expiry, pct)
|
||||||
|
const overridden = isOverridden(exp.days_to_expiry, pct)
|
||||||
|
return (
|
||||||
|
<td key={pct} className="px-1 py-1">
|
||||||
|
<input
|
||||||
|
type="number" step={0.005}
|
||||||
|
value={v != null ? Math.round(v * 1000) / 1000 : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value === '' ? null : parseFloat(e.target.value)
|
||||||
|
setOverride(exp.days_to_expiry, pct, val)
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'w-16 text-right bg-dark-700 border rounded px-1 py-0.5',
|
||||||
|
overridden ? 'border-amber-500 text-amber-300' : 'border-slate-700/40 text-slate-300',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manual leg builder ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LegRow({
|
||||||
|
leg, chain, onChange, onRemove,
|
||||||
|
}: {
|
||||||
|
leg: StrategyLeg; chain: any
|
||||||
|
onChange: (leg: StrategyLeg) => void
|
||||||
|
onRemove: () => void
|
||||||
|
}) {
|
||||||
|
const expiry = chain?.expiries.find((e: any) => e.expiry_date === leg.expiry_date)
|
||||||
|
const rows = expiry ? (leg.option_type === 'call' ? expiry.calls : expiry.puts) : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-12 gap-2 items-center text-xs">
|
||||||
|
<select
|
||||||
|
className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
|
||||||
|
value={leg.expiry_date}
|
||||||
|
onChange={(e) => {
|
||||||
|
const exp = chain.expiries.find((x: any) => x.expiry_date === e.target.value)
|
||||||
|
onChange({ ...leg, expiry_date: e.target.value, days_to_expiry: exp?.days_to_expiry ?? leg.days_to_expiry })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chain?.expiries.map((e: any) => (
|
||||||
|
<option key={e.expiry_date} value={e.expiry_date}>{e.expiry_date} ({e.days_to_expiry}j)</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
|
||||||
|
value={leg.option_type}
|
||||||
|
onChange={(e) => onChange({ ...leg, option_type: e.target.value as 'call' | 'put' })}
|
||||||
|
>
|
||||||
|
<option value="call">Call</option>
|
||||||
|
<option value="put">Put</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
|
||||||
|
value={leg.strike}
|
||||||
|
onChange={(e) => onChange({ ...leg, strike: parseFloat(e.target.value) })}
|
||||||
|
>
|
||||||
|
{rows.map((r: any) => (
|
||||||
|
<option key={r.strike} value={r.strike}>{r.strike} (bid {r.bid} / ask {r.ask})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
|
||||||
|
value={leg.position}
|
||||||
|
onChange={(e) => onChange({ ...leg, position: e.target.value as 'long' | 'short' })}
|
||||||
|
>
|
||||||
|
<option value="long">Achat</option>
|
||||||
|
<option value="short">Vente</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number" min={1} value={leg.quantity}
|
||||||
|
onChange={(e) => onChange({ ...leg, quantity: parseInt(e.target.value) || 1 })}
|
||||||
|
className="col-span-1 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
|
||||||
|
/>
|
||||||
|
<button onClick={onRemove} className="col-span-1 text-slate-500 hover:text-red-400 flex justify-center">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Optimizer panel ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const OBJECTIVES: { value: OptimizeConstraints['objective']; label: string }[] = [
|
||||||
|
{ value: 'net_pnl', label: 'P&L net max' },
|
||||||
|
{ value: 'return_on_risk', label: 'Retour sur risque (P&L / perte max)' },
|
||||||
|
{ value: 'prob_weighted', label: 'Espérance pondérée par probabilité' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function OptimizerPanel({
|
||||||
|
constraints, setConstraints, onRun, isRunning,
|
||||||
|
}: {
|
||||||
|
constraints: OptimizeConstraints; setConstraints: (v: OptimizeConstraints) => void
|
||||||
|
onRun: () => void; isRunning: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="stat-label">Optimiseur — contraintes & objectif</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 block mb-1">Max jambes</label>
|
||||||
|
<select
|
||||||
|
value={constraints.max_legs}
|
||||||
|
onChange={(e) => setConstraints({ ...constraints, max_legs: parseInt(e.target.value) })}
|
||||||
|
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4].map(n => <option key={n} value={n}>{n}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 block mb-1">Seuil neutralité Δ</label>
|
||||||
|
<input
|
||||||
|
type="number" step={0.01} min={0} value={constraints.delta_threshold}
|
||||||
|
onChange={(e) => setConstraints({ ...constraints, delta_threshold: parseFloat(e.target.value) || 0 })}
|
||||||
|
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 block mb-1">Plafond perte max (optionnel)</label>
|
||||||
|
<input
|
||||||
|
type="number" placeholder="illimité"
|
||||||
|
value={constraints.max_loss_cap ?? ''}
|
||||||
|
onChange={(e) => setConstraints({ ...constraints, max_loss_cap: e.target.value === '' ? null : parseFloat(e.target.value) })}
|
||||||
|
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 block mb-1">Objectif</label>
|
||||||
|
<select
|
||||||
|
value={constraints.objective}
|
||||||
|
onChange={(e) => setConstraints({ ...constraints, objective: e.target.value as OptimizeConstraints['objective'] })}
|
||||||
|
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
|
||||||
|
>
|
||||||
|
{OBJECTIVES.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onRun}
|
||||||
|
disabled={isRunning}
|
||||||
|
className="flex items-center gap-1.5 text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-1.5 rounded font-semibold"
|
||||||
|
>
|
||||||
|
<Search className={clsx('w-3.5 h-3.5', isRunning && 'animate-pulse')} />
|
||||||
|
{isRunning ? 'Recherche parmi des milliers de combinaisons…' : 'Trouver la stratégie optimale'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
return (
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
<div className="stat-label mb-2">{results.length} candidats classés</div>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-slate-500 text-left">
|
||||||
|
<th className="py-1 pr-3">Structure</th>
|
||||||
|
<th className="py-1 pr-3">Jambes</th>
|
||||||
|
<th className="py-1 pr-3 text-right">Score</th>
|
||||||
|
<th className="py-1 pr-3 text-right">P&L net</th>
|
||||||
|
<th className="py-1 pr-3 text-right">Max gain</th>
|
||||||
|
<th className="py-1 pr-3 text-right">Max perte</th>
|
||||||
|
<th className="py-1 pr-3 text-right">Δ net</th>
|
||||||
|
<th className="py-1 pr-1"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{results.map((r, i) => (
|
||||||
|
<tr key={i} className="border-t border-slate-700/30 hover:bg-dark-700/40 cursor-pointer" onClick={() => onSelect(r)}>
|
||||||
|
<td className="py-1.5 pr-3 text-slate-200">{r.template_name}</td>
|
||||||
|
<td className="py-1.5 pr-3 text-slate-400">{r.legs.length}</td>
|
||||||
|
<td className="py-1.5 pr-3 text-right font-semibold text-white">{r.score.toFixed(2)}</td>
|
||||||
|
<td className={clsx('py-1.5 pr-3 text-right font-semibold', pnlColor(r.net_pnl))}>{fmtMoney(r.net_pnl)}</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-slate-400">{r.net_delta_now.toFixed(3)}</td>
|
||||||
|
<td className="py-1.5 pr-1 text-blue-400 text-right">Charger →</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenario save/load ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ScenarioLibrary({
|
||||||
|
symbol, scenario, onLoad,
|
||||||
|
}: {
|
||||||
|
symbol: string; scenario: StrategyScenario; onLoad: (s: SavedScenario) => void
|
||||||
|
}) {
|
||||||
|
const { data: saved = [] } = useScenarios(symbol)
|
||||||
|
const saveScenario = useSaveScenario()
|
||||||
|
const deleteScenario = useDeleteScenario()
|
||||||
|
const [label, setLabel] = useState('')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-sm space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={label} onChange={(e) => setLabel(e.target.value)} placeholder="Nom du scénario…"
|
||||||
|
className="flex-1 bg-dark-700 border border-slate-700/50 rounded px-2 py-1 text-xs text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => { saveScenario.mutate({ ...scenario, label }); setLabel('') }}
|
||||||
|
disabled={!label || saveScenario.isPending}
|
||||||
|
className="flex items-center gap-1 text-xs bg-dark-700 hover:bg-dark-600 border border-slate-700/50 text-slate-300 px-2 py-1 rounded disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Save className="w-3 h-3" /> Sauvegarder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{saved.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{saved.map(s => (
|
||||||
|
<span key={s.id} className="badge-blue flex items-center gap-1">
|
||||||
|
<button onClick={() => onLoad(s)} className="flex items-center gap-1">
|
||||||
|
<FolderOpen className="w-3 h-3" /> {s.label || s.id}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteScenario.mutate(s.id)}><X className="w-3 h-3" /></button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Saved strategies library ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SavedStrategiesLibrary({ symbol, onLoad }: { symbol: string; onLoad: (legs: StrategyLeg[], templateName: string) => void }) {
|
||||||
|
const { data: saved = [] } = useSavedStrategies(symbol)
|
||||||
|
const deleteStrategy = useDeleteSavedStrategy()
|
||||||
|
|
||||||
|
if (!saved.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-sm space-y-2">
|
||||||
|
<div className="stat-label">Stratégies sauvegardées ({symbol})</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{saved.map(s => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between text-xs bg-dark-700/50 rounded px-2 py-1.5">
|
||||||
|
<button onClick={() => onLoad(s.legs, s.template_name)} className="flex items-center gap-2 text-slate-300 hover:text-white">
|
||||||
|
<FolderOpen className="w-3 h-3 text-blue-400" />
|
||||||
|
<span>{s.template_name}</span>
|
||||||
|
<span className={pnlColor(s.net_pnl_scenario)}>{fmtMoney(s.net_pnl_scenario)}</span>
|
||||||
|
<span className="text-slate-600">{s.legs.length} jambes</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteStrategy.mutate(s.id)} className="text-slate-500 hover:text-red-400">
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function StrategyBuilder() {
|
||||||
|
const [symbol, setSymbol] = useState('SPY')
|
||||||
|
const [horizonDays, setHorizonDays] = useState(8)
|
||||||
|
const [scenario, setScenario] = useState<StrategyScenario>({
|
||||||
|
symbol: 'SPY', horizon_days: 8, spot_shock_pct: 0, iv_level_shift: 0, skew_tilt: 0, term_shift: 0, manual_grid: [],
|
||||||
|
})
|
||||||
|
const [legs, setLegs] = useState<StrategyLeg[]>([])
|
||||||
|
const [constraints, setConstraints] = useState<OptimizeConstraints>({
|
||||||
|
max_legs: 4, delta_threshold: 0.15, max_loss_cap: null, objective: 'net_pnl', top_n: 20,
|
||||||
|
})
|
||||||
|
const [activeTemplate, setActiveTemplate] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data: chain, isLoading: chainLoading, isError: chainError, refetch: refetchChain, isFetching } =
|
||||||
|
useOptionChainSlice(symbol, horizonDays, 3)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setScenario(s => ({ ...s, symbol, horizon_days: horizonDays }))
|
||||||
|
}, [symbol, horizonDays])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chain && chain.expiries.length && legs.length === 0) {
|
||||||
|
const exp = chain.expiries[0]
|
||||||
|
const atm = exp.calls.reduce((best: any, r: any) =>
|
||||||
|
Math.abs(r.strike - chain.spot) < Math.abs(best.strike - chain.spot) ? r : best, exp.calls[0])
|
||||||
|
if (atm) setLegs([emptyLeg(exp.expiry_date, exp.days_to_expiry, atm.strike)])
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [chain])
|
||||||
|
|
||||||
|
const priceMutation = usePriceStrategy()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chain || legs.length === 0) return
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
priceMutation.mutate({ scenario, legs })
|
||||||
|
}, 400)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [JSON.stringify(scenario), JSON.stringify(legs), chain])
|
||||||
|
|
||||||
|
const priced = priceMutation.data
|
||||||
|
|
||||||
|
const isNonDirectional = useMemo(() => {
|
||||||
|
if (!priced) return null
|
||||||
|
return Math.abs(priced.net_delta_now) <= DELTA_NEUTRAL_THRESHOLD
|
||||||
|
}, [priced])
|
||||||
|
|
||||||
|
const addLeg = () => {
|
||||||
|
if (!chain || legs.length >= 4) return
|
||||||
|
const exp = chain.expiries[0]
|
||||||
|
setLegs([...legs, emptyLeg(exp.expiry_date, exp.days_to_expiry, chain.spot)])
|
||||||
|
}
|
||||||
|
|
||||||
|
const optimizeMutation = useOptimizeStrategy()
|
||||||
|
const saveStrategy = useSaveStrategyRecord()
|
||||||
|
|
||||||
|
const handleOptimize = () => {
|
||||||
|
setActiveTemplate(null)
|
||||||
|
optimizeMutation.mutate({ scenario, constraints })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectCandidate = (c: StrategyCandidate) => {
|
||||||
|
setActiveTemplate(c.template_name)
|
||||||
|
setLegs(c.legs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadScenario = (s: SavedScenario) => {
|
||||||
|
setSymbol(s.symbol)
|
||||||
|
setHorizonDays(s.horizon_days)
|
||||||
|
setScenario({
|
||||||
|
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,
|
||||||
|
manual_grid: s.manual_grid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveStrategy = () => {
|
||||||
|
if (!priced) return
|
||||||
|
saveStrategy.mutate({
|
||||||
|
symbol, template_name: activeTemplate || 'Manuel', objective: constraints.objective, legs,
|
||||||
|
entry_cost: priced.entry_cost, max_gain: priced.max_gain, max_loss: priced.max_loss,
|
||||||
|
net_pnl_scenario: priced.net_pnl, net_delta: priced.net_delta_now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<Layers className="w-5 h-5 text-blue-400" /> Strategy Builder
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
|
Scénario spot/IV/surface à J+N · builder manuel 1-4 jambes · payoff & greeks avec spread broker réel
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetchChain()}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="flex items-center gap-1.5 text-xs border border-slate-600 text-slate-400 hover:text-slate-200 hover:border-slate-500 px-3 py-1.5 rounded transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||||
|
{isFetching ? 'Chargement...' : 'Rafraîchir la chaîne'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chainError && (
|
||||||
|
<div className="px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4" /> Chaîne d'options indisponible pour {symbol}. Essayez un autre symbole (ETF/action optionable).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ScenarioPanel symbol={symbol} setSymbol={setSymbol} horizonDays={horizonDays} setHorizonDays={setHorizonDays}
|
||||||
|
scenario={scenario} setScenario={setScenario} />
|
||||||
|
|
||||||
|
<ScenarioLibrary symbol={symbol} scenario={scenario} onLoad={handleLoadScenario} />
|
||||||
|
<SavedStrategiesLibrary symbol={symbol} onLoad={(legs, templateName) => { setActiveTemplate(templateName); setLegs(legs) }} />
|
||||||
|
|
||||||
|
{chainLoading && <div className="card-sm text-xs text-slate-500">Chargement de la chaîne réelle ({symbol})…</div>}
|
||||||
|
|
||||||
|
{chain && <ScenarioGrid chain={chain} spot={chain.spot} scenario={scenario} setScenario={setScenario} />}
|
||||||
|
|
||||||
|
{chain && (
|
||||||
|
<div className="card space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="stat-label">Jambes (1-4) — Spot {chain.spot}</div>
|
||||||
|
<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 className="space-y-2">
|
||||||
|
{legs.map((leg, i) => (
|
||||||
|
<LegRow
|
||||||
|
key={i} leg={leg} chain={chain}
|
||||||
|
onChange={(l) => setLegs(legs.map((x, j) => j === i ? l : x))}
|
||||||
|
onRemove={() => setLegs(legs.filter((_, j) => j !== i))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{legs.length === 0 && <div className="text-xs text-slate-500">Aucune jambe — ajoutez-en une pour commencer.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chain && (
|
||||||
|
<OptimizerPanel constraints={constraints} setConstraints={setConstraints} onRun={handleOptimize} isRunning={optimizeMutation.isPending} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{optimizeMutation.isError && (
|
||||||
|
<div className="px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300">
|
||||||
|
Erreur lors de l'optimisation.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{optimizeMutation.data && (
|
||||||
|
<ResultsTable results={optimizeMutation.data} onSelect={handleSelectCandidate} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{priceMutation.isPending && <div className="card-sm text-xs text-slate-500">Calcul en cours…</div>}
|
||||||
|
{priceMutation.isError && (
|
||||||
|
<div className="px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300">
|
||||||
|
Erreur de pricing — vérifiez les jambes sélectionnées.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{priced && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div className="card-sm">
|
||||||
|
<div className="stat-label">Coût d'entrée (spread inclus)</div>
|
||||||
|
<div className={clsx('text-lg font-bold', priced.entry_cost >= 0 ? 'text-white' : 'text-emerald-400')}>{fmtMoney(priced.entry_cost)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-sm">
|
||||||
|
<div className="stat-label">P&L net scénario J+{horizonDays}</div>
|
||||||
|
<div className={clsx('text-lg font-bold', pnlColor(priced.net_pnl))}>{fmtMoney(priced.net_pnl)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-sm">
|
||||||
|
<div className="stat-label">Coût spread broker</div>
|
||||||
|
<div className="text-lg font-bold text-amber-400">{fmtMoney(priced.broker_spread_cost)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-sm">
|
||||||
|
<div className="stat-label">Max gain / Max perte</div>
|
||||||
|
<div className="text-sm font-semibold">
|
||||||
|
<span className="text-emerald-400">{priced.max_gain != null ? fmtMoney(priced.max_gain) : '∞'}</span>
|
||||||
|
<span className="text-slate-500"> / </span>
|
||||||
|
<span className="text-red-400">{priced.max_loss != null ? fmtMoney(priced.max_loss) : '−∞'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={clsx('badge', priced.bounded_risk ? 'badge-green' : 'badge-red')}>
|
||||||
|
{priced.bounded_risk ? 'Risque borné' : 'Risque non borné'}
|
||||||
|
</span>
|
||||||
|
<span className={clsx('badge', isNonDirectional ? 'badge-blue' : 'badge-orange')}>
|
||||||
|
Δ net {priced.net_delta_now.toFixed(3)} — {isNonDirectional ? 'non-directionnel' : 'directionnel'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveStrategy}
|
||||||
|
disabled={saveStrategy.isPending}
|
||||||
|
className="ml-auto flex items-center gap-1 text-xs bg-dark-700 hover:bg-dark-600 border border-slate-700/50 text-slate-300 px-2.5 py-1 rounded disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Save className="w-3 h-3" /> {saveStrategy.isSuccess ? 'Sauvegardé ✓' : 'Sauvegarder cette stratégie'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="stat-label mb-2">Diagramme payoff</div>
|
||||||
|
<PayoffChart priced={priced} spot={priced.spot} scenarioSpot={priced.scenario_spot} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<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="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} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
out2.txt
Normal file
39
out2.txt
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
--- IBKR Ticket ---
|
||||||
|
s · FastAPI + React + SQLite + GPT-4o 23 pages · 50 signaux macro · IV Gate · Pattern Convergence · IBKR Ticket Table des Matières 1. Vue d'ensemble du système 1.1 Ce que fait le système 1.2 Les 3 principes fondamentaux 1.3 Architecture technique 1.4 Les 15 pages du cockpit 2. Le Cycle Automatique — Simulation complète étape par étape 2.1 Les 9 étapes du cycle (Étapes 0–8) 2.2 Exemple de sortie GPT-4o simulée 2b. Phase 5 — Cycle Context Engine (Phases 1–5) 2b.1 Phase 1 — Delta Temporel & Decay News 2b.2 Phase 2 — Snapshot Contexte & Releases FRED 2b.3 Phase 3 — Indicateurs Techniques par Horizon 2b.4 Phase 4+5 — Price Discovery & Replay 3. Phase 1 — Volatilité Implicite & Structure Marché 3.1 IV Rank & IV Percentile 3.2 Term Structure & Skew 3.3 Options Flow & O
|
||||||
|
|
||||||
|
--- IBKR Ticket ---
|
||||||
|
ies Thématiques 5b.2 Signal Direction & Conviction Score 5b.3 Trade Ideas — Onglet Journal 5b.4 IBKR Ticket Auto-Calculé 6. Phase 4 — IA Probabiliste & Apprentissage 6.1 Mise à jour Bayésienne 6.2 Clustering de Régimes K-Means 6.3 Embeddings Sémantiques 6.4 Analytics Dashboard 7. Les Prompts IA — Boîte Noire Ouverte 8. Tables de la Base de Données 9. Guide Trader — Lire le Cockpit Page par Page 10. Décisions de Conception 11. Glossaire 2c. Workflow Complet du Cycle — Graphe des Calculs 2c.1 Graphe Visuel du Pipeline 2c.2 Démonstration des Calculs Étape par Étape 12. Specialist Desks v2 — COT · Forward Curves · Surprise · Hawk/Dove 12.1 Architecture et tables DB 12.2 Injection dans les prompts IA 12.3 COT Positioning — CFTC Socrata 12.4 Forward Curves 12.5 Surprise Index 12.6 Hawk/D
|
||||||
|
|
||||||
|
--- IBKR Ticket ---
|
||||||
|
arre visuelle. Un bouton 🔍 ouvre le détail complet et un bouton 🔒 loggue le trade directement. 5b.4 IBKR Ticket Auto-Calculé Pour chaque trade idea ou trade ouvert dans le Journal MtM, le système génère automatiquement un ticket Interactive Brokers avec tous les paramètres pré-remplis : Exemple — Ticket IBKR pour "US-Iran Diplomatic Shift → Long Straddle ^GSPC" UNDERLYING : ^GSPC (S&P 500) STRIKE : $7,500 (ATM — calculé depuis prix spot au moment du trade) EXPIRY : 2026-09-19 (vendredi le plus proche à 90 jours) LEG 1 : BUY 1 CALL $7,500 · 2026-09-19 · LIMIT LEG 2 : BUY 1 PUT $7,500 · 2026-09-19 · LIMIT BUDGET : 1,000€ / 2 legs = 500€ par leg (budget Kelly calculé) TARGET : +30% (config exit default) ORDER TYPE : LIMIT (jamais market) Le strike ATM est calculé à partir du prix spot le
|
||||||
|
|
||||||
|
--- IBKR Ticket ---
|
||||||
|
ckwardation_stress (IV30 > IV90 sur plusieurs actifs). Utilisée comme 5ème bloc du macro engine. IBKR Ticket Ticket Interactive Brokers auto-calculé à partir du pattern : underlying, strike ATM (prix spot arrondi), expiry (vendredi le plus proche de today + horizon_days), legs BUY/SELL CALL/PUT, order type LIMIT, budget Kelly. Affiché dans Dashboard et Journal MtM. Copier-coller dans TWS — aucune exécution automatique. DTE (Days to Expiration) Jours restants avant la date d'expiration estimée d'un trade. Calculé en temps réel dans l'onglet Open du Journal : DTE = expiry_date - today. Affiché en rouge si DTE < 14 (theta decay accéléré). Distinct de horizon_days (durée planifiée à l'entrée). Context Snapshot Enregistrement complet du contexte transmis à l'IA pour un cycle donné : 21 ch
|
||||||
|
|
||||||
|
--- IBKR Ticket ---
|
||||||
|
V/Structure + IV Gate + Watchlist · Phase 2 Fiabilité · Phase 3 Risk Engine · Pattern Convergence + IBKR Ticket · Phase 4 IA Probabiliste · 7 Prompts IA · 20+ Tables DB (incl. cot_data, forward_curve_data) · Guide 19 pages · 19 Décisions conception · Glossaire 50+ termes · Specialist Desks v2 (COT 19 marchés · Forward Curves · Surprise Index · Hawk/Dove Scorer) · Calibration Progressive · Find Similar+Merge · Workflow Calculs
|
||||||
|
|
||||||
|
--- Vol Surface Regime ---
|
||||||
|
tion_score par pattern. Remplace le simple score IA comme critère de priorité dans les Trade Ideas. Vol Surface Regime Classification composite de l'état de la surface de volatilité : calm (SKEW normal, VVIX bas), elevated_vol (VIX > 20, VVIX élevé), extreme_skew (SKEW > 140 = achat massif de protection tail risk), backwardation_stress (IV30 > IV90 sur plusieurs actifs). Utilisée comme 5ème bloc du macro engine. IBKR Ticket Ticket Interactive Brokers auto-calculé à partir du pattern : underlying, strike ATM (prix spot arrondi), expiry (vendredi le plus proche de today + horizon_days), legs BUY/SELL CALL/PUT, order type LIMIT, budget Kelly. Affiché dans Dashboard et Journal MtM. Copier-coller dans TWS — aucune exécution automatique. DTE (Days to Expiration) Jours restants avant la
|
||||||
|
|
||||||
|
--- signal_direction ---
|
||||||
|
sh Surprise, ECB Pivot Signal 5b.2 Signal Direction & Conviction Score Chaque pattern reçoit un signal_direction et un conviction_score calculés à l'issue du scoring IA : signal_direction : "bullish" | "bearish" | "volatility" | "neutral" bullish → expected_move > 0 et stratégie directionnelle haussière bearish → expected_move < 0 et stratégie directionnelle baissière volatility → straddle / strangle / long vol — parie sur l'amplitude, pas la direction neutral → iron condor / calendar spread — range-bound conviction_score (0–100) : = (score_ia / 100 × 40) — poids score IA + (alignment_score / 25 × 30) — poids alignement news IA + (reliability_score / 3 × 20) — poids fiabilité historique + (tech_confirmation × 10) — bonus si RSI + MA confirment Pattern Direction Conviction Score I
|
||||||
|
|
||||||
|
--- signal_direction ---
|
||||||
|
Chaque pattern reçoit un signal_direction et un conviction_score calculés à l'issue du scoring IA : signal_direction : "bullish" | "bearish" | "volatility" | "neutral" bullish → expected_move > 0 et stratégie directionnelle haussière bearish → expected_move < 0 et stratégie directionnelle baissière volatility → straddle / strangle / long vol — parie sur l'amplitude, pas la direction neutral → iron condor / calendar spread — range-bound conviction_score (0–100) : = (score_ia / 100 × 40) — poids score IA + (alignment_score / 25 × 30) — poids alignement news IA + (reliability_score / 3 × 20) — poids fiabilité historique + (tech_confirmation × 10) — bonus si RSI + MA confirment Pattern Direction Conviction Score IA Alignement Fiabilité US-Iran Diplomatic Shift 📊 Volatility 78 60 +18 2.27
|
||||||
|
|
||||||
|
--- signal_direction ---
|
||||||
|
on qui relance le scoring IA sur les patterns du cycle actuel Chaque ligne affiche : pattern name · signal_direction badge · stratégie · score IA · EV + move + max/target · profil de risque · alignement macro · date d'entrée · durée barre visuelle. Un bouton 🔍 ouvre le détail complet et un bouton 🔒 loggue le trade directement. 5b.4 IBKR Ticket Auto-Calculé Pour chaque trade idea ou trade ouvert dans le Journal MtM, le système génère automatiquement un ticket Interactive Brokers avec tous les paramètres pré-remplis : Exemple — Ticket IBKR pour "US-Iran Diplomatic Shift → Long Straddle ^GSPC" UNDERLYING : ^GSPC (S&P 500) STRIKE : $7,500 (ATM — calculé depuis prix spot au moment du trade) EXPIRY : 2026-09-19 (vendredi le plus proche à 90 jours) LEG 1 : BUY 1 CALL $7,500 · 2026-09-19 · LIM
|
||||||
|
|
||||||
|
--- signal_direction ---
|
||||||
|
rique consultable dans VaR Analysis. Colonnes ajoutées aux tables existantes (v5.0) analyses_ia : + signal_direction (bullish/bearish/volatility/neutral), conviction_score (0–100), thematic_category trade_entry_prices : + strike_guidance (strike ATM calculé), expiry_days_at_entry (DTE à l'entrée), entry_price_fresh (bool — prix <30min) knowledge_base : + signal_direction , thematic_category , horizon_days Relations Clés portfolio.id → trades.portfolio_id · knowledge_base.id → pattern_performance.pattern_id · knowledge_base.id → bayesian_estimates.pattern_id · analyses_ia.id → brier_tracking.cycle_id 9. Guide Trader — Lire le Cockpit Page par Page Page 1 — Dashboard Regarder en premier : Bandeau supérieur — régime dominant + geo score. Si géo > 60 (rouge) ou VIX > 25 → journée à ri
|
||||||
|
|
||||||
|
--- signal_direction ---
|
||||||
|
expiry_days_at_entry (DTE à l'entrée), entry_price_fresh (bool — prix <30min) knowledge_base : + signal_direction , thematic_category , horizon_days Relations Clés portfolio.id → trades.portfolio_id · knowledge_base.id → pattern_performance.pattern_id · knowledge_base.id → bayesian_estimates.pattern_id · analyses_ia.id → brier_tracking.cycle_id 9. Guide Trader — Lire le Cockpit Page par Page Page 1 — Dashboard Regarder en premier : Bandeau supérieur — régime dominant + geo score. Si géo > 60 (rouge) ou VIX > 25 → journée à risque élevé. Quand agir : Si "Nouveaux patterns disponibles" avec score > 70 → aller sur page Patterns avant l'ouverture de marché. Page 2 — Radar Géopolitique Onglet Actualités — regarder en premier : Geo Score global en haut à droite (rouge ≥ 75 = extrême)
|
||||||
|
|
||||||
|
--- signal_direction ---
|
||||||
|
/strangle — amplitude sans direction), neutral (iron condor — range-bound). Stocké dans analyses_ia.signal_direction. Conviction Score Score composite 0–100 calculé par convergence de 4 sources : score IA (40%), alignement news IA (30%), fiabilité historique (20%), confirmation technique RSI/MA (10%). Utilisé pour trier les Trade Ideas dans le Journal. Pattern Convergence Moteur qui fusionne les signaux géopolitiques, macro et techniques pour produire un signal_direction et un conviction_score par pattern. Remplace le simple score IA comme critère de priorité dans les Trade Ideas. Vol Surface Regime Classification composite de l'état de la surface de volatilité : calm (SKEW normal, VVIX bas), elevated_vol (VIX > 20, VVIX élevé), extreme_skew (SKEW > 140 = achat massif de protection t
|
||||||
|
|
||||||
|
--- signal_direction ---
|
||||||
|
ern Convergence Moteur qui fusionne les signaux géopolitiques, macro et techniques pour produire un signal_direction et un conviction_score par pattern. Remplace le simple score IA comme critère de priorité dans les Trade Ideas. Vol Surface Regime Classification composite de l'état de la surface de volatilité : calm (SKEW normal, VVIX bas), elevated_vol (VIX > 20, VVIX élevé), extreme_skew (SKEW > 140 = achat massif de protection tail risk), backwardation_stress (IV30 > IV90 sur plusieurs actifs). Utilisée comme 5ème bloc du macro engine. IBKR Ticket Ticket Interactive Brokers auto-calculé à partir du pattern : underlying, strike ATM (prix spot arrondi), expiry (vendredi le plus proche de today + horizon_days), legs BUY/SELL CALL/PUT, order type LIMIT, budget Kelly. Affiché dans D
|
||||||
|
|
||||||
Reference in New Issue
Block a user