Skip to content

Releases: intrepidkarthi/orderbook

v0.26.0 — a second implementation, and a log that can forget

Choose a tag to compare

@intrepidkarthi intrepidkarthi released this 21 Aug 06:13

Forty-nine commits since v0.25.0, and the two arcs that dominate them are a second
implementation of the matching rules that disagrees with the engine on purpose, and a
write-ahead log that can finally forget. Between them they found six correctness
defects — three of which no test, fuzzer or race detector in this repository had
caught — and turned restart cost from a property of how long the venue has been up
into a byte budget an operator sets.

The measurement that corrects a published claim is in here too: distinct symbols do
not scale linearly across cores. They scale 2.24×.

A reference matcher, and the three defects it found

internal/refmatch is a limit order book written to be read — two sorted slices
scanned linearly, cancel by linear search, depth by a fold. No index, no pool, no
id-to-node map. It is slow on purpose and must never be optimised, because a model
with an index has the engine's bug class. It imports the standard library and nothing
else, and a test parses the files to keep it that way. pkg/types is the tempting
exception and it is refused: an invariant both sides inherit from the same seven lines
is an invariant nothing is checking.

Both sides produce one comparable Observation per command, compared whole with
reflect.DeepEqual rather than field by field — because a field-list comparator is
where a future field silently stops being checked. Twenty-one deliberate engine
mutations are all caught, and every shrunk reproduction is 1 to 4 commands.
One seed
catches 8 of 18; the sweep catches 18 of 18, which is the argument for the sweep's
size.

It found three real defects. Each had more than one defensible answer, so each was
pinned as a failing test first and decided in
DIFFERENTIAL-FINDINGS.md before being repaired:

  • A rejected fill-or-kill moved LastTradePrice. match recorded the price from
    the final print, the fill-or-kill branch reversed every one of those prints, and
    nothing put the price back. The rule is now stated in recordLast's doc comment:
    LastTradePrice is the price of the last trade this venue published.
  • Under ProRata, a taker meeting its own resting liquidity was skipped entirely.
    matchProRata never called takerSTP, so all five self-trade-prevention modes left
    the taker's remainder resting across the spread — bid 100 / ask 99 on a
    continuous book. A venue configured ALLOW did not get the self-trade it asked for
    and one configured CANCEL_BOTH cancelled neither order: pro-rata was silently
    overriding the venue's STP configuration, and declining unrelated accounts'
    liquidity on the way.
  • A REJECTED command's event batch may now carry further events. A rejection
    drops only the events describing state the engine actually undid, so a REJECTED
    may be followed by CANCELED, REPLACED, ACCEPTED or TRIGGERED. Consumers of
    EventSink must apply them
    — see Breaking, below.

The harness's own guards were then sabotaged twelve ways, and the two that did not
behave as specified are written down rather than quietly fixed
(REFERENCE-MATCHER.md §10.3).

Three iceberg defects, one of which bricked checkpoints

  • A refused iceberg made the venue's own snapshot unloadable. ProcessIceberg
    registered the iceberg before settling it and did not undo that when the settle
    refused, so TakeSnapshot wrote an entry for an order that is not on the book and
    LoadSnapshot refuses such a snapshot outright. One refused iceberg and the venue
    could never load a checkpoint again
    — not after further trading, not after a
    restart. It also landed on this release's own upgrade path: the runbook tells an
    operator to checkpoint after accepting a semantics mismatch, and measured end to
    end, that produced a venue that would not restart. The repair is the invariant, not
    the symptom — the registry holds icebergs whose displayed slice is resting.
  • A recovery from the journal alone lost every iceberg's hidden reserve. A nine-lot
    iceberg shown three was journalled as quantity 3, display 3, and a replay rebuilt
    it with hidden = 0. A venue recovering from its log alone came back with every
    client's reserve gone
    — the path a venue takes when its snapshot is missing,
    refused as corrupt, or below the retention floor. Recovery with a snapshot was
    never affected.
  • A failing fill-or-kill corrupted an iceberg it consumed. Negative FilledQty,
    the entire hidden reserve displayed in the open, a shown size larger than the order's
    own quantity, and its refill registration dropped. On the two-command reproduction, 9
    total / 3 displayed became FilledQty -6, RemainingQty 9, reserve 0, best ask
    100:9. An iceberg exists to hide size; one rejected order from an unrelated account
    leaked all of it, permanently.

Also fixed: admission measured an iceberg's displayed slice rather than its total,
so a client could switch off the fat-finger cap by choosing an order type — the audit
behind it found five admission checks measuring the wrong quantity
(ICEBERG-ADMISSION.md).

Restart cost becomes a number you choose

Two changes, in order. A restart no longer parses the part of the log its snapshot
already covers
— every record is still read and CRC-verified, but only records past
the snapshot's sequence are decoded and retained. On a 500,000-record covered prefix
that is 1.66 s → 64 ms, and allocation goes flat in the prefix. The saving was
~26×, more than the design predicted, because the decode turned out to be ~97% of the
marginal cost of a covered record; the BENCHMARKS.md row that mispredicted it is
corrected.

Then the log rotates into segments, and a prefix of them can be deleted once a
verified snapshot covers it. Restart cost is now O(retained log), and the retained size
is a byte budget you set. The decision the slice turns on is that a segment declares
its base sequence in an 18-byte header rather than having one inferred from its
position — a renamed, copied or restored segment cannot quietly put records into the
wrong sequence space. Design, and every place the code disagreed with its own spec:
LOG-ROTATION.md §12.

That budget is not set by default, and a venue that leaves -wal-retain unset
still gets slower to restart every day it stays up. That is the honest state of it.

A journal that refuses rather than lying

Three of the changes above alter what matching does with the same input, so a log
recorded before them and recovered after them produces state that never existed on the
venue that wrote it. Until this release nothing on disk said so: recovery replayed the
log, started, and the book was wrong in a way nothing downstream flagged.

matching.SemanticsVersion stamps an equivalence class of builds — two builds
share a version if and only if, for every command sequence and configuration, they
produce the same trades, events, verdicts and book. It is 2 on this release.
A release version used as the stamp would refuse journals that replay identically on
every upgrade, and the response to a check that cries wolf is a permanent override.

The override names the versions it accepts rather than being a boolean, which is the
most important detail in it: -wal-semantics-mismatch-ok goes into a unit file during
one incident and stays for the life of the deployment.
-wal-accept-semantics 1 stops working the moment the number moves again.

What this deliberately does not cover is engine configuration: two builds at the
same semantics version with different ProRata, SelfTradePrevention, MaxOrders or
PriceBand replay the same log into different books and nothing notices. That gap is
arguably larger than the one this closes, and it is named in
SEMANTICS-VERSION.md §6 rather than implied to be handled.

The venue counts what it refuses

Sixteen metric families already said what the venue did; nothing said what it
dropped, and the durability path was untimed.

obgw_refused_total{reason} counts every refusal at the single funnel all fifty
refusal sites pass through, and increments before the encode, so it can never claim
a rejection the client did not get — measured equal end to end at 51,568 against 51,568
CmdRejects received. obgw_shed_unreported_total counts the shed with nobody left to
tell. obgw_wal_append_latency_ns and obgw_wal_sync_latency_ns are timed on opposite
sides of the group commit, and are 225× apart under -sync-every-command (17 µs against
3.9 ms) — a sync latency that is really an append latency reads healthy while fsync is
the slow thing. That also corrects the recovery point objective, which is
20 ms + p99 fsync and not the 20 ms ticker alone.

Cost: 82 ns and zero allocations per timing. Thirteen threshold rows in
RUNBOOKS.md, each with a normal value, a trouble value and an
action, because a metric nobody has a threshold for is a metric nobody looks at.

Adversarial review caught two of these before they shipped: a paging threshold of 1 s
standing against a histogram whose top bucket was 250 ms, so that tier could never fire
and an operator would have read healthy through an arbitrarily slow disk; and failed
logins invisible to a counter whose name implied it covered refusals.

Measured, and corrected

  • Sharding scales 2.24×, not linearly. Two documents said distinct symbols "scale
    linearly across cores" and nobody had measured it. BenchmarkShards_Scaling, pinned
    to the four performance cores of an M4: 876 K ops/s at one book, 1.64 M at two
    (1.88×), 1.97 M at four (2.24×)
    — and then nothing, 2.00 M at six and 1.99 M at
    eight, inside the ±4% spread. Each shard is a pair of goroutines, so past the core
    count the machine goes into the handoff. Books beyond that buy queue ...
Read more

v0.25.0 — checking its own claims

Choose a tag to compare

@intrepidkarthi intrepidkarthi released this 11 Aug 10:55

A documentation release, and every item in it is this project checking its own
claims rather than adding new ones. Two published allocation ratios were stale, one
of them flattering by 2×; a test count corrected yesterday was stale again today;
and the rule that catches all of this finally got written down instead of being
folklore in a test-file header.

Fixed

  • The published test count goes stale by construction, so it is now a floor. It
    read 480 for several releases after it stopped being true, was corrected to an
    exact 584 — and was stale again within a day. It now reads "over 600" with the
    command to count them, for the same reason v0.19.0 deleted the hardcoded "latest
    version" from the docs page rather than updating it. A floor can only ever become
    an understatement.

  • Two stale allocation ratios in BENCHMARKS.md. Add into
    a growing book was published as 1.05 allocs/op against a measured 2.01 — the
    page understated the cost of growing a book by half, which is the third correction
    this file has taken and the third in the flattering direction. Cancel + replace
    read 0.009 against a measured 0.0000, understating the engine instead.

    Neither is a regression: both reproduce identically on pre-session code. Both are
    deterministic allocation counts rather than timings, and both are printed by
    pkg/orderbook/alloc_test.go on every run — the figures were in the test log the
    whole time and nobody re-read them.

  • Config.ShardIndex moved to the end of the struct. Appending cannot shift the
    offsets of fields the match path reads; inserting can.

Added

  • TESTING.md — the rule the rest of the documentation rests
    on, written down: a test does not count until it has been run against code
    deliberately broken in the way it claims to detect.
    It was already the standing
    rule for the replication drills, whose file header names the sabotage each was
    verified against; this generalises it, because the same mistake has now been made
    six times across five tests, in code written carefully by people who knew about
    the rule.

    The case studies are the point, and they are all from this repository: a digest
    test satisfied by a sequence counter, a checksum test satisfied by a magic number,
    a timing test that first passed against a short-circuit and then failed against
    correct code, a drill that blamed the wrong follower, and a test double more
    permissive than the venue it stood in for. Each read correctly and would have
    passed review. Linked from CONTRIBUTING's quality bar, where it changes what a
    contributor is asked to have done rather than merely believed.


Upgrading: nothing to do — documentation and one struct field reordering, no API or wire changes.

Full changelog: v0.24.0...v0.25.0

v0.24.0 — labelled gauges, and a dashboard that could not connect

Choose a tag to compare

@intrepidkarthi intrepidkarthi released this 11 Aug 05:40

Two things the previous release named as unfinished, and one it did not know about.
The gauge work was the stated item; making it exposed that cmd/obdash had been
unable to connect to the gateway since the wire went to v4, and that every one of
its tests passed anyway because its own test double never checked the field the real
venue refuses on. That is the more useful of the two findings, and it is the one
that was not on the list.

Fixed

  • A constant-time-auth test that flaked, and would have taught people to ignore
    it.
    TestAnUnknownAccountDoesTheSameWorkAsAWrongSecret summed wall clock across
    rounds, so it measured the work plus however long the scheduler kept the
    goroutine off a core — and the second term is unbounded. Running the package
    alongside the rest of the repo pushed the ratio to 4.19 against a threshold of 4,
    twice, on code with no short-circuit in it. It now takes the floor of five
    rounds: noise only ever adds time, so the minimum estimates the real cost while a
    sum estimates the real cost plus the worst interference either arm happened to
    meet. Against a genuine short-circuit the ratio is now ~8000 rather than barely
    over 4. SOAK.md reached the same conclusion about heap growth for
    the same reason: watch the floor, not the trend.

  • cmd/obdash could not connect to a wire-v4 gateway. It sent no Symbol on
    its market-data subscribe, so the venue would have refused it outright — and its
    own test double never validated the field, which is why every test passed. The
    double is now as strict as the real venue: a test double more permissive than
    the thing it stands in for certifies the wrong system.

Changed

  • Price gauges are a labelled family, one series per book.
    observability.Collector.GaugeFamily registers a gauge that has one reading per
    label set, with HELP and TYPE written once; orderbook_best_bid,
    _best_ask, _spread, _last_trade_price and _phase now carry symbol="…".
    Countable gauges (queue depth, resting orders) stay bare and sum across books,
    because a venue's queue depth is its queue depth — but a last trade price across
    two instruments is not a number.

    They carry the label at a one-instrument venue too, which is a breaking change
    for anything scraping the bare names. A metric whose label set depends on how the
    venue happens to be configured is one no dashboard can be written against, since
    series would appear and disappear as instruments were added. cmd/obdash selects
    the series for its -symbol.


Upgrading: the price gauges (orderbook_best_bid, _best_ask, _spread, _last_trade_price, _phase) now carry a symbol label at every venue, including one-instrument ones. Anything scraping them by bare name needs updating; countable gauges are unchanged. cmd/obdash selects the series for its -symbol flag, which it now also sends when subscribing.

Full changelog: v0.23.0...v0.24.0

v0.23.0 — the multi-symbol gateway

Choose a tag to compare

@intrepidkarthi intrepidkarthi released this 10 Aug 16:03

The reference gateway catches up with the core. cmd/obgw was the last thing
standing between the multi-symbol design and a venue anyone can run, and converting
it found the bug that refactor was most likely to hide: buildOrder validated the
incoming symbol and then stamped the configured one onto the order, so at a
two-book venue every order silently landed on the first book. A refactor that
compiles is not a refactor that works.

Added

  • cmd/obgw serves a set of instruments. Config.Symbols and Config.DataDir;
    one book per symbol — matching goroutine, command log, market-data feed, rate gate
    — under a venue-wide account layer: one Registry, one publisher, one stream per
    account. A client holds one session and sees one ordered conversation whatever mix
    it trades, which is what makes a client id enough to name an order without also
    naming a symbol.

    A one-book venue is unchanged: same config fields, same WALPath and
    SnapshotPath, same small dense ids, and the entire existing test suite passes
    untouched.

  • Mass cancel and Query fan across every book and aggregate — an account's orders
    are its orders. Readiness takes the worst book, since a venue with one wedged
    matcher is not ready however healthy the others are.

Fixed

  • Routing a cancel needed the session to remember, not the registry to be asked.
    wire.Cancel names an order by ClOrdID alone, so the gateway must pick a book
    without being told which. Resolving the engine order id up front to read its shard
    field is the obvious move and it is wrong: the naming index is written by the
    matching goroutine, so a cancel arriving while its own Enter is still queued
    resolves to nothing and is refused for an order that is about to exist — the
    orphaned-order defect SOAK.md measured at 12,843 orders in thirty
    seconds. The session already knows: it read the Enter, and the Enter carried the
    symbol.

Known limits

  • Price gauges do not aggregate across books and report the first one. A last
    trade price averaged over two instruments is not a number; the right answer is one
    series per symbol, which needs label support pkg/observability does not have.

Upgrading: nothing to do for a one-book venue — Config.Symbol, WALPath and SnapshotPath behave exactly as before, and ids are unchanged. For more than one instrument, set Config.Symbols and Config.DataDir.

Full changelog: v0.22.0...v0.23.0

v0.22.0 — the multi-symbol release

Choose a tag to compare

@intrepidkarthi intrepidkarthi released this 10 Aug 15:24

The multi-symbol release, and the third in a row where writing the spec first found
a defect that predated the feature. PRODUCTION-READINESS.md called a multi-symbol
venue "a routing layer you write." Checking the code instead of the sentence:
ShardsConfig had no way to supply a CommandLog, and durability, recovery and
replication all hang off it — so a sharded venue could not survive a restart at
all.
That is not a routing layer, it is most of a venue.

The design decision everything else follows from is a refusal: there is no order of
events across symbols, because a venue-wide sequence needs a serialisation point
every command passes through, which is the bottleneck sharding exists to remove.
Each symbol is its own timeline; what that costs is listed in the spec rather than
discovered later. Ids are the one thing shared across books, and they are
partitioned rather than centralised precisely so that sharing costs no
coordination — a shared counter would have made a shard's ids depend on how its
traffic interleaved with every other shard's, so replaying one log alone would no
longer reproduce its own ids.

cmd/obgw still serves one instrument, and is now the only thing between this
design and a multi-symbol venue anyone can run.

Added

  • Multi-symbol identity (MULTI-SYMBOL.md, deliverables
    all six). Order and trade ids partition into a 15-bit shard index and a 48-bit
    per-shard counter, so an int64 names an order at a venue with many books. A
    shared counter would have been simpler and wrong: it makes a shard's ids depend on
    how its traffic interleaved with every other shard's, so replaying one log alone
    would no longer reproduce its own ids — trading the property this project is most
    confident about for one it merely wants. Shard 0 composes to the sequence itself,
    so every single-symbol deployment keeps the ids, snapshots, logs and golden vectors
    it already had.

    matching.Manifest is the price: a durable, CRC-checked symbol -> index mapping,
    refused rather than repaired. Losing it is worse than losing a snapshot, which
    costs only a replay.

  • ShardsConfig.NewLog — the finding that turned a one-line gap into a spec.
    ShardsConfig had no way to supply a CommandLog, and durability, recovery and
    replication all hang off it, so a sharded venue could not survive a restart at
    all.
    PRODUCTION-READINESS.md called multi-symbol "a routing layer you write";
    it was most of a venue. One log per shard, so recovery and the replication drills
    are the existing single-symbol code paths run N times.

  • Venue-wide ClOrdID admission (Registry.IsLiveClOrdID). The naming index is
    keyed by account and client id with no symbol, so at two instruments a repeat
    overwrites the first and the account's next cancel retargets.

  • examples/multisymbol — a two-book reference venue with a feed and a log per
    shard, serving both books over sockets. It exists for the reason
    examples/replication does: the multi-symbol seams are each plausible on the page,
    and this repository's record with seams claimed but never consumed is documented
    and bad. Replication drill D8 covers a two-symbol venue, with a negative
    control — a follower on the wrong shard index rebuilds the same orders under
    different numbers, and the digest catches it.

    cmd/obgw is deliberately not converted: it still serves one instrument, and
    it is now the only thing between this design and a multi-symbol venue anyone can
    run. Converting it is one runner, one feed, one gate, one recovery path, sixteen
    call sites and fifteen test files — its own arc, not a rider on a protocol change.

Changed

  • BREAKING: wire protocol v3 → v4. MDSubscribe gains Symbol. It named an
    incarnation and a sequence but no instrument, so a market-data connection could
    only ever mean "the one book this venue serves". A subscription now selects
    exactly one symbol and every message on that connection belongs to it, so no
    other market-data payload changed
    — the regenerated golden vectors differ only
    in their version byte. A subscription for an unserved instrument is refused with
    MDRejectUnknownSymbol rather than quietly given the wrong book, which a
    subscriber cannot detect for itself.

  • BREAKING: Shards refuses a second symbol without a Manifest. It previously
    gave every shard index 0 and served colliding ids silently — a failure whose only
    symptom is two orders nobody can tell apart, discovered much later. Anyone running
    Shards multi-symbol today was doing this unknowingly.

  • Engine.Bust validates the shard field, so busting another symbol's trade is
    ErrUnknownTrade instead of annulling whichever local print shared the low bits.

Fixed

  • Replication drill D6 blamed the wrong follower, about one run in twelve. The
    drill drove traffic until Shed() != 0 and assumed the follower cut was the
    wedged one. A follower that actually applies commands is slower than a wedged
    socket — which merely fills a kernel buffer and costs the primary nothing until it
    is full — so driven flat out, the healthy follower's own ship buffer overflowed
    first and it was the one shed. The drill then reported "shedding the wedge broke
    the healthy follower", the opposite of what had happened.

    Primary.ShedPeers now attributes each cut to a peer address, which is the part
    worth having beyond the test: a bare drop counter cannot tell an operator whether
    a client stopped reading or a follower is merely running behind, and those need
    opposite responses. D6 waits for the wedge specifically, asserts no other follower
    was cut, and paces the tape against the healthy follower so there is one candidate
    rather than two. 0 failures in 40 runs, against roughly 8% before; still fails
    against a fanout that blocks instead of shedding.


Upgrading: this release contains two breaking changes. Any client built against wire v3 must be rebuilt (MDSubscribe gains Symbol), and anyone driving matching.Shards with more than one symbol must now supply a Manifest — previously every shard silently used index 0 and minted colliding ids.

Full changelog: v0.21.0...v0.22.0

v0.21.0 — the trade-bust release

Choose a tag to compare

@intrepidkarthi intrepidkarthi released this 10 Aug 10:10

The trade-bust release, and a reminder of why this project writes specs first: the
spec found a defect that had been shipping for four releases before a line of the
feature existed. An operator halt issued after the last checkpoint was never written
to the log, so a venue somebody had deliberately stopped came back open. Trade bust
needed a durable seam for control commands, went looking for one, and there wasn't
one.

The feature itself is mostly a list of things it refuses to do. A bust annuls a
print; it does not put the orders back, does not un-fire the stops the print
triggered, does not rewind the reference price, and does not amend the event that
reported the trade. Each of those is a test, because each looks like a bug until you
notice the book at bust time is not the book at trade time.

Added

  • Trade bust (Engine.Bust, TRADE-BUST.md) — annulling a
    print that has already been published. It is an appended EventBusted referring
    backwards by trade id, never a rewrite, because the tape a follower replays has to
    stay identical to the tape the primary produced. The surprising part is what it
    deliberately does not do, and each of the four has a test: the busted orders are
    not re-rested, the stops the print fired stay fired, LastTradePrice is not
    rewound, and the trade event is not amended. A bust arrives after the market has
    moved, and each of those undos would be a second wrong rather than a correction of
    the first.

    The registry lives in the snapshot and therefore in the digest — two engines that
    applied the same commands are equal only if they also agree on what settled. Drill
    D7 is why that matters: a follower that drops the bust has a byte-identical book
    and a different digest, which is the only reason the divergence is detectable at
    all. marketdata.Feed is the consumer, publishing UpdateBust alongside the trade
    id that UpdateTrade never carried.

    Validation is identity-only: the engine refuses ids it never issued and says
    nothing about price, size or counterparty, because it does not retain the trades it
    printed. Duplicate busts are refused rather than swallowed.

Fixed

  • Control commands were applied but never written down. Runner.logCommand
    ended with default: return // control commands carry no book state; the snapshot covers them. The snapshot covers them as of the snapshot — so a halt, resume,
    cancel-only or mark-price change issued after the last checkpoint was in no log,
    recovery did not replay it, and a venue an operator had deliberately halted came
    back Open, ready to trade, with nobody told. Shipped in every release since
    control commands existed.

    It is the same reasoning error as the durability comment corrected in v0.20.0: a
    guarantee stated against the wrong reference point. It surfaced because trade bust
    needed a durable seam for control commands and the seam turned out not to exist —
    CommandLog now carries AppendHalt/Resume/CancelOnly/SetMark/Bust, and
    TestControlCommandsSurviveRecovery fails against the old code. Breaking for
    anyone implementing matching.CommandLog outside this repository.

  • The threat model claimed a trade-bust path that did not exist, and named the
    wrong mechanism for it.
    THREAT-MODEL.md credited the WAL
    spine with "clean trade-bust / replay" while
    PRODUCTION-READINESS.md said there was no way to
    amend a published trade. Writing the spec settled it: nobody had built one, and
    replaying a log without the busted trade — the mechanism the row described —
    rewrites history and hands every downstream consumer a tape that never happened.

  • The published test count was 100 short. PRODUCTION-READINESS.md said 480 test
    functions for several releases after the suite passed it; it is 584, and the line
    now carries the command that produces the number so the next reader can check it
    instead of believing it. The event-conformance suite is 23 scenarios, not 22.

Changed

  • Wire protocol v2 → v3: a trade now has a name. Executed and MDTrade
    reported price, quantity and aggressor but no identifier, so no message could ever
    refer back to one specific print — which meant a venue with trade bust could annul
    a fill it had never named, and no client could be told which one. Both payloads
    gain TradeID (+8 bytes each), and two messages use it: Busted (U) on
    order entry, private to the two counterparties, and MDBust (u) on market
    data, public. Every other payload is byte-identical to v2 apart from the version
    field itself — the discipline a bump is supposed to carry, and what the regenerated
    golden vectors show.

    Breaking: internal/wire is not importable, but any client built against v2
    must be rebuilt. pkg/orderentry.Msg gains TradeID and KindBusted.

    Routing the bust turned out to be harder than encoding it, and for a reason that
    is the whole shape of this feature: by the time a bust arrives, both orders have
    usually left the book.
    orderentry.Registry forgets an order the moment it fills
    or cancels, so the obvious implementation — look the trade up among live orders —
    delivers a bust to nobody in the common case. The Registry now keeps a bounded
    memory of recent prints (SetFillMemory, default 65,536, about 26 seconds of tape
    at the SOAK.md rate) purely so a bust can be routed, and one older than that memory
    increments UnroutableBusts rather than vanishing — "we could not tell the client"
    is an operational fact somebody has to act on. Size it to your bust window; CME's
    is eight minutes.


Full changelog: v0.20.0...v0.21.0

v0.20.0 — the outside-review release

Choose a tag to compare

@intrepidkarthi intrepidkarthi released this 10 Aug 08:55

The first release whose corrections came from outside. A reader on
r/highfreqtrading read the WAL's durability comment closely enough to see it was
ordered against the wrong thing, and proposed the recovery test that would have
caught it; both are below, credited, because the alternative is pretending the
audit was internal. Shipping alongside them: the interactive tutorial, which
teaches the order book by making you every player in it, on the real engine.

Fixed

  • The WAL's durability claim was ordered against the wrong thing. The package
    comment said records are written write-ahead "so no acknowledged command is
    lost." Write-ahead is ordered against apply, not against the acknowledgement a
    client receives: append writes into a buffer, only Sync survives the process,
    and with obgw group-committing every 20ms an order could be acknowledged and
    then vanish. The comment stated the opposite for four releases. A reader on
    r/highfreqtrading pushed on exactly this and was right. The window is now stated
    rather than denied, and obgw -sync-every-command closes it for anyone who
    wants acknowledgement to follow durability — correct, and ~210× the cost,
    because the fsync lands on the matching goroutine.

Added

  • Crash-at-every-boundary recovery test. The suite sampled five checkpoints
    across a 2000-command tape and compared only the book; the new test kills at
    every write and emit boundary and compares the trade tape too. Both halves are
    verified against deliberately broken code: dropping every replayed cancel fails
    the book assertion, and publishing replayed event batches in reverse order —
    same event count, so the digest is untouched — fails the tape assertion while
    the old test passes against the identical sabotage. Also proposed by the same
    reader.

  • The interactive tutorial (web/learn.html,
    TUTORIAL-SPEC.md) — learn the order book by being
    every player in it, on the real engine. Two devices no static explainer has:
    the ladder assembles itself as concepts arrive (chapter 1 opens on an empty
    market, because a market is a list of intentions and the list starts empty),
    and every objective is verified against the engine's actual book state — "get
    filled before the rival" completes when the book proves it, not when a Next
    button is pressed. Six chapters: the first seller, the wall builder, the
    taker, the queue jumper, the whale (the same 8-lot order against a thin and a
    deep book, with the measured slippage as the lesson), and the market maker,
    quoting both sides of a live market with honest P&L — including the loss
    case, when the price moves through the quotes. Browser-verified end to end;
    the pacing fixes came from watching an impatient automated learner reset its
    own queue position by re-quoting — which is itself the chapter's lesson.


Full changelog: v0.19.0...v0.20.0

v0.19.0 — the showcase release

Choose a tag to compare

@intrepidkarthi intrepidkarthi released this 03 Aug 09:22

The library learns to show itself — and stops calling itself production-grade.

  • The live consoletry it: the real engine compiled to WebAssembly matching a noise-trader market in your browser, with OFI/CVD/imbalance/Kyle-λ computed by the shipping pkg/signals code and pkg/surveillance watching every event. Every panel is titled by the exact library call that produces it. Place a limit order and watch it rest; run a spoof or a flood and watch the shipping detectors name the account. The market bar streams EngineSnapshot.Digest, so the determinism claim is falsifiable from the page.
  • cmd/obdash — an operator dashboard that is deliberately an ordinary subscriber: the venue's own market-data wire plus its /metrics page, re-served over SSE. The venue gains no code, no port, no attack surface — and the protocol gets a consumer written from the format alone, outside its own test tree.
  • "Production-grade" is retired from every header. Production readiness is a property of a deployment, not of code; no deployment exists, and independent review stands at none — so the headers now carry only what the tests can prove, and docs/PRODUCTION-READINESS.md carries the honest account. The docs page's stale benchmark figures (published for twelve releases) were caught and corrected in the same pass.

Full detail: CHANGELOG.md, docs/CONSOLE-SPEC.md.

v0.18.0 — the replication release

Choose a tag to compare

@intrepidkarthi intrepidkarthi released this 03 Aug 09:22

The HA seams get the consumer that would have noticed if they were phantoms, and the edge stops holding plaintext.

  • EngineSnapshot.Digest — the crash-recovery fingerprint, promoted from a test helper to a contract.
  • wal.SetOnAppend — the live log tail. Its first shape handed subscribers a pointer into state the matcher keeps mutating; the drills' first -race run caught it, and the hook now hands record bytes — the wire is byte-identical to the log.
  • examples/replication — the drilled primary-backup reference: async shipping with a measured loss window, the incarnation fence on promotion, drills D1–D6 in CI, and a failover runbook. Split-brain prevention stays deliberately yours.
  • Digests at restorderentry.HashedAccounts, sha256: credential entries, and obgw -hash-secret. A memory disclosure now yields digests, not passwords; rotation, revocation and expiry remain honestly absent.

Full detail: CHANGELOG.md, docs/REPLICATION.md — including §8, what building it found versus what the spec predicted.

v0.6.0 — market-integrity release

Choose a tag to compare

@intrepidkarthi intrepidkarthi released this 23 Jul 10:46

The market-integrity release: a research-grounded threat model and the defenses it prioritized, plus durable persistence. Try it live: https://intrepidkarthi.github.io/orderbook/

Highlights

  • Durable WAL (pkg/wal) — write-ahead command log + snapshots + torn-tail-safe recovery.
  • Threat model (docs/THREAT-MODEL.md) — 27 real order-book attacks → detection → defense → core-vs-layer, each tied to a named enforcement case.
  • In-core pre-trade risk controls — fat-finger/dust caps, per-account order cap, minimum resting time, client-order-id idempotency, mark-price step + depth bounds, chunked liquidation, timed band-breach pause, randomized iceberg peaks, notional-overflow guard, HALTED/RESUMED events.
  • Surveillance (pkg/surveillance) — order-to-trade ratio, marking-the-close, ramping, pinging, and cross-book detectors.
  • Call-auction session (pkg/auction) — open/close/recovery with a replay-safe randomized close.
  • Enforcing gateway (pkg/gateway) — token-bucket rate gate + taker speed bump.

Breaking

  • Engine.SetMarkPrice now returns error (rejects a mark update violating MaxMarkStep / MinMarkDepth).

Full notes: CHANGELOG.md