"""Strategy SDK. Lifecycle (identical in backtest, paper and live engines): 1. `features(df)` — causal feature computation on closed bars (row t may only use rows <= t) 2. `target(feats)` — target exposure in [0, 1] (spot: no shorting unless the exchange account supports it) decided AT THE CLOSE of bar t and executable no earlier than the OPEN of bar t+1 3. `uncertainty(feats)`— optional per-bar confidence/uncertainty (used for sizing & reporting) 4. risk gating + execution are done by the engine, never by the strategy. Causality is enforced by tests (`tests/test_no_lookahead.py`): for every registered strategy, targets computed on a truncated history must equal targets computed on the full history, bar for bar. """ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Any import numpy as np import pandas as pd from pydantic import BaseModel class ParamSpec(BaseModel): name: str type: str = "int" # int | float | bool | choice default: Any min: float | None = None max: float | None = None step: float | None = None choices: list[Any] | None = None description: str = "" class StrategyMeta(BaseModel): name: str label: str family: str hypothesis: str universe_note: str = "Kraken spot pairs in the configured universe" experimental: bool = True allows_short: bool = False min_bars: int = 50 params: list[ParamSpec] = [] failure_modes: list[str] = [] references: list[str] = [] @dataclass class StrategyResult: features: pd.DataFrame target: pd.Series # exposure fraction in [0,1] (or [-1,1] if shorting allowed), index-aligned with features uncertainty: pd.Series | None = None diagnostics: dict[str, Any] = field(default_factory=dict) class Strategy(ABC): meta: StrategyMeta def __init__(self, params: dict[str, Any] | None = None) -> None: self.params = self.validate_params(params or {}) @classmethod def validate_params(cls, params: dict[str, Any]) -> dict[str, Any]: out: dict[str, Any] = {} for spec in cls.meta.params: v = params.get(spec.name, spec.default) if spec.type == "int": v = int(v) elif spec.type == "float": v = float(v) elif spec.type == "bool": v = bool(v) if not isinstance(v, str) else v.lower() in ("1", "true", "yes") elif spec.type == "choice" and spec.choices is not None and v not in spec.choices: raise ValueError(f"{spec.name}: {v!r} not in {spec.choices}") if spec.min is not None and isinstance(v, int | float) and v < spec.min: raise ValueError(f"{spec.name}: {v} < min {spec.min}") if spec.max is not None and isinstance(v, int | float) and v > spec.max: raise ValueError(f"{spec.name}: {v} > max {spec.max}") out[spec.name] = v unknown = set(params) - {s.name for s in cls.meta.params} if unknown: raise ValueError(f"unknown params: {sorted(unknown)}") return out @abstractmethod def features(self, df: pd.DataFrame) -> pd.DataFrame: ... @abstractmethod def target(self, feats: pd.DataFrame) -> pd.Series: ... def uncertainty(self, feats: pd.DataFrame) -> pd.Series | None: return None def run(self, df: pd.DataFrame) -> StrategyResult: feats = self.features(df) tgt = self.target(feats).astype(float) lo = -1.0 if self.meta.allows_short else 0.0 tgt = tgt.clip(lo, 1.0).fillna(0.0) return StrategyResult(features=feats, target=tgt, uncertainty=self.uncertainty(feats)) def log_returns(close: pd.Series) -> pd.Series: return np.log(close).diff() def vol_target_scale(ret: pd.Series, lookback: int, target_annual_vol: float, bars_per_year: float, cap: float = 1.0) -> pd.Series: """Position scale so that realized vol ≈ target; uses only trailing returns (shifted to be causal at bar close).""" rv = ret.rolling(lookback, min_periods=max(5, lookback // 2)).std(ddof=1) * np.sqrt(bars_per_year) scale = (target_annual_vol / rv.replace(0, np.nan)).clip(upper=cap) return scale.fillna(0.0)