Custom Feature

Write your own indicator and plug it into a strategy

Custom features are just subclasses of Feature. This example shows a MaxDrawdown feature (largest peak-to-trough drop over a rolling window. used inside a mean-reversion strategy.

The file

python
# custom_feature.py
import math

import horizon as hz
from horizon import Signal, Strategy
from horizon.asset_classes import AssetClass, Equity
from horizon.context import FeedData
from horizon.data import SyntheticGBM
from horizon.discovery import StaticUniverse
from horizon.discovery.base import Market
from horizon.features import Zscore
from horizon.features.base import Feature, PriceHistory
from horizon.portfolio import KellyOptimizer
from horizon.risk import RiskProfile


# -----------------------------------------------------------------------------
# Custom feature
# -----------------------------------------------------------------------------
class MaxDrawdown(Feature):
    """Largest peak-to-trough drop over the last `window` bars.

    Returns a value in [0, 1]. `0` means no drawdown, `0.5` means 50% drawdown.
    """

    def __init__(self, window: int = 20, market: str | None = None):
        super().__init__(market=market)
        if window < 2:
            raise ValueError(f"window must be >= 2, got {window}")
        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:
                dd = (peak - p) / peak
                max_dd = max(max_dd, dd)
        return max_dd


# -----------------------------------------------------------------------------
# Strategy using the custom feature
# -----------------------------------------------------------------------------
class DrawdownAwareMeanReversion(Strategy):
    """Mean reversion, but only on names with meaningful recent drawdown.

    Rationale: a name that just fell 10% is more likely to mean-revert than
    a name that's been flat. We filter the universe by recent drawdown and
    then apply a standard z-score-based mean reversion.
    """

    name = "dd_aware_mr"
    asset_classes = [Equity]
    features = {
        "dd": MaxDrawdown(window=20),
        "z": Zscore(window=10),
    }

    def evaluate(self, f, universe):
        return [
            Signal.from_score(
                m,
                score=-f.z[m.id],       # negative z → long
                edge_per_stdev=25,
                horizon="3d",
                reason=f"dd={f.dd[m.id]:.2%} z={f.z[m.id]:.2f}",
            )
            for m in universe
            if not math.isnan(f.dd[m.id])
            and f.dd[m.id] > 0.05            # recent drawdown > 5%
            and abs(f.z[m.id]) > 1.5          # and an oversold z-score
        ]


# -----------------------------------------------------------------------------
# Run
# -----------------------------------------------------------------------------
def main():
    universe = StaticUniverse([
        Market(id=t, asset_class=AssetClass.Equity)
        for t in ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"]
    ])

    data = SyntheticGBM(
        market_ids=["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"],
        n_bars=252,
        mu=0.08,
        sigma=0.25,           # higher vol → more drawdowns
        seed=42,
    )

    result = hz.run(
        mode="backtest",
        strategies=[DrawdownAwareMeanReversion],
        asset_classes=[Equity],
        universe=universe,
        portfolio=KellyOptimizer(kelly_fraction=0.25, max_gross_leverage=1.0),
        risk=RiskProfile.moderate(),
        data_source=data,
        backtest=hz.BacktestConfig(initial_cash_usd=100_000),
    )

    print(f"Trades:       {result.n_trades}")
    print(f"Total return: {result.total_return:+.2%}")
    print(f"Sharpe:       {result.sharpe:+.3f}")
    print(f"Max drawdown: {result.max_drawdown:.2%}")


if __name__ == "__main__":
    main()

What this shows

Subclass Feature

Implement compute(market_id, history, feeds) -> float. Return NaN when history is insufficient.

Declare the feature in a Strategy

Add it to the features dict with a name. The engine auto-caches and memoizes it.

Read it in evaluate()

Access via f.dd[market.id] like any built-in feature.

No registration required

Custom features just work. The FeatureStore doesn't need to know about them in advance.

Why this feature matters

Classic mean reversion (z-score only) fires on every noise move. Filtering by recent drawdown restricts it to names that actually moved significantly. higher hit rate per trade.

Run it

bash
PYTHONPATH=. python3 custom_feature.py

Next