feat: strategy builder
This commit is contained in:
@@ -41,12 +41,21 @@ class ScenarioIn(BaseModel):
|
||||
# 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
|
||||
# "Dériver d'un historique" mode: reconstruct the chain (and every leg strike drawn
|
||||
# "Analyse période historique" mode: reconstruct the chain (and every leg strike drawn
|
||||
# from it, including in /optimize) as it stood at/before this date instead of live —
|
||||
# e.g. so the optimizer searches over what was ACTUALLY quoted on the day a realized
|
||||
# scenario's window starts, not today's chain. None (default) = live, unchanged
|
||||
# behavior for the normal Construire flow.
|
||||
# behavior for the normal Construire flow. This is the position's ENTRY date.
|
||||
as_of: Optional[str] = None
|
||||
# The scrubbed-to day within the historical period: when set, surface_scenario is a
|
||||
# REAL smile fit from the Saxo chain at/before this date (services.vol_surface.Surface,
|
||||
# built the same way surface_now already is) instead of apply_scenario's parametric
|
||||
# spot/IV/skew/term shock — Surface and ScenarioSurface expose the same .spot/.iv_at()
|
||||
# interface, so this is a drop-in substitution, not a new pricing path. horizon_days
|
||||
# should equal (checkpoint_as_of - as_of).days so the elapsed-time math stays
|
||||
# consistent with what's actually being priced. None (default) = today's synthetic
|
||||
# scenario shock, unchanged behavior.
|
||||
checkpoint_as_of: Optional[str] = None
|
||||
|
||||
@property
|
||||
def shocked_rate(self) -> float:
|
||||
@@ -119,6 +128,7 @@ class StrategySaveRequest(BaseModel):
|
||||
net_pnl_scenario: Optional[float] = None
|
||||
net_delta: Optional[float] = None
|
||||
notes: Optional[str] = ""
|
||||
source: str = "synthetic" # "synthetic" (Construire) | "historical" (Analyse période historique)
|
||||
|
||||
|
||||
def _build_surfaces(scenario: ScenarioIn):
|
||||
@@ -127,14 +137,23 @@ def _build_surfaces(scenario: ScenarioIn):
|
||||
dte_min=scenario.dte_min, dte_max=scenario.dte_max, as_of=scenario.as_of,
|
||||
)
|
||||
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_slope_shift=scenario.term_slope_shift,
|
||||
manual_grid=scenario.manual_grid,
|
||||
)
|
||||
if scenario.checkpoint_as_of:
|
||||
# Real smile-of-the-day, not a hypothesis — same fitting code as surface_now
|
||||
# (build_surface), just fed the chain as it stood at the scrubbed-to date.
|
||||
checkpoint_chain = get_chain_slice(
|
||||
scenario.symbol, scenario.horizon_days, scenario.n_expiries,
|
||||
dte_min=scenario.dte_min, dte_max=scenario.dte_max, as_of=scenario.checkpoint_as_of,
|
||||
)
|
||||
surface_scenario = build_surface(checkpoint_chain)
|
||||
else:
|
||||
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_slope_shift=scenario.term_slope_shift,
|
||||
manual_grid=scenario.manual_grid,
|
||||
)
|
||||
return chain_slice, surface_now, surface_scenario
|
||||
|
||||
|
||||
@@ -287,6 +306,8 @@ def optimize(req: OptimizeRequest):
|
||||
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,
|
||||
as_of=req.scenario.as_of,
|
||||
checkpoint_as_of=req.scenario.checkpoint_as_of,
|
||||
)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -341,6 +341,11 @@ def init_db():
|
||||
"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",
|
||||
# Strategy Builder — Construire/Analyse historique merge: tags which mode priced
|
||||
# this strategy when it was saved, so the saved-strategies library (now shown in
|
||||
# both modes) can badge it and the other mode knows it's re-pricing a strategy that
|
||||
# wasn't originally priced there.
|
||||
"ALTER TABLE saved_strategies ADD COLUMN source TEXT DEFAULT 'synthetic'",
|
||||
]:
|
||||
try:
|
||||
c.execute(_sql)
|
||||
@@ -6421,8 +6426,8 @@ def save_strategy(strategy: Dict[str, Any]) -> str:
|
||||
conn = get_conn()
|
||||
conn.execute("""INSERT INTO saved_strategies (
|
||||
id, scenario_id, symbol, template_name, objective, legs,
|
||||
entry_cost, max_gain, max_loss, net_pnl_scenario, net_delta, notes
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", (
|
||||
entry_cost, max_gain, max_loss, net_pnl_scenario, net_delta, notes, source
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", (
|
||||
strategy_id,
|
||||
strategy.get("scenario_id"),
|
||||
strategy["symbol"],
|
||||
@@ -6435,6 +6440,7 @@ def save_strategy(strategy: Dict[str, Any]) -> str:
|
||||
strategy.get("net_pnl_scenario"),
|
||||
strategy.get("net_delta"),
|
||||
strategy.get("notes", ""),
|
||||
strategy.get("source", "synthetic"),
|
||||
))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -274,14 +274,23 @@ def optimize(
|
||||
dte_max: Optional[int] = None,
|
||||
greek_profile: Optional[Dict[str, Any]] = None,
|
||||
as_of: Optional[str] = None,
|
||||
checkpoint_as_of: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
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, as_of=as_of)
|
||||
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_slope_shift=term_slope_shift, manual_grid=manual_grid,
|
||||
)
|
||||
if checkpoint_as_of:
|
||||
# Real smile-of-the-day (same fitting as surface_now) instead of a parametric
|
||||
# shock — see routers.strategy_builder.ScenarioIn.checkpoint_as_of. Surface and
|
||||
# ScenarioSurface share the same .spot/.iv_at() interface, so every candidate
|
||||
# evaluated below (_evaluate/_residual_search) needs no change.
|
||||
checkpoint_chain = get_chain_slice(symbol, horizon_days, n_expiries, dte_min=dte_min, dte_max=dte_max, as_of=checkpoint_as_of)
|
||||
surface_scenario = build_surface(checkpoint_chain)
|
||||
else:
|
||||
surface_scenario = apply_scenario(
|
||||
surface_now, spot_shock_pct=spot_shock_pct, iv_level_shift=iv_level_shift,
|
||||
skew_tilt=skew_tilt, term_slope_shift=term_slope_shift, manual_grid=manual_grid,
|
||||
)
|
||||
|
||||
candidates = generate_all(chain_slice)
|
||||
scored: List[Dict[str, Any]] = []
|
||||
|
||||
@@ -118,6 +118,10 @@ def replay_position(
|
||||
"date": d, "spot": chain.get("spot"),
|
||||
"position_value": round(value, 2),
|
||||
"pnl": round(value - entry_value, 2),
|
||||
# Every day's real per-leg quote/IV/greeks, not just entry/exit — lets the
|
||||
# "Analyse période historique" day-scrubber show the real leg detail for
|
||||
# whichever day is currently scrubbed to, not only the window's endpoints.
|
||||
"legs": day_legs,
|
||||
})
|
||||
|
||||
if not points:
|
||||
|
||||
Reference in New Issue
Block a user