feat(EXEC-001A): add named strategy templates
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
from .option import Greeks, OptionContract, OptionMoneyness
|
||||
from .portfolio import LombardPortfolio
|
||||
from .strategy import HedgingStrategy, ScenarioResult, StrategyType
|
||||
from .strategy_template import EntryPolicy, RollPolicy, StrategyTemplate, TemplateLeg
|
||||
|
||||
__all__ = [
|
||||
"Greeks",
|
||||
@@ -12,4 +13,8 @@ __all__ = [
|
||||
"OptionMoneyness",
|
||||
"ScenarioResult",
|
||||
"StrategyType",
|
||||
"StrategyTemplate",
|
||||
"TemplateLeg",
|
||||
"RollPolicy",
|
||||
"EntryPolicy",
|
||||
]
|
||||
|
||||
309
app/models/strategy_template.py
Normal file
309
app/models/strategy_template.py
Normal file
@@ -0,0 +1,309 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
StrategyTemplateKind = Literal["protective_put", "laddered_put"]
|
||||
StrategyTemplateStatus = Literal["draft", "active", "archived"]
|
||||
ContractMode = Literal["continuous_units", "listed_contracts"]
|
||||
LegSide = Literal["long", "short"]
|
||||
LegOptionType = Literal["put", "call"]
|
||||
StrikeRuleType = Literal["spot_pct"]
|
||||
QuantityRule = Literal["target_coverage_pct"]
|
||||
RollPolicyType = Literal["hold_to_expiry", "roll_n_days_before_expiry"]
|
||||
EntryTiming = Literal["scenario_start_close"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StrikeRule:
|
||||
rule_type: StrikeRuleType
|
||||
value: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.rule_type != "spot_pct":
|
||||
raise ValueError("unsupported strike rule")
|
||||
if self.value <= 0:
|
||||
raise ValueError("strike rule value must be positive")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"rule_type": self.rule_type, "value": self.value}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> StrikeRule:
|
||||
return cls(rule_type=payload["rule_type"], value=float(payload["value"]))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TemplateLeg:
|
||||
leg_id: str
|
||||
side: LegSide
|
||||
option_type: LegOptionType
|
||||
allocation_weight: float
|
||||
strike_rule: StrikeRule
|
||||
target_expiry_days: int
|
||||
quantity_rule: QuantityRule
|
||||
target_coverage_pct: float = 1.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.side not in {"long", "short"}:
|
||||
raise ValueError("unsupported leg side")
|
||||
if self.option_type not in {"put", "call"}:
|
||||
raise ValueError("unsupported option type")
|
||||
if self.allocation_weight <= 0:
|
||||
raise ValueError("allocation_weight must be positive")
|
||||
if self.target_expiry_days <= 0:
|
||||
raise ValueError("target_expiry_days must be positive")
|
||||
if self.quantity_rule != "target_coverage_pct":
|
||||
raise ValueError("unsupported quantity rule")
|
||||
if self.target_coverage_pct <= 0:
|
||||
raise ValueError("target_coverage_pct must be positive")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"leg_id": self.leg_id,
|
||||
"side": self.side,
|
||||
"option_type": self.option_type,
|
||||
"allocation_weight": self.allocation_weight,
|
||||
"strike_rule": self.strike_rule.to_dict(),
|
||||
"target_expiry_days": self.target_expiry_days,
|
||||
"quantity_rule": self.quantity_rule,
|
||||
"target_coverage_pct": self.target_coverage_pct,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> TemplateLeg:
|
||||
return cls(
|
||||
leg_id=payload["leg_id"],
|
||||
side=payload["side"],
|
||||
option_type=payload["option_type"],
|
||||
allocation_weight=float(payload["allocation_weight"]),
|
||||
strike_rule=StrikeRule.from_dict(payload["strike_rule"]),
|
||||
target_expiry_days=int(payload["target_expiry_days"]),
|
||||
quantity_rule=payload.get("quantity_rule", "target_coverage_pct"),
|
||||
target_coverage_pct=float(payload.get("target_coverage_pct", 1.0)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RollPolicy:
|
||||
policy_type: RollPolicyType
|
||||
days_before_expiry: int | None = None
|
||||
rebalance_on_new_deposit: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.policy_type not in {"hold_to_expiry", "roll_n_days_before_expiry"}:
|
||||
raise ValueError("unsupported roll policy")
|
||||
if self.policy_type == "roll_n_days_before_expiry" and (self.days_before_expiry or 0) <= 0:
|
||||
raise ValueError("days_before_expiry is required for rolling policies")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"policy_type": self.policy_type,
|
||||
"days_before_expiry": self.days_before_expiry,
|
||||
"rebalance_on_new_deposit": self.rebalance_on_new_deposit,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> RollPolicy:
|
||||
return cls(
|
||||
policy_type=payload["policy_type"],
|
||||
days_before_expiry=payload.get("days_before_expiry"),
|
||||
rebalance_on_new_deposit=bool(payload.get("rebalance_on_new_deposit", False)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EntryPolicy:
|
||||
entry_timing: EntryTiming
|
||||
stagger_days: int | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.entry_timing != "scenario_start_close":
|
||||
raise ValueError("unsupported entry timing")
|
||||
if self.stagger_days is not None and self.stagger_days < 0:
|
||||
raise ValueError("stagger_days must be non-negative")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"entry_timing": self.entry_timing, "stagger_days": self.stagger_days}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> EntryPolicy:
|
||||
return cls(entry_timing=payload["entry_timing"], stagger_days=payload.get("stagger_days"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StrategyTemplate:
|
||||
template_id: str
|
||||
slug: str
|
||||
display_name: str
|
||||
description: str
|
||||
template_kind: StrategyTemplateKind
|
||||
status: StrategyTemplateStatus
|
||||
version: int
|
||||
underlying_symbol: str
|
||||
contract_mode: ContractMode
|
||||
legs: tuple[TemplateLeg, ...]
|
||||
roll_policy: RollPolicy
|
||||
entry_policy: EntryPolicy
|
||||
tags: tuple[str, ...] = field(default_factory=tuple)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.template_kind not in {"protective_put", "laddered_put"}:
|
||||
raise ValueError("unsupported template_kind")
|
||||
if self.status not in {"draft", "active", "archived"}:
|
||||
raise ValueError("unsupported template status")
|
||||
if self.contract_mode not in {"continuous_units", "listed_contracts"}:
|
||||
raise ValueError("unsupported contract mode")
|
||||
if not self.slug:
|
||||
raise ValueError("slug is required")
|
||||
if not self.display_name:
|
||||
raise ValueError("display_name is required")
|
||||
if self.version <= 0:
|
||||
raise ValueError("version must be positive")
|
||||
if not self.legs:
|
||||
raise ValueError("at least one template leg is required")
|
||||
if self.template_kind in {"protective_put", "laddered_put"}:
|
||||
if any(leg.side != "long" or leg.option_type != "put" for leg in self.legs):
|
||||
raise ValueError("put templates support only long put legs")
|
||||
total_weight = sum(leg.allocation_weight for leg in self.legs)
|
||||
if abs(total_weight - 1.0) > 1e-9:
|
||||
raise ValueError("weights must sum to 1.0")
|
||||
expiry_days = {leg.target_expiry_days for leg in self.legs}
|
||||
if len(expiry_days) != 1:
|
||||
raise ValueError("all template legs must share target_expiry_days in MVP")
|
||||
|
||||
@property
|
||||
def target_expiry_days(self) -> int:
|
||||
return self.legs[0].target_expiry_days
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"template_id": self.template_id,
|
||||
"slug": self.slug,
|
||||
"display_name": self.display_name,
|
||||
"description": self.description,
|
||||
"template_kind": self.template_kind,
|
||||
"status": self.status,
|
||||
"version": self.version,
|
||||
"underlying_symbol": self.underlying_symbol,
|
||||
"contract_mode": self.contract_mode,
|
||||
"legs": [leg.to_dict() for leg in self.legs],
|
||||
"roll_policy": self.roll_policy.to_dict(),
|
||||
"entry_policy": self.entry_policy.to_dict(),
|
||||
"tags": list(self.tags),
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> StrategyTemplate:
|
||||
return cls(
|
||||
template_id=payload["template_id"],
|
||||
slug=payload["slug"],
|
||||
display_name=payload["display_name"],
|
||||
description=payload.get("description", ""),
|
||||
template_kind=payload["template_kind"],
|
||||
status=payload.get("status", "active"),
|
||||
version=int(payload.get("version", 1)),
|
||||
underlying_symbol=payload.get("underlying_symbol", "GLD"),
|
||||
contract_mode=payload.get("contract_mode", "continuous_units"),
|
||||
legs=tuple(TemplateLeg.from_dict(leg) for leg in payload["legs"]),
|
||||
roll_policy=RollPolicy.from_dict(payload["roll_policy"]),
|
||||
entry_policy=EntryPolicy.from_dict(payload["entry_policy"]),
|
||||
tags=tuple(payload.get("tags", [])),
|
||||
created_at=datetime.fromisoformat(payload["created_at"]),
|
||||
updated_at=datetime.fromisoformat(payload["updated_at"]),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def protective_put(
|
||||
cls,
|
||||
*,
|
||||
template_id: str,
|
||||
slug: str,
|
||||
display_name: str,
|
||||
description: str,
|
||||
strike_pct: float,
|
||||
target_expiry_days: int,
|
||||
underlying_symbol: str = "GLD",
|
||||
tags: tuple[str, ...] = (),
|
||||
) -> StrategyTemplate:
|
||||
now = datetime.now(timezone.utc)
|
||||
return cls(
|
||||
template_id=template_id,
|
||||
slug=slug,
|
||||
display_name=display_name,
|
||||
description=description,
|
||||
template_kind="protective_put",
|
||||
status="active",
|
||||
version=1,
|
||||
underlying_symbol=underlying_symbol,
|
||||
contract_mode="continuous_units",
|
||||
legs=(
|
||||
TemplateLeg(
|
||||
leg_id=f"{template_id}-leg-1",
|
||||
side="long",
|
||||
option_type="put",
|
||||
allocation_weight=1.0,
|
||||
strike_rule=StrikeRule(rule_type="spot_pct", value=strike_pct),
|
||||
target_expiry_days=target_expiry_days,
|
||||
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"),
|
||||
tags=tags,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def laddered_put(
|
||||
cls,
|
||||
*,
|
||||
template_id: str,
|
||||
slug: str,
|
||||
display_name: str,
|
||||
description: str,
|
||||
strike_pcts: tuple[float, ...],
|
||||
weights: tuple[float, ...],
|
||||
target_expiry_days: int,
|
||||
underlying_symbol: str = "GLD",
|
||||
tags: tuple[str, ...] = (),
|
||||
) -> StrategyTemplate:
|
||||
if len(strike_pcts) != len(weights):
|
||||
raise ValueError("strike_pcts and weights must have the same length")
|
||||
now = datetime.now(timezone.utc)
|
||||
return cls(
|
||||
template_id=template_id,
|
||||
slug=slug,
|
||||
display_name=display_name,
|
||||
description=description,
|
||||
template_kind="laddered_put",
|
||||
status="active",
|
||||
version=1,
|
||||
underlying_symbol=underlying_symbol,
|
||||
contract_mode="continuous_units",
|
||||
legs=tuple(
|
||||
TemplateLeg(
|
||||
leg_id=f"{template_id}-leg-{index}",
|
||||
side="long",
|
||||
option_type="put",
|
||||
allocation_weight=weight,
|
||||
strike_rule=StrikeRule(rule_type="spot_pct", value=strike_pct),
|
||||
target_expiry_days=target_expiry_days,
|
||||
quantity_rule="target_coverage_pct",
|
||||
target_coverage_pct=1.0,
|
||||
)
|
||||
for index, (strike_pct, weight) in enumerate(zip(strike_pcts, weights, strict=True), start=1)
|
||||
),
|
||||
roll_policy=RollPolicy(policy_type="hold_to_expiry"),
|
||||
entry_policy=EntryPolicy(entry_timing="scenario_start_close"),
|
||||
tags=tags,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
Reference in New Issue
Block a user