Writing Strategies

Everything you can do inside evaluate(), and how it connects to the rest of the pipeline

A strategy is a class with one method: evaluate(). It receives features and markets, and returns a list of opinions (Signal). That’s the only contract. Everything else (sizing, risk, execution) happens downstream.

The minimal strategy

python
from horizon import Strategy, Signal
from horizon.asset_classes import Equity
from horizon.features import Zscore

class MyStrategy(Strategy):
    name = "my_strategy"
    asset_classes = [Equity]
    features = {"z": Zscore(window=20)}

    def evaluate(self, f, universe):
        signals = []
        for market in universe:
            z = f.z[market.id]
            if z < -2.0:
                signals.append(Signal.increase(market, confidence=0.7, edge_bps=30, expected_vol_bps=100, horizon="3d"))
        return signals

That’s a complete working strategy. Let’s break down exactly what you can use and how.


What evaluate() receives

f - your features

A namespace where each key is a feature name you declared in features = {...}. Access values by market id.

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

def evaluate(self, f, universe):
    z = f.z["AAPL"]       # float: the z-score for AAPL this tick
    vol = f.vol["AAPL"]   # float: realized vol for AAPL
    rsi = f.rsi["AAPL"]   # float: RSI for AAPL

If a feature can’t compute yet (not enough history), it returns NaN. Always check:

python
import math
if math.isnan(f.z[market.id]):
    continue  # skip this market

Available features: Price, Return, Zscore, RealizedVol, SMA, EMA, RSI, BollingerZ, MovingAverageCross. You can also write your own.

universe - the markets you can trade

A list of Market objects filtered to your declared asset_classes. Each market has:

python
for market in universe:
    market.id            # "AAPL", "BTC-USD", "TRUMP-2028-YES", etc.
    market.asset_class   # AssetClass.Equity, AssetClass.Prediction, etc.
    market.metadata      # dict with extra info (sector, market_cap, etc.)

ctx (optional third argument) - portfolio state

Add ctx as a third parameter to access your portfolio, positions, P&L, and drawdown. The engine detects this automatically.

python
def evaluate(self, f, universe, ctx):
    ctx.now                          # datetime: current tick timestamp
    ctx.portfolio.equity             # float: total portfolio value
    ctx.portfolio.cash               # float: available cash
    ctx.portfolio.drawdown_pct       # float: current drawdown from peak
    ctx.portfolio.peak_equity        # float: highest equity seen
    ctx.portfolio.n_positions        # int: open position count
    ctx.portfolio.gross_leverage     # float: gross notional / equity
    ctx.portfolio.realized_pnl_today # float: today's realized P&L
    ctx.portfolio.sharpe_30d         # float: rolling 30-day Sharpe
    ctx.portfolio.hit_rate_30d       # float: win rate over 30 days
    ctx.portfolio.has_position("AAPL")  # bool: do I hold AAPL?
    ctx.portfolio.positions          # list: all open positions
    ctx.portfolio.kill_switch_armed  # bool: is kill switch on?

What evaluate() returns

A list[Signal]. Zero signals is fine (no opinion this tick). Each signal is an opinion, not an order.

Signal constructors

Pick the one that matches your input:

python
# You know the direction and edge directly
Signal.increase(market, confidence=0.7, edge_bps=30, expected_vol_bps=100, horizon="3d")
Signal.decrease(market, confidence=0.6, edge_bps=20, expected_vol_bps=80, horizon="1d")
Signal.flatten(market)

# You have a score (e.g., z-score) and want direction inferred from sign
Signal.from_score(market, score=-2.4, edge_per_stdev=20, horizon="3d",
                  expected_expected_vol_bps=100)

# You're trading prediction markets and have a probability estimate
Signal.from_probability(market, fair_prob=0.72, current_price=0.55,
                        horizon="7d", confidence=0.8)

Signal fields you can set

FieldRequiredWhat it means
market_idyesWhich market (filled by the constructor from market)
directionyesIncrease, Decrease, or Flatten
confidenceyesProbability you’re right, 0 to 1
expected_edge_bpsyesExpected return in bps per $1 notional
expected_vol_bpsyesExpected std dev in bps
horizonyesHow long the edge lasts ("3d", "1h", timedelta(...))
reasonnoHuman-readable note ("z=-2.4, strong MR signal")
urgencynoImmediate, Patient (default), or Passive
preferred_notional_usdnoSoft size hint the sizer may honor
max_notional_usdnoHard cap on position size
tagsnoFree-form labels for post-hoc analysis

Using helper functions

Yes, you can define functions outside the class and call them from evaluate(). Normal Python applies.

python
def is_oversold(z, rsi):
    return z < -2.0 and rsi < 30

def compute_edge(z, vol):
    return abs(z) * 15  # 15 bps per unit of |z|

class MyStrategy(Strategy):
    name = "my_mr"
    asset_classes = [Equity]
    features = {"z": Zscore(20), "rsi": RSI(14), "vol": RealizedVol(60)}

    def evaluate(self, f, universe):
        signals = []
        for m in universe:
            if is_oversold(f.z[m.id], f.rsi[m.id]):
                signals.append(Signal.increase(
                    m,
                    confidence=0.7,
                    edge_bps=compute_edge(f.z[m.id], f.vol[m.id]),
                    expected_vol_bps=f.vol[m.id] * 100,
                    horizon="3d",
                    reason=f"z={f.z[m.id]:.1f} rsi={f.rsi[m.id]:.0f}",
                ))
        return signals

You can also import any library (numpy, scipy, sklearn) inside your functions.


Adding protection inside the strategy

Use ctx to add self-protection logic:

python
def evaluate(self, f, universe, ctx):
    # 1. Pause trading when in drawdown
    if ctx.portfolio.drawdown_pct > 0.05:
        return []

    # 2. Don't open new positions if leverage is high
    if ctx.portfolio.gross_leverage > 0.8:
        return []

    # 3. Skip markets you already hold
    signals = []
    for m in universe:
        if ctx.portfolio.has_position(m.id):
            continue
        # ... signal logic ...

    # 4. Limit position count
    room = 10 - ctx.portfolio.n_positions
    return signals[:room]

This is strategy-level protection. For portfolio-level protection (stop losses, drawdown guards, kill switch), use the risk config instead. Both work together.


Handling different asset classes

Declare which asset classes your strategy trades. The engine filters the universe automatically.

python
from horizon.asset_classes import Equity, Option, Prediction, Crypto

# Equities only
class EquityMR(Strategy):
    asset_classes = [Equity]

# Prediction markets only
class PredictionTrader(Strategy):
    asset_classes = [Prediction]

# Multiple asset classes
class CrossAsset(Strategy):
    asset_classes = [Equity, Prediction]

    def evaluate(self, f, universe):
        for m in universe:
            if m.asset_class == AssetClass.Equity:
                # equity-specific logic
            elif m.asset_class == AssetClass.Prediction:
                # prediction-specific logic

Your Signal uses Direction.Increase / Direction.Decrease regardless of asset class. The executor translates: Increase becomes “buy shares” for equities, “buy Yes” for predictions, “go long” for perps.


How the strategy connects to the portfolio

Your strategy returns signals. The pipeline hands them to the portfolio sizer. You choose the sizer when calling hz.run():

python
from horizon.portfolio import KellyOptimizer, CarverSystematic, EqualWeight

# Kelly: sizes based on edge/vol² from your signal
result = hz.run(
    strategies=[MyStrategy()],
    portfolio=KellyOptimizer(kelly_fraction=0.25),
    ...
)

# Carver: sizes based on vol targeting
result = hz.run(
    strategies=[MyStrategy()],
    portfolio=CarverSystematic(annual_vol_target=0.15),
    ...
)

# Equal weight: same dollar amount per signal (good baseline)
result = hz.run(
    strategies=[MyStrategy()],
    portfolio=EqualWeight(max_positions=20),
    ...
)

The sizer reads your signal’s edge_bps, vol_bps, and confidence to decide how much capital to allocate. Better signals get larger positions. The strategy never decides size directly.


Function-form shortcut

For quick experiments, skip the class:

python
from horizon import strategy, Signal
from horizon.asset_classes import Equity
from horizon.features import Zscore

@strategy(name="quick_mr", asset_classes=[Equity], features={"z": Zscore(20)})
def quick_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
    ]

Works exactly like a class. Pass it to hz.run(strategies=[quick_mr]).


Running multiple strategies

Pass a list. All signals go into the same signal store and the sizer sees them together:

python
hz.run(
    strategies=[
        MyMeanReversion(),
        TSMomentum(lookback=20),
        BollingerMeanRev(window=15),
    ],
    portfolio=KellyOptimizer(kelly_fraction=0.25),
    ...
)

If two strategies disagree on the same market (one says long, one says short), the sizer sees both signals and the net target will be small or zero. If they agree, the target is larger.

For weighted composition, use Ensemble.


Two ways to use Horizon

Pipeline mode (hz.run()): you write a strategy, pick a sizer, set risk config. The pipeline handles everything. This is the main way to use Horizon.

python
result = hz.run(
    mode="backtest",
    strategies=[MyStrategy()],
    portfolio=KellyOptimizer(kelly_fraction=0.25),
    risk=RiskProfile.moderate(),
    ...
)

Imperative mode (hz.connect()): you place orders manually. No strategy, no signals, no optimizer. Just buy/sell/flatten. Good for notebooks, one-off trades, or when you want full control.

python
with hz.connect("paper", initial_cash_usd=100_000) as ex:
    ex.buy("AAPL", qty=10, limit=185.0)
    ex.sell("AAPL", qty=5, market=True)
    print(ex.positions())
    print(ex.balance())
    ex.flatten_all()

See the Manual Orders code-along for the full walkthrough.


Testing

python
result = hz.run(
    mode="backtest",
    strategies=[MyStrategy()],
    asset_classes=[Equity],
    universe=StaticUniverse([Market(id="AAPL", asset_class=AssetClass.Equity)]),
    data_source=SyntheticGBM(["AAPL"], n_bars=200, seed=42),
    backtest=hz.BacktestConfig(initial_cash_usd=100_000),
)

assert result.n_trades > 0
assert result.max_drawdown < 0.30

Seeded data = deterministic = assertable.

Next