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
+
+
+
+
+
+
04 / 16
+
How the desks talk to each other
+
Top-down regime. Bottom-up book. They meet in the middle, at your positions.
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
+
+
+
+
+
+
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.
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
+
+
+
+
+
+
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
+
+
+
+
+
+
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
+
+
+
+
+
+
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 role
What they'd hand you
The OpenFin desk
+
Macro strategist
A morning view on the regime, updated once a day at best.
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)}
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.
+