-
Notifications
You must be signed in to change notification settings - Fork 65
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
loband are meant to be read in order. Every other section starts from a freshOrderBookand stands alone, so the identifiers in its output start at 1 again.
Contents
- The first book
- Configuration, and what it costs to skip
- Limit orders that rest
- Reading the book
- Price-time priority
- Crossing: the maker names the price
- Partial fills
- Market orders are immediate-or-cancel
- Cancelling
- Modifying, and the priority rule
- The self-match gate
- What the engine refuses
- Balances and commissions
- Ticks
- The clock
- The legacy quote API
- Sessions and episodes
- Several instruments
- Where to go next
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.
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 configuredTrader(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_tradersilently 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_instrumentrefusesNone. It can be changed, which re-denominates later trades.
configure_trader is re-callable; the last call wins.
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.
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.
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.
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.
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.
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.
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.
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=Nonemeans "keep the price". It emphatically does not mean "become a market order"; a market order cannot be modified at all.
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.
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.
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.
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.
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).
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.
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.
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.
-
Recording and analysis — attach a
SQLiteSinkand 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.pyis the event vocabulary and the balance rule;PyLOB/engine.pyhas the internals and the cost table;PyLOB/sinks/sqlite.pydocuments 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.