From 4620234967f3988a8d06d8b17fda54a23d9b61ae Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Fri, 27 Mar 2026 22:33:20 +0100 Subject: [PATCH] feat(EXEC-001): add hedge strategy builder --- .gitignore | 1 + app/pages/common.py | 7 +- app/pages/hedge.py | 124 +++++++++++++- app/services/strategy_templates.py | 156 +++++++++++++++++- docs/roadmap/ROADMAP.yaml | 4 +- .../backlog/EXEC-001-strategy-builder.yaml | 13 -- .../done/EXEC-001-strategy-builder.yaml | 19 +++ tests/test_hedge_builder_playwright.py | 62 +++++++ tests/test_strategy_templates.py | 80 ++++++++- 9 files changed, 429 insertions(+), 37 deletions(-) delete mode 100644 docs/roadmap/backlog/EXEC-001-strategy-builder.yaml create mode 100644 docs/roadmap/done/EXEC-001-strategy-builder.yaml create mode 100644 tests/test_hedge_builder_playwright.py diff --git a/.gitignore b/.gitignore index eb2ef3c..4c1fe62 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ config/secrets.yaml data/cache/ data/workspaces/ +data/strategy_templates.json .idea/ .vscode/ .worktrees/ diff --git a/app/pages/common.py b/app/pages/common.py index 87c0bc0..70a8251 100644 --- a/app/pages/common.py +++ b/app/pages/common.py @@ -110,14 +110,15 @@ def option_chain() -> list[dict[str, Any]]: def strategy_metrics( - strategy_name: str, + strategy_key: str, scenario_pct: int, *, portfolio: dict[str, Any] | None = None, ) -> dict[str, Any]: + catalog = strategy_catalog() strategy = next( - (item for item in strategy_catalog() if item["name"] == strategy_name), - strategy_catalog()[0], + (item for item in catalog if item.get("template_slug") == strategy_key or item.get("name") == strategy_key), + catalog[0], ) portfolio = portfolio or portfolio_snapshot() return strategy_metrics_from_snapshot(strategy, scenario_pct, portfolio) diff --git a/app/pages/hedge.py b/app/pages/hedge.py index 6c4a539..cf2e660 100644 --- a/app/pages/hedge.py +++ b/app/pages/hedge.py @@ -12,10 +12,10 @@ from app.pages.common import ( demo_spot_price, portfolio_snapshot, split_page_panes, - strategy_catalog, strategy_metrics, ) from app.services.runtime import get_data_service +from app.services.strategy_templates import StrategyTemplateService 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: - 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) - strategies = strategy_catalog() - strategy_map = {strategy["label"]: strategy["name"] for strategy in strategies} - selected = {"strategy": strategies[0]["name"], "label": strategies[0]["label"], "scenario_pct": 0} + template_service = StrategyTemplateService() + strategies_state = {"items": template_service.catalog_items()} + + 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": 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" ): 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 = 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") @@ -144,6 +152,45 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None: "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( "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" ) + 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: metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"], portfolio=portfolio) strategy = metrics["strategy"] summary.clear() with summary: 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") with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"): 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"): result_cards = [ ("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}"), ("Hedged equity", f"${metrics['hedged_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() def refresh_from_selector(event) -> None: + if syncing_controls["value"]: + return selected["label"] = str(event.value) - selected["strategy"] = strategy_map[selected["label"]] + selected["strategy"] = strategy_map()[selected["label"]] render_summary() 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']}%") 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) slider.on_value_change(refresh_from_slider) + save_template_button.on_click(lambda: save_template()) render_summary() diff --git a/app/services/strategy_templates.py b/app/services/strategy_templates.py index 4a11b6c..6d43a54 100644 --- a/app/services/strategy_templates.py +++ b/app/services/strategy_templates.py @@ -1,15 +1,19 @@ from __future__ import annotations import json +import re from pathlib import Path from typing import Any +from uuid import uuid4 from app.models.strategy_template import StrategyTemplate from app.strategies.base import BaseStrategy, StrategyConfig from app.strategies.laddered_put import LadderedPutStrategy, LadderSpec 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]: @@ -65,26 +69,51 @@ def default_strategy_templates() -> list[StrategyTemplate]: 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.seed_path = Path(seed_path) if seed_path is not None else None def list_templates(self) -> list[StrategyTemplate]: - self._ensure_seeded() + self._ensure_store() + defaults = self._seed_templates() 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: return next((template for template in self.list_templates() if template.slug == slug), None) def save_all(self, templates: list[StrategyTemplate]) -> None: 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") - def _ensure_seeded(self) -> None: + def _ensure_store(self) -> None: if self.path.exists(): 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: @@ -105,6 +134,77 @@ class StrategyTemplateService: raise KeyError(f"Unknown strategy template: {slug}") 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: return self.build_strategy_from_template(config, self.get_template(slug)) @@ -157,6 +257,7 @@ class StrategyTemplateService: for leg in template.legs if leg.side == "long" and leg.option_type == "put" ] + defaults = ui_defaults.get(strategy_name, {}) if "system" in template.tags else {} items.append( { "name": strategy_name, @@ -164,7 +265,8 @@ class StrategyTemplateService: "label": template.display_name, "description": template.description, "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 @@ -176,6 +278,44 @@ class StrategyTemplateService: ) 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 def _protective_label(strike_pct: float) -> str: if abs(strike_pct - 1.0) < 1e-9: diff --git a/docs/roadmap/ROADMAP.yaml b/docs/roadmap/ROADMAP.yaml index f5dec09..dcdbc31 100644 --- a/docs/roadmap/ROADMAP.yaml +++ b/docs/roadmap/ROADMAP.yaml @@ -13,7 +13,6 @@ notes: - 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. priority_queue: - - EXEC-001 - EXEC-002 - DATA-002A - DATA-001A @@ -21,6 +20,7 @@ priority_queue: - BT-003 - BT-002A recently_completed: + - EXEC-001 - BT-001C - BT-002 - PORT-003 @@ -42,7 +42,6 @@ states: - DATA-002A - DATA-001A - OPS-001 - - EXEC-001 - EXEC-002 - BT-003 - BT-002A @@ -59,6 +58,7 @@ states: - SEC-001 - SEC-001A - EXEC-001A + - EXEC-001 - BT-001 - BT-001A - BT-001C diff --git a/docs/roadmap/backlog/EXEC-001-strategy-builder.yaml b/docs/roadmap/backlog/EXEC-001-strategy-builder.yaml deleted file mode 100644 index c1174d9..0000000 --- a/docs/roadmap/backlog/EXEC-001-strategy-builder.yaml +++ /dev/null @@ -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. diff --git a/docs/roadmap/done/EXEC-001-strategy-builder.yaml b/docs/roadmap/done/EXEC-001-strategy-builder.yaml new file mode 100644 index 0000000..2b43000 --- /dev/null +++ b/docs/roadmap/done/EXEC-001-strategy-builder.yaml @@ -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`. diff --git a/tests/test_hedge_builder_playwright.py b/tests/test_hedge_builder_playwright.py new file mode 100644 index 0000000..c06485f --- /dev/null +++ b/tests/test_hedge_builder_playwright.py @@ -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() diff --git a/tests/test_strategy_templates.py b/tests/test_strategy_templates.py index e06b4d8..5f6fc4d 100644 --- a/tests/test_strategy_templates.py +++ b/tests/test_strategy_templates.py @@ -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: - catalog = StrategyTemplateService().catalog_items() +def test_strategy_template_service_catalog_reads_named_templates(tmp_path: Path) -> None: + catalog = StrategyTemplateService( + repository=FileStrategyTemplateRepository(tmp_path / "strategy_templates.json") + ).catalog_items() assert [item["label"] for item in catalog] == [ "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_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