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
from horizon.features import (
Price,
Return,
Zscore,
RealizedVol,
SMA,
EMA,
RSI,
BollingerZ,
MovingAverageCross,
)
How strategies use them
Strategies declare features in a class dict:
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:
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
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
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:
@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_lenis exceeded. Default 1000; configurable perFeatureStore. - 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:
>>> 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:
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:
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:
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():
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:
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:
Zscoreon flat prices → exactly 0.0Zscoreon upward spike → > 3 stddevsRealizedVolon known-vol returns → matches analytical answer within toleranceRSIon all-up series → > 95RSIon all-down series → under 5MovingAverageCrosson uptrend → positiveMovingAverageCrosson downtrend → negative- …
Run PYTHONPATH=. python3 -m pytest tests/test_features.py -v to see them all.