Skip to content

Paper trading with PaperBroker

PaperBroker runs your strategy against a live market data feed but routes orders to a SimulatedExecutor instead of a real exchange. The fill model is the same one backtests use: configurable slippage, queue position tracking, top-of-book or full-depth modes. A strategy that survives backtest gets to rehearse against current market conditions before any real capital is at stake.

Install

pip install lrvx

If you also want to drive the broker off a live ccxt feed, add [ccxt]:

pip install "lrvx[ccxt]"

The broker itself does not depend on ccxt; it is feed-agnostic.

Quick start

import lrvx
from lrvx.paper import PaperBroker

registry = lrvx.SymbolRegistry()
sym = registry.add_symbol("paper", "BTCUSDT", tick_size=0.01)

broker = PaperBroker(
    registry=registry,
    default_slippage_model="fixed_bps",
    default_slippage_params={"bps": 5.0},  # 5 bps against the taker
)

class MyStrategy(lrvx.Strategy):
    def on_trade(self, ctx, trade):
        if ctx.is_flat():
            self.market_buy(0.01)

broker.runner.add_strategy(MyStrategy([int(sym)]))
broker.start()

# In your feed loop, forward each trade:
for ts_ns, price, qty, is_buy in your_live_feed():
    broker.observe_trade(int(sym), price, qty, is_buy, ts_ns)

broker.stop()
print(broker.fills_list())

How the wiring works

your feed ──► broker.observe_trade ──┬─► runner.on_trade ──► strategy
                                     │
                                     └─► sim.on_trade_qty (queue tracker)

strategy ──► self.market_buy() ──► broker._route_signal ──► sim.submit_order
                                                                  │
                                                                  ▼
                                                                fill

Every observed trade goes both into the runner (so strategies see it) and into the simulator (so its queue tracker stays current). When the strategy emits a signal, the broker's signal callback maps it to SimulatedExecutor.submit_order. No network call, no exchange. The fill comes back through sim.fills_list() once the simulator's matching engine settles it against subsequent observed trades.

Slippage and queue model

PaperBroker exposes the same slippage / queue knobs the backtest engine uses. Defaults are:

  • Slippage: none
  • Queue model: whatever SimulatedExecutor's default is (typically TOB)

Override per-broker at construction:

broker = PaperBroker(
    registry=registry,
    default_slippage_model="volume_impact",
    default_slippage_params={"impact_coeff": 0.002},
)

Or per-symbol after construction:

broker.set_symbol_slippage(int(sym), "fixed_bps", bps=10.0)

What works, what does not

Routed to the simulator:

  • MARKET (market_buy, market_sell)
  • LIMIT (limit_buy, limit_sell)
  • CANCEL for a known order_id
  • CANCEL_ALL for a symbol

Not routed (yet). These signal types fall through to the user's optional on_signal callback so you can log or approximate them externally:

  • STOP_MARKET / STOP_LIMIT
  • TAKE_PROFIT_MARKET / TAKE_PROFIT_LIMIT
  • TRAILING_STOP / TRAILING_STOP_PERCENT
  • CLOSE_POSITION
  • MODIFY

The gap is in the broker, not the simulator. SimulatedExecutor.submit_order already accepts stop_market, stop_limit, tp_market, tp_limit, and trailing_stop with trigger / trailing_offset / trailing_bps kwargs — PaperBroker._route_signal just does not map those signal types onto it yet. lrvx.bundle shows the full mapping if you want to route them yourself; otherwise keep paper-mode strategies on market and limit.

Hooking in ccxt for a live feed

import asyncio
import ccxt.pro as ccxt
from lrvx import SymbolRegistry
from lrvx.paper import PaperBroker

async def main():
    registry = SymbolRegistry()
    btc = registry.add_symbol("ccxt", "BTCUSDT", tick_size=0.01)

    broker = PaperBroker(
        registry=registry,
        default_slippage_model="fixed_bps",
        default_slippage_params={"bps": 5.0},
    )
    # ... add_strategy / start as in the quick start ...
    broker.start()

    exchange = ccxt.binance()
    try:
        while True:
            trades = await exchange.watch_trades("BTC/USDT")
            for t in trades:
                broker.observe_trade(
                    int(btc),
                    float(t["price"]),
                    float(t["amount"]),
                    t["side"] == "buy",
                    int(t["timestamp"]) * 1_000_000,
                )
    finally:
        broker.stop()
        await exchange.close()
        print(broker.fills_list())

asyncio.run(main())

This is the minimal pattern. Production setups will add error handling around watch_trades, persistent reconnection, and probably a separate task for watch_order_book plus broker.observe_book_snapshot so the simulator's queue tracker has top-of-book context.

See also

  • Backtest with realistic fills. The same SimulatedExecutor powering paper mode runs the backtest fills.
  • CCXT adapter. The live order broker for the same lrvx pipeline; swap in PaperBroker to rehearse without sending live orders.
  • Record and replay tapes. Recorded tapes can drive PaperBroker instead of a live feed for repeatable rehearsals.