Changelog
PyLOB has never been released. There are no earlier tags, so this file starts
at 1.0.0 rather than reconstructing a history it does not have, and the entry
below is not "what changed since last time". It is what the library is — and
first, because it is the part that costs someone an afternoon, what a person
arriving with code written against the engine that came before has to change.
Later entries will follow Keep a Changelog
and semantic versioning. This one does
not use those categories: with no predecessor, "Added" is every line, and the
distinction worth drawing instead is between the reader who is porting and the
reader who is arriving.
1.0.0 — 2026-08-14
A limit order book for simulation research: standard price-time priority,
market and limit orders with add, cancel and modify, single-threaded, and
zero-latency by assumption. Matching is in memory, in one layer. SQLite is
optional and off the matching path — attach a sink and the session is recorded
for querying afterwards, attach nothing and no event is constructed at all.
The library dates from 2013. From 2023 until this year a SQLite-backed engine
did the matching behind the same public API;
ADR-0003 retired it, and the
in-memory engine that
ADR-0001 specified is now the
only one. There is no LegacyOrderBook, and OrderBook(db=conn) no longer
means anything.
Porting from the retired SQL engine
Every name that engine exposed is still here — processOrder, cancelOrder,
modifyOrder, getVolumeAtPrice, getBestBid/getBestAsk,
getWorstBid/getWorstAsk, print — and none of them is deprecated. So a
port is not a rewrite of call sites. It is a question of what those calls now
return, write back and refuse.
docs/migrating-from-the-legacy-engine.md
enumerates every difference and is the document to work from. What follows is
not a summary of it. It is the handful that answer rather than raise — the
ones a port will not discover on its own, because nothing goes wrong loudly.
getVolumeAtPricewith a side that is not"bid"or"ask"returned
0, out of acoalesce(sum(available), 0)over rows that matched nothing —
indistinguishable from a price with no volume resting at it. It now raises
InvalidOrdernaming the two valid sides.clipPricestill exists and answers differently. It is the same function
object asquantize. The retired engine computed
round(price, int(log10(1/tick)))— a digit count rather than a grid, which
treated 0.25, 1 and 5 alike and put 100.03 on a 0.05 tick at 100.0. The
grid is real now, indecimal, and the same call gives 100.05. Ordinary
decimal prices on the default 0.0001 tick are unaffected; a simulation run on
a non-decimal tick (0.05, 0.25, 5) will produce different resting prices.- A market order's unfilled remainder no longer rests. It used to sit in
the book at a NULL price, outranking every limit order indefinitely, invisible
togetBestBid/getBestAsk— which reportedNoneover a book that was not
empty. Market orders are immediate-or-cancel now, and the remainder is
cancelled withcancel_reason="ioc_remainder". A simulation that leaned on
resting market orders for liquidity will fill less than it used to. - A trade prices at the maker. The retired engine priced a fill against a
resting NULL-price market order at the taker's price instead. modifyOrderno longer writes into the dict you pass it. It used to set
idNum,timestamp,type,order_id,instrumentandfulfilledon your
orderUpdateand quantize itspricein place. It now reads it and leaves it
untouched. Code that read results back out of the update dict —
orderUpdate["fulfilled"]was the common one — should ask the order:
book.order(idNum).fulfilled.modifyOrderwithprice=Nonemeans "leave the price alone". It
emphatically does not mean "become a market order", which is how the retired
engine read it while keeping the order's stored limit.- A reprice always re-crosses the book. The retired engine re-matched only
when itsbetterPricetest judged the new price more aggressive. - Zero is a value now.
cancelOrder(side, idNum, time=0)testedif time:,
so0meant "nothing supplied, tick the clock". It now sets the clock to 0.
OnlyNonemeans "not supplied", forcancelOrder,modifyOrderand
submit/processOrderalike. The clock is afloatby default where it used
to be anint.
The rest of the differences announce themselves, and are the cheap ones:
Trade is a 10-field NamedTuple whose two identifiers are idNums rather
than SQLite rowids, so five-way unpacking raises ValueError instead of quietly
handing back the wrong columns; quote["order_id"] and quote["lastprice"] are
gone and raise KeyError; OrderBook(connection) complains about the tick
size, because the first parameter is tick_size. The migration document's
tables cover these and the refusals that used to be silent — cancelling an
unknown, mismatched, already-cancelled or fully filled order all did nothing at
all, and reported nothing.
Bad input raises rather than terminating the host process: processOrder used
to call sys.exit() on a non-positive quantity, an unknown type and an unknown
side. Nothing moves when a submission is refused — no order, no event, no clock
tick, no identifier consumed — so a caught InvalidOrder leaves a book you can
carry on using.
Fixed, in code that may still be running
GitHub issue #8 documented two independent fill-accounting bugs in the retired
engine, both of which let a resting order be matched past its true size,
corrupting fills, trader balances and counterparty liquidity. The trade_insert
trigger credited fills to rows matched by idNum while the trade table stored
order_id values, so fills landed on the wrong orders whenever the two
identifier sequences diverged; and processMatchesDB took the quantity to
execute from the quote without subtracting fulfilled, so a repriced, partially
filled order re-traded its full size.
Both were fixed in the SQL engine before it was retired, and cannot recur here:
there is one identifier, one layer doing the accounting, and a ratified
order-matching contract requiring that fills be credited to the orders that
traded and that no order ever trade beyond its unfulfilled remainder. Nine
further defects specific to that engine closed as won't-fix with the code that
carried them (ADR-0003).
The engine
- One
OrderBookholds many instruments; an instrument springs into being on
first mention. Order identifiers are unique across every instrument the
engine holds, for that engine's lifetime, andcancel/modifyreach an
order by identifier without being told the instrument. Two engines both start
at 1 — the scope is the engine, not the process. submit(tid, instrument, side, order_type, qty, price)returns
(order, trades)in match order. TheOrdergoes on answering for itself —
fulfilled,remaining,resting,commission— so nothing else needs to
be tracked to see what became of it.processOrderis the same operation in
the 2013 dict-quote shape, returning(trades, quote).cancel(idNum, *, side=None, timestamp=None)and
modify(idNum, *, qty=None, price=None, side=None, timestamp=None): the
identifier comes first and everything else is keyword-only, so it cannot land
in the wrong slot, and the clock is spelledtimestamp=throughout.
cancelOrderandmodifyOrderare these two under their original names and
original positional signatures.- Every refusal is an exception.
PyLOBErroris the base of everything the
library raises;InvalidOrderis also aValueError,UnknownOrderalso a
LookupError, andDuplicateOrderIDis anInvalidOrder. - Modify is validated and priority-aware: the side is immutable, a quantity
reduction keeps time priority, a quantity increase or a price change loses it,
and a fully filled order is finished — modify refuses it, as cancel already
did. - Commissions (per unit, with a floor and a percentage cap), per-trader
balances and a per-trader self-matching gate, all computed in core so PnL is
available online rather than after a query. Balances track and do not
gate: there is no margin or sufficient-funds enforcement, and a seller's
instrument balance goes negative freely. That is a stated requirement, so
nobody quietly "fixes" it. - Prices are quantized to a real tick grid. The tick is fixed for a book's
life, so a different grid means a newOrderBookrather than a setter.
The read side
depth(instrument, side, levels=None) is the Level 2 read: the aggregated
ladder as (price, volume) pairs, one per price at which orders rest, best
first. getBestBid, getBestAsk, getWorstBid, getWorstAsk,
getVolumeAtPrice, getLastPrice, snapshot and print answer beside it, and
agree with the ladder by construction rather than by arrangement — all of them
read the same levels. Every one of them is scoped to a single instrument, which
the book-queries contract now says in as many words rather than leaving to be
derived from a neighbouring requirement.
Recording, replay and inspection
- Sinks are optional and off the hot path. With none attached, no event is
constructed at all. PyLOB.sinks.sqlite.SQLiteSinkturns a session into queryable history: an
append-onlyeventlog as the source of truth, projections of it
(session,instrument,trader,orders,trade,balance) so that a
question is aSELECTrather than a fold over the log, three views
(resting_order,trader_commission,trade_leg) and three tables about the
recording itself (session_meta,session_end,event_loss). Every table
and column carries its comment inside theCREATE, so.schemais the
reference.SQLiteSink(path, meta={...})labels a run inside the file — written when
the recording opens rather than at close, so a run killed before its first
flush still says which one it was.read_metareads it back with types
intact and does not require a complete log, which is the point: the run worth
naming is usually the one that died.check_logandread_eventsare how a
killed session is told apart from a shorter one.trade_legunpivots each trade into the balance movements it caused, one row
per (trader, symbol), booked in the currency in force at the fill.replay(events)re-issues the recorded commands into a fresh engine and
lets that engine derive every fill again, so an identical book is evidence of
determinism rather than of a restore. It takes an iterable of events, not a
path, so a session held in memory byPyLOB.sinks.ListSinkreplays with no
file and nosqlite3involved.import PyLOBdoes not importsqlite3. That is a promise, and a subprocess
test holds it.- A recording names the library version that produced it, in
session.pylob_version, without the caller having supplied anything. The
statement travels in the event stream, so re-folding a log into a fresh
database keeps the original's answer rather than stamping the re-folding
release. A replay is not a re-folding — it re-derives its events by matching
again — so a recorded replay names the replaying release. - The sink schema is at version 5. Readers accept a window down to version 3,
so a recording made beforetrade_leg,session_metaor the version stamp
existed still opens, says on the way in what it does not carry, and answers
what it can; the writer stays exact, so nothing appends to an old recording
(ADR-0007). - Adding a field to an event does not move
STREAM_VERSIONwhen no replay path
reads it, because moving it would strand every recording ever made for both
reading and replay.decode_eventrefuses a field it does not understand
instead, which buys the same clean failure permanently and for every future
field (ADR-0008). - A sink observes the stream and does not act on the engine:
consumeruns
inside the operation being recorded, a sink must not call back into the
engine from it, and the engine does not detect or refuse one that does. The
non-enforcement is ratified deliberately, so adding a guard later is a
contract change rather than a tidy-up.
Benchmarking
python -m PyLOB.bench measures the engine on a seeded, deterministic
workload, compares the result against a recorded baseline, and answers "did I
make it slower?" through its exit code: 0 no regression (or nothing recorded
to compare against), 1 a regression, 2 a bad command line, 3 the guard
could not be applied at all. Three is deliberately not one — "you made it
slower" and "I could not find out whether you made it slower" are different
facts.
Every run also times an engine-independent calibration workload and gates on
the work index, orders processed per calibration pass, so a machine that is
uniformly slower reads as no regression rather than as a regression the size of
the difference (ADR-0005,
superseding ADR-0002's ratio to an engine that no longer exists to be
re-measured). Each run records its provenance — machine, CPU, core counts and
which class of core it landed on, Python version, commit, load average, power
source. --rebaseline moves the floor as a deliberate, reviewable act; it
refuses a contended machine without --force and refuses to move the floor
down without --rebaseline-down.
The harness is not part of ./verify, by standing project constraint: a
fifteen-second correctness gate must not grow a stage whose answer depends on
what else the laptop is doing.
The first baseline is recorded in benchmarks/baselines.json — mixed-v1,
20,000 orders, seed 42, five repeats on an Apple M1 on mains power. Treat the
figures there and in docs/engine-review-2026-08.md
(130k–307k orders/sec across seven workload shapes, no degradation over a
2M-operation sustained run) as measurements recorded on particular machines
rather than as promises about yours; a bare orders/sec figure is not comparable
across machines, or even across two runs on one, which is the entire reason the
work index exists. Attaching a SQLiteSink costs roughly an order of magnitude
of throughput — ADR-0002 measured about 8x, and the recorded baseline's own
sinkless and sink figures are about 11x apart. The cost is the sink's per-event
encoding, not the matching engine.
What is contractually promised
Seven capabilities are ratified in openspec/specs/, one directory each:
order-lifecycle (what is accepted, identity, cancel, modify, market orders,
priority), order-matching (fill accounting), book-queries (best and worst
prices, volume at a price, last trade, snapshot, the depth ladder),
commissions, trader-balances, recording-sink and benchmarking.
Four are covered by acceptance suites in tests/acceptance/, one test per
ratified scenario against an engine-neutral adapter. order-matching is
guarded by tests/reference/matcher.py — a second matcher written from the
frozen specs alone, sharing no code with the engine, compared against it
operation for operation. recording-sink is guarded by the emission-coverage
and sink suites, and benchmarking by its workload tests and the harness's own
baseline guard.
Not every behaviour the engine has is ratified, and the difference is worth
knowing before you depend on one. Of cancel's terminal refusals, only the
unknown-identifier case is required by a spec; refusing an already-cancelled or
fully filled order is current engine behaviour that no requirement states.
The same is true of processOrder(..., fromData=True) refusing a quote with no
idNum or timestamp. tests/reference/matcher.py marks these where it
implements them.
Decisions
docs/adr/README.md is the index; the records themselves carry the rejected
alternatives, which is the part that leaves no other trace.
- ADR-0001 — matching moves in memory; SQLite becomes an optional
off-hot-path sink. - ADR-0002 — the throughput target is measured with no sink attached.
Superseded by ADR-0005. - ADR-0003 — the legacy SQL engine is retired, and its differential oracle
is replaced by a spec-derived reference matcher rather than deleted. What was
genuinely lost is recorded there too: an independent implementation, written
by someone else at another time. - ADR-0004 —
Tradeis aNamedTupleand not a frozen dataclass, worth a
measured 9% of the sinkless hot path. The type widens to a tuple, so field
order became public surface. - ADR-0005 — throughput is judged against a calibration-normalised
baseline. Supersedes ADR-0002. - ADR-0006 — there is no
reset(), andclose()clears nothing; an episode
is a freshOrderBook. - ADR-0007 — sink readers accept a schema-version window; the writer stays
exact.
Requirements and installation
Python 3.11 or newer. No runtime dependencies — the optional recording sink
uses the standard library's sqlite3. MIT licensed.
Not published on PyPI: the pylob name there belongs to an unrelated project.
Install from GitHub:
pip install "PyLOB @ git+https://github.com/DrAshBooth/PyLOB.git"
./verify is the definition of done for contributors, in six stages: format,
lint, test, specs, smoke and packaging. src/example.py is executed by the
smoke stage on every run, so the walkthrough cannot rot silently.
Deliberately not included
- No
reset(). Construct a freshOrderBookfor each episode — the
intended pattern for RL gyms and parameter sweeps, measured faster than
reusing one book, and the only arrangement that bounds memory. A book
remembers every order it has ever seen, by design: a filled or cancelled
order stays addressable for itsfulfilledandcommission, which is what
the identity rule requires.close()flushes the sink and does nothing else. - No margin, and no sufficient-funds check. Balances track.
- No trade log inside the engine.
getLastPriceis what it keeps; attach a
sink for history. - No enforcement of the sink re-entrancy prohibition, as above.
- No deprecation of the 2013 spellings.
processOrder,cancelOrderand
modifyOrderare supported surface, not a compatibility shim.
Zero latency and a single thread are simplifying assumptions rather than
omissions: a quote is processed the instant it is submitted, and every other
trader can react to it before the next quote arrives.