diff --git a/backend/main.py b/backend/main.py index 0ade838..0ba10b1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 wavelet as wavelet_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 import os import logging @@ -247,6 +248,7 @@ app.include_router(instrument_models_router.router) app.include_router(instruments_watchlist_router.router) app.include_router(wavelet_router.router) app.include_router(ai_chat_router.router) +app.include_router(strategy_builder_router.router) @app.get("/") diff --git a/backend/routers/strategy_builder.py b/backend/routers/strategy_builder.py new file mode 100644 index 0000000..9931b4f --- /dev/null +++ b/backend/routers/strategy_builder.py @@ -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} diff --git a/backend/services/database.py b/backend/services/database.py index a497a8e..a0df6a2 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -44,6 +44,36 @@ def init_db(): 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 ( id TEXT PRIMARY KEY, 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] finally: 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 diff --git a/backend/services/option_chain.py b/backend/services/option_chain.py new file mode 100644 index 0000000..c550517 --- /dev/null +++ b/backend/services/option_chain.py @@ -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 diff --git a/backend/services/strategy_engine.py b/backend/services/strategy_engine.py new file mode 100644 index 0000000..1cc1686 --- /dev/null +++ b/backend/services/strategy_engine.py @@ -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} diff --git a/backend/services/strategy_optimizer.py b/backend/services/strategy_optimizer.py new file mode 100644 index 0000000..7807001 --- /dev/null +++ b/backend/services/strategy_optimizer.py @@ -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) diff --git a/backend/services/strategy_templates.py b/backend/services/strategy_templates.py new file mode 100644 index 0000000..432d244 --- /dev/null +++ b/backend/services/strategy_templates.py @@ -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 diff --git a/backend/services/vol_surface.py b/backend/services/vol_surface.py new file mode 100644 index 0000000..ec4f0cc --- /dev/null +++ b/backend/services/vol_surface.py @@ -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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0b79dfa..23a5692 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import GeoRadar from './pages/GeoRadar' import Markets from './pages/Markets' import MacroRegime from './pages/MacroRegime' import OptionsLab from './pages/OptionsLab' +import StrategyBuilder from './pages/StrategyBuilder' import WaveletsSimulation from './pages/WaveletsSimulation' import Backtest from './pages/Backtest' import CalendarPage from './pages/CalendarPage' @@ -50,6 +51,7 @@ const KEEP_ALIVE_DEFS: { path: string; component: ComponentType }[] = [ { path: '/markets', component: Markets }, { path: '/macro', component: MacroRegime }, { path: '/options', component: OptionsLab }, + { path: '/strategy-builder', component: StrategyBuilder }, { path: '/wavelets-simulation', component: WaveletsSimulation }, { path: '/patterns', component: PatternExplorer }, { path: '/pattern-lab', component: PatternLab }, diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 0777907..56f936e 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ import { NavLink } from 'react-router-dom' import { 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' import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi' import clsx from 'clsx' @@ -12,6 +12,7 @@ const nav = [ { to: '/markets', icon: BarChart2, label: 'Markets & Prices' }, { to: '/macro', icon: Activity, label: 'Macro Regime' }, { to: '/options', icon: TrendingUp, label: 'Options Lab' }, + { to: '/strategy-builder', icon: Layers, label: 'Strategy Builder' }, { to: '/wavelets-simulation', icon: Waves, label: 'Wavelets Simulation' }, { to: '/patterns', icon: Zap, label: 'Patterns' }, { to: '/pattern-lab', icon: FlaskConical, label: 'Pattern Lab' }, diff --git a/frontend/src/config/keepAliveMeta.ts b/frontend/src/config/keepAliveMeta.ts index a803d10..617814c 100644 --- a/frontend/src/config/keepAliveMeta.ts +++ b/frontend/src/config/keepAliveMeta.ts @@ -2,7 +2,7 @@ import { LayoutDashboard, Globe, BarChart2, Activity, TrendingUp, Zap, FlaskConical, DollarSign, BookOpen, FileBarChart, Brain, Microscope, ShieldAlert, Gauge, 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' import type { LucideIcon } from 'lucide-react' @@ -18,6 +18,7 @@ export const KEEP_ALIVE_META: KeepAliveMeta[] = [ { path: '/markets', label: 'Markets', icon: BarChart2 }, { path: '/macro', label: 'Macro Regime', icon: Activity }, { path: '/options', label: 'Options Lab', icon: TrendingUp }, + { path: '/strategy-builder', label: 'Strategy Builder', icon: Layers }, { path: '/wavelets-simulation', label: 'Wavelets Sim.', icon: Waves }, { path: '/patterns', label: 'Patterns', icon: Zap }, { path: '/pattern-lab', label: 'Pattern Lab', icon: FlaskConical }, diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 8e7d4b4..f799459 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1515,3 +1515,152 @@ export const useRejectAiTradeProposal = () => { 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({ + 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('/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('/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({ + 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({ + 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'] }), + }) +} diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx new file mode 100644 index 0000000..e1ffb43 --- /dev/null +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -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 ( + + + + v.toFixed(0)} /> + `${v}`} /> + `Sous-jacent: ${Number(v).toFixed(2)}`} + formatter={(v: number, name: string) => [fmtMoney(v), name]} + /> + + + + + + + + + ) +} + +function GreeksTile({ label, now, scenario }: { label: string; now: number; scenario: number }) { + return ( +
+
{label}
+
+ {now.toFixed(4)} + + = now ? 'text-emerald-400' : 'text-red-400')}>{scenario.toFixed(4)} +
+
+ ) +} + +// ── 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, + ) => ( +
+
+ {label} + {fmt(scenario[key])} +
+ setScenario({ ...scenario, [key]: parseFloat(e.target.value) })} + className="w-full accent-blue-500" + /> +
+ ) + + return ( +
+
+
+ + 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…" + /> +
+
+ + 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" + /> +
+
+ +
+ {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`)} +
+
+ ) +} + +// ── 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 ( +
+
+
Grille IV scénario (éditable — clic sur une cellule)
+ {overrides.length > 0 && ( + + )} +
+
+ + + + + {STRIKE_PCTS.map(p => )} + + + + {chain.expiries.map((exp: any) => ( + + + {STRIKE_PCTS.map(pct => { + const v = cellValue(exp.days_to_expiry, pct) + const overridden = isOverridden(exp.days_to_expiry, pct) + return ( + + ) + })} + + ))} + +
Expiry / Strike%{p}%
{exp.expiry_date} ({exp.days_to_expiry}j) + { + 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', + )} + /> +
+
+
+ ) +} + +// ── 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 ( +
+ + + + + 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" + /> + +
+ ) +} + +// ── 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 ( +
+
Optimiseur — contraintes & objectif
+
+
+ + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+
+ +
+ ) +} + +function ResultsTable({ results, onSelect }: { results: StrategyCandidate[]; onSelect: (c: StrategyCandidate) => void }) { + if (!results.length) return
Aucun candidat ne satisfait les contraintes — élargissez le seuil de delta ou le plafond de perte.
+ return ( +
+
{results.length} candidats classés
+ + + + + + + + + + + + + + + {results.map((r, i) => ( + onSelect(r)}> + + + + + + + + + + ))} + +
StructureJambesScoreP&L netMax gainMax perteΔ net
{r.template_name}{r.legs.length}{r.score.toFixed(2)}{fmtMoney(r.net_pnl)}{r.max_gain != null ? fmtMoney(r.max_gain) : '∞'}{r.max_loss != null ? fmtMoney(r.max_loss) : '−∞'}{r.net_delta_now.toFixed(3)}Charger →
+
+ ) +} + +// ── 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 ( +
+
+ 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" + /> + +
+ {saved.length > 0 && ( +
+ {saved.map(s => ( + + + + + ))} +
+ )} +
+ ) +} + +// ── 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 ( +
+
Stratégies sauvegardées ({symbol})
+
+ {saved.map(s => ( +
+ + +
+ ))} +
+
+ ) +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function StrategyBuilder() { + const [symbol, setSymbol] = useState('SPY') + const [horizonDays, setHorizonDays] = useState(8) + const [scenario, setScenario] = useState({ + 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([]) + const [constraints, setConstraints] = useState({ + max_legs: 4, delta_threshold: 0.15, max_loss_cap: null, objective: 'net_pnl', top_n: 20, + }) + const [activeTemplate, setActiveTemplate] = useState(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 ( +
+
+
+

+ Strategy Builder +

+

+ Scénario spot/IV/surface à J+N · builder manuel 1-4 jambes · payoff & greeks avec spread broker réel +

+
+ +
+ + {chainError && ( +
+ Chaîne d'options indisponible pour {symbol}. Essayez un autre symbole (ETF/action optionable). +
+ )} + + + + + { setActiveTemplate(templateName); setLegs(legs) }} /> + + {chainLoading &&
Chargement de la chaîne réelle ({symbol})…
} + + {chain && } + + {chain && ( +
+
+
Jambes (1-4) — Spot {chain.spot}
+ +
+
+ {legs.map((leg, i) => ( + setLegs(legs.map((x, j) => j === i ? l : x))} + onRemove={() => setLegs(legs.filter((_, j) => j !== i))} + /> + ))} + {legs.length === 0 &&
Aucune jambe — ajoutez-en une pour commencer.
} +
+
+ )} + + {chain && ( + + )} + + {optimizeMutation.isError && ( +
+ Erreur lors de l'optimisation. +
+ )} + {optimizeMutation.data && ( + + )} + + {priceMutation.isPending &&
Calcul en cours…
} + {priceMutation.isError && ( +
+ Erreur de pricing — vérifiez les jambes sélectionnées. +
+ )} + + {priced && ( + <> +
+
+
Coût d'entrée (spread inclus)
+
= 0 ? 'text-white' : 'text-emerald-400')}>{fmtMoney(priced.entry_cost)}
+
+
+
P&L net scénario J+{horizonDays}
+
{fmtMoney(priced.net_pnl)}
+
+
+
Coût spread broker
+
{fmtMoney(priced.broker_spread_cost)}
+
+
+
Max gain / Max perte
+
+ {priced.max_gain != null ? fmtMoney(priced.max_gain) : '∞'} + / + {priced.max_loss != null ? fmtMoney(priced.max_loss) : '−∞'} +
+
+
+ +
+ + {priced.bounded_risk ? 'Risque borné' : 'Risque non borné'} + + + Δ net {priced.net_delta_now.toFixed(3)} — {isNonDirectional ? 'non-directionnel' : 'directionnel'} + + +
+ +
+
Diagramme payoff
+ +
+ +
+ + + + +
+ + )} +
+ ) +} diff --git a/out2.txt b/out2.txt new file mode 100644 index 0000000..779ea4b --- /dev/null +++ b/out2.txt @@ -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 +