feat(EXEC-001): add hedge strategy builder

This commit is contained in:
Bu5hm4nn
2026-03-27 22:33:20 +01:00
parent 554a41a060
commit 4620234967
9 changed files with 429 additions and 37 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@ __pycache__/
config/secrets.yaml config/secrets.yaml
data/cache/ data/cache/
data/workspaces/ data/workspaces/
data/strategy_templates.json
.idea/ .idea/
.vscode/ .vscode/
.worktrees/ .worktrees/

View File

@@ -110,14 +110,15 @@ def option_chain() -> list[dict[str, Any]]:
def strategy_metrics( def strategy_metrics(
strategy_name: str, strategy_key: str,
scenario_pct: int, scenario_pct: int,
*, *,
portfolio: dict[str, Any] | None = None, portfolio: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
catalog = strategy_catalog()
strategy = next( strategy = next(
(item for item in strategy_catalog() if item["name"] == strategy_name), (item for item in catalog if item.get("template_slug") == strategy_key or item.get("name") == strategy_key),
strategy_catalog()[0], catalog[0],
) )
portfolio = portfolio or portfolio_snapshot() portfolio = portfolio or portfolio_snapshot()
return strategy_metrics_from_snapshot(strategy, scenario_pct, portfolio) return strategy_metrics_from_snapshot(strategy, scenario_pct, portfolio)

View File

@@ -12,10 +12,10 @@ from app.pages.common import (
demo_spot_price, demo_spot_price,
portfolio_snapshot, portfolio_snapshot,
split_page_panes, split_page_panes,
strategy_catalog,
strategy_metrics, strategy_metrics,
) )
from app.services.runtime import get_data_service from app.services.runtime import get_data_service
from app.services.strategy_templates import StrategyTemplateService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -99,12 +99,18 @@ async def _resolve_hedge_spot(workspace_id: str | None = None) -> tuple[dict[str
async def _render_hedge_page(workspace_id: str | None = None) -> None: async def _render_hedge_page(workspace_id: str | None = None) -> None:
repo = get_workspace_repository()
config = repo.load_portfolio_config(workspace_id) if workspace_id else None
portfolio, quote_source, quote_updated_at = await _resolve_hedge_spot(workspace_id) portfolio, quote_source, quote_updated_at = await _resolve_hedge_spot(workspace_id)
strategies = strategy_catalog() template_service = StrategyTemplateService()
strategy_map = {strategy["label"]: strategy["name"] for strategy in strategies} strategies_state = {"items": template_service.catalog_items()}
selected = {"strategy": strategies[0]["name"], "label": strategies[0]["label"], "scenario_pct": 0}
def strategy_map() -> dict[str, str]:
return {strategy["label"]: strategy["template_slug"] for strategy in strategies_state["items"]}
selected = {
"strategy": strategies_state["items"][0]["template_slug"],
"label": strategies_state["items"][0]["label"],
"scenario_pct": 0,
}
if quote_source == "configured_entry_price": if quote_source == "configured_entry_price":
spot_label = f"Current spot reference: ${portfolio['spot_price']:,.2f}/oz (configured entry price)" spot_label = f"Current spot reference: ${portfolio['spot_price']:,.2f}/oz (configured entry price)"
@@ -130,7 +136,9 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
): ):
ui.label("Strategy Controls").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label("Strategy Controls").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
selector = ui.select(strategy_map, value=selected["label"], label="Strategy selector").classes("w-full") selector = ui.select(
list(strategy_map().keys()), value=selected["label"], label="Strategy selector"
).classes("w-full")
slider_value = ui.label("Scenario move: +0%").classes("text-sm text-slate-500 dark:text-slate-400") slider_value = ui.label("Scenario move: +0%").classes("text-sm text-slate-500 dark:text-slate-400")
slider = ui.slider(min=-25, max=25, value=0, step=5).classes("w-full") slider = ui.slider(min=-25, max=25, value=0, step=5).classes("w-full")
ui.label(spot_label).classes("text-sm text-slate-500 dark:text-slate-400") ui.label(spot_label).classes("text-sm text-slate-500 dark:text-slate-400")
@@ -144,6 +152,45 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
"text-xs text-slate-500 dark:text-slate-400" "text-xs text-slate-500 dark:text-slate-400"
) )
with (
ui.card()
.classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
)
.props("data-testid=strategy-builder-card")
):
ui.label("Strategy Builder").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(
"Save a custom protective put or equal-weight two-leg ladder for reuse across hedge, backtests, and event comparison."
).classes("text-sm text-slate-500 dark:text-slate-400")
builder_type_options = {"Protective put": "protective_put", "Two-leg ladder": "laddered_put"}
builder_name = ui.input("Template name", placeholder="Crash Guard 97").classes("w-full")
builder_type = ui.select(
list(builder_type_options.keys()),
value="Protective put",
label="Strategy type",
).classes("w-full")
builder_expiry_days = ui.number("Expiration days", value=365, min=30, step=30).classes("w-full")
builder_primary_strike = ui.number(
"Primary strike (% of spot)",
value=100,
min=1,
max=150,
step=1,
).classes("w-full")
builder_secondary_strike = ui.number(
"Secondary strike (% of spot)",
value=95,
min=1,
max=150,
step=1,
).classes("w-full")
ui.label("Two-leg ladders currently save with equal 50/50 weights.").classes(
"text-xs text-slate-500 dark:text-slate-400"
)
builder_status = ui.label("").classes("text-sm text-slate-600 dark:text-slate-300")
save_template_button = ui.button("Save template").props("color=primary outline")
summary = ui.card().classes( summary = ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
) )
@@ -161,12 +208,32 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
"h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900" "h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900"
) )
syncing_controls = {"value": False}
def refresh_available_strategies() -> None:
strategies_state["items"] = template_service.catalog_items()
options = strategy_map()
syncing_controls["value"] = True
try:
selector.options = list(options.keys())
if selected["label"] not in options:
first_label = next(iter(options))
selected["label"] = first_label
selected["strategy"] = options[first_label]
selector.value = first_label
selector.update()
finally:
syncing_controls["value"] = False
def render_summary() -> None: def render_summary() -> None:
metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"], portfolio=portfolio) metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"], portfolio=portfolio)
strategy = metrics["strategy"] strategy = metrics["strategy"]
summary.clear() summary.clear()
with summary: with summary:
ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(f"Selected template: {strategy['label']}").classes(
"text-sm text-slate-500 dark:text-slate-400"
)
ui.label(strategy["description"]).classes("text-sm text-slate-600 dark:text-slate-300") ui.label(strategy["description"]).classes("text-sm text-slate-600 dark:text-slate-300")
with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"): with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"):
cards = [ cards = [
@@ -190,7 +257,7 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
with ui.grid(columns=2).classes("w-full gap-4 max-md:grid-cols-1"): with ui.grid(columns=2).classes("w-full gap-4 max-md:grid-cols-1"):
result_cards = [ result_cards = [
("Scenario spot", f"${metrics['scenario_price']:,.2f}"), ("Scenario spot", f"${metrics['scenario_price']:,.2f}"),
("Hedge cost", f"${strategy['estimated_cost']:,.2f}/oz"), ("Hedge cost", f"${float(strategy.get('estimated_cost', 0.0)):,.2f}/oz"),
("Unhedged equity", f"${metrics['unhedged_equity']:,.0f}"), ("Unhedged equity", f"${metrics['unhedged_equity']:,.0f}"),
("Hedged equity", f"${metrics['hedged_equity']:,.0f}"), ("Hedged equity", f"${metrics['hedged_equity']:,.0f}"),
("Net hedge benefit", f"${metrics['hedged_equity'] - metrics['unhedged_equity']:,.0f}"), ("Net hedge benefit", f"${metrics['hedged_equity'] - metrics['unhedged_equity']:,.0f}"),
@@ -211,8 +278,10 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
waterfall_chart.update() waterfall_chart.update()
def refresh_from_selector(event) -> None: def refresh_from_selector(event) -> None:
if syncing_controls["value"]:
return
selected["label"] = str(event.value) selected["label"] = str(event.value)
selected["strategy"] = strategy_map[selected["label"]] selected["strategy"] = strategy_map()[selected["label"]]
render_summary() render_summary()
def refresh_from_slider(event) -> None: def refresh_from_slider(event) -> None:
@@ -221,6 +290,43 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
slider_value.set_text(f"Scenario move: {sign}{selected['scenario_pct']}%") slider_value.set_text(f"Scenario move: {sign}{selected['scenario_pct']}%")
render_summary() render_summary()
def save_template() -> None:
builder_status.set_text("")
try:
builder_kind = builder_type_options[str(builder_type.value)]
strikes = (float(builder_primary_strike.value or 0.0) / 100.0,)
weights: tuple[float, ...] | None = None
if builder_kind == "laddered_put":
strikes = (
float(builder_primary_strike.value or 0.0) / 100.0,
float(builder_secondary_strike.value or 0.0) / 100.0,
)
weights = (0.5, 0.5)
template = template_service.create_custom_template(
display_name=str(builder_name.value or ""),
template_kind=builder_kind,
target_expiry_days=int(builder_expiry_days.value or 0),
strike_pcts=strikes,
weights=weights,
)
except (ValueError, KeyError) as exc:
builder_status.set_text(str(exc))
return
refresh_available_strategies()
selected["label"] = template.display_name
selected["strategy"] = template.slug
syncing_controls["value"] = True
try:
selector.value = template.display_name
selector.update()
finally:
syncing_controls["value"] = False
builder_status.set_text(
f"Saved template {template.display_name}. Reusable on hedge, backtests, and event comparison."
)
render_summary()
selector.on_value_change(refresh_from_selector) selector.on_value_change(refresh_from_selector)
slider.on_value_change(refresh_from_slider) slider.on_value_change(refresh_from_slider)
save_template_button.on_click(lambda: save_template())
render_summary() render_summary()

View File

@@ -1,15 +1,19 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4
from app.models.strategy_template import StrategyTemplate from app.models.strategy_template import StrategyTemplate
from app.strategies.base import BaseStrategy, StrategyConfig from app.strategies.base import BaseStrategy, StrategyConfig
from app.strategies.laddered_put import LadderedPutStrategy, LadderSpec from app.strategies.laddered_put import LadderedPutStrategy, LadderSpec
from app.strategies.protective_put import ProtectivePutSpec, ProtectivePutStrategy from app.strategies.protective_put import ProtectivePutSpec, ProtectivePutStrategy
DEFAULT_TEMPLATE_FILE = Path(__file__).resolve().parents[2] / "config" / "strategy_templates.json" CONFIG_TEMPLATE_FILE = Path(__file__).resolve().parents[2] / "config" / "strategy_templates.json"
DATA_TEMPLATE_FILE = Path("data/strategy_templates.json")
_SLUGIFY_RE = re.compile(r"[^a-z0-9]+")
def default_strategy_templates() -> list[StrategyTemplate]: def default_strategy_templates() -> list[StrategyTemplate]:
@@ -65,26 +69,51 @@ def default_strategy_templates() -> list[StrategyTemplate]:
class FileStrategyTemplateRepository: class FileStrategyTemplateRepository:
def __init__(self, path: str | Path = DEFAULT_TEMPLATE_FILE) -> None: def __init__(
self,
path: str | Path = DATA_TEMPLATE_FILE,
*,
seed_path: str | Path | None = CONFIG_TEMPLATE_FILE,
) -> None:
self.path = Path(path) self.path = Path(path)
self.seed_path = Path(seed_path) if seed_path is not None else None
def list_templates(self) -> list[StrategyTemplate]: def list_templates(self) -> list[StrategyTemplate]:
self._ensure_seeded() self._ensure_store()
defaults = self._seed_templates()
payload = json.loads(self.path.read_text()) payload = json.loads(self.path.read_text())
return [StrategyTemplate.from_dict(item) for item in payload.get("templates", [])] customs = [StrategyTemplate.from_dict(item) for item in payload.get("templates", [])]
merged: dict[str, StrategyTemplate] = {template.slug: template for template in defaults}
for template in customs:
merged[template.slug] = template
return list(merged.values())
def get_by_slug(self, slug: str) -> StrategyTemplate | None: def get_by_slug(self, slug: str) -> StrategyTemplate | None:
return next((template for template in self.list_templates() if template.slug == slug), None) return next((template for template in self.list_templates() if template.slug == slug), None)
def save_all(self, templates: list[StrategyTemplate]) -> None: def save_all(self, templates: list[StrategyTemplate]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
payload = {"templates": [template.to_dict() for template in templates]} default_slugs = {template.slug for template in self._seed_templates()}
payload = {
"templates": [
template.to_dict()
for template in templates
if template.slug not in default_slugs or "system" not in template.tags
]
}
self.path.write_text(json.dumps(payload, indent=2) + "\n") self.path.write_text(json.dumps(payload, indent=2) + "\n")
def _ensure_seeded(self) -> None: def _ensure_store(self) -> None:
if self.path.exists(): if self.path.exists():
return return
self.save_all(default_strategy_templates()) self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps({"templates": []}, indent=2) + "\n")
def _seed_templates(self) -> list[StrategyTemplate]:
if self.seed_path is not None and self.seed_path.exists():
payload = json.loads(self.seed_path.read_text())
return [StrategyTemplate.from_dict(item) for item in payload.get("templates", [])]
return default_strategy_templates()
class StrategyTemplateService: class StrategyTemplateService:
@@ -105,6 +134,77 @@ class StrategyTemplateService:
raise KeyError(f"Unknown strategy template: {slug}") raise KeyError(f"Unknown strategy template: {slug}")
return template return template
def create_custom_template(
self,
*,
display_name: str,
template_kind: str,
target_expiry_days: int,
strike_pcts: tuple[float, ...],
weights: tuple[float, ...] | None = None,
underlying_symbol: str = "GLD",
) -> StrategyTemplate:
name = display_name.strip()
if not name:
raise ValueError("Template name is required")
if target_expiry_days <= 0:
raise ValueError("Expiration days must be positive")
if not strike_pcts:
raise ValueError("At least one strike is required")
if any(strike_pct <= 0 for strike_pct in strike_pcts):
raise ValueError("Strike percentages must be positive")
templates = self.repository.list_templates()
normalized_name = name.casefold()
if any(template.display_name.casefold() == normalized_name for template in templates):
raise ValueError("Template name already exists")
slug = self._slugify(name)
if any(template.slug == slug for template in templates):
raise ValueError("Template slug already exists; choose a different name")
template_id = f"custom-{uuid4()}"
if template_kind == "protective_put":
if len(strike_pcts) != 1:
raise ValueError("Protective put builder expects exactly one strike")
template = StrategyTemplate.protective_put(
template_id=template_id,
slug=slug,
display_name=name,
description=f"Custom {target_expiry_days}-day protective put at {strike_pcts[0] * 100:.0f}% strike.",
strike_pct=strike_pcts[0],
target_expiry_days=target_expiry_days,
underlying_symbol=underlying_symbol,
tags=("custom", "protective_put"),
)
elif template_kind == "laddered_put":
if len(strike_pcts) < 2:
raise ValueError("Laddered put builder expects at least two strikes")
resolved_weights = weights or self._equal_weights(len(strike_pcts))
if len(resolved_weights) != len(strike_pcts):
raise ValueError("Weights must match the number of strikes")
template = StrategyTemplate.laddered_put(
template_id=template_id,
slug=slug,
display_name=name,
description=(
f"Custom {target_expiry_days}-day put ladder at "
+ ", ".join(f"{strike_pct * 100:.0f}%" for strike_pct in strike_pcts)
+ " strikes."
),
strike_pcts=strike_pcts,
weights=resolved_weights,
target_expiry_days=target_expiry_days,
underlying_symbol=underlying_symbol,
tags=("custom", "laddered_put"),
)
else:
raise ValueError(f"Unsupported strategy type: {template_kind}")
templates.append(template)
self.repository.save_all(templates)
return template
def build_strategy(self, config: StrategyConfig, slug: str) -> BaseStrategy: def build_strategy(self, config: StrategyConfig, slug: str) -> BaseStrategy:
return self.build_strategy_from_template(config, self.get_template(slug)) return self.build_strategy_from_template(config, self.get_template(slug))
@@ -157,6 +257,7 @@ class StrategyTemplateService:
for leg in template.legs for leg in template.legs
if leg.side == "long" and leg.option_type == "put" if leg.side == "long" and leg.option_type == "put"
] ]
defaults = ui_defaults.get(strategy_name, {}) if "system" in template.tags else {}
items.append( items.append(
{ {
"name": strategy_name, "name": strategy_name,
@@ -164,7 +265,8 @@ class StrategyTemplateService:
"label": template.display_name, "label": template.display_name,
"description": template.description, "description": template.description,
"downside_put_legs": downside_put_legs, "downside_put_legs": downside_put_legs,
**ui_defaults.get(strategy_name, {}), "estimated_cost": defaults.get("estimated_cost", self._estimated_cost(template)),
"coverage": defaults.get("coverage", self._coverage_label(template)),
} }
) )
return items return items
@@ -176,6 +278,44 @@ class StrategyTemplateService:
) )
return strategy.name return strategy.name
@staticmethod
def _slugify(display_name: str) -> str:
slug = _SLUGIFY_RE.sub("-", display_name.strip().lower()).strip("-")
if not slug:
raise ValueError("Template name must contain letters or numbers")
return slug
@staticmethod
def _equal_weights(count: int) -> tuple[float, ...]:
if count <= 0:
raise ValueError("count must be positive")
base = round(1.0 / count, 10)
weights = [base for _ in range(count)]
weights[-1] = 1.0 - sum(weights[:-1])
return tuple(weights)
@staticmethod
def _estimated_cost(template: StrategyTemplate) -> float:
weighted_cost = sum(
leg.allocation_weight * max(1.1, 6.25 - ((1.0 - leg.strike_rule.value) * 25.5)) for leg in template.legs
)
expiry_factor = max(0.45, (template.target_expiry_days / 365) ** 0.5)
weighted_cost *= expiry_factor
if len(template.legs) > 1:
weighted_cost *= 0.8
return round(weighted_cost, 2)
@staticmethod
def _coverage_label(template: StrategyTemplate) -> str:
if len(template.legs) > 1:
return "Layered"
strike_pct = template.legs[0].strike_rule.value
if strike_pct >= 0.99:
return "High"
if strike_pct >= 0.95:
return "Balanced"
return "Cost-efficient"
@staticmethod @staticmethod
def _protective_label(strike_pct: float) -> str: def _protective_label(strike_pct: float) -> str:
if abs(strike_pct - 1.0) < 1e-9: if abs(strike_pct - 1.0) < 1e-9:

View File

@@ -13,7 +13,6 @@ notes:
- Pre-alpha policy: we may cut or replace old features without backward compatibility until alpha is declared. - Pre-alpha policy: we may cut or replace old features without backward compatibility until alpha is declared.
- Alpha migration policy: once alpha is declared, compatibility only needs to move forward; backward migrations are not required. - Alpha migration policy: once alpha is declared, compatibility only needs to move forward; backward migrations are not required.
priority_queue: priority_queue:
- EXEC-001
- EXEC-002 - EXEC-002
- DATA-002A - DATA-002A
- DATA-001A - DATA-001A
@@ -21,6 +20,7 @@ priority_queue:
- BT-003 - BT-003
- BT-002A - BT-002A
recently_completed: recently_completed:
- EXEC-001
- BT-001C - BT-001C
- BT-002 - BT-002
- PORT-003 - PORT-003
@@ -42,7 +42,6 @@ states:
- DATA-002A - DATA-002A
- DATA-001A - DATA-001A
- OPS-001 - OPS-001
- EXEC-001
- EXEC-002 - EXEC-002
- BT-003 - BT-003
- BT-002A - BT-002A
@@ -59,6 +58,7 @@ states:
- SEC-001 - SEC-001
- SEC-001A - SEC-001A
- EXEC-001A - EXEC-001A
- EXEC-001
- BT-001 - BT-001
- BT-001A - BT-001A
- BT-001C - BT-001C

View File

@@ -1,13 +0,0 @@
id: EXEC-001
title: Strategy Builder
status: backlog
priority: P1
effort: L
depends_on:
- DATA-003
tags: [strategies, hedge]
summary: Build and compare hedge strategies from the product UI.
acceptance_criteria:
- Select strategy type, strikes, and expirations.
- Show payoff diagrams and compare cost vs protection.
- Store strategy templates for reuse.

View File

@@ -0,0 +1,19 @@
id: EXEC-001
title: Strategy Builder
status: done
priority: P1
effort: L
depends_on:
- DATA-003
tags:
- strategies
- hedge
summary: The product now includes a thin hedge-page strategy builder that saves reusable custom templates and immediately compares their cost vs protection in the existing hedge charts.
completed_notes:
- Updated `app/services/strategy_templates.py` to persist templates under `data/strategy_templates.json`, seed from the shipped default template catalog, and expose a fail-closed `create_custom_template(...)` builder API.
- Added custom-template coverage in `tests/test_strategy_templates.py` for persistence, duplicate-name rejection, and custom catalog rendering.
- Updated `app/pages/hedge.py` to add a product-visible `Strategy Builder` card where users can choose strategy type, strike inputs, and expiration days, then save a reusable template from the UI.
- Saved templates now immediately participate in the existing hedge-page cost/protection comparison flow by auto-selecting the new template and reusing the existing payoff/cost charts on `/{workspace_id}/hedge`.
- Saved templates are reusable beyond the hedge page because the same app-global repository-backed template catalog now feeds `/backtests` and `/event-comparison` template selectors.
- The current thin builder scope supports custom protective puts and equal-weight two-leg put ladders; richer template management and more advanced builder controls can be added later as follow-up work.
- Local Docker validation closed the loop on the exact changed route: `/health` returned OK, `tests/test_hedge_builder_playwright.py` passed against the Docker-served app, and the saved template was verified as reusable from `/{workspace_id}/backtests`.

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from pathlib import Path
from uuid import uuid4
from playwright.sync_api import expect, sync_playwright
BASE_URL = "http://127.0.0.1:8000"
ARTIFACTS = Path("tests/artifacts")
ARTIFACTS.mkdir(parents=True, exist_ok=True)
def test_hedge_builder_saves_template_and_reuses_it_in_backtests() -> None:
template_name = f"Crash Guard 95 {uuid4().hex[:8]}"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 1000})
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
page.get_by_role("button", name="Get started").click()
page.wait_for_url(f"{BASE_URL}/*", timeout=15000)
workspace_url = page.url
page.goto(f"{workspace_url}/hedge", wait_until="domcontentloaded", timeout=30000)
expect(page.locator("text=Hedge Analysis").first).to_be_visible(timeout=15000)
expect(page.locator("text=Strategy Builder").first).to_be_visible(timeout=15000)
expect(page.get_by_label("Strategy selector")).to_be_visible(timeout=15000)
hedge_text = page.locator("body").inner_text(timeout=15000)
assert "$6.25/oz" in hedge_text
page.get_by_label("Template name").fill(template_name)
page.get_by_label("Expiration days").fill("180")
page.get_by_label("Primary strike (% of spot)").fill("95")
page.get_by_role("button", name="Save template").click()
expect(
page.locator(f"text=Saved template {template_name}. Reusable on hedge, backtests, and event comparison.")
).to_be_visible(timeout=15000)
expect(page.get_by_label("Strategy selector")).to_have_value(template_name, timeout=15000)
hedge_text = page.locator("body").inner_text(timeout=15000)
assert "Selected template: " + template_name in hedge_text
assert "Custom 180-day protective put at 95% strike." in hedge_text
assert "$3.49/oz" in hedge_text
assert "$4.95/oz" not in hedge_text
assert "RuntimeError" not in hedge_text
assert "Server error" not in hedge_text
page.screenshot(path=str(ARTIFACTS / "hedge-builder.png"), full_page=True)
page.goto(f"{workspace_url}/backtests", wait_until="networkidle", timeout=30000)
expect(page.locator("text=Backtests").first).to_be_visible(timeout=15000)
page.get_by_label("Template").click()
expect(page.get_by_text(template_name, exact=True)).to_be_visible(timeout=15000)
page.goto(f"{workspace_url}/event-comparison", wait_until="networkidle", timeout=30000)
expect(page.locator("text=Event Comparison").first).to_be_visible(timeout=15000)
page.get_by_label("Strategy templates").click()
expect(page.get_by_text(template_name, exact=True)).to_be_visible(timeout=15000)
browser.close()

View File

@@ -125,8 +125,10 @@ def test_strategy_selection_engine_uses_named_templates(monkeypatch: pytest.Monk
] ]
def test_strategy_template_service_catalog_reads_named_templates() -> None: def test_strategy_template_service_catalog_reads_named_templates(tmp_path: Path) -> None:
catalog = StrategyTemplateService().catalog_items() catalog = StrategyTemplateService(
repository=FileStrategyTemplateRepository(tmp_path / "strategy_templates.json")
).catalog_items()
assert [item["label"] for item in catalog] == [ assert [item["label"] for item in catalog] == [
"Protective Put ATM", "Protective Put ATM",
@@ -142,3 +144,77 @@ def test_strategy_template_service_catalog_reads_named_templates() -> None:
"laddered_put_50_50_atm_otm95", "laddered_put_50_50_atm_otm95",
"laddered_put_33_33_33_atm_otm95_otm90", "laddered_put_33_33_33_atm_otm95_otm90",
] ]
def test_strategy_template_service_creates_and_persists_custom_protective_template(tmp_path: Path) -> None:
repository = FileStrategyTemplateRepository(tmp_path / "strategy_templates.json")
service = StrategyTemplateService(repository=repository)
template = service.create_custom_template(
display_name="Crash Guard 97%",
template_kind="protective_put",
target_expiry_days=180,
strike_pcts=(0.97,),
)
assert template.display_name == "Crash Guard 97%"
assert template.slug == "crash-guard-97"
assert template.target_expiry_days == 180
assert template.legs[0].strike_rule.value == 0.97
assert template.tags == ("custom", "protective_put")
assert service.get_template("crash-guard-97").display_name == "Crash Guard 97%"
def test_strategy_template_service_rejects_duplicate_custom_template_name(tmp_path: Path) -> None:
repository = FileStrategyTemplateRepository(tmp_path / "strategy_templates.json")
service = StrategyTemplateService(repository=repository)
service.create_custom_template(
display_name="Crash Guard 97%",
template_kind="protective_put",
target_expiry_days=180,
strike_pcts=(0.97,),
)
with pytest.raises(ValueError, match="Template name already exists"):
service.create_custom_template(
display_name="Crash Guard 97%",
template_kind="protective_put",
target_expiry_days=90,
strike_pcts=(0.92,),
)
def test_strategy_template_service_catalog_includes_custom_ladder_template(tmp_path: Path) -> None:
repository = FileStrategyTemplateRepository(tmp_path / "strategy_templates.json")
service = StrategyTemplateService(repository=repository)
service.create_custom_template(
display_name="Crash Ladder 98/92",
template_kind="laddered_put",
target_expiry_days=270,
strike_pcts=(0.98, 0.92),
weights=(0.5, 0.5),
)
custom_item = next(item for item in service.catalog_items() if item["label"] == "Crash Ladder 98/92")
assert custom_item["coverage"] == "Layered"
assert custom_item["estimated_cost"] > 0
assert custom_item["downside_put_legs"] == [
{"allocation_weight": 0.5, "strike_pct": 0.98},
{"allocation_weight": 0.5, "strike_pct": 0.92},
]
def test_strategy_template_service_catalog_custom_cost_reflects_expiry_days(tmp_path: Path) -> None:
repository = FileStrategyTemplateRepository(tmp_path / "strategy_templates.json")
service = StrategyTemplateService(repository=repository)
service.create_custom_template(
display_name="Crash Guard 95 180d",
template_kind="protective_put",
target_expiry_days=180,
strike_pcts=(0.95,),
)
custom_item = next(item for item in service.catalog_items() if item["label"] == "Crash Guard 95 180d")
assert custom_item["estimated_cost"] == 3.49