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
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.
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:
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:
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.
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:
# 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
| Field | Required | What it means |
|---|---|---|
market_id | yes | Which market (filled by the constructor from market) |
direction | yes | Increase, Decrease, or Flatten |
confidence | yes | Probability you’re right, 0 to 1 |
expected_edge_bps | yes | Expected return in bps per $1 notional |
expected_vol_bps | yes | Expected std dev in bps |
horizon | yes | How long the edge lasts ("3d", "1h", timedelta(...)) |
reason | no | Human-readable note ("z=-2.4, strong MR signal") |
urgency | no | Immediate, Patient (default), or Passive |
preferred_notional_usd | no | Soft size hint the sizer may honor |
max_notional_usd | no | Hard cap on position size |
tags | no | Free-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.
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:
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.
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():
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:
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:
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.
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.
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
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.