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

View File

@@ -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: