Modes & Execution
Backtest, paper, shadow, live. Plus order types, fill policies, and execution control.
The four modes
Horizon has one pipeline with four modes. The code stays the same. Only the data source and venue change.
backtest → synthetic or historical data → paper venue → BacktestResult
paper → live data feed → paper venue → runs until stopped
shadow → live data feed → real venue (log-only, no real orders)
live → live data feed → real venue (real money)
Backtest
Run a strategy against historical data. Get metrics at the end.
# With pipe builder
result = (hz.pipe("AAPL", "MSFT", "NVDA")
.strategy(BollingerMeanRev(window=20))
.kelly(fraction=0.25)
.stop_loss(pct=0.05)
.backtest(bars=252, cash=100_000)
)
print(result.sharpe)
print(result.max_drawdown)
print(result.equity_curve)
Data options:
- Synthetic (default): auto-generated, deterministic, no API key needed
- Yahoo:
.yahoo(start="2023-01-01", end="2024-01-01")(free) - Polygon:
.polygon(start="2023-01-01")(needsPOLYGON_API_KEY) - CSV / DataFrame:
.data(dp.csv("prices.csv"))or.data(dp.dataframe(df))
Paper
Same strategy, but against live market data with a simulated venue. No real money moves.
from horizon.data import providers as dp
(hz.pipe("AAPL", "MSFT")
.strategy(BollingerMeanRev(window=20))
.kelly(fraction=0.25)
.stop_loss(pct=0.05)
.yahoo(start="2024-01-01") # or a live feed
.paper(cash=100_000)
)
Paper mode is where you validate that your strategy works on real data before risking capital.
Shadow
Live data, real venue connection, but orders are logged only - not actually submitted. You see what the system would have done.
(hz.pipe("AAPL", "MSFT")
.strategy(BollingerMeanRev(window=20))
.kelly(fraction=0.25)
.run(mode="shadow", cash=100_000)
)
Live
Real money. Real orders. Real fills.
(hz.pipe("AAPL", "MSFT")
.strategy(BollingerMeanRev(window=20))
.kelly(fraction=0.25)
.stop_loss(pct=0.05)
.drawdown(daily=0.03, weekly=0.08, monthly=0.15)
.live(cash=100_000)
)
The progression
backtest → paper → shadow → live
↓ ↓ ↓ ↓
synthetic real data real venue real money
paper venue paper venue log-only real orders
Same strategy code at every step. The only things that change are the data source and the venue.
Order types
The executor converts your strategy’s signals into concrete orders. You control how via the urgency field on your signals and the executor configuration.
Market orders (immediate fill)
When a signal has urgency=Urgency.Immediate, the executor emits a market order. It fills at the current price (plus slippage in paper mode).
Signal.increase(market, edge_bps=50, expected_vol_bps=100, horizon="1d",
urgency=Urgency.Immediate) # → market order, fills now
Use for: emergency exits, stop-loss closes, time-sensitive entries.
Limit orders (patient fill)
Default behavior. The executor places a limit order at the current mid price (or last price). It fills when the market crosses your price.
Signal.increase(market, edge_bps=50, expected_vol_bps=100, horizon="3d",
urgency=Urgency.Patient) # → limit order at mid price (default)
Passive orders (post-only)
The executor places a limit order inside the spread, willing to miss if the market moves away.
Signal.increase(market, edge_bps=50, expected_vol_bps=100, horizon="5d",
urgency=Urgency.Passive) # → limit order, willing to miss
Configuring the executor
The EquityExecutor has options for limit price strategy:
from horizon.executors import EquityExecutor
executor = EquityExecutor(
min_shares=1.0, # don't place orders for < 1 share
allow_fractional=False, # round to whole shares
limit_price_strategy="mid", # "mid" | "last" | "aggressive"
)
# "mid": limit at bid-ask midpoint (default, patient)
# "last": limit at last traded price
# "aggressive": limit at the ask (for buys) - crosses the spread for fast fill
hz.run(executors={AssetClass.Equity: executor}, ...)
Or in the pipe builder:
(hz.pipe("AAPL")
.strategy(my_strat)
.run()
)
# The pipe uses EquityExecutor with defaults.
# For custom executors, drop down to hz.run().
Time-in-force
Orders support standard time-in-force policies via the OrderAction:
| TIF | Meaning | When to use |
|---|---|---|
"day" | Cancel at end of session (default) | Normal trading |
"gtc" | Good til canceled | Resting orders you want to persist |
"ioc" | Immediate or cancel | Fill what you can now, cancel the rest |
"fok" | Fill or kill | All shares or nothing |
When placing manual orders:
ex = hz.connect("paper", initial_cash_usd=100_000)
# Fill or Kill - all 100 shares or nothing
ex.place_order("AAPL", side="buy", quantity=100, price=185.0,
order_type="limit", time_in_force="fok")
# Immediate or Cancel - fill what you can, cancel rest
ex.place_order("AAPL", side="buy", quantity=100, price=185.0,
order_type="limit", time_in_force="ioc")
# Good til canceled
ex.buy("AAPL", qty=50, limit=180.0, time_in_force="gtc")
Flip-through-zero protection
When a strategy flips direction (e.g., from long 100 shares to short 50), the executor automatically splits it into two orders:
- Close: sell 100 shares (flatten the long)
- Open: sell 50 shares (open the short)
This prevents a single large order from crossing through zero, which can cause fill-quality issues on real exchanges.
Ensuring fills on equities
In live trading, limit orders can miss. Here are the patterns for ensuring you get filled:
Use Immediate urgency for critical orders
def evaluate(self, f, universe, ctx):
# If drawdown is > 5%, exit immediately - don't wait for a limit fill
if ctx.portfolio.drawdown_pct > 0.05:
return [Signal.flatten(m, urgency=Urgency.Immediate)
for m in universe if ctx.portfolio.has_position(m.id)]
# Normal entries can be patient
return [Signal.increase(m, edge_bps=30, expected_vol_bps=100, horizon="3d",
urgency=Urgency.Patient)
for m in universe if f.z[m.id] < -2]
Use aggressive limit pricing
executor = EquityExecutor(limit_price_strategy="aggressive")
# Buys at the ask, sells at the bid - crosses the spread for immediate fill
# Still a limit order (won't fill worse than your price), but practically fills instantly
Manual FOK for large orders
with hz.connect("paper", initial_cash_usd=1_000_000) as ex:
order = ex.place_order("AAPL", side="buy", quantity=500, price=186.0,
order_type="limit", time_in_force="fok")
if order.status == "rejected":
# FOK rejected - not enough liquidity at that price
# Try at a wider price or smaller size
ex.buy("AAPL", qty=500, market=True) # fall back to market
The reduce_only flag
For closing positions, set reduce_only=True to prevent accidentally opening a new position in the opposite direction:
ex.place_order("AAPL", side="sell", quantity=100,
order_type="market", reduce_only=True)
# If you only hold 80 shares, this sells 80 (not 100)
Full example: backtest → validate → paper
import horizon as hz
from horizon.quant import BollingerMeanRev
from horizon.data import providers as dp
from horizon.validate import Bootstrap
# Step 1: Backtest on historical data
result = (hz.pipe("AAPL", "MSFT", "NVDA", "GOOGL", "AMZN")
.strategy(BollingerMeanRev(window=20, entry_z=2.0))
.kelly(fraction=0.25)
.stop_loss(pct=0.05)
.drawdown(daily=0.03, weekly=0.08, monthly=0.15)
.yahoo(start="2022-01-01", end="2024-01-01")
.backtest(cash=100_000)
)
print(f"Backtest Sharpe: {result.sharpe:+.3f}")
print(f"Max DD: {result.max_drawdown:.2%}")
# Step 2: Validate
equities = [e for _, e in result.equity_curve]
returns = [(equities[i] / equities[i-1]) - 1 for i in range(1, len(equities))]
bs = Bootstrap(metrics=["sharpe"], n_samples=500, seed=42)
bs_result = bs.run(returns=returns)
lo, hi = bs_result.ci("sharpe", conf=0.95)
print(f"Sharpe 95% CI: [{lo:+.3f}, {hi:+.3f}]")
if lo > 0:
print("Signal has edge. Proceed to paper trading.")
# Step 3: Paper trade on recent data
(hz.pipe("AAPL", "MSFT", "NVDA", "GOOGL", "AMZN")
.strategy(BollingerMeanRev(window=20, entry_z=2.0))
.kelly(fraction=0.25)
.stop_loss(pct=0.05)
.drawdown(daily=0.03, weekly=0.08, monthly=0.15)
.yahoo(start="2024-01-01")
.paper(cash=100_000)
)
else:
print("No statistical edge. Don't deploy.")