Features Overview

The feature library: rolling-history indicators your strategies read

Features are the inputs to strategies. Each feature is a parameterized class that reads a rolling price history and computes a scalar value. The FeatureStore holds the histories and memoizes computations per tick.

At a glance

Quick import

python
from horizon.features import (
    Price,
    Return,
    Zscore,
    RealizedVol,
    SMA,
    EMA,
    RSI,
    BollingerZ,
    MovingAverageCross,
)

How strategies use them

Strategies declare features in a class dict:

python
from horizon import Strategy
from horizon.features import Zscore, RealizedVol, RSI

class MyStrategy(Strategy):
    features = {
        "z": Zscore(window=20),
        "vol": RealizedVol(window=60),
        "rsi": RSI(window=14),
    }

    def evaluate(self, f, universe):
        for m in universe:
            z_value = f.z[m.id]
            vol_value = f.vol[m.id]
            rsi_value = f.rsi[m.id]
            ...

The keys ("z", "vol", "rsi") are your choice: they’re just the names you use to access each feature inside evaluate(). The values are Feature instances with their parameters baked in.

Per-market access

Every feature, accessed via f.name, returns a dict keyed by market id:

python
f.z["AAPL"]       # z-score for AAPL
f.z["MSFT"]       # z-score for MSFT
f.z[m.id]         # z-score for whatever market you're iterating

This lets a single strategy run on a whole universe without manually looping over features.

Feature computation flow

Per tick, update_feeds is called

FeatureStore.update_feeds(feeds, now) appends the new price to every market's PriceHistory. The per-tick cache is cleared.

For each strategy, compute its features

FeatureStore.compute(strat.features, universe_ids, feeds) runs every declared feature for every market in the strategy's universe.

Memoization within a tick

If two strategies both declare Zscore(20), it's computed once per market per tick. The cache key is (feature.key, market_id) where feature.key incorporates all parameters.

Return as FeatureNamespace

The result is a namespace accessed as f.name[market_id]. The strategy reads what it needs.

The PriceHistory

Every market has a PriceHistory: a rolling buffer of (price, log_return, timestamp) tuples:

python
@dataclass
class PriceHistory:
    max_len: int = 1000
    prices: list[float]
    log_returns: list[float]
    timestamps: list[datetime]

    def update(self, price: float, ts: datetime | None = None) -> None:
        """Append a new price; compute log return; trim to max_len."""

    def last_price(self) -> float | None
    def last_n_prices(self, n: int) -> list[float]
    def last_n_returns(self, n: int) -> list[float]

Key properties:

  • Bounded length: oldest entries drop when max_len is exceeded. Default 1000; configurable per FeatureStore.
  • Invalid data rejected. NaN, Inf, ≤0 prices are silently dropped before being appended. Your rolling windows don’t get poisoned.
  • Log returns computed on update: feature implementations can use either raw prices or log returns without recomputing each tick.

Feature key format

Every feature has a .key property used as the memoization key:

python
>>> Zscore(window=20).key
'Zscore:market=None:window=20'
>>> Zscore(window=30).key
'Zscore:market=None:window=30'
>>> Zscore(window=20, market='AAPL').key
'Zscore:market=AAPL:window=20'

The key incorporates all non-underscore attributes via introspection. Two feature instances with identical params produce the same key and share cache entries.

Cross-asset features

Every feature accepts an optional market parameter to pin it to a specific market id, regardless of the strategy’s current market:

python
class RecessionHedge(Strategy):
    asset_classes = [Equity]
    features = {
        "recession_prob": Price(market="polymarket:recession-2025"),  # pinned
        "spy_return": Return(window=5, market="SPY"),                   # pinned
        "my_z": Zscore(window=20),                                      # current market
    }

    def evaluate(self, f, universe):
        # Cross-asset gate
        if f.recession_prob["AAPL"] > 0.6:     # same value for every market
            return []
        ...

When market=None (default), the feature is computed for the current market in the iteration. When market="something", it’s always computed on that specific market’s history regardless of the strategy’s universe.

This is the asset-class-neutral way to reference prediction market probabilities, VIX levels, benchmark returns, or any other cross-asset signal.

Insufficient history

When history is shorter than the feature’s lookback window, compute() returns NaN. Strategies should check:

python
import math

def evaluate(self, f, universe):
    signals = []
    for m in universe:
        z = f.z[m.id]
        if math.isnan(z):
            continue   # not enough history yet
        if abs(z) > 2:
            ...
    return signals

Or use a .get()-style accessor with a default:

python
z = f.z.get(m.id, math.nan)
if not math.isnan(z) and abs(z) > 2:
    ...

Invalid feature results

Feature computations that raise exceptions return NaN silently (the store catches the exception in compute()). Strategies should always treat feature outputs as potentially NaN.

Writing your own

Subclass Feature and implement compute():

python
import math
from horizon.features.base import Feature, PriceHistory
from horizon.context import FeedData

class MaxDrawdownN(Feature):
    """Largest peak-to-trough drop over the last `window` bars."""

    def __init__(self, window: int = 20, market: str | None = None):
        super().__init__(market=market)
        self.window = window

    def compute(
        self,
        market_id: str,
        history: PriceHistory,
        feeds: dict[str, FeedData],
    ) -> float:
        prices = history.last_n_prices(self.window)
        if len(prices) < self.window:
            return float("nan")
        peak = prices[0]
        max_dd = 0.0
        for p in prices:
            peak = max(peak, p)
            if peak > 0:
                max_dd = max(max_dd, (peak - p) / peak)
        return max_dd

Use it immediately, no registration needed:

python
class AvoidDrawdownStrat(Strategy):
    features = {
        "dd20": MaxDrawdownN(window=20),
        "z": Zscore(window=10),
    }

    def evaluate(self, f, universe):
        return [
            Signal.increase(m, edge_bps=30)
            for m in universe
            if f.dd20[m.id] < 0.05      # low recent drawdown
            and f.z[m.id] < -1.5         # recently pulled back
        ]

See Custom features for more recipes.

Tests

21 unit tests in tests/test_features.py cover every built-in feature against hand-calculated expected values:

  • Zscore on flat prices → exactly 0.0
  • Zscore on upward spike → > 3 stddevs
  • RealizedVol on known-vol returns → matches analytical answer within tolerance
  • RSI on all-up series → > 95
  • RSI on all-down series → under 5
  • MovingAverageCross on uptrend → positive
  • MovingAverageCross on downtrend → negative

Run PYTHONPATH=. python3 -m pytest tests/test_features.py -v to see them all.

Next