221 lines
8.2 KiB
Python
221 lines
8.2 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import app.core.pricing.black_scholes as black_scholes
|
|
from app.models.strategy_template import EntryPolicy, RollPolicy, StrategyTemplate, StrikeRule, TemplateLeg
|
|
from app.services.strategy_templates import FileStrategyTemplateRepository, StrategyTemplateService
|
|
from app.strategies.engine import StrategySelectionEngine
|
|
|
|
|
|
def _force_analytic_pricing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(black_scholes, "ql", None)
|
|
|
|
|
|
def test_strategy_template_rejects_invalid_weight_sum() -> None:
|
|
template = StrategyTemplate.protective_put(
|
|
template_id="bad-template",
|
|
slug="bad-template",
|
|
display_name="Bad Template",
|
|
description="Invalid weights should fail validation.",
|
|
strike_pct=1.0,
|
|
target_expiry_days=365,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="weights must sum to 1.0"):
|
|
StrategyTemplate(
|
|
template_id=template.template_id,
|
|
slug=template.slug,
|
|
display_name=template.display_name,
|
|
description=template.description,
|
|
template_kind=template.template_kind,
|
|
status=template.status,
|
|
version=template.version,
|
|
underlying_symbol=template.underlying_symbol,
|
|
contract_mode=template.contract_mode,
|
|
legs=(
|
|
TemplateLeg(
|
|
leg_id="leg-1",
|
|
side="long",
|
|
option_type="put",
|
|
allocation_weight=0.6,
|
|
strike_rule=template.legs[0].strike_rule,
|
|
target_expiry_days=365,
|
|
quantity_rule="target_coverage_pct",
|
|
target_coverage_pct=1.0,
|
|
),
|
|
TemplateLeg(
|
|
leg_id="leg-2",
|
|
side="long",
|
|
option_type="put",
|
|
allocation_weight=0.3,
|
|
strike_rule=template.legs[0].strike_rule,
|
|
target_expiry_days=365,
|
|
quantity_rule="target_coverage_pct",
|
|
target_coverage_pct=1.0,
|
|
),
|
|
),
|
|
roll_policy=template.roll_policy,
|
|
entry_policy=template.entry_policy,
|
|
tags=template.tags,
|
|
created_at=template.created_at,
|
|
updated_at=template.updated_at,
|
|
)
|
|
|
|
|
|
def test_file_strategy_template_repository_seeds_default_templates(tmp_path: Path) -> None:
|
|
repository = FileStrategyTemplateRepository(tmp_path / "strategy_templates.json")
|
|
|
|
templates = repository.list_templates()
|
|
|
|
assert [template.slug for template in templates] == [
|
|
"protective-put-atm-12m",
|
|
"protective-put-95pct-12m",
|
|
"protective-put-90pct-12m",
|
|
"ladder-50-50-atm-95pct-12m",
|
|
"ladder-33-33-33-atm-95pct-90pct-12m",
|
|
]
|
|
assert (tmp_path / "strategy_templates.json").exists() is True
|
|
|
|
|
|
def test_strategy_template_rejects_collar_kind_for_mvp() -> None:
|
|
with pytest.raises(ValueError, match="unsupported template_kind"):
|
|
StrategyTemplate(
|
|
template_id="collar-template",
|
|
slug="collar-template",
|
|
display_name="Collar Template",
|
|
description="Collars are not supported by the template service in the MVP.",
|
|
template_kind="collar", # type: ignore[arg-type]
|
|
status="active",
|
|
version=1,
|
|
underlying_symbol="GLD",
|
|
contract_mode="continuous_units",
|
|
legs=(
|
|
TemplateLeg(
|
|
leg_id="collar-put-leg",
|
|
side="long",
|
|
option_type="put",
|
|
allocation_weight=1.0,
|
|
strike_rule=StrikeRule(rule_type="spot_pct", value=0.95),
|
|
target_expiry_days=365,
|
|
quantity_rule="target_coverage_pct",
|
|
target_coverage_pct=1.0,
|
|
),
|
|
),
|
|
roll_policy=RollPolicy(policy_type="hold_to_expiry"),
|
|
entry_policy=EntryPolicy(entry_timing="scenario_start_close"),
|
|
)
|
|
|
|
|
|
def test_strategy_selection_engine_uses_named_templates(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_force_analytic_pricing(monkeypatch)
|
|
service = StrategyTemplateService()
|
|
engine = StrategySelectionEngine(template_service=service)
|
|
|
|
strategies = engine.compare_all_strategies()
|
|
|
|
assert [item["name"] for item in strategies[:5]] == [
|
|
"protective_put_atm",
|
|
"protective_put_otm_95",
|
|
"protective_put_otm_90",
|
|
"laddered_put_50_50_atm_otm95",
|
|
"laddered_put_33_33_33_atm_otm95_otm90",
|
|
]
|
|
|
|
|
|
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",
|
|
"Protective Put 95%",
|
|
"Protective Put 90%",
|
|
"Laddered Puts 50/50 ATM + 95%",
|
|
"Laddered Puts 33/33/33 ATM + 95% + 90%",
|
|
]
|
|
assert [item["name"] for item in catalog] == [
|
|
"protective_put_atm",
|
|
"protective_put_otm_95",
|
|
"protective_put_otm_90",
|
|
"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
|