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.

python
# 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") (needs POLYGON_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.

python
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.

python
(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.

python
(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).

python
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.

python
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.

python
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:

python
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:

python
(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:

TIFMeaningWhen to use
"day"Cancel at end of session (default)Normal trading
"gtc"Good til canceledResting orders you want to persist
"ioc"Immediate or cancelFill what you can now, cancel the rest
"fok"Fill or killAll shares or nothing

When placing manual orders:

python
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:

  1. Close: sell 100 shares (flatten the long)
  2. 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

python
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

python
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

python
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:

python
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

python
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.")

Next