Skip to content
Ash Booth edited this page Aug 14, 2026 · 1 revision

Usage walkthrough

Everything a researcher does in the first session with PyLOB: build a book, submit orders, watch them match, cancel and modify them, and read the state back out. Every transcript below is the real output of the code above it, against the library as it currently ships.

The README has the eight-line quickstart and src/example.py is the runnable short version. This page is the long one — the same ground with the rules spelled out and the edge cases shown rather than mentioned. Length is affordable here in a way it is not in a README.

Sections from Limit orders that rest to Market orders build up one book called lob and are meant to be read in order. Every other section starts from a fresh OrderBook and stands alone, so the identifiers in its output start at 1 again.

Contents


The first book

from PyLOB import OrderBook

book = OrderBook(tick_size=0.01)
book.configure_instrument("FAKE", "USD")

order, trades = book.submit(1, "FAKE", "ask", "limit", 5, 101.0)
print(order.idNum, order.price, order.resting, trades)
1 101.0 True []

submit is the modern entry point and its signature is positional:

submit(tid, instrument, side, order_type, qty, price=None,
       idNum=None, timestamp=None)

It returns (order, trades) — the order object itself, and the list of executions it caused in match order. Nothing crossed here, so the list is empty and the order rests.

The returned Order goes on answering for itself for the rest of the session. order.fulfilled, order.remaining, order.value, order.commission, order.cancelled and order.resting all stay current as the market moves around it, so a caller that keeps the object needs no separate bookkeeping.

OrderBook() takes three arguments, all optional: tick_size (default 0.0001), sink (default none — see the recording guide), and timestamp, the value the engine clock starts at.

Configuration, and what it costs to skip

Neither instruments nor traders have to be declared. Both spring into being on first mention. But the defaults are consequential, so it is worth knowing what you get.

book = OrderBook(tick_size=0.01)
book.configure_instrument("FAKE", "USD")
maker = book.configure_trader(
    1, name="maker", commission_min=2.5, commission_max_percnt=1.0,
    commission_per_unit=0.01,
)
print(maker)
print(book.trader(99))          # never configured
Trader(tid=1, name='maker', allow_self_matching=False, commission_min=2.5, commission_max_percnt=1.0, commission_per_unit=0.01)
Trader(tid=99, name='99', allow_self_matching=False, commission_min=0.0, commission_max_percnt=0.0, commission_per_unit=0.0)
  • An unconfigured trader pays no commission. That is a zero schedule, not an error. If your experiment is about transaction costs, forgetting configure_trader silently gives you a frictionless market.
  • An instrument with no declared currency can only move the instrument leg of a trade. The engine settles the shares and leaves the cash leg alone, because it has not been told what "cash" means here. Balances will look half-finished, and the reason will be twenty lines further up your script. Declare the currency.
  • A currency, once declared, cannot be withdrawn. configure_instrument refuses None. It can be changed, which re-denominates later trades.

configure_trader is re-callable; the last call wins.

Limit orders that rest

A limit order that crosses nothing joins the book at its price, at the back of that price's queue.

lob = OrderBook(tick_size=0.01)
lob.configure_instrument("FAKE", "USD")
for tid, side, qty, price in [
    (1, "ask", 5, 101), (2, "ask", 5, 103), (3, "ask", 5, 101),
    (4, "bid", 5, 99),  (5, "bid", 5, 98),  (6, "bid", 5, 99),
]:
    lob.submit(tid, "FAKE", side, "limit", qty, price)

lob.print("FAKE")
------ Bids -------
4)5-0 @ 99.0 t=4.0
6)5-0 @ 99.0 t=6.0
5)5-0 @ 98.0 t=5.0

------ Asks -------
1)5-0 @ 101.0 t=1.0
3)5-0 @ 101.0 t=3.0
2)5-0 @ 103.0 t=2.0

------ Trades ------
last price: None

volume bid if i ask 98: 15
volume ask if i bid 101: 10
best bid: 99.0
worst bid: 98.0
best ask: 101.0
worst ask: 103.0

lob.print(instrument) both prints and returns that string. Each book line reads id)qty-fulfilled @ price t=timestamp. Both sides list best price first, and within a price, oldest first.

Two warnings about print. The two volume ... probes are hardcoded at 98 and 101 — they are inherited from the 2013 engine's debug output and mean nothing in particular for your book; getVolumeAtPrice is the query to ask directly. And the "Trades" section shows only the last trade price, not a trade history: this engine keeps no trade log in memory. A full history is what attaching a sink is for. Nothing parses this output and no test asserts on its shape — it is for eyeballs.

Reading the book

print("best bid:", lob.getBestBid("FAKE"))
print("best ask:", lob.getBestAsk("FAKE"))
print("worst bid:", lob.getWorstBid("FAKE"))
print("last price:", lob.getLastPrice("FAKE"))
print("ask volume a bid at 101 could take:", lob.getVolumeAtPrice("FAKE", "ask", 101))
print("ask volume a bid at 103 could take:", lob.getVolumeAtPrice("FAKE", "ask", 103))
best bid: 99.0
best ask: 101.0
worst bid: 98.0
last price: None
ask volume a bid at 101 could take: 10
ask volume a bid at 103 could take: 15

Note the shape of getVolumeAtPrice(instrument, side, price): it answers "how much of that side could an opposing order priced here take", so it is cumulative through the price, not the volume sitting at that one level. A bid at 103 can take everything up to 103, which is all 15.

Every query takes the instrument as its first argument. None is the answer for an empty side, and for an instrument nobody has traded — not an error.

There is no depth() method for the whole ladder. snapshot gives you the resting orders in priority order and you can group them yourself; a recorded session gives you the ladder in SQL.

Price-time priority

for o in lob.snapshot("FAKE", "ask"):
    print(o.idNum, o.side, o.price, o.qty, "priority", o.priority)
1 ask 101.0 5 priority 1
3 ask 101.0 5 priority 3
2 ask 103.0 5 priority 2

snapshot(instrument, side) returns the resting orders in exactly the order they will match: by price first, then by priority.

priority is an arrival counter, not a timestamp and not an event sequence number. Order 1 and order 3 share a price, so they queue by priority. Order 2 has a worse price, so it sorts behind both despite a lower priority than order 3. That is the whole matching rule: (price, priority) and nothing else.

The distinction between priority and timestamp matters because a modify can re-stamp priority while leaving timestamp alone — see the priority rule below.

Crossing: the maker names the price

taker, trades = lob.submit(7, "FAKE", "bid", "limit", 2, 102)
for t in trades:
    print(t)
print("taker limit was", taker.price, "and it paid", trades[0].price)
print("maker was order", trades[0].maker_idNum, "| taker was order", trades[0].taker_idNum)
Trade(trade_id=1, timestamp=7.0, instrument='FAKE', price=101.0, qty=2, taker_side=<Side.BID: 'bid'>, bid_idNum=7, bid_tid=7, ask_idNum=1, ask_tid=1)
taker limit was 102.0 and it paid 101.0
maker was order 1 | taker was order 7

A trade prices at the maker — the resting order's limit, not the arriving order's. The bid was willing to pay 102 and paid 101, because order 1 was already in the book naming 101 and the book honours the order that got there first. This is the single most common surprise for people arriving from a naive matching model, and it is worth checking your strategy code assumes it.

Trade is a NamedTuple, so it unpacks, indexes and compares equal to a plain tuple of its fields, and t._asdict() gives you a dict. Field order is part of the public surface. taker_idNum and maker_idNum are convenience properties derived from taker_side.

Partial fills

A limit order that crosses but is not fully matched trades what it can and rests the remainder at its own limit.

order, trades = lob.submit(8, "FAKE", "bid", "limit", 50, 102)
print("filled %d in %d trades: %s" % (order.fulfilled, len(trades),
                                      [(t.qty, t.price) for t in trades]))
print("resting %d @ %s" % (order.remaining, order.price))
filled 8 in 2 trades: [(3, 101.0), (5, 101.0)]
resting 42 @ 102.0

It took the 3 left on order 1 and all 5 of order 3 — both at 101, both makers — and the 42 it could not fill rest at 102, which is now the best bid.

Market orders are immediate-or-cancel

A market order names no price. It takes what it can from the inside of the book and never rests; whatever is left is cancelled by the engine.

order, trades = lob.submit(9, "FAKE", "ask", "market", 100)
print("price named:", order.price)
print("filled %d of %d across %d trades" % (order.fulfilled, order.qty, len(trades)))
print([(t.qty, t.price) for t in trades])
print("remainder:", order.remaining, "| cancelled:", order.cancelled,
      "| reason:", order.cancel_reason, "| resting:", order.resting)
price named: None
filled 57 of 100 across 4 trades
[(42, 102.0), (5, 99.0), (5, 99.0), (5, 98.0)]
remainder: 43 | cancelled: True | reason: ioc_remainder | resting: False

The sweep walks down the bid side: 42 at 102, then both 5s at 99, then 5 at 98, and stops because the book is empty. The 43 it could not fill are cancelled with reason ioc_remainder, which is how you tell an engine cancellation from one a trader asked for.

order.price is None for a market order, always. A market order into an empty book fills nothing and is cancelled in full — no error, no trades.

Cancelling

book = OrderBook(tick_size=0.01)
resting, _ = book.submit(1, "FAKE", "bid", "limit", 5, 97)
book.cancelOrder("bid", resting.idNum)
print(resting.cancelled, resting.cancel_reason, resting.resting)
True requested False

cancelOrder(side, idNum) takes the side first — a legacy argument order, and one that catches people out. side may be None to address the order by identifier alone, which is usually what you want:

book.cancelOrder(None, idNum)

Every way of naming an order that cannot be cancelled raises, rather than quietly doing nothing:

cancel it twice  -> InvalidOrder: order 1 is already cancelled
wrong side       -> InvalidOrder: order 1 is a bid, not a ask
no such order    -> UnknownOrder: no order with idNum 4242

A fully filled order also refuses: "order N is fully filled, nothing to cancel". This is deliberate — the previous engine no-opped silently, which is how a trader loses an order it believes it cancelled. Nothing changes before the last check passes, so a refused cancel leaves the book exactly as it was.

Commission already charged on the filled part of a partly filled order stays charged. Cancelling does not refund.

Modifying, and the priority rule

modifyOrder(idNum, {"side": ..., "qty": ..., "price": ...}) changes a resting order. All three keys are required; passing None as a value means leave that one alone. A missing key is an InvalidOrder that names it.

The rule worth internalising:

A price change or a quantity increase costs time priority. A pure quantity decrease does not.

book = OrderBook(tick_size=0.01)
first, _ = book.submit(1, "FAKE", "bid", "limit", 10, 99)
second, _ = book.submit(2, "FAKE", "bid", "limit", 10, 99)

def queue():
    return [(o.idNum, o.qty, o.priority) for o in book.snapshot("FAKE", "bid")]

print("start:            ", queue())
book.modifyOrder(first.idNum, dict(side="bid", qty=4, price=None))
print("after shrink 10->4:", queue())
book.modifyOrder(first.idNum, dict(side="bid", qty=12, price=None))
print("after grow 4->12:  ", queue())
start:             [(1, 10, 1), (2, 10, 2)]
after shrink 10->4: [(1, 4, 1), (2, 10, 2)]
after grow 4->12:   [(2, 10, 2), (1, 12, 3)]

Shrinking kept order 1 at the front with priority 1. Growing sent it behind order 2 with a fresh priority of 3. This is the standard exchange rule and it is what makes "shave a share off to stay at the front" a real tactic.

A modify that changes the price is a re-submission: the order leaves the book, crosses as a taker like any arriving order, and only what survives goes back in.

book = OrderBook(tick_size=0.01)
book.submit(1, "FAKE", "ask", "limit", 5, 101)
mine, _ = book.submit(2, "FAKE", "bid", "limit", 5, 99)
trades, _ = book.modifyOrder(mine.idNum, dict(side="bid", qty=5, price=101.5))
print("the modify traded:", [(t.qty, t.price) for t in trades])
print("order now:", mine.price, "fulfilled", mine.fulfilled, "resting", mine.resting)
the modify traded: [(5, 101.0)]
order now: 101.5 fulfilled 5 resting False

modifyOrder returns (trades, orderUpdate) — note that it returns trades first, where submit returns the order first. Two other rules:

  • A quantity below what is already fulfilled clamps up to the fulfilled amount, which finishes the order. It is reported as a modify, not a cancel.
  • price=None means "keep the price". It emphatically does not mean "become a market order"; a market order cannot be modified at all.

The self-match gate

By default a trader's arriving order steps over its own resting orders rather than trading with itself.

book = OrderBook(tick_size=0.01)
book.configure_instrument("FAKE", "USD")
book.submit(1, "FAKE", "ask", "limit", 5, 101)   # trader 1's own ask
book.submit(2, "FAKE", "ask", "limit", 5, 101)   # trader 2's ask, behind it

order, trades = book.submit(1, "FAKE", "bid", "limit", 10, 101)
print("trades:", [(t.qty, t.price, "ask from tid %d" % t.ask_tid) for t in trades])
print("filled %d, rested %d @ %s" % (order.fulfilled, order.remaining, order.price))
print("still on the book:", [(o.idNum, "tid %d" % o.tid, o.remaining)
                             for o in book.snapshot("FAKE", "ask")])
trades: [(5, 101.0, 'ask from tid 2')]
filled 5, rested 5 @ 101.0
still on the book: [(1, 'tid 1', 5)]

Trader 1's bid for 10 skipped its own ask at the front of the queue, traded the 5 behind it from trader 2, and rested the other 5. The skipped order is untouched — still in the book, still at the front of its level, still ahead of everyone else.

This has a consequence worth planning for: a book can sit in a state that looks crossed, with a resting bid and ask at the same price, because the only thing that could match them is a single trader's own pair.

Opt out per trader:

book = OrderBook(tick_size=0.01)
book.configure_trader(1, allow_self_matching=True)
book.submit(1, "FAKE", "ask", "limit", 5, 101)
order, trades = book.submit(1, "FAKE", "bid", "limit", 5, 101)
print("trades:", [(t.qty, t.price) for t in trades])
trades: [(5, 101.0)]

For a single-agent experiment — one strategy quoting both sides — the default gate means your orders never trade with each other, which is either exactly what you want or the reason your fill count is zero. It is worth deciding which on purpose.

What the engine refuses

Validation happens before anything changes, including the clock. A rejected submission leaves the book untouched and records nothing.

book = OrderBook(tick_size=0.01)
attempts = [
    ("quantity 0",         lambda: book.submit(1, "FAKE", "bid", "limit", 0, 99)),
    ("fractional qty",     lambda: book.submit(1, "FAKE", "bid", "limit", 2.5, 99)),
    ("side 'sell'",        lambda: book.submit(1, "FAKE", "sell", "limit", 5, 99)),
    ("type 'stop'",        lambda: book.submit(1, "FAKE", "bid", "stop", 5, 99)),
    ("limit, no price",    lambda: book.submit(1, "FAKE", "bid", "limit", 5, None)),
    ("price NaN",          lambda: book.submit(1, "FAKE", "bid", "limit", 5, float("nan"))),
    ("set the last price", lambda: book.setLastPrice("FAKE", 999.0)),
]
for label, call in attempts:
    try:
        call()
        print("%-18s -> accepted" % label)
    except Exception as exc:
        print("%-18s -> %s: %s" % (label, type(exc).__name__, exc))
quantity 0         -> InvalidOrder: quantity must be positive, got 0
fractional qty     -> InvalidOrder: quantity must be an integer, got 2.5
side 'sell'        -> InvalidOrder: side must be one of ('bid', 'ask'), got 'sell'
type 'stop'        -> InvalidOrder: order type must be one of ('limit', 'market'), got 'stop'
limit, no price    -> InvalidOrder: a limit order needs a price
price NaN          -> InvalidOrder: price must be finite, got nan
set the last price -> InvalidOrder: the last-trade price of 'FAKE' is engine output, set by executions (999.0): assigning it reports a price no trade made and no event records

The exception hierarchy is small and worth catching by base class:

PyLOBError
├── InvalidOrder (also a ValueError)
│   └── DuplicateOrderID
└── UnknownOrder (also a LookupError)

Because InvalidOrder is a ValueError and UnknownOrder is a LookupError, generic handlers catch them too. Supplying your own idNum that is already in use raises DuplicateOrderID; supplying one above the engine's counter pushes the counter past it, so identifiers stay unique.

setLastPrice is a special case: it exists only to explain why it refuses. The last trade price is engine output. Seeding an opening price is not supported.

Balances and commissions

The engine keeps a ledger per (trader, symbol), where a symbol is either an instrument or a currency — both are things a trader holds.

book = OrderBook(tick_size=0.01)
book.configure_instrument("FAKE", "USD")
for tid in (1, 2):
    book.configure_trader(tid, commission_min=2.5, commission_max_percnt=1.0,
                          commission_per_unit=0.01)

seller, _ = book.submit(1, "FAKE", "ask", "limit", 5, 101)
buyer, trades = book.submit(2, "FAKE", "bid", "limit", 5, 101)

print("seller: FAKE %+g, USD %+g" % (book.balance(1, "FAKE"), book.balance(1, "USD")))
print("buyer:  FAKE %+g, USD %+g" % (book.balance(2, "FAKE"), book.balance(2, "USD")))
print("commission charged to each order:", seller.commission, buyer.commission)
print("all holdings:", sorted(book.holdings()))
seller: FAKE -5, USD +502.5
buyer:  FAKE +5, USD -507.5
commission charged to each order: 2.5 2.5
all holdings: [(1, 'FAKE', -5.0), (1, 'USD', 502.5), (2, 'FAKE', 5.0), (2, 'USD', -507.5)]

5 shares at 101 is 505, and each side paid 2.5 commission: the buyer is out 507.5, the seller is up 502.5. Balances go negative freely — a short position and an overdraft are positions the ledger records, not states it refuses. There is no margin model and no credit check.

The commission schedule is:

min(max_percnt * V / 100, max(commission_min, commission_per_unit * Q))

over the order's cumulative filled quantity Q and value V — never over a single fill. The percentage cap binds ahead of the floor, so a very small order pays the cap rather than the minimum. Because it is recomputed from cumulative totals on every fill, commission does not simply accumulate per trade:

fulfilled  5, value   500.0, commission 2.5
fulfilled 10, value  1000.0, commission 2.5

Ten shares cost the same 2.5 as the first five: the floor was already binding. An order with no fills owes nothing whatever the floor says.

Ticks

Prices are quantized to the book's tick grid at submission. The stored price is always the quantized one.

from PyLOB import DEFAULT_TICK_SIZE
print("DEFAULT_TICK_SIZE:", DEFAULT_TICK_SIZE)

penny = OrderBook(tick_size=0.01)
print("0.01 tick: 100.123 ->", penny.clipPrice(100.123))

quarter = OrderBook(tick_size=0.25)
print("0.25 tick: 100.30  ->", quarter.clipPrice(100.30))
o, _ = quarter.submit(1, "FAKE", "bid", "limit", 5, 100.30)
print("submitted 100.30, rests at", o.price)
DEFAULT_TICK_SIZE: 0.0001
0.01 tick: 100.123 -> 100.12
0.25 tick: 100.30  -> 100.25
submitted 100.30, rests at 100.25

The default tick is 0.0001, not 0.01. If you want penny prices, say so. clipPrice(price) shows you where a price will land; quantize is an alias for the same method. Query prices are quantized too, so asking about a price off the grid asks about the grid point it names. A price that quantizes to zero or below is refused.

The clock

timestamp is a logical clock, not wall time. It advances by one per operation.

clock = OrderBook(tick_size=0.01)
print("t =", clock.time)
a, _ = clock.submit(1, "FAKE", "bid", "limit", 5, 99)
b, _ = clock.submit(1, "FAKE", "bid", "limit", 5, 98)
print("after two submissions, t =", clock.time, "| stamps", a.timestamp, b.timestamp)
t = 0.0
after two submissions, t = 2.0 | stamps 1.0 2.0

So "order lifetime" in a default session is measured in operations, not seconds. If you want your own time base — wall clock, simulation time, a tick index — supply it:

wall = OrderBook(tick_size=0.01, timestamp=1_000.0)
c, _ = wall.submit(1, "FAKE", "bid", "limit", 5, 99, timestamp=1_234.5)
print("supplied stamp:", c.timestamp, "| engine clock now:", wall.time)
supplied stamp: 1234.5 | engine clock now: 1234.5

A supplied timestamp is used as given and moves the engine clock to it. Timestamps are recorded data and never a sort key — two orders may share one, and matching would not care if they did. Sorting is (price, priority).

The legacy quote API

processOrder is the 2013 dict-quote interface, kept because the public API is a standing constraint. It is submit in older clothes.

legacy = OrderBook(tick_size=0.01)
legacy.configure_instrument("FAKE", "USD")
legacy.submit(1, "FAKE", "ask", "limit", 5, 101)

quote = dict(type="limit", side="bid", instrument="FAKE", qty=3, price=101.007, tid=2)
trades, quote = legacy.processOrder(quote, False, False)
print("trades:", [(t.qty, t.price) for t in trades])
print("quote:", quote)
trades: [(3, 101.0)]
quote: {'type': 'limit', 'side': 'bid', 'instrument': 'FAKE', 'qty': 3, 'price': 101.01, 'tid': 2, 'idNum': 2, 'timestamp': 2.0}

It returns (trades, quote) and writes the assigned identifier, timestamp and quantized price back into the dict you passed. Note 'price': 101.01 — the submitted 101.007 quantized onto the grid.

The quote must carry instrument; the 2013 version did not, because that engine held one book. The signature is processOrder(quote, fromData=False, verbose=False), where fromData=True takes the quote's own idNum and timestamp instead of assigning them.

Prefer submit in new code. processOrder is supported, not recommended.

Sessions and episodes

One OrderBook is one session. There is no reset(), and none is needed: construct a fresh book.

results = []
for episode in range(3):
    ep = OrderBook(tick_size=0.01)          # a fresh book *is* the reset
    ep.configure_instrument("FAKE", "USD")
    ep.submit(1, "FAKE", "ask", "limit", 5, 101)
    _, trades = ep.submit(2, "FAKE", "bid", "limit", 5, 101)
    results.append(trades[0].price)
    ep.close()
print("three independent episodes:", results)
three independent episodes: [101.0, 101.0, 101.0]

This is the intended pattern for RL gyms and parameter sweeps, not a workaround. Construction is a handful of empty dicts and costs a fraction of a microsecond — no episode notices it — and the pre-retirement review measured fresh-engine-per-episode as both cheaper and faster than reusing one book.

It also bounds memory. A book remembers every order it has ever seen: the store maps idNum to Order and is never pruned, because a filled or cancelled order must go on answering for its fulfilled and commission, and identifiers must stay unique for the life of the book. A single book driven through a long sweep grows without bound by design. A fresh book per episode is what stops that.

close() flushes an attached sink and does nothing else; on a sinkless book it does nothing at all, and calling it is harmless.

Several instruments

One book handles many instruments. They are fully independent.

multi = OrderBook(tick_size=0.01)
multi.configure_instrument("AAA", "USD")
multi.configure_instrument("BBB", "USD")
multi.submit(1, "AAA", "bid", "limit", 5, 10)
multi.submit(1, "BBB", "bid", "limit", 5, 20)
print("instruments:", sorted(multi.instruments()))
print("AAA best bid:", multi.getBestBid("AAA"), "| BBB best bid:", multi.getBestBid("BBB"))
print("never mentioned:", multi.getBestBid("ZZZ"))
instruments: ['AAA', 'BBB']
AAA best bid: 10.0 | BBB best bid: 20.0
never mentioned: None

They share one tick size, one clock, one identifier sequence and one ledger. Querying an instrument nobody has mentioned returns None and quietly creates an empty book for it.

Where to go next

  • Recording and analysis — attach a SQLiteSink and get the whole session back as queryable history.
  • README — installation, the contract specs, benchmarking, and the reading map.
  • help(PyLOB), and the module docstrings. PyLOB/events.py is the event vocabulary and the balance rule; PyLOB/engine.py has the internals and the cost table; PyLOB/sinks/sqlite.py documents the recorded schema.
  • openspec/specs/ in the repo is what the book is contractually required to do — order-lifecycle, order-matching, book-queries, commissions, trader-balances, recording-sink. Where this page and a spec disagree, the spec is right and this page is a bug.

Clone this wiki locally