diff --git a/OpenFin_Intelligence_Brochure.html b/OpenFin_Intelligence_Brochure.html new file mode 100644 index 0000000..bb5fbc8 --- /dev/null +++ b/OpenFin_Intelligence_Brochure.html @@ -0,0 +1,558 @@ +OpenFin Intelligence — The Desk That Never Sleeps + + +
+ ◆ OPENFIN +
+ DECK · 16 SEC. +
+ + +
+ +
+
+
+
Institutional intelligence, one seat
+

Every desk
a hedge fund keeps.
Running on one screen.

+

OpenFin Intelligence turns the research stack of a macro fund — + regime strategist, options quant, geopolitical desk, risk officer, report writer — into software that reads + every market, every instrument, continuously, and hands you the trade that balances what you already hold.

+
+ macro regime enginewavelet decomposition + real broker option chainsportfolio scenario alignment + VaR & Kelly sizing +
+
+
+ +
+ + +
+
02 / 16
+
The problem
+

Run the full playbook by hand and the org chart gets long before the book gets big.

+
+
+

A macro strategist to read the regime. An instrument analyst to track each ticker's own + state. An options quant to build and stress the vol surface. A geopolitical desk to flag the headline + before it moves the tape. A risk officer to keep sizing honest. A report writer to turn all of that into + something a committee can read. Someone to keep the economic calendar current across time zones. And a + quant, quietly, to keep the curve models from drifting out of date.

+

None of them is optional, none of them is cheap, and — this is the part that actually costs + money — none of them is looking at the whole book at once.

+
+
+
Headcount, priced inannual, fully loaded
+
Macro strategist$$$
+
Options quant$$$
+
Geo / news desk$$
+
Risk officer$$
+
Report writer$$
+
Calendar analyst$
+
Curve modeler$$
+
Junior — spreadsheets$
+
+ OpenFin, one seat + ¢
+
+
+
+ + +
+
03 / 16
+
What it is
+

Not a dashboard. Seven specialist functions, each automated end to end.

+

Every panel in the cockpit is a completed piece of analysis a real desk would produce — not a + chart someone still has to interpret. The macro regime is scored, not plotted. The vol surface is fitted, not + eyeballed. The hedge is sized, not suggested.

+
openfin — cockpit
+
cockpit screenshot
+
+ + +
+
04 / 16
+
How the desks talk to each other
+

Top-down regime. Bottom-up book. They meet in the middle, at your positions.

+
+
01 — MACRO
Regime
30+ gauges score + 8 canonical scenarios market-wide.
+
02 — INSTRUMENT
Curve Regime
Wavelets + + synthetic curve replay classify each ticker into 1 of 15 states.
+
03 — OPTIONS
Options Lab
Real broker + chains price IV rank, skew, term structure.
+
04 — PORTFOLIO
Context
Every open + position repriced against all three layers above.
+
05 — ACTION
Balance
Strategy Builder + finds the trade that squares the book.
+
+

This is the difference between a data terminal and a desk: a data + terminal shows you the regime and the chain side by side and leaves the synthesis to you. OpenFin does the + synthesis — and shows its work at every step.

+
+ + +
+
05 / 16
+
Layer 1 — the macro strategist
+

30 institutional gauges, scored into 8 regimes, live.

+

Rates, credit spreads, dollar liquidity, energy, industrial metals, breadth — the same + indicators a macro desk pins to the wall — feed a continuous scoring model across Goldilocks, + Reflation, Stagflation, Inflation Shock, Recession, Liquidity Crisis, Soft Landing and + Disinflation / Rate Cuts, each carrying a confidence score and a plain-English "why." + Every asset class then inherits a directional bias from the winning regime, propagated automatically into + every layer below.

+
openfin — macro regime
+
macro_regime screenshot
+
+ + +
+
06 / 16
+
Layer 2 — the instrument specialist
+
+
+
Curve Regime — per instrument
+
CRUDEBull Trend
+
GOLDDispersion / Décorrélation
+
SP500Compression
+
NASDAQRisk-Off
+
EURUSDCompression
+
BRENTWhipsaw
+
+
+

The macro regime is the headline. This is the instrument underneath it.

+

A global "Reflation" call doesn't mean every ticker is trending — one instrument classifier, + 15 named regimes deep, reads each position's own wavelet state, options skew and trend + signal to answer the sharper question: is this instrument breaking out, compressing, whipsawing, + or quietly decorrelating from everything else in the book?

+

Two positions on the same macro thesis can carry completely different curve regimes — and + that gap is exactly where a desk's edge, or its blind spot, usually lives.

+
+
+
+ + +
+
07 / 16
+
The technology underneath
+

Wavelets, synthetic curve replay, and a causal graph that never peeks at the future.

+
+
+

Every instrument's price series is run through a continuous wavelet transform — a Morlet / + generalized-Morlet basis — decomposing price into overlapping cycles from a few hours to several weeks, + instead of one flattened trendline. A synthetic theoretical curve is then rebuilt from those bands — + absorption and decay, template by template — so a real move can be compared directly against what the + model expected, at the cursor, not just at the close.

+

Every chart carries a causal mode: each day's decomposition only ever sees + data up to that day — the same walk-forward discipline a quant desk enforces before a signal goes near + real capital. No look-ahead bias hiding in a pretty backtest.

+
CWT / GMWsynthetic curve replaycausal, walk-forward
+
+
openfin — wavelets / curve analysis
+
wavelets screenshot
+
+
+ + +
+
08 / 16
+
Layer 3 — the options quant
+

Real broker chains, not a modeled surface.

+

IV rank, term structure and skew are built from your own linked broker option-chain history — + the same data an execution desk quotes off, not a theoretical smile. IV rank above 80 flags premium worth + selling; below 20 flags convexity worth owning — read at a glance, per instrument, refreshed on a schedule.

+
openfin — options lab
+
option_lab screenshot
+
+ + +
+
09 / 16
+
Layer 4 — building the hedge
+

Shock the surface. Price the spread. See the greeks move.

+

Strategy Builder prices 1-to-4-leg structures against the real bid/ask spread + pulled from your broker, on a vol surface you can tilt by hand — spot shock, IV level, skew tilt, term + structure — to stress a candidate hedge against the exact scenario the regime layers above are already + flagging. No spreadsheet round-trip: the chain, the surface and the payoff live in the same screen the + regime call came from.

+
openfin — strategy builder
+
strategy_builder screenshot
+
+ + +
+
10 / 16
+
Where it all lands
+

One repeated bet, or genuinely diversified ones — across however many positions you run.

+
+
+

This is the payoff of the whole pipeline: every open position — a handful or several dozen, + the book scales either way — is repriced under each of the 8 macro scenarios above, using its + real capital at risk and its real greeks, not a guess from the + strategy's name.

+

A short call spread on the S&P and another on crude can look like diversification. Priced + this way, they're revealed as the same directional bet, twice. The book's true concentration shows up as a + scenario, not a ticker — and the next trade in Strategy Builder is the one built to offset exactly that.

+
+
+
+
Portfolio aligned oncapital-at-risk weighted
+
Stagflation35.2%
+
Inflation Shock35.2%
+
Recession14.8%
+
+ Independent bets + 7.4 of 11 +
+
+
Example shown: an 11-position book — the same repricing runs at any size.
+
+
+
+ + +
+
11 / 16
+
Layer 5 — the risk officer
+

Historical, parametric and stressed Monte Carlo — checked against what actually happened.

+

CVaR, a rolling 30-day trend and a Kupiec backtest that grades the model against real outcomes, + not just its own predictions. Correlation-adjusted position sizing — fractional Kelly, cut when a risk cluster + saturates — keeps conviction from quietly turning into concentration.

+
openfin — var analysis
+
var screenshot
+
+ + +
+
12 / 16
+
Layer 0 — the wire
+

Every ticker gets the analyst treatment before it ever reaches the book.

+
+
Geopolitical risk
+
35/100
+
Moderate · scored from live wire, not sentiment
+
Economic calendar
+
82,494
+
Events synced · FF-sourced, auto-refreshing
+
Institutional reports
+
Live
+
Cycle Report & Super Context, generated every cycle
+
+

Geopolitical scoring, the macro calendar, institutional report + generation and live quotes aren't side panels — they feed the same regime and curve engines above, so a + headline that moves oil shows up first as a number, not a scroll of unread news.

+
+ + +
+
13 / 16
+
Built to fit the desk you run
+

One engine. Configured per desk, not rebuilt per desk.

+
+

The regime and instrument engines are asset-class aware, not + index-specific. A generalist macro book runs the full 8-scenario read across rates, indices and FX out of + the box. A specialty desk — cotton, coffee, base metals, energy — swaps in its own fundamentals, macro + sensitivity table and price-move thresholds, and inherits the exact same wavelet, options and risk + machinery the flagship book uses.

+
+
Desk configsfundamentals-aware
+
Metalsreal rates · COT · ETF flows
+
Agri & softsWASDE · weather · crop calendar
+
EnergyOPEC+ · EIA draws · rig count
+
Forexrate differentials · carry
+
Bonds & ratesFed path · term premium
+
+
+
+ + +
+
14 / 16
+
What each desk stands in for
+

Seven specialist functions. Same seat, same read — running continuously instead of once a morning.

+
+ + + + + + + + + + +
Traditional roleWhat they'd hand youThe OpenFin desk
Macro strategistA morning view on the regime, updated once a day at best.Macro Regime — 30 gauges, 8 scenarios, scored continuously
Instrument analystChart annotations on the names that matter that week.Curve Regime — 15-state classifier on every watched ticker
Options quantA vol surface fitted overnight, stale by the open.Options Lab + Strategy Builder — live broker chain, editable surface
Geopolitical analystA read on the wire, filtered by whoever's on shift.Geopolitical Risk score, wired into every regime call
Risk officerA VaR report and a sizing memo, typically weekly.VaR suite + Portfolio Scenario Alignment, on every refresh
Report writerA committee-ready narrative, drafted the night before.Cycle Report / Super Context — auto-generated, every cycle
Calendar analystA hand-kept spreadsheet of releases across time zones.Economic Calendar — FF-synced, 80,000+ events, self-refreshing
Curve modelerA synthetic-curve model, rebuilt by hand after every regime shift.Wavelet engine — synthetic curve replay, causal / no-lookahead
+
+
+ + +
+
15 / 16
+
Built to run at your size
+

Five positions or five hundred. One instrument watched or an entire desk's worth.

+

Nothing in the pipeline assumes a book size. The same repricing, the same regime scoring and the + same scenario alignment that run on a handful of positions run unchanged on a full institutional + book — add instruments, add desks, add positions, and the engine scales with you instead of asking you to + simplify for it.

+
+ + +
+
16 / 16
+
One cockpit. Every desk. Always on.
+

OpenFin Intelligence doesn't replace your judgment — + it replaces the team it used to take to get you the read fast enough to use it.

+
OpenFin Intelligence — Product Overview
+
+ + diff --git a/OpenFin_Intelligence_Brochure.pdf b/OpenFin_Intelligence_Brochure.pdf new file mode 100644 index 0000000..774f4ff Binary files /dev/null and b/OpenFin_Intelligence_Brochure.pdf differ diff --git a/backend/routers/strategy_builder.py b/backend/routers/strategy_builder.py index 1c28652..088d153 100644 --- a/backend/routers/strategy_builder.py +++ b/backend/routers/strategy_builder.py @@ -26,15 +26,25 @@ class LegIn(BaseModel): class ScenarioIn(BaseModel): symbol: str - horizon_days: int = 8 + horizon_days: int = 8 # scenario P&L evaluation date — NOT the expiry filter, see dte_min/dte_max spot_shock_pct: float = 0.0 - iv_level_shift: float = 0.0 + iv_level_shift: float = 0.0 # parallel IV shift — applies to every strike/expiry uniformly skew_tilt: float = 0.0 - term_shift: float = 0.0 + term_slope_shift: float = 0.0 # term-structure slope, per 30 days (0 at days=0) + rate_shock_bps: float = 0.0 manual_grid: Optional[List[Dict[str, Any]]] = None rate: float = 0.05 n_expiries: int = 3 contract_size: float = DEFAULT_CONTRACT_SIZE + # Which expiries the chain/optimizer may pick legs from — independent of horizon_days, + # so a short-horizon scenario (e.g. 8 days) can still be evaluated with longer-dated + # options (e.g. dte_min=20, dte_max=60) instead of horizon_days doing double duty. + dte_min: Optional[int] = None + dte_max: Optional[int] = None + + @property + def shocked_rate(self) -> float: + return self.rate + self.rate_shock_bps / 10000.0 class PriceRequest(BaseModel): @@ -50,9 +60,31 @@ class ConstraintsIn(BaseModel): top_n: int = 20 +class GreekTargetIn(BaseModel): + """One Greek's desired behavior — deliberately NOT a numeric slider (see project memory, + Strategy Builder Greeks plan): a qualitative state the optimizer resolves against the + actual candidate pool, so "strongly positive" means "top of what's achievable for this + instrument/scenario right now" rather than a guessed absolute number.""" + state: str = "free" # "strong_negative"|"negative"|"neutral"|"positive"|"strong_positive"|"free" + tolerance: str = "normale" # "etroite"|"normale"|"large" — etroite hard-filters sign mismatches + weight: float = 50.0 # 0-100, importance relative to the base objective (net_pnl/return_on_risk/...) + + +class GreekProfileIn(BaseModel): + """Layer B of the scenario/profile/constraints split: the behavior the user wants, + kept separate from the scenario (Layer A, what's anticipated) and from ConstraintsIn + (Layer C, hard construction limits).""" + delta: GreekTargetIn = GreekTargetIn() + gamma: GreekTargetIn = GreekTargetIn() + theta: GreekTargetIn = GreekTargetIn() + vega: GreekTargetIn = GreekTargetIn() + rho: GreekTargetIn = GreekTargetIn() + + class OptimizeRequest(BaseModel): scenario: ScenarioIn constraints: ConstraintsIn + greek_profile: Optional[GreekProfileIn] = None class ScenarioSaveRequest(BaseModel): @@ -62,7 +94,10 @@ class ScenarioSaveRequest(BaseModel): spot_shock_pct: float iv_level_shift: float skew_tilt: float - term_shift: float + term_slope_shift: float + rate_shock_bps: float = 0.0 + dte_min: Optional[int] = None + dte_max: Optional[int] = None manual_grid: Optional[List[Dict[str, Any]]] = None @@ -81,14 +116,17 @@ class StrategySaveRequest(BaseModel): def _build_surfaces(scenario: ScenarioIn): - chain_slice = get_chain_slice(scenario.symbol, scenario.horizon_days, scenario.n_expiries) + chain_slice = get_chain_slice( + scenario.symbol, scenario.horizon_days, scenario.n_expiries, + dte_min=scenario.dte_min, dte_max=scenario.dte_max, + ) surface_now = build_surface(chain_slice) surface_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, + term_slope_shift=scenario.term_slope_shift, manual_grid=scenario.manual_grid, ) return chain_slice, surface_now, surface_scenario @@ -99,9 +137,11 @@ def chain( symbol: str = Query(...), horizon_days: int = Query(8), n_expiries: int = Query(3), + dte_min: Optional[int] = Query(None), + dte_max: Optional[int] = Query(None), ): try: - return get_chain_slice(symbol, horizon_days, n_expiries) + return get_chain_slice(symbol, horizon_days, n_expiries, dte_min=dte_min, dte_max=dte_max) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -121,7 +161,7 @@ def price(req: PriceRequest): legs = [leg.model_dump() for leg in req.legs] result = payoff_curves( legs, chain_slice, surface_now, surface_scenario, - req.scenario.horizon_days, req.scenario.rate, + req.scenario.horizon_days, req.scenario.shocked_rate, contract_size=req.scenario.contract_size, ) result["spot"] = chain_slice["spot"] @@ -130,10 +170,24 @@ def price(req: PriceRequest): return result +@router.post("/suggested-profile") +def suggested_profile(scenario: ScenarioIn): + """Mode 1 of the scenario/profile/constraints split: what Greek behavior this scenario + already implies on its own, before the user sets any explicit target — see + services.scenario_profile.infer_natural_greek_profile.""" + from services.scenario_profile import infer_natural_greek_profile + return infer_natural_greek_profile(scenario.spot_shock_pct, scenario.iv_level_shift, scenario.horizon_days) + + @router.post("/optimize") def optimize(req: OptimizeRequest): if req.constraints.max_legs > 4: raise HTTPException(status_code=400, detail="4 jambes maximum") + from services.scenario_profile import detect_greek_contradictions + warnings = detect_greek_contradictions( + req.greek_profile.model_dump() if req.greek_profile else None, + req.scenario.n_expiries, req.scenario.dte_min, req.scenario.dte_max, + ) try: results = run_optimizer( symbol=req.scenario.symbol, @@ -141,14 +195,18 @@ def optimize(req: OptimizeRequest): 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, + term_slope_shift=req.scenario.term_slope_shift, manual_grid=req.scenario.manual_grid, n_expiries=req.scenario.n_expiries, rate=req.scenario.rate, + rate_shock_bps=req.scenario.rate_shock_bps, + dte_min=req.scenario.dte_min, + dte_max=req.scenario.dte_max, constraints=req.constraints.model_dump(), objective=req.constraints.objective, top_n=req.constraints.top_n, contract_size=req.scenario.contract_size, + greek_profile=req.greek_profile.model_dump() if req.greek_profile else None, ) except Exception as e: import traceback @@ -161,7 +219,7 @@ def optimize(req: OptimizeRequest): ) status = 404 if isinstance(e, ValueError) else 500 raise HTTPException(status_code=status, detail=f"{e}") - return results + return {"candidates": results, "warnings": warnings} @router.post("/scenarios") diff --git a/backend/services/database.py b/backend/services/database.py index 49c5596..18199e5 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -94,7 +94,10 @@ def init_db(): spot_shock_pct REAL NOT NULL, iv_level_shift REAL NOT NULL, skew_tilt REAL NOT NULL, - term_shift REAL NOT NULL, + term_slope_shift REAL NOT NULL, + rate_shock_bps REAL DEFAULT 0, + dte_min INTEGER, + dte_max INTEGER, manual_grid TEXT, created_at TEXT DEFAULT (datetime('now')) )""") @@ -324,6 +327,11 @@ def init_db(): profile_json TEXT, has_options_data INTEGER DEFAULT 0 )""", + # Strategy Builder — Greeks scenario plan Phase 1 (2026-07-27) + "ALTER TABLE strategy_scenarios RENAME COLUMN term_shift TO term_slope_shift", + "ALTER TABLE strategy_scenarios ADD COLUMN rate_shock_bps REAL DEFAULT 0", + "ALTER TABLE strategy_scenarios ADD COLUMN dte_min INTEGER", + "ALTER TABLE strategy_scenarios ADD COLUMN dte_max INTEGER", ]: try: c.execute(_sql) @@ -6351,8 +6359,9 @@ def save_scenario(scenario: Dict[str, Any]) -> str: scenario_id = scenario.get("id") or f"SCN-{uuid.uuid4().hex[:8].upper()}" 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 (?,?,?,?,?,?,?,?,?)""", ( + id, symbol, label, horizon_days, spot_shock_pct, iv_level_shift, skew_tilt, term_slope_shift, + rate_shock_bps, dte_min, dte_max, manual_grid + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", ( scenario_id, scenario["symbol"], scenario.get("label", ""), @@ -6360,7 +6369,10 @@ def save_scenario(scenario: Dict[str, Any]) -> str: scenario["spot_shock_pct"], scenario["iv_level_shift"], scenario["skew_tilt"], - scenario["term_shift"], + scenario["term_slope_shift"], + scenario.get("rate_shock_bps", 0.0), + scenario.get("dte_min"), + scenario.get("dte_max"), json.dumps(scenario.get("manual_grid") or []), )) conn.commit() diff --git a/backend/services/option_chain.py b/backend/services/option_chain.py index 2cd22ad..5a8e99a 100644 --- a/backend/services/option_chain.py +++ b/backend/services/option_chain.py @@ -10,13 +10,22 @@ from datetime import date, datetime from typing import Any, Dict, List, Optional -def get_chain_slice(symbol: str, target_days: int = 8, n_expiries: int = 3) -> Dict[str, Any]: +def get_chain_slice( + symbol: str, target_days: int = 8, n_expiries: int = 3, + dte_min: Optional[int] = None, dte_max: Optional[int] = None, +) -> Dict[str, Any]: """ Builds a chain slice from the latest accumulated Saxo snapshot rows for `symbol` (services/database.get_latest_saxo_snapshot_rows). Returns the `n_expiries` expirations closest to target_days, each with calls/puts rows shaped {strike, bid, ask, mid, last, iv, open_interest, volume} — same shape regardless of source, so vol_surface.py/strategy_engine.py need no changes. + + `dte_min`/`dte_max`, when given, restrict the candidate expiries to that DTE window + before picking the `n_expiries` closest to target_days — lets a caller evaluate a + scenario at a short horizon (e.g. target_days=8) while still building legs from + longer-dated options (e.g. dte_min=20, dte_max=60), which target_days alone can't + express since it drives both the evaluation date and (until now) the expiry pick. """ from services.database import get_latest_saxo_snapshot_rows @@ -39,7 +48,18 @@ def get_chain_slice(symbol: str, target_days: int = 8, n_expiries: int = 3) -> D def _days_to(expiry_date: str) -> int: return (datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() - today).days - selected = sorted(by_expiry.keys(), key=lambda e: abs(_days_to(e) - target_days))[:max(1, n_expiries)] + candidates = list(by_expiry.keys()) + if dte_min is not None or dte_max is not None: + lo = dte_min if dte_min is not None else 0 + hi = dte_max if dte_max is not None else 10 ** 6 + candidates = [e for e in candidates if lo <= _days_to(e) <= hi] + if not candidates: + raise ValueError( + f"Aucune échéance Saxo entre {dte_min}j et {dte_max}j pour '{symbol}' " + f"— élargissez la fenêtre DTE ou laissez-la vide." + ) + + selected = sorted(candidates, key=lambda e: abs(_days_to(e) - target_days))[:max(1, n_expiries)] def _row_shape(r: Dict[str, Any]) -> Dict[str, Any]: bid = r.get("bid") or 0.0 diff --git a/backend/services/options_pricer.py b/backend/services/options_pricer.py index 1209674..1584112 100644 --- a/backend/services/options_pricer.py +++ b/backend/services/options_pricer.py @@ -6,17 +6,30 @@ import math def black_scholes(S: float, K: float, T: float, r: float, sigma: float, option_type: str = "call") -> Dict[str, float]: - """Black-Scholes pricing + Greeks.""" + """Black-Scholes pricing + Greeks (first-order delta/gamma/theta/vega/rho, plus the + second-order Greeks used by Strategy Builder's "advanced sensitivities" panel: vanna, + charm, vomma/volga, veta, speed, color, zomma — vera deliberately omitted, see project + memory "Strategy Builder Greeks plan"). All second-order values are scaled to match the + convention their related first-order Greek already uses here — e.g. vanna/vomma/zomma + are "per vol POINT" like vega already is (not per unit of raw decimal sigma), charm/ + color/veta are "per DAY" like theta already is (not per year) — every formula/scaling + is verified against finite-difference bumps of this same function's own first-order + outputs (see scratchpad test_second_order_greeks.py from the Phase 3 build), not just + hand-derived from a textbook, since these third-derivative formulas are easy to get + subtly wrong.""" S = float(S or 100.0) K = float(K or S) T = float(T or 0.001) sigma = float(sigma or 0.25) if T <= 0 or sigma <= 0: intrinsic = max(0, S - K) if option_type == "call" else max(0, K - S) - return {"price": intrinsic, "delta": 0, "gamma": 0, "theta": 0, "vega": 0, "rho": 0} + return {"price": intrinsic, "delta": 0, "gamma": 0, "theta": 0, "vega": 0, "rho": 0, + "vanna": 0, "charm": 0, "vomma": 0, "veta": 0, "speed": 0, "color": 0, "zomma": 0} - d1 = (math.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * math.sqrt(T)) - d2 = d1 - sigma * math.sqrt(T) + sqrtT = math.sqrt(T) + d1 = (math.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * sqrtT) + d2 = d1 - sigma * sqrtT + phi_d1 = norm.pdf(d1) if option_type == "call": price = S * norm.cdf(d1) - K * math.exp(-r * T) * norm.cdf(d2) @@ -27,9 +40,19 @@ def black_scholes(S: float, K: float, T: float, r: float, sigma: float, option_t delta = norm.cdf(d1) - 1 rho = -K * T * math.exp(-r * T) * norm.cdf(-d2) / 100 - gamma = norm.pdf(d1) / (S * sigma * math.sqrt(T)) - theta = (-(S * norm.pdf(d1) * sigma) / (2 * math.sqrt(T)) - r * K * math.exp(-r * T) * norm.cdf(d2 if option_type == "call" else -d2)) / 365 - vega = S * norm.pdf(d1) * math.sqrt(T) / 100 + gamma = phi_d1 / (S * sigma * sqrtT) + theta = (-(S * phi_d1 * sigma) / (2 * sqrtT) - r * K * math.exp(-r * T) * norm.cdf(d2 if option_type == "call" else -d2)) / 365 + vega = S * phi_d1 * sqrtT / 100 + + # Second-order — same for calls and puts (this pricer carries no dividend yield, so the + # extra q-term that would otherwise make charm/veta/color differ by option_type is zero). + vanna = (-phi_d1 * d2 / sigma) / 100 + vomma = (S * phi_d1 * sqrtT * d1 * d2 / sigma) / 10_000 + charm = (-phi_d1 * (2 * r * T - d2 * sigma * sqrtT) / (2 * T * sigma * sqrtT)) / 365 + veta = (S * phi_d1 * sqrtT * ((r * d1) / (sigma * sqrtT) - (1 + d1 * d2) / (2 * T))) / 36_500 + speed = -(gamma / S) * (d1 / (sigma * sqrtT) + 1) + color = (phi_d1 / (2 * S * T * sigma * sqrtT) * (2 * r * T + 1 + d1 * (2 * r * T - d2 * sigma * sqrtT) / (sigma * sqrtT))) / 365 + zomma = (gamma * (d1 * d2 - 1) / sigma) / 100 return { "price": round(price, 4), @@ -38,6 +61,13 @@ def black_scholes(S: float, K: float, T: float, r: float, sigma: float, option_t "theta": round(theta, 4), "vega": round(vega, 4), "rho": round(rho, 4), + "vanna": round(vanna, 6), + "charm": round(charm, 6), + "vomma": round(vomma, 6), + "veta": round(veta, 6), + "speed": round(speed, 8), + "color": round(color, 8), + "zomma": round(zomma, 6), } diff --git a/backend/services/scenario_profile.py b/backend/services/scenario_profile.py new file mode 100644 index 0000000..46c649a --- /dev/null +++ b/backend/services/scenario_profile.py @@ -0,0 +1,139 @@ +""" +Phase 4 of the Strategy Builder Greeks plan (see project memory) — Mode 1 ("scenario only") +and the contradiction-detection layer from the user's spec, section 12. + +`infer_natural_greek_profile` answers "what Greek behavior does this scenario already imply, +before the user sets any explicit target?" — a deterministic, rule-based reading of the +spec's own lookup tables (2.1 spot / 2.2 IV), NOT a fitted or learned model. Thresholds are +judgment calls, documented inline, meant as a starting suggestion the Phase 2 profile panel +can be pre-filled with and the user can freely override — not an authoritative answer. + +`detect_greek_contradictions` answers "did the user just ask for something that's hard to +get on a single option structure?" — static checks on the requested profile alone (no need +to run the optimizer), returned as non-blocking warnings, never filtering the request. +""" +from typing import Any, Dict, List, Optional + +_POSITIVE_STATES = {"positive", "strong_positive"} +_STRONG_STATES = {"strong_positive", "strong_negative"} + + +def infer_natural_greek_profile(spot_shock_pct: float, iv_level_shift: float, horizon_days: int) -> Dict[str, Any]: + horizon_days = max(horizon_days, 1) + speed = abs(spot_shock_pct) / horizon_days # %/day intensity of the anticipated move + + if abs(spot_shock_pct) < 1.0: + spot_dir = "stable" + elif spot_shock_pct > 0: + spot_dir = "hausse" + else: + spot_dir = "baisse" + + # Thresholds are a judgment call, not calibrated against real move distributions — + # ~0.8%/day is "a few percent in a few days" (fast), ~0.15%/day is "a percent or two + # over a couple weeks" (progressive), below that reads as effectively directionless drift. + if speed >= 0.8: + spot_speed = "rapide" + elif speed >= 0.15: + spot_speed = "moderee" + else: + spot_speed = "lente" + + if iv_level_shift >= 0.05: + iv_bucket = "forte_hausse" + elif iv_level_shift >= 0.02: + iv_bucket = "hausse_moderee" + elif iv_level_shift <= -0.02: + iv_bucket = "baisse" + else: + iv_bucket = "faible" + + delta = gamma = theta = "free" + rationale: List[str] = [] + + # Spot -> delta/gamma/theta, spec section 2.1's table + if spot_dir == "stable": + delta, theta = "neutral", "positive" + rationale.append("Spot quasi stable → Delta proche de zéro, Theta plutôt positif (collecte de temps).") + elif spot_dir == "hausse" and spot_speed == "rapide": + delta, gamma = "strong_positive", "positive" + rationale.append("Hausse forte et rapide → Delta et Gamma positifs, la vitesse du mouvement compte autant que le niveau.") + elif spot_dir == "hausse": + delta = "positive" + theta = "positive" if spot_speed == "lente" else "neutral" + rationale.append("Hausse modérée/progressive → Delta positif, Theta plutôt positif si le mouvement reste lent.") + elif spot_dir == "baisse" and spot_speed == "rapide": + delta, gamma = "strong_negative", "positive" + rationale.append("Baisse forte et rapide → Delta négatif et Gamma positif, la vitesse compte plus que le niveau.") + else: # baisse, lente/modérée + delta = "negative" + theta = "positive" if spot_speed == "lente" else "neutral" + rationale.append("Baisse modérée ou stagnation baissière → Delta négatif faible, Theta plutôt positif.") + + # IV -> vega, spec section 2.2's table — can nuance the theta read above when IV dominates + if iv_bucket == "forte_hausse": + vega = "strong_positive" + rationale.append("Forte hausse d'IV anticipée → Vega positif, idéalement avec de la convexité de vol (Vomma).") + elif iv_bucket == "hausse_moderee": + vega = "positive" + rationale.append("Hausse modérée d'IV → Vega positif, sans excès.") + elif iv_bucket == "baisse": + vega = "negative" + if theta == "free": + theta = "positive" + rationale.append("Baisse d'IV attendue (normalisation) → Vega négatif, Theta plutôt positif.") + else: + vega = "free" + + return { + "delta": delta, "gamma": gamma, "theta": theta, "vega": vega, "rho": "free", + "rationale": rationale, + "reading": {"spot_direction": spot_dir, "spot_speed": spot_speed, "iv_bucket": iv_bucket}, + } + + +def detect_greek_contradictions( + greek_profile: Optional[Dict[str, Any]], n_expiries: int, + dte_min: Optional[int], dte_max: Optional[int], +) -> List[str]: + if not greek_profile: + return [] + + def state_of(key: str) -> str: + return (greek_profile.get(key) or {}).get("state", "free") + + def weight_of(key: str) -> float: + return (greek_profile.get(key) or {}).get("weight", 50.0) + + single_expiry = (n_expiries or 1) <= 1 or ( + dte_min is not None and dte_max is not None and dte_max - dte_min <= 5 + ) + + warnings: List[str] = [] + gamma_state, theta_state, delta_state, vega_state = ( + state_of("gamma"), state_of("theta"), state_of("delta"), state_of("vega"), + ) + + if (gamma_state in _POSITIVE_STATES and theta_state in _POSITIVE_STATES + and weight_of("gamma") >= 30 and weight_of("theta") >= 30 and single_expiry): + warnings.append( + "Gamma positif et Theta positif en même temps sont difficiles à obtenir sur une seule " + "échéance. Solutions : élargir la fenêtre DTE (calendars/diagonales), réduire l'exigence " + "sur l'un des deux, ou n'exiger un Theta positif qu'autour du scénario central." + ) + + if delta_state == "neutral" and gamma_state in _STRONG_STATES and weight_of("delta") >= 30 and weight_of("gamma") >= 30: + warnings.append( + "Delta neutre et Gamma fortement positif se contredisent dans la durée : un Gamma élevé " + "fait bouger le Delta dès que le marché évolue — il ne restera « neutre » qu'au voisinage " + "immédiat du scénario central." + ) + + if vega_state == "strong_positive" and theta_state == "strong_positive" and weight_of("vega") >= 30 and weight_of("theta") >= 30: + warnings.append( + "Vega fortement positif et Theta fortement positif combinent rarement bien : la convexité " + "de volatilité coûte généralement du portage — vérifiez que le crédit net visé reste " + "cohérent avec cet objectif." + ) + + return warnings diff --git a/backend/services/strategy_engine.py b/backend/services/strategy_engine.py index dc153c1..3b383dc 100644 --- a/backend/services/strategy_engine.py +++ b/backend/services/strategy_engine.py @@ -97,7 +97,10 @@ def value_at( def greeks_at(legs: List[Dict[str, Any]], S: float, eval_days_from_now: float, surface: Any, r: float) -> Dict[str, float]: - net = {"delta": 0.0, "gamma": 0.0, "theta": 0.0, "vega": 0.0} + net = { + "delta": 0.0, "gamma": 0.0, "theta": 0.0, "vega": 0.0, "rho": 0.0, + "vanna": 0.0, "charm": 0.0, "vomma": 0.0, "veta": 0.0, "speed": 0.0, "color": 0.0, "zomma": 0.0, + } for leg in legs: remaining = max(leg["days_to_expiry"] - eval_days_from_now, 0.001) qty = leg.get("quantity", 1) @@ -106,7 +109,34 @@ def greeks_at(legs: List[Dict[str, Any]], S: float, eval_days_from_now: float, s g = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"]) for k in net: net[k] += g[k] * qty * sign - return {k: round(v, 4) for k, v in net.items()} + return {k: round(v, 6) for k, v in net.items()} + + +def vanna_simulation( + legs: List[Dict[str, Any]], S: float, eval_days_from_now: float, surface: Any, r: float, + spot_shock_pct: float = -5.0, iv_shock_pts: float = 8.0, +) -> Dict[str, float]: + """A concrete joint spot+IV shock reprice — "if spot drops 5% and IV jumps 8pts, what + actually happens to my net delta" — rather than a bare "vanna is positive/negative" + label. Uses a real Black-Scholes reprice (not the linear vanna approximation) so it's + accurate for shocks this large, matching the same "show a simulation, not a sign" + principle the payoff diagram already uses elsewhere in Strategy Builder.""" + delta_before = greeks_at(legs, S, eval_days_from_now, surface, r)["delta"] + + class _ShockedSurface: + def iv_at(self, strike: float, days: float) -> float: + return max(0.01, surface.iv_at(strike, days) + iv_shock_pts / 100.0) + + S_shocked = S * (1 + spot_shock_pct / 100.0) + delta_after = greeks_at(legs, S_shocked, eval_days_from_now, _ShockedSurface(), r)["delta"] + + return { + "spot_shock_pct": spot_shock_pct, + "iv_shock_pts": iv_shock_pts, + "delta_before": delta_before, + "delta_after": delta_after, + "delta_change": round(delta_after - delta_before, 6), + } def price_combo( @@ -176,6 +206,9 @@ def price_combo( "greeks_scenario": greeks_at(legs, spot_scenario, horizon_days, surface_scenario, r), "net_delta_now": delta_now, "net_delta_scenario": delta_scenario, + # Skipped during the optimizer's bulk scan (precise=False, hundreds of candidates + # per request) — only computed for the single position actually loaded/priced. + "vanna_simulation": vanna_simulation(legs, spot_now, 0, surface_now, r) if precise else None, }) diff --git a/backend/services/strategy_optimizer.py b/backend/services/strategy_optimizer.py index ad5978b..5734253 100644 --- a/backend/services/strategy_optimizer.py +++ b/backend/services/strategy_optimizer.py @@ -17,6 +17,122 @@ MAX_SEEDS_FOR_RESIDUAL_SEARCH = 40 RESIDUAL_ITERATIONS_PER_SEED = 8 RESIDUAL_MAX_EVALS = 400 +GREEK_KEYS = ("delta", "gamma", "theta", "vega", "rho") + +# Neutral band and "strong" percentile threshold, both self-calibrated against the actual +# candidate pool (see _greek_target_match) rather than a hardcoded absolute number — there's +# no single "big gamma" that means the same thing for EURUSD and for GOLD, but "top third of +# what's achievable for this instrument under this scenario" means the same thing for both. +_TOLERANCE_NEUTRAL_FRACTION = {"etroite": 0.05, "normale": 0.15, "large": 0.30} +_TOLERANCE_STRONG_PERCENTILE = {"etroite": 0.75, "normale": 0.66, "large": 0.50} + + +def _percentile_rank(sorted_vals: List[float], v: float) -> float: + if not sorted_vals: + return 0.0 + import bisect + return bisect.bisect_left(sorted_vals, v) / len(sorted_vals) + + +def _greek_target_match(value: float, state: str, tolerance: str, sorted_abs_pool: List[float]): + """Returns (match_score in [0,1], hard_fail: bool) for one candidate's Greek value + against one target. hard_fail only ever fires under "etroite" tolerance on a sign/neutral + violation — everything else is a soft score, blended in by optimize().""" + if state == "free": + return 1.0, False + max_abs = sorted_abs_pool[-1] if sorted_abs_pool else 0.0 + neutral_band = max_abs * _TOLERANCE_NEUTRAL_FRACTION.get(tolerance, 0.15) + if state == "neutral": + ok = abs(value) <= max(neutral_band, 1e-9) + return (1.0 if ok else 0.0), (tolerance == "etroite" and not ok) + desired_sign = -1 if "negative" in state else 1 + actual_sign = 1 if value > 1e-9 else (-1 if value < -1e-9 else 0) + if actual_sign != desired_sign: + return 0.0, (tolerance == "etroite") + if state in ("strong_negative", "strong_positive"): + pct = _percentile_rank(sorted_abs_pool, abs(value)) + threshold = _TOLERANCE_STRONG_PERCENTILE.get(tolerance, 0.66) + return (1.0 if pct >= threshold else 0.55), False + return 1.0, False # plain "positive"/"negative": correct sign is enough + + +def _apply_greek_profile(scored: List[Dict[str, Any]], greek_profile: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Post-hoc re-ranking of an already-evaluated candidate pool against the requested + Greek behavior profile — see routers/strategy_builder.GreekProfileIn and project memory + (Strategy Builder Greeks plan, Phase 2). Scoped to entry-state greeks_now (a candidate's + immediate nature), not the residual search's own hill-climbing objective — the search + still climbs toward the base objective (net_pnl/return_on_risk/prob_weighted); this only + re-orders the resulting pool, it doesn't steer the search itself (a Phase 2.x refinement + if the un-guided pool turns out too shallow in practice).""" + if not scored: + return scored + active = { + k: t for k, t in (greek_profile or {}).items() + if k in GREEK_KEYS and t.get("state", "free") != "free" + } + if not active: + scored.sort(key=lambda c: c["score"], reverse=True) + return scored + + # For "strong" states, the percentile pool must be restricted to candidates that already + # share the desired sign — otherwise a large WRONG-signed value (e.g. a deep-negative + # delta candidate) inflates the pool's max and makes a genuinely strong correctly-signed + # candidate look merely average by comparison. + def _sign_of(v: float) -> int: + return 1 if v > 1e-9 else (-1 if v < -1e-9 else 0) + + pool_abs: Dict[str, List[float]] = {} + for k, t in active.items(): + state = t.get("state", "free") + if state in ("strong_negative", "strong_positive"): + desired_sign = -1 if "negative" in state else 1 + pool_abs[k] = sorted( + abs(c["greeks_now"][k]) for c in scored if _sign_of(c["greeks_now"][k]) == desired_sign + ) + else: + pool_abs[k] = sorted(abs(c["greeks_now"][k]) for c in scored) + kept: List[Dict[str, Any]] = [] + for c in scored: + hard_fail = False + match_scores, weights = [], [] + for k, t in active.items(): + v = c["greeks_now"].get(k, 0.0) + m, fail = _greek_target_match(v, t.get("state", "free"), t.get("tolerance", "normale"), pool_abs[k]) + if fail: + hard_fail = True + break + match_scores.append(m) + weights.append(max(0.0, min(100.0, t.get("weight", 50.0))) / 100.0) + if hard_fail: + continue + avg_weight = sum(weights) / len(weights) if weights else 0.0 + greek_match = sum(m * w for m, w in zip(match_scores, weights)) / sum(weights) if weights else 1.0 + c["greek_match_score"] = round(greek_match, 3) + c["greek_weight"] = round(avg_weight, 3) + kept.append(c) + + if not kept: + # Every candidate hard-failed an "étroite" target — fall back to the un-filtered + # pool (ranked on the base objective alone) rather than returning nothing, since an + # empty result reads as "no strategies exist" instead of "no strategy matches this + # strict a Greek target". + scored.sort(key=lambda c: c["score"], reverse=True) + for c in scored: + c["greek_match_score"] = 0.0 + c["greek_weight"] = 0.0 + return scored + + # Linear blend of the two percentile ranks, NOT a product — a candidate with a perfect + # Greek match but the pool's worst raw score has base_pct=0, and multiplying would zero + # it out regardless of how much weight the user put on the Greek profile. + base_scores = sorted(c["score"] for c in kept) + for c in kept: + base_pct = _percentile_rank(base_scores, c["score"]) + aw, gm = c["greek_weight"], c["greek_match_score"] + c["final_rank_score"] = round((1 - aw) * base_pct + aw * gm, 4) + kept.sort(key=lambda c: c["final_rank_score"], reverse=True) + return kept + def _score(priced: Dict[str, Any], legs: List[Dict[str, Any]], objective: str, surface_scenario: ScenarioSurface, horizon_days: int, r: float, contract_size: float) -> Optional[float]: if objective == "net_pnl": @@ -145,7 +261,7 @@ def optimize( spot_shock_pct: float, iv_level_shift: float, skew_tilt: float, - term_shift: float, + term_slope_shift: float, manual_grid: Optional[List[Dict[str, Any]]], n_expiries: int, rate: float, @@ -153,26 +269,31 @@ def optimize( objective: str, top_n: int = 20, contract_size: float = DEFAULT_CONTRACT_SIZE, + rate_shock_bps: float = 0.0, + dte_min: Optional[int] = None, + dte_max: Optional[int] = None, + greek_profile: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: - chain_slice = get_chain_slice(symbol, horizon_days, n_expiries) + r = rate + rate_shock_bps / 10000.0 + chain_slice = get_chain_slice(symbol, horizon_days, n_expiries, dte_min=dte_min, dte_max=dte_max) surface_now = build_surface(chain_slice) surface_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, + skew_tilt=skew_tilt, term_slope_shift=term_slope_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, contract_size) + evaluated = _evaluate(name, legs, chain_slice, surface_now, surface_scenario, horizon_days, r, constraints, objective, contract_size) 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, contract_size) + refined = _residual_search(seeds, chain_slice, surface_now, surface_scenario, horizon_days, r, constraints, objective, contract_size) scored.extend(refined) - scored.sort(key=lambda c: c["score"], reverse=True) + scored = _apply_greek_profile(scored, greek_profile) return to_native(_dedup_top_n(scored, top_n)) diff --git a/backend/services/vol_surface.py b/backend/services/vol_surface.py index ec4f0cc..a21d91d 100644 --- a/backend/services/vol_surface.py +++ b/backend/services/vol_surface.py @@ -43,7 +43,13 @@ class Surface: class ScenarioSurface: - """Shocked surface at the scenario horizon: parametric shifts + manual overrides.""" + """Shocked surface at the scenario horizon: parametric shifts + manual overrides. + + `iv_level_shift` is already the "parallel" component (applies to every strike/expiry + uniformly) — `term_slope_shift` is the term-structure *slope* (zero at days=0, growing + linearly per 30 days), so between the two the surface already has the "parallel vs. + slope" split a term-structure model needs; a third "curvature" term is deliberately + not modeled yet (see project memory: Strategy Builder Greeks plan, Phase 1).""" def __init__( self, @@ -51,14 +57,14 @@ class ScenarioSurface: scenario_spot: float, iv_level_shift: float, skew_tilt: float, - term_shift: float, + term_slope_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.term_slope_shift = term_slope_shift self.manual_overrides = manual_overrides or {} def iv_at(self, strike: float, days: float) -> float: @@ -71,7 +77,7 @@ class ScenarioSurface: base_iv + self.iv_level_shift + self.skew_tilt * moneyness - + self.term_shift * (days / 30.0) + + self.term_slope_shift * (days / 30.0) ) return max(0.01, shocked) @@ -137,7 +143,7 @@ def apply_scenario( spot_shock_pct: float = 0.0, iv_level_shift: float = 0.0, skew_tilt: float = 0.0, - term_shift: float = 0.0, + term_slope_shift: float = 0.0, manual_grid: Optional[List[Dict[str, Any]]] = None, ) -> ScenarioSurface: """ @@ -149,4 +155,4 @@ def apply_scenario( 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) + return ScenarioSurface(surface, scenario_spot, iv_level_shift, skew_tilt, term_slope_shift, overrides) diff --git a/cockpit.png b/cockpit.png new file mode 100644 index 0000000..3760fe1 Binary files /dev/null and b/cockpit.png differ diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 7f530f9..62f627b 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1677,11 +1677,14 @@ export type StrategyScenario = { spot_shock_pct: number iv_level_shift: number skew_tilt: number - term_shift: number + term_slope_shift: number + rate_shock_bps?: number manual_grid?: ManualGridCell[] rate?: number n_expiries?: number contract_size?: number + dte_min?: number | null + dte_max?: number | null } export type StrategyLeg = { @@ -1693,7 +1696,14 @@ export type StrategyLeg = { quantity: number } -export type Greeks = { delta: number; gamma: number; theta: number; vega: number } +export type Greeks = { + delta: number; gamma: number; theta: number; vega: number; rho: number + vanna: number; charm: number; vomma: number; veta: number; speed: number; color: number; zomma: number +} +export type VannaSimulation = { + spot_shock_pct: number; iv_shock_pts: number + delta_before: number; delta_after: number; delta_change: number +} export type PayoffPoint = { underlying: number; pnl: number } export type PriceCombo = { @@ -1710,6 +1720,7 @@ export type PriceCombo = { greeks_scenario: Greeks net_delta_now: number net_delta_scenario: number + vanna_simulation: VannaSimulation | null at_expiry: PayoffPoint[] at_scenario: PayoffPoint[] spot: number @@ -1722,12 +1733,30 @@ export type StrategyCandidate = { legs: StrategyLeg[] score: number objective: string + greek_match_score?: number + final_rank_score?: number } & PriceCombo -export const useOptionChainSlice = (symbol: string, horizonDays: number, nExpiries = 3, enabled = true) => +export type GreekState = 'strong_negative' | 'negative' | 'neutral' | 'positive' | 'strong_positive' | 'free' +export type GreekTolerance = 'etroite' | 'normale' | 'large' +export type GreekTarget = { state: GreekState; tolerance: GreekTolerance; weight: number } +export type GreekProfile = { delta: GreekTarget; gamma: GreekTarget; theta: GreekTarget; vega: GreekTarget; rho: GreekTarget } + +export const FREE_GREEK_TARGET: GreekTarget = { state: 'free', tolerance: 'normale', weight: 50 } +export const DEFAULT_GREEK_PROFILE: GreekProfile = { + delta: { ...FREE_GREEK_TARGET }, gamma: { ...FREE_GREEK_TARGET }, theta: { ...FREE_GREEK_TARGET }, + vega: { ...FREE_GREEK_TARGET }, rho: { ...FREE_GREEK_TARGET }, +} + +export const useOptionChainSlice = ( + symbol: string, horizonDays: number, nExpiries = 3, enabled = true, + dteMin?: number | null, dteMax?: number | null, +) => useQuery({ - 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), + queryKey: ['strategy-builder-chain', symbol, horizonDays, nExpiries, dteMin, dteMax], + queryFn: () => api.get('/strategy-builder/chain', { + params: { symbol, horizon_days: horizonDays, n_expiries: nExpiries, dte_min: dteMin ?? undefined, dte_max: dteMax ?? undefined }, + }).then(r => r.data), enabled: enabled && !!symbol, staleTime: 30_000, retry: 1, @@ -1747,15 +1776,35 @@ export type OptimizeConstraints = { top_n?: number } +export type OptimizeResponse = { candidates: StrategyCandidate[]; warnings: string[] } + export const useOptimizeStrategy = () => useMutation({ - mutationFn: (body: { scenario: StrategyScenario; constraints: OptimizeConstraints }) => - api.post('/strategy-builder/optimize', body).then(r => r.data), + mutationFn: (body: { scenario: StrategyScenario; constraints: OptimizeConstraints; greek_profile?: GreekProfile }) => + api.post('/strategy-builder/optimize', body).then(r => r.data), + }) + +// Mode 1 of the scenario/profile/constraints split — what Greek behavior the scenario +// alone already implies, before the user sets any explicit target (project memory: +// Strategy Builder Greeks plan, Phase 4). +export type SuggestedProfile = { + delta: GreekState; gamma: GreekState; theta: GreekState; vega: GreekState; rho: GreekState + rationale: string[] + reading: { spot_direction: string; spot_speed: string; iv_bucket: string } +} + +export const useSuggestedProfile = (scenario: StrategyScenario, enabled: boolean) => + useQuery({ + queryKey: ['strategy-suggested-profile', scenario.symbol, scenario.spot_shock_pct, scenario.iv_level_shift, scenario.horizon_days], + queryFn: () => api.post('/strategy-builder/suggested-profile', scenario).then(r => r.data), + enabled: enabled && !!scenario.symbol, + staleTime: 30_000, }) export type SavedScenario = { id: string; symbol: string; label: string; horizon_days: number - spot_shock_pct: number; iv_level_shift: number; skew_tilt: number; term_shift: number + spot_shock_pct: number; iv_level_shift: number; skew_tilt: number; term_slope_shift: number + rate_shock_bps: number; dte_min: number | null; dte_max: number | null manual_grid: ManualGridCell[]; created_at: string } diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index b49f9d1..01b41ec 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -5,12 +5,13 @@ import { import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X } from 'lucide-react' import clsx from 'clsx' import { - useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, + useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useSuggestedProfile, useScenarios, useSaveScenario, useDeleteScenario, useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy, useWatchlistTickers, useSaxoCatalog, useIvForTrade, type StrategyLeg, type StrategyScenario, type PriceCombo, type StrategyCandidate, type OptimizeConstraints, type SavedScenario, + type GreekProfile, type GreekTarget, type GreekState, type GreekTolerance, DEFAULT_GREEK_PROFILE, } from '../hooks/useApi' import { fmtPrice, fmtAsOf } from '../lib/format' @@ -87,14 +88,14 @@ function PayoffChart({ priced, spot, scenarioSpot }: { priced: PriceCombo; spot: ) } -function GreeksTile({ label, now, scenario }: { label: string; now: number; scenario: number }) { +function GreeksTile({ label, now, scenario, precision = 4, hint }: { label: string; now: number; scenario: number; precision?: number; hint?: string }) { return ( -
+
{label}
- {now.toFixed(4)} + {now.toFixed(precision)} - = now ? 'text-emerald-400' : 'text-red-400')}>{scenario.toFixed(4)} + = now ? 'text-emerald-400' : 'text-red-400')}>{scenario.toFixed(precision)}
) @@ -111,16 +112,16 @@ function ScenarioPanel({ watchlistTickers: string[] }) { const slider = ( - key: 'spot_shock_pct' | 'iv_level_shift' | 'skew_tilt' | 'term_shift', + key: 'spot_shock_pct' | 'iv_level_shift' | 'skew_tilt' | 'term_slope_shift' | 'rate_shock_bps', label: string, min: number, max: number, step: number, fmt: (v: number) => string, ) => (
{label} - {fmt(scenario[key])} + {fmt(scenario[key] ?? 0)}
setScenario({ ...scenario, [key]: parseFloat(e.target.value) })} className="w-full accent-blue-500" /> @@ -152,20 +153,41 @@ function ScenarioPanel({
- + 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" />
+
+ + setScenario({ ...scenario, dte_min: e.target.value === '' ? null : parseInt(e.target.value) })} + className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" + /> +
+
+ + setScenario({ ...scenario, dte_max: e.target.value === '' ? null : parseInt(e.target.value) })} + className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" + /> +
{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`)} + {slider('term_slope_shift', 'Pente du terme (/30j)', -0.1, 0.1, 0.005, (v) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}pts`)} + {slider('rate_shock_bps', 'Choc de taux', -200, 200, 5, (v) => `${v >= 0 ? '+' : ''}${v.toFixed(0)}bps`)}
) @@ -187,7 +209,7 @@ function ScenarioGrid({ 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)) + return Math.max(0.01, base + scenario.iv_level_shift + scenario.skew_tilt * moneyness + scenario.term_slope_shift * (daysToExpiry / 30)) } const setOverride = (daysToExpiry: number, strikePct: number, iv: number | null) => { @@ -468,6 +490,134 @@ function OptimizerPanel({ ) } +const GREEK_STATES: { value: GreekState; label: string }[] = [ + { value: 'strong_negative', label: 'Fortement négatif' }, + { value: 'negative', label: 'Négatif' }, + { value: 'neutral', label: 'Neutre' }, + { value: 'positive', label: 'Positif' }, + { value: 'strong_positive', label: 'Fortement positif' }, + { value: 'free', label: 'Libre / non contraint' }, +] +const GREEK_TOLERANCES: { value: GreekTolerance; label: string }[] = [ + { value: 'etroite', label: 'Étroite' }, + { value: 'normale', label: 'Normale' }, + { value: 'large', label: 'Large' }, +] +const GREEK_ROWS: { key: keyof GreekProfile; label: string; hint: string }[] = [ + { key: 'delta', label: 'Delta', hint: 'Exposition directionnelle immédiate' }, + { key: 'gamma', label: 'Gamma', hint: 'Le Delta s’améliore-t-il avec le mouvement ?' }, + { key: 'theta', label: 'Theta', hint: 'Collecte de prime (+) ou achat de temps (-)' }, + { key: 'vega', label: 'Vega', hint: 'Acheteur (+) ou vendeur (-) de volatilité' }, + { key: 'rho', label: 'Rho', hint: 'Sensibilité au taux — surtout utile en LEAPS/futures' }, +] + +const GREEK_STATE_LABEL: Record = { + strong_negative: 'Fortement négatif', negative: 'Négatif', neutral: 'Neutre', + positive: 'Positif', strong_positive: 'Fortement positif', free: 'Libre', +} + +function SuggestedProfileCard({ + scenario, enabled, onAdopt, +}: { scenario: StrategyScenario; enabled: boolean; onAdopt: (states: Record<'delta' | 'gamma' | 'theta' | 'vega' | 'rho', GreekState>) => void }) { + const { data } = useSuggestedProfile(scenario, enabled) + if (!data) return null + const rows: { key: 'delta' | 'gamma' | 'theta' | 'vega' | 'rho'; label: string }[] = [ + { key: 'delta', label: 'Delta' }, { key: 'gamma', label: 'Gamma' }, { key: 'theta', label: 'Theta' }, { key: 'vega', label: 'Vega' }, + ] + const active = rows.filter(r => data[r.key] !== 'free') + if (active.length === 0) return null + + return ( +
+
+
Profil suggéré par le scénario seul (avant vos propres cibles)
+ +
+
+ {active.map(r => ( + + {r.label} : {GREEK_STATE_LABEL[data[r.key]]} + + ))} +
+
    + {data.rationale.map((r, i) =>
  • {r}
  • )} +
+
+ ) +} + +function GreekProfilePanel({ + profile, setProfile, +}: { profile: GreekProfile; setProfile: (v: GreekProfile) => void }) { + const setTarget = (key: keyof GreekProfile, patch: Partial) => + setProfile({ ...profile, [key]: { ...profile[key], ...patch } }) + + const anyActive = Object.values(profile).some(t => t.state !== 'free') + + return ( +
+
+
Profil recherché — comportement Greeks, pas un nouveau scénario
+ {anyActive && ( + + )} +
+

+ Le scénario ci-dessus produit déjà naturellement certains Greeks — ces contrôles servent à favoriser, + tolérer ou interdire certaines expositions parmi les candidats trouvés, pas à décrire un second scénario. +

+
+ {GREEK_ROWS.map(({ key, label, hint }) => { + const t = profile[key] + const isFree = t.state === 'free' + return ( +
+
+
{label}
+
{hint}
+
+ + + setTarget(key, { weight: parseFloat(e.target.value) })} + className="col-span-3 accent-blue-500 disabled:opacity-30" + /> + + {isFree ? '—' : `${t.weight.toFixed(0)}%`} + +
+ ) + })} +
+
+ ) +} + 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 ( @@ -483,6 +633,7 @@ function ResultsTable({ results, onSelect }: { results: StrategyCandidate[]; onS Max gain Max perte Δ net + Profil @@ -496,6 +647,9 @@ function ResultsTable({ results, onSelect }: { results: StrategyCandidate[]; onS {r.max_gain != null ? fmtMoney(r.max_gain) : '∞'} {r.max_loss != null ? fmtMoney(r.max_loss) : '−∞'} {r.net_delta_now.toFixed(3)} + + {r.greek_match_score != null ? `${(r.greek_match_score * 100).toFixed(0)}%` : '—'} + Charger → ))} @@ -585,7 +739,8 @@ export default function StrategyBuilder() { const [debouncedSymbol, setDebouncedSymbol] = useState('') const [horizonDays, setHorizonDays] = useState(8) const [scenario, setScenario] = useState({ - symbol: '', horizon_days: 8, spot_shock_pct: 0, iv_level_shift: 0, skew_tilt: 0, term_shift: 0, manual_grid: [], + symbol: '', horizon_days: 8, spot_shock_pct: 0, iv_level_shift: 0, skew_tilt: 0, term_slope_shift: 0, + rate_shock_bps: 0, dte_min: null, dte_max: null, manual_grid: [], contract_size: 100_000, }) @@ -599,6 +754,8 @@ export default function StrategyBuilder() { const [constraints, setConstraints] = useState({ max_legs: 4, delta_threshold: 0.15, max_loss_cap: null, objective: 'net_pnl', top_n: 20, }) + const [greekProfile, setGreekProfile] = useState(DEFAULT_GREEK_PROFILE) + const [showAdvancedGreeks, setShowAdvancedGreeks] = useState(false) const [activeTemplate, setActiveTemplate] = useState(null) const { data: watchlistData } = useWatchlistTickers() @@ -609,7 +766,7 @@ export default function StrategyBuilder() { ])).sort() const { data: chain, isLoading: chainLoading, isError: chainError, error: chainErrorObj, refetch: refetchChain, isFetching } = - useOptionChainSlice(debouncedSymbol, horizonDays, 3) + useOptionChainSlice(debouncedSymbol, horizonDays, 3, true, scenario.dte_min, scenario.dte_max) const { data: ivForTrade } = useIvForTrade(debouncedSymbol) useEffect(() => { @@ -655,7 +812,7 @@ export default function StrategyBuilder() { const handleOptimize = () => { setActiveTemplate(null) - optimizeMutation.mutate({ scenario, constraints }) + optimizeMutation.mutate({ scenario, constraints, greek_profile: greekProfile }) } const handleSelectCandidate = (c: StrategyCandidate) => { @@ -668,7 +825,8 @@ export default function StrategyBuilder() { setHorizonDays(s.horizon_days) setScenario(prev => ({ symbol: s.symbol, horizon_days: s.horizon_days, spot_shock_pct: s.spot_shock_pct, - iv_level_shift: s.iv_level_shift, skew_tilt: s.skew_tilt, term_shift: s.term_shift, + iv_level_shift: s.iv_level_shift, skew_tilt: s.skew_tilt, term_slope_shift: s.term_slope_shift, + rate_shock_bps: s.rate_shock_bps, dte_min: s.dte_min, dte_max: s.dte_max, manual_grid: s.manual_grid, contract_size: prev.contract_size, })) } @@ -775,7 +933,20 @@ export default function StrategyBuilder() { )} {chain && ( - + <> + setGreekProfile({ + delta: { state: states.delta, tolerance: 'normale', weight: 60 }, + gamma: { state: states.gamma, tolerance: 'normale', weight: 60 }, + theta: { state: states.theta, tolerance: 'normale', weight: 60 }, + vega: { state: states.vega, tolerance: 'normale', weight: 60 }, + rho: { state: states.rho, tolerance: 'normale', weight: 60 }, + })} + /> + + + )} {optimizeMutation.isError && ( @@ -783,8 +954,18 @@ export default function StrategyBuilder() { {(optimizeMutation.error as any)?.response?.data?.detail ?? "Erreur lors de l'optimisation."} )} + {optimizeMutation.data && optimizeMutation.data.warnings.length > 0 && ( +
+ {optimizeMutation.data.warnings.map((w, i) => ( +
+ + {w} +
+ ))} +
+ )} {optimizeMutation.data && ( - + )} {priceMutation.isPending &&
Calcul en cours…
} @@ -843,11 +1024,56 @@ export default function StrategyBuilder() {

-
+
+ +
+ + {priced.vanna_simulation && ( +
+
Simulation Vanna — pas juste un signe
+

+ Spot {priced.vanna_simulation.spot_shock_pct}%{' '} + et IV +{priced.vanna_simulation.iv_shock_pts}pts → + {' '}Delta net passe de{' '} + {priced.vanna_simulation.delta_before.toFixed(4)} + {' '}à{' '} + {priced.vanna_simulation.delta_after.toFixed(4)} + = 0 ? 'text-emerald-400' : 'text-red-400')}> + ({priced.vanna_simulation.delta_change >= 0 ? '+' : ''}{priced.vanna_simulation.delta_change.toFixed(4)}) + +

+

+ Repricing Black-Scholes réel sous ce choc conjoint — pas une approximation linéaire, valable même pour un choc large. +

+
+ )} + +
+ + {showAdvancedGreeks && ( +
+ + + + + + + +
+ )}
)} diff --git a/macro_regime.png b/macro_regime.png new file mode 100644 index 0000000..e711c1e Binary files /dev/null and b/macro_regime.png differ diff --git a/option_lab.png b/option_lab.png new file mode 100644 index 0000000..c110cd4 Binary files /dev/null and b/option_lab.png differ diff --git a/strategy_builder.png b/strategy_builder.png new file mode 100644 index 0000000..3bc40e8 Binary files /dev/null and b/strategy_builder.png differ diff --git a/var.png b/var.png new file mode 100644 index 0000000..a20618d Binary files /dev/null and b/var.png differ diff --git a/wavelets.png b/wavelets.png new file mode 100644 index 0000000..f0b54d8 Binary files /dev/null and b/wavelets.png differ