Strategy
Strategy base class and @strategy decorator
Strategy base class
from horizon import Strategy, Signal
from horizon.asset_classes import Equity
from horizon.features import Zscore
class MyStrategy(Strategy):
name = "my_strategy" # optional, defaults to class name
asset_classes: list[AssetClass] = [Equity] # required
features: dict[str, Feature] = {"z": Zscore(20)} # optional
def evaluate(
self,
features: FeatureNamespace,
universe: list[Market],
) -> list[Signal]:
...
Class attributes
name: str: strategy identifier. Tagged onto every signal and trade for attribution. Defaults to the class name.asset_classes: list[AssetClass]: which asset classes this strategy trades. Used by the engine to filter the universe each tick. Defaults to[].features: dict[str, Feature]: feature requirements. Each entry is a namedFeatureinstance. The engine computes them once per tick and passes the result asftoevaluate().
Required method
def evaluate(self, features, universe) -> list[Signal]:
...
features: aFeatureNamespacegiving per-market access by name:features.z[market.id].universe: the list ofMarketobjects eligible for this strategy (already filtered by asset class).
Returns a list of Signal objects. Zero signals is fine.
Optional third argument
def evaluate(self, features, universe, ctx: Context) -> list[Signal]:
if ctx.portfolio.drawdown_pct > 0.05:
return []
...
The engine detects the third argument’s presence automatically and injects the Context only when requested.
Optional hooks
def on_start(self, ctx: Context) -> None:
"""Called once when the run begins."""
def on_stop(self, ctx: Context) -> None:
"""Called once when the run ends."""
def eligible_markets(self, universe: list[Market]) -> list[Market]:
"""Narrow the universe further than asset-class filtering."""
return [m for m in universe if m.metadata.get("market_cap", 0) > 1e9]
State
Strategies can hold stateful fields declared as class attributes. State persists across ticks within one backtest run:
@dataclass
class KalmanState:
mean: float = 100.0
variance: float = 1.0
class KalmanMR(Strategy):
state: KalmanState = KalmanState()
def evaluate(self, f, universe):
self.state.mean = ... # update
return [...]
@strategy decorator
Function form for one-off strategies:
from horizon import strategy, Signal
from horizon.asset_classes import Equity
from horizon.features import Zscore
@strategy(
name="simple_mr",
asset_classes=[Equity],
features={"z": Zscore(20)},
)
def simple_mr(f, universe):
return [
Signal.from_score(m, score=-f.z[m.id], edge_per_stdev=15)
for m in universe
if abs(f.z[m.id]) < -2
]
The decorator returns a class (not an instance) that implements the Strategy protocol. Drops straight into hz.run(strategies=[simple_mr]).
Decorator signature
@strategy(
name: str | None = None,
asset_classes: list[AssetClass] | None = None,
features: dict[str, Feature] | None = None,
)
def my_strategy(f, universe):
...
The wrapped function can accept (f, universe) or (f, universe, ctx): the decorator detects the signature the same way as class strategies.
Signature detection
The engine looks at evaluate’s parameter list to decide whether to inject ctx:
def evaluate(self, f, universe): # no ctx injection
def evaluate(self, f, universe, ctx): # ctx injected
Detected via inspect.signature(evaluate_fn). No flags, no metadata.
Exception safety
If evaluate() raises an exception, the run loop catches it, logs it, and continues with the next strategy. Other strategies in the same run still get to evaluate their signals. See tests/test_behavioral_audit.py::test_loop_survives_bad_strategy_exception.
What the engine does for you
- Filters the universe by
asset_classes: you only see markets you care about - Computes features once per tick and passes them as a typed namespace
- Tags signals with
strategy_idautomatically before depositing in the signal store - Detects
ctxpresence inevaluate()and injects it only if requested - Catches exceptions so one broken strategy can’t kill the whole run
- Routes trades through the right asset-class executor based on market asset class