Paper Venue
In-memory exchange with tick-based matching, fees, and slippage
Paper is Horizon’s in-memory exchange. It supports limit and market orders, matches resting limits on tick crosses, applies fees and slippage, and tracks positions and cash. everything you need for backtest and paper-mode testing.
Import
from horizon.venues import Paper
Signature
Paper(
initial_cash_usd: float = 100_000.0,
supported_classes: list[AssetClass] | None = None,
fee_bps: float = 0.0,
slippage_bps: float = 0.0,
bar_fill_mode: str = "cross", # "cross" | "touch"
)
initial_cash_usdfloatsupported_classeslist | Nonefee_bpsfloatslippage_bpsfloatbar_fill_modestrTick-based matching
The critical method is tick(market_id, price, timestamp):
paper.tick("AAPL", 99.0, datetime(2024, 1, 1))
What happens:
_last_prices["AAPL"] = 99.0- For every resting limit order on AAPL:
- If buy limit AND price ≤ limit → fill at limit
- If sell limit AND price ≥ limit → fill at limit
- Returns a list of new
VenueFillevents
Market orders fill immediately at submit time using the last known price plus slippage. Limit orders rest until a crossing tick arrives.
Usage
from horizon.venues import Paper
from horizon.types import OrderAction, Urgency
paper = Paper(initial_cash_usd=100_000, fee_bps=10, slippage_bps=5)
paper.connect()
# Submit a limit order
order = paper.submit(OrderAction.place(
market_id="AAPL",
side="buy",
quantity=10,
price=180.0,
order_type="limit",
))
print(order.status) # "new". resting
# Tick with a crossing price
fills = paper.tick("AAPL", 179.5, datetime.now())
print(len(fills)) # 1. filled at 180.0 (the limit)
# Check state
print(paper.cash()) # 100_000 - 10*180 - fee
print(paper.positions()) # [VenuePosition(AAPL, qty=10, avg_cost=180)]
print(paper.balance()) # VenueCapital with equity, cash, buying_power
Market orders
# Establish a last price first
paper.tick("AAPL", 180.0)
order = paper.submit(OrderAction.place(
market_id="AAPL",
side="buy",
quantity=10,
order_type="market",
urgency=Urgency.Immediate,
))
# Fills immediately at 180 + slippage
print(order.status) # "filled"
Market orders with no prior tick are rejected cleanly (status="rejected").
Cancel + amend
order = paper.submit(OrderAction.place(market_id="AAPL", side="buy", quantity=10, price=180, order_type="limit"))
# Cancel
paper.cancel(order.id)
# Or amend before filling
updated = paper.amend(order.id, new_quantity=20, new_price=181.0)
# Cancel all on a market
paper.cancel_all("AAPL")
# Cancel all on the venue
paper.cancel_all()
Fills
Use drain_fills() to retrieve fills since the last drain call:
fills = paper.drain_fills() # returns list[VenueFill], empties the queue
for f in fills:
print(f.market_id, f.side, f.quantity, f.price, f.fee)
The backtest loop calls drain_fills() implicitly after every tick to route fills into the ledger.
Cash + P&L tracking
The Paper venue maintains its own cash balance internally:
paper.cash() # current cash
paper.realized_pnl() # cumulative realized P&L
paper.balance() # full VenueCapital with equity, buying power, used BP
Tests
16 tests in tests/test_paper_venue.py:
def test_limit_fills_when_price_crosses(self):
p = Paper(initial_cash_usd=100_000)
p.connect()
p.submit(_buy_limit(qty=10, price=100))
fills = p.tick("AAPL", 99.0, datetime(2024, 1, 1))
assert len(fills) == 1
assert p.positions()[0].quantity == 10
def test_limit_does_not_fill_above_for_buy(self):
p = Paper(initial_cash_usd=100_000)
p.connect()
p.submit(_buy_limit(qty=10, price=100))
p.tick("AAPL", 101.0) # above limit. no fill
assert len(p.open_orders()) == 1
def test_market_order_fills_at_last_price(self):
p = Paper(initial_cash_usd=100_000)
p.connect()
p.tick("AAPL", 100.0)
order = p.submit(_buy_market(qty=10))
assert order.status == "filled"
Covers limit matching on cross, market fills, rejects without prior tick, slippage, cancel, amend, cash tracking, fee application, and drain semantics.
Source
horizon/venues/paper.py. ~280 lines. Pure Python, no dependencies.