itch-book 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. itch_book-0.1.0/.github/workflows/ci.yml +87 -0
  2. itch_book-0.1.0/.github/workflows/wheels.yml +32 -0
  3. itch_book-0.1.0/.gitignore +14 -0
  4. itch_book-0.1.0/CMakeLists.txt +74 -0
  5. itch_book-0.1.0/LICENSE +21 -0
  6. itch_book-0.1.0/PKG-INFO +363 -0
  7. itch_book-0.1.0/README.md +336 -0
  8. itch_book-0.1.0/bench/apply_latency.cpp +87 -0
  9. itch_book-0.1.0/bench/book_throughput.cpp +80 -0
  10. itch_book-0.1.0/bench/parse_throughput.cpp +73 -0
  11. itch_book-0.1.0/bench/rss.hpp +33 -0
  12. itch_book-0.1.0/bench/slurp.hpp +27 -0
  13. itch_book-0.1.0/fuzz/fuzz_parse.cpp +13 -0
  14. itch_book-0.1.0/include/itch/book.hpp +182 -0
  15. itch_book-0.1.0/include/itch/book_manager.hpp +290 -0
  16. itch_book-0.1.0/include/itch/mapped_file.hpp +94 -0
  17. itch_book-0.1.0/include/itch/messages.hpp +259 -0
  18. itch_book-0.1.0/include/itch/order_store.hpp +325 -0
  19. itch_book-0.1.0/include/itch/parser.hpp +123 -0
  20. itch_book-0.1.0/include/itch/stream.hpp +73 -0
  21. itch_book-0.1.0/include/itch/wire.hpp +62 -0
  22. itch_book-0.1.0/pyproject.toml +58 -0
  23. itch_book-0.1.0/python/CMakeLists.txt +15 -0
  24. itch_book-0.1.0/python/itch_book/__init__.py +219 -0
  25. itch_book-0.1.0/python/itch_book/__main__.py +3 -0
  26. itch_book-0.1.0/python/itch_book/_core.pyi +15 -0
  27. itch_book-0.1.0/python/itch_book/_dates.py +25 -0
  28. itch_book-0.1.0/python/itch_book/cli.py +414 -0
  29. itch_book-0.1.0/python/itch_book/py.typed +0 -0
  30. itch_book-0.1.0/python/src/module.cpp +547 -0
  31. itch_book-0.1.0/python/tests/itch_stream.py +75 -0
  32. itch_book-0.1.0/python/tests/test_bbo.py +198 -0
  33. itch_book-0.1.0/python/tests/test_cli.py +296 -0
  34. itch_book-0.1.0/python/tests/test_depth.py +131 -0
  35. itch_book-0.1.0/python/tests/test_tables.py +303 -0
  36. itch_book-0.1.0/src/replay.cpp +152 -0
  37. itch_book-0.1.0/test/book_test.cpp +298 -0
  38. itch_book-0.1.0/test/check.hpp +27 -0
  39. itch_book-0.1.0/test/differential_test.cpp +112 -0
  40. itch_book-0.1.0/test/encode.hpp +177 -0
  41. itch_book-0.1.0/test/parse_test.cpp +381 -0
  42. itch_book-0.1.0/test/reference.hpp +104 -0
  43. itch_book-0.1.0/test/steady_state_test.cpp +63 -0
  44. itch_book-0.1.0/test/stream_test.cpp +173 -0
  45. itch_book-0.1.0/test/synthetic.hpp +204 -0
  46. itch_book-0.1.0/tools/gen_synthetic.cpp +43 -0
@@ -0,0 +1,87 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ gcc:
10
+ name: GCC
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
15
+ - run: cmake --build build
16
+ - run: ctest --test-dir build --output-on-failure
17
+
18
+ clang:
19
+ name: Clang
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - run: sudo apt-get update && sudo apt-get install -y libc++-dev libc++abi-dev
24
+ - run: >
25
+ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=clang++
26
+ -DCMAKE_CXX_FLAGS="-stdlib=libc++"
27
+ - run: cmake --build build
28
+ - run: ctest --test-dir build --output-on-failure
29
+
30
+ sanitizers:
31
+ name: ASan + UBSan
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+ - run: >
36
+ cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
37
+ -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all -O1 -g"
38
+ - run: cmake --build build
39
+ - run: ctest --test-dir build --output-on-failure
40
+
41
+ python:
42
+ name: Python (${{ matrix.os }})
43
+ runs-on: ${{ matrix.os }}
44
+ strategy:
45
+ fail-fast: false
46
+ matrix:
47
+ os: [ubuntu-latest, windows-latest]
48
+ defaults:
49
+ run:
50
+ shell: bash
51
+ steps:
52
+ - uses: actions/checkout@v4
53
+ - uses: actions/setup-python@v5
54
+ id: build-py
55
+ with:
56
+ python-version: "3.12"
57
+ - uses: actions/setup-python@v5
58
+ id: test-py
59
+ with:
60
+ python-version: "3.13"
61
+ - if: runner.os == 'Windows'
62
+ run: |
63
+ echo "CMAKE_GENERATOR=Ninja" >> "$GITHUB_ENV"
64
+ echo "CMAKE_ARGS=-DCMAKE_CXX_COMPILER=clang-cl" >> "$GITHUB_ENV"
65
+ - run: '"${{ steps.build-py.outputs.python-path }}" -m pip install ninja'
66
+ - run: '"${{ steps.build-py.outputs.python-path }}" -m pip wheel . -w dist --no-deps -v'
67
+ - run: '"${{ steps.test-py.outputs.python-path }}" -m pip install abi3audit pytest polars pyarrow'
68
+ - run: '"${{ steps.test-py.outputs.python-path }}" -m abi3audit --strict --report dist/*.whl'
69
+ - run: '"${{ steps.test-py.outputs.python-path }}" -m pip install --only-binary=:all: --find-links dist itch-book'
70
+ - run: '"${{ steps.test-py.outputs.python-path }}" -m pytest -q'
71
+
72
+ fuzz:
73
+ name: Fuzz (libFuzzer)
74
+ runs-on: ubuntu-latest
75
+ steps:
76
+ - uses: actions/checkout@v4
77
+ - run: wget -qO- https://apt.llvm.org/llvm.sh | sudo bash -s -- 19
78
+ - run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
79
+ - run: cmake --build build --target gen-synthetic
80
+ - run: >
81
+ clang++-19 -std=c++23 -O1 -g -fsanitize=fuzzer,address,undefined
82
+ -Iinclude -Ibuild/_deps/fixed_decimal-src/cpp/include
83
+ fuzz/fuzz_parse.cpp -o fuzz_parse
84
+ - run: |
85
+ mkdir corpus
86
+ ./build/gen-synthetic corpus/seed.itch 2000 10 7
87
+ - run: ./fuzz_parse -max_total_time=60 -max_len=65536 corpus/
@@ -0,0 +1,32 @@
1
+ name: Wheels
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ wheels:
10
+ name: ${{ matrix.os }}
11
+ runs-on: ${{ matrix.os }}
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ os: [ubuntu-latest, ubuntu-24.04-arm, macos-15-intel, macos-15, windows-latest]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - run: pipx run cibuildwheel==4.2.0
19
+ - uses: actions/upload-artifact@v4
20
+ with:
21
+ name: wheels-${{ matrix.os }}
22
+ path: wheelhouse/*.whl
23
+
24
+ sdist:
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - run: pipx run build --sdist
29
+ - uses: actions/upload-artifact@v4
30
+ with:
31
+ name: sdist
32
+ path: dist/*.tar.gz
@@ -0,0 +1,14 @@
1
+ build/
2
+ dist/
3
+ *.o
4
+ *.exe
5
+ *.pyd
6
+ *.so
7
+ __pycache__/
8
+ .pytest_cache/
9
+ *.egg-info/
10
+ .cache/
11
+ compile_commands.json
12
+ *.NASDAQ_ITCH50*
13
+ *.BX_ITCH_50*
14
+ *.itch
@@ -0,0 +1,74 @@
1
+ cmake_minimum_required(VERSION 3.21)
2
+ project(itch_book CXX)
3
+
4
+ add_library(itch_book INTERFACE)
5
+ target_include_directories(itch_book INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include)
6
+ target_compile_features(itch_book INTERFACE cxx_std_23)
7
+
8
+ include(FetchContent)
9
+ FetchContent_Declare(fixed_decimal
10
+ URL https://github.com/groovg/fixed-decimal/archive/a25e15801e32977df9abbea6a1819eecc9befaa9.tar.gz
11
+ URL_HASH SHA256=aedd636c8ba9874f7fdeda5b7e7d08f4a6a38c9cb0cc330153865bfe7202d5fa
12
+ SOURCE_SUBDIR cpp)
13
+ FetchContent_MakeAvailable(fixed_decimal)
14
+ target_link_libraries(itch_book INTERFACE fixed_decimal)
15
+
16
+ if(PROJECT_IS_TOP_LEVEL)
17
+ enable_testing()
18
+ add_executable(parse_test test/parse_test.cpp)
19
+ target_link_libraries(parse_test PRIVATE itch_book)
20
+ target_compile_options(parse_test PRIVATE -Wall -Wextra -Werror)
21
+ add_test(NAME parse COMMAND parse_test)
22
+
23
+ add_executable(stream_test test/stream_test.cpp)
24
+ target_link_libraries(stream_test PRIVATE itch_book)
25
+ target_compile_options(stream_test PRIVATE -Wall -Wextra -Werror)
26
+ add_test(NAME stream COMMAND stream_test)
27
+
28
+ add_executable(book_test test/book_test.cpp)
29
+ target_link_libraries(book_test PRIVATE itch_book)
30
+ target_compile_options(book_test PRIVATE -Wall -Wextra -Werror)
31
+ add_test(NAME book COMMAND book_test)
32
+
33
+ add_executable(steady_state_test test/steady_state_test.cpp)
34
+ target_link_libraries(steady_state_test PRIVATE itch_book)
35
+ target_compile_options(steady_state_test PRIVATE -Wall -Wextra -Werror)
36
+ add_test(NAME steady_state COMMAND steady_state_test)
37
+
38
+ add_executable(differential_test test/differential_test.cpp)
39
+ target_link_libraries(differential_test PRIVATE itch_book)
40
+ target_compile_options(differential_test PRIVATE -Wall -Wextra -Werror)
41
+ add_test(NAME differential COMMAND differential_test)
42
+
43
+ add_executable(gen-synthetic tools/gen_synthetic.cpp)
44
+ target_link_libraries(gen-synthetic PRIVATE itch_book)
45
+ target_include_directories(gen-synthetic PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/test)
46
+ target_compile_options(gen-synthetic PRIVATE -Wall -Wextra -Werror)
47
+
48
+ add_executable(parse_throughput bench/parse_throughput.cpp)
49
+ target_link_libraries(parse_throughput PRIVATE itch_book)
50
+ target_compile_options(parse_throughput PRIVATE -Wall -Wextra -Werror)
51
+
52
+ add_executable(book_throughput bench/book_throughput.cpp)
53
+ target_link_libraries(book_throughput PRIVATE itch_book)
54
+ target_include_directories(book_throughput PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/test)
55
+ target_compile_options(book_throughput PRIVATE -Wall -Wextra -Werror)
56
+ if(WIN32)
57
+ target_link_libraries(book_throughput PRIVATE psapi)
58
+ endif()
59
+
60
+ option(ITCH_BENCH_LATENCY "Build the apply-latency bench (fetches tsc-latency, x86 only)" OFF)
61
+ if(ITCH_BENCH_LATENCY)
62
+ FetchContent_Declare(tsc_latency
63
+ GIT_REPOSITORY https://github.com/groovg/tsc-latency
64
+ GIT_TAG 8b432562323a9c2c5f9f54064d7c1127139daa4c)
65
+ FetchContent_MakeAvailable(tsc_latency)
66
+ add_executable(apply_latency bench/apply_latency.cpp)
67
+ target_link_libraries(apply_latency PRIVATE itch_book tsclat)
68
+ target_compile_options(apply_latency PRIVATE -Wall -Wextra -Werror)
69
+ endif()
70
+
71
+ add_executable(itch-replay src/replay.cpp)
72
+ target_link_libraries(itch-replay PRIVATE itch_book)
73
+ target_compile_options(itch-replay PRIVATE -Wall -Wextra -Werror)
74
+ endif()
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 groovg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,363 @@
1
+ Metadata-Version: 2.4
2
+ Name: itch-book
3
+ Version: 0.1.0
4
+ Summary: NASDAQ TotalView-ITCH 5.0 to research tables: BBO, trades, order book. C++23 core, zero-copy numpy.
5
+ Keywords: nasdaq,itch,totalview,order-book,market-data,microstructure,parquet
6
+ Author: groovg
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Programming Language :: C++
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Topic :: Office/Business :: Financial
12
+ Classifier: Topic :: Scientific/Engineering
13
+ Project-URL: Homepage, https://github.com/groovg/itch-book
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: numpy>=1.24
16
+ Requires-Dist: tzdata
17
+ Provides-Extra: polars
18
+ Requires-Dist: polars>=1.0; extra == "polars"
19
+ Provides-Extra: cli
20
+ Requires-Dist: pyarrow>=16; extra == "cli"
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest; extra == "test"
23
+ Requires-Dist: polars>=1.0; extra == "test"
24
+ Requires-Dist: pyarrow>=16; extra == "test"
25
+ Requires-Dist: abi3audit; extra == "test"
26
+ Description-Content-Type: text/markdown
27
+
28
+ # itch-book
29
+
30
+ [![CI](https://github.com/groovg/itch-book/actions/workflows/ci.yml/badge.svg)](https://github.com/groovg/itch-book/actions/workflows/ci.yml)
31
+
32
+ NASDAQ TotalView-ITCH 5.0 feed handler and limit order book reconstruction in C++23.
33
+ Header-only, no dependencies beyond [fixed-decimal](https://github.com/groovg/fixed-decimal)
34
+ for exact prices. Parses the raw `BinaryFILE` day dumps NASDAQ publishes at
35
+ [emi.nasdaq.com/ITCH](https://emi.nasdaq.com/ITCH/) and maintains per-symbol books with
36
+ FIFO order queues, aggregate price levels and best bid/offer tracking.
37
+
38
+ On a full trading day (`12302019.NASDAQ_ITCH50`, 268.7M messages, 8.25 GB) it replays
39
+ parse + full book apply for all 8,907 symbols at **~17.3M messages/s single-threaded**
40
+ (58 ns/message) inside **~1.4 GB** of book structures, with zero unresolved order
41
+ references and zero crossed books at the close.
42
+
43
+ ## Usage
44
+
45
+ ```cpp
46
+ #include <itch/book_manager.hpp>
47
+ #include <itch/mapped_file.hpp>
48
+ #include <itch/parser.hpp>
49
+
50
+ itch::MappedFile file("12302019.NASDAQ_ITCH50");
51
+ itch::BookManager<> books;
52
+ itch::ParseResult r = itch::parse(file.bytes(), books);
53
+
54
+ itch::Bbo q = books.bbo(locate); // best bid/offer for a symbol
55
+ const auto* book = books.book(locate); // full depth, FIFO queues per level
56
+ ```
57
+
58
+ Handlers are plain structs; implement only the callbacks you need. Messages you skip cost
59
+ one length lookup, nothing is decoded for them:
60
+
61
+ ```cpp
62
+ struct Trades {
63
+ void on_trade(const itch::Trade& t) { /* ... */ }
64
+ };
65
+ Trades h;
66
+ itch::parse(file.bytes(), h);
67
+ ```
68
+
69
+ Input does not have to be one whole buffer. `StreamParser` reassembles frames that arrive
70
+ split across arbitrary chunk boundaries (socket reads, packet payloads); whole frames
71
+ inside a chunk are still parsed in place, only a partial tail is ever copied:
72
+
73
+ ```cpp
74
+ itch::StreamParser stream(books);
75
+ while (read_chunk(buf)) stream.feed(buf);
76
+ ```
77
+
78
+ The manager can also emit a time-and-sales stream: `E` executions print at the *resting
79
+ order's* price, which only the book knows, plus printable `C`, non-cross trades, crosses
80
+ and broken-trade voids, all in feed order:
81
+
82
+ ```cpp
83
+ itch::BookManager tape(nullptr, [](const itch::TradePrint& t) { /* ... */ });
84
+ ```
85
+
86
+ Build and test:
87
+
88
+ ```
89
+ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
90
+ cmake --build build
91
+ ctest --test-dir build
92
+ ```
93
+
94
+ Tools: `itch-replay <file> [--book]` (per-type counts, or full book replay with stats),
95
+ `gen-synthetic <out> <messages> [symbols] [seed]` (deterministic test feed),
96
+ `parse_throughput` / `book_throughput` / `apply_latency` benches (the last needs
97
+ `-DITCH_BENCH_LATENCY=ON`, x86 only). GCC and Clang; MSVC is out because the price
98
+ type needs `__int128`.
99
+
100
+ ## Python
101
+
102
+ The same core ships as a Python package: `pip install itch-book`, wheels for Linux x86_64 and
103
+ aarch64 (manylinux_2_28), macOS 11+ arm64 and x86_64, Windows x64, Python 3.10+; it depends on
104
+ numpy and tzdata. A source build needs a C++23 compiler. It reads raw or gzipped day files
105
+ straight from emi.nasdaq.com and hands out columnar batches as numpy arrays, zero-copy, so
106
+ Polars, pandas and pyarrow ingest them without conversion.
107
+
108
+ ```python
109
+ import itch_book as ib
110
+
111
+ feed = ib.open("20190730.BX_ITCH_50.gz") # session date from the filename
112
+ for batch in feed.batches(tables=("bbo", "symbols"), rows=1_000_000):
113
+ df = ib.to_polars(batch.bbo) # ts_event as Datetime("ns", "UTC")
114
+ feed.stats # message counts and book invariants
115
+ ```
116
+
117
+ Every table carries `ts_event` (int64 ns UTC: New York midnight of the session plus the
118
+ ITCH timestamp), `seq` (ordinal among decoded messages) and `locate`. Prices are float64
119
+ by default (every ITCH `Price(4)` is exact in a double) or the raw int64 mantissa with
120
+ `price_type="fixed"`; a missing price is NaN / 0. Single-character columns come out as
121
+ `S1`; `to_polars` turns them into strings. `rows` is a lower bound per batch: batches are
122
+ cut at chunk boundaries.
123
+
124
+ | table | one row per | columns |
125
+ |---|---|---|
126
+ | `bbo` | best bid or offer change (price or size) | `bid_px bid_sz bid_ct ask_px ask_sz ask_ct` |
127
+ | `trades` | E, printable C, P, Q, B | `kind price size side order_id match_number cross_type` |
128
+ | `messages` | A, F, E, C, X, D, U | `type action side price size remaining printable order_id old_order_id mpid` |
129
+ | `depth` | change within the top N levels of either side (`depth=10`) | `bid_px_00 bid_sz_00 bid_ct_00 ask_px_00 … ask_ct_09` |
130
+ | `system_events` | S | `event` |
131
+ | `symbols` | R | the stock directory fields |
132
+
133
+ `batches(..., symbols=("AAPL", "MSFT"))` keeps every book (executes and replaces need the
134
+ order they refer to, wherever it lives) and emits rows only for the selected locates;
135
+ `feed.stats` stays feed-wide, and `stats["selected"]` says how many names matched the
136
+ day's directory (a miss is a warning). `depth` rows carry no trigger columns; join
137
+ `messages` on `seq` for the event that produced a snapshot. On the BX day 99.8% of
138
+ book-changing messages touch the top ten levels, so `depth` at N=10 is effectively one row
139
+ per event there.
140
+
141
+ `trades`: an `E` prints at the resting order's price, a `C` at the message price and only
142
+ when printable, `side` is the resting side for E/C and `N` otherwise (the P side field is
143
+ always `B` on the wire and carries nothing), `order_id` is the resting order for E/C and 0
144
+ otherwise, `size` is uint64 because cross sizes are 8 bytes. A `B` row carries only
145
+ `match_number`; the trade it voids may sit in an earlier batch, so anti-join over the day.
146
+
147
+ `messages` is order-by-order with the resting state looked up before the message is
148
+ applied: `side`, `locate` and (for E/X/D) `price` come from the resting order, so a D/X/E
149
+ row is self-contained; `remaining` is what is left after apply, clamped at zero. `action`
150
+ is `A` add, `F` fill (E/C), `C` cancel (X/D), `M` replace (U, where `order_id` is the new
151
+ reference and `old_order_id` the old). Rows the book did not apply say so: `side == N` means
152
+ the reference was unknown and nothing changed; an A or U with `remaining == 0` was rejected
153
+ (zero shares or price). An A or U onto a live reference evicts it first. Non-printable C
154
+ executions are here with `printable == False` and absent from `trades`. `mpid` indexes
155
+ `feed.mpids` (0 = none) and is only set on F rows. `test_tables.py` replays this table with
156
+ those rules and reproduces `bbo` exactly on random days that include unknown references,
157
+ over-sized executes and duplicate references.
158
+
159
+ `symbols` is the stock directory keyed by `locate`. Decompression runs on a reader thread
160
+ in Python's zlib; the parser and books run in C++ with the GIL released.
161
+
162
+ BX 2019-07-30 (391 MB gzip, 28.7M messages, 8,849 symbols) on the machine above, gunzip
163
+ included: `bbo` alone 2.3 s (19.1M rows), the five row tables without `depth` 2.5 s
164
+ (`messages` 23.8M rows, `trades` 925k), every book invariant at zero. `depth` at N=10 for
165
+ all 8,849 symbols is the one expensive table: 7.0 s for 23.8M rows of 63 columns; with
166
+ three symbols selected the whole run is back to 2.3 s. One abi3 wheel covers 3.12 and later,
167
+ 3.10 and 3.11 get their own; Windows builds with clang-cl (MSVC has no `__int128`).
168
+
169
+ ### itch2parquet
170
+
171
+ `pip install 'itch-book[cli]'` adds a command that writes the tables as Parquet (pyarrow,
172
+ zstd, one row group per batch) and takes care of getting the data:
173
+
174
+ ```
175
+ itch2parquet list # what emi.nasdaq.com has, with sizes and session dates
176
+ itch2parquet fetch 20190730.BX_ITCH_50.gz --dir data # resumes a partial download, checks the published md5
177
+ itch2parquet verify data/20190730.BX_ITCH_50.gz # replays the day and prints the book invariants
178
+ itch2parquet convert data/20190730.BX_ITCH_50.gz out # bbo + trades by default
179
+ itch2parquet convert FILE out --tables messages,depth --symbols AAPL,MSFT --depth 5 --price-type fixed
180
+ ```
181
+
182
+ Every row table gets a dictionary-encoded `symbol` column next to `locate`, `trades` gets a
183
+ `broken` flag (true on a print that a later `B` voided; the `B` rows stay; the BX day above
184
+ has no `B` at all, so that path is exercised by the tests only), and `symbols.parquet` /
185
+ `system_events.parquet` are always written. Files are written to `.part` and renamed at the
186
+ end, so a failed run leaves nothing behind, and stale table files from an earlier run in the
187
+ same directory are removed first. The footer of each file carries `itch_book.*` key-value
188
+ metadata: a schema version, the source file name and its md5, session date, time zone,
189
+ `price_type` and `price_scale` (dollars = stored value / scale), the table list and symbol
190
+ filter, tool version, creation time, and the full stats dictionary (message counts,
191
+ unresolved references, `crossed_books` and `live_orders` as of the end of the file, last
192
+ system event), so a Parquet file states where it came from and whether the book that
193
+ produced it was clean. The session date comes from the filename (both emi naming schemes)
194
+ and is printed when inferred; `--date` overrides it. `trades` is held in memory for the
195
+ whole day to compute `broken` (about 50 bytes per print: ~100 MB for BX, ~1 GB for a NASDAQ
196
+ day); the other tables stream. Unsigned columns are stored with Parquet unsigned
197
+ annotations, which polars, pyarrow, duckdb and pandas read directly and some older JVM
198
+ readers do not. There is no per-symbol partitioning; filter the tables afterwards. The BX
199
+ day above converts to `bbo` (267 MB) + `trades` (15 MB) in 6.6 s including the md5 pass.
200
+
201
+ ## Wire format notes
202
+
203
+ Every message sits behind a 2-byte big-endian length prefix; a zero length marks end of
204
+ session. The parser treats the prefix as authoritative: known types are additionally
205
+ checked against their fixed spec length (one table lookup), unknown or mismatched frames
206
+ are skipped by length and counted, never parsed. Nasdaq adds message types over the years
207
+ (`O`, Direct Listing with Capital Raise, arrived in 2023), and parsers that abort on
208
+ unknown bytes die on the first file recorded after their spec revision.
209
+
210
+ All multi-byte fields are big-endian at odd offsets. Fields are decoded with `memcpy`
211
+ into an integer plus `std::byteswap`; at `-O2` GCC and Clang compile that to the same
212
+ single `mov + bswap` a `reinterpret_cast` of a packed struct would produce, but without
213
+ the unaligned-access UB, so the hot path runs clean under UBSan and works on
214
+ strict-alignment targets. The 6-byte timestamps are copied as 6 bytes; the popular trick
215
+ of one 8-byte load at offset 5 shifted right reads past the end of the buffer on the
216
+ final 12-byte message of a file.
217
+
218
+ Dispatch is a `switch` on the type byte into a compile-time handler concept
219
+ (`if constexpr (requires { h.on_add(...); })`), so decode inlines straight into book
220
+ application. No virtual calls anywhere on the hot path.
221
+
222
+ Prices are ITCH `Price(4)`, 32-bit unsigned with four implied decimals, and land in
223
+ `fixed_decimal::Fixed<4, PriceTag, int64_t>`: exact integer mantissa arithmetic, no
224
+ floats, `from_raw` costs nothing.
225
+
226
+ ## Book design
227
+
228
+ - Books live in a flat vector indexed by `stock locate` (the spec defines it as a dense,
229
+ day-scoped array index), so no symbol hashing ever happens per message.
230
+ - Price levels per side are a sorted vector with the best level at the back. Ask prices
231
+ are stored negated so both sides share one ascending comparator and the same
232
+ scan-from-back loop. Adds and deletes overwhelmingly hit within a few levels of the
233
+ touch, so the linear scan typically ends in 1–5 comparisons; a deep insert pays an
234
+ O(levels) `memmove`, which the latency table below quantifies.
235
+ - Level records (aggregate shares, order count, FIFO head/tail) are pooled per book
236
+ behind 32-bit handles with a LIFO freelist: no allocation per level after warm-up, and
237
+ handles stay valid across vector growth.
238
+ - Orders carry their level handle, so executes, cancels, deletes and replaces never
239
+ search the book: one order lookup, one level dereference. Messages that mutate an
240
+ existing order are more than half of a NASDAQ day (deletes alone are ~43%), which is
241
+ why this is the property worth paying for.
242
+ - FIFO queues are intrusive doubly-linked chains of order references per level, so queue
243
+ position is reconstructible. That is the part aggregate-only books throw away.
244
+ - The order-reference index exploits that ITCH refs are day-unique and near-dense: a
245
+ paged direct index (8,192 refs per page) instead of a hash. The default store keeps
246
+ pages of 32-bit handles into a recycled order pool; a page is freed to a spare list the
247
+ moment its last order dies, so resident memory is bounded by the live window of the ref
248
+ space, not by the day's 118M adds. A cap on the accepted ref space (`kMaxRef`) keeps a
249
+ corrupt or adversarial feed from growing the page table without bound.
250
+ - `reserve()` on the manager and the store pre-sizes everything for a strict
251
+ zero-allocation steady state, verified by a test that counts global `operator new`
252
+ calls across 400k messages after warm-up: zero.
253
+ - Trading-action state (`H`) is tracked per locate and queryable
254
+ (`trading_state(locate)`), but books are deliberately not gated on it: Nasdaq keeps
255
+ order maintenance flowing during halts, so a handler that stops applying messages on
256
+ `H` resumes with a corrupt book.
257
+
258
+ Robustness rules: unknown refs are counted and ignored, duplicate adds replace the stale
259
+ order, over-sized executes clamp, zero-share or zero-price messages are rejected. Each
260
+ path is unit-tested and mirrored exactly by the reference implementation used for
261
+ differential testing.
262
+
263
+ ## Correctness
264
+
265
+ - Differential test: a deliberately naive reference book (`std::map` levels,
266
+ `std::unordered_map` orders, ~100 lines) consumes the same synthetic feeds as the fast
267
+ book; full book states (every level, every side, live-order counts) are compared at
268
+ checkpoints. Runs over 3 seeds × 400k messages × all three order-store variants.
269
+ - Structural invariants (`Book::validate`): sorted sides, level aggregates equal to the
270
+ sum of their FIFO chain, link consistency, order counts.
271
+ - Real-data smoke: the full NASDAQ and BX days replay with zero missing refs, zero
272
+ duplicates, zero rejects, zero clamps, and zero crossed books at the close.
273
+ - Fuzzing: a libFuzzer harness drives `parse` + book apply in CI (ASan+UBSan); a
274
+ deterministic mutation test (bit flips + truncations over a synthetic feed) runs in the
275
+ regular suite. The framing layer never reads outside the buffer by construction; decode
276
+ only happens after the length check.
277
+ - CI: GCC, Clang, ASan+UBSan, fuzz, all on every push.
278
+
279
+ ## Benchmarks
280
+
281
+ Machine: AMD Ryzen 9 9950X3D (Zen 5), Windows 11, GCC 16.1 `-O3`, single thread, no core
282
+ isolation. Input: `12302019.NASDAQ_ITCH50` (268,744,780 messages, 8.25 GB) fully resident
283
+ in a RAM buffer, so no IO or page-cache effects in the measured loop. Reproduce with
284
+ `parse_throughput <file>` and `book_throughput <file> <variant>`.
285
+
286
+ Parse only:
287
+
288
+ | tier | throughput | per message |
289
+ |---|---|---|
290
+ | framing walk (length-prefix skip) | 747 M msg/s (~23 GB/s) | 1.3 ns |
291
+ | full decode, all 10 book-affecting types, checksummed | 194 M msg/s | 5.2 ns |
292
+
293
+ Parse + apply, whole day, all symbols (best of repeated runs; "structures" is peak RSS
294
+ minus the input buffer):
295
+
296
+ | variant | throughput | per message | structures |
297
+ |---|---|---|---|
298
+ | **pooled pages + order pool (default)** | **17.3 M msg/s** | **58 ns** | **~1.4 GB** |
299
+ | inline paged records | 14.5 M msg/s | 69 ns | ~9.8 GB |
300
+ | open-addressing flat hash | 9.7 M msg/s | 103 ns | ~0.3 GB |
301
+ | `unordered_map` ref index, same book | 5.7 M msg/s | 174 ns | ~0.3 GB |
302
+ | naive book (`std::map` + `unordered_map`) | 3.5 M msg/s | 287 ns | ~0.2 GB |
303
+
304
+ Where the factors come from. Replacing `std::map` levels with the sorted vector is ~1.6×
305
+ (touch-local scans instead of pointer chasing). Replacing the hash ref-index with paged
306
+ direct indexing is another ~3×: one arithmetic dereference, no hashing, no probe chains,
307
+ no rehash stalls, and near-monotonic refs keep the hot pages cached. The flat hash
308
+ (fibonacci hashing, linear probing, backward-shift deletion) isolates how much of the
309
+ `unordered_map` cost is the container itself: dropping per-node allocation and
310
+ bucket-chain chasing buys ~1.7×, but it still hashes, probes and moves 40-byte slots on
311
+ every delete, where the direct index just dereferences. When the key space is day-unique
312
+ and near-dense, indexing beats even a good hash. The inline variant stores whole order
313
+ records in the pages and skips the second indirection, but at ~10 GB of sparse pages the
314
+ TLB pressure eats the win; the pooled variant keeps the live set compact and is both
315
+ faster and 7× smaller. The `itch-replay --book` tool (mmap file, BBO tracking on) does
316
+ the same day at 12.8 M msg/s.
317
+
318
+ Per-operation apply latency (rdtsc via
319
+ [tsc-latency](https://github.com/groovg/tsc-latency), uncorrected, includes the ~10 ns
320
+ timestamp-pair floor; ns):
321
+
322
+ | op | count | p50 | p90 | p99 | p99.9 | p99.99 | max |
323
+ |---|---|---|---|---|---|---|---|
324
+ | add | 118.6M | 100 | 170 | 380 | 537 | 3,728 | 51.6 ms |
325
+ | reduce (E/C/X) | 8.6M | 40 | 110 | 309 | 514 | 954 | 152 µs |
326
+ | delete | 114.4M | 60 | 140 | 358 | 604 | 4,175 | 2.4 ms |
327
+ | replace | 21.6M | 140 | 287 | 567 | 865 | 4,235 | 1.5 ms |
328
+
329
+ The reduce p50 of 40 ns is the O(1) level-handle path. The p99.99 band is deep sorted-
330
+ vector `memmove`s and fresh page allocations; the millisecond maxima are OS scheduler
331
+ preemptions. Nothing was pinned or isolated, and a single uncorrected run over 268M
332
+ messages will catch a few.
333
+
334
+ For context, published single-threaded parse+apply numbers elsewhere:
335
+ charles-cooper/itch-order-book reports 61 ns/tick (~16.4 M msg/s) on a 2012 i7-3820 with
336
+ aggregate-only levels and a 4.4 GB preallocated ref array; CppTrader reports 3.2 M msg/s
337
+ for its reference book and ~9.8 M for its stripped benchmark variant on an i7-4790K.
338
+ Different hardware and different feature sets, so the numbers are not directly
339
+ comparable; this implementation keeps FIFO queues, bounded memory and feed-robustness
340
+ checks on at all times.
341
+
342
+ ## Limitations
343
+
344
+ - Replay, not a live feed handler. `StreamParser` reassembles frames split across
345
+ arbitrary chunk boundaries, but there is no MoldUDP64/SoupBinTCP session layer on top,
346
+ no A/B feed arbitration, no gap or retransmission requests.
347
+ - Book-affecting messages, trades and trade voids (`P`/`Q`/`B`) and trading actions (`H`)
348
+ are decoded; NOII, RegSHO, LULD and the other administrative types are framed and
349
+ counted but not decoded.
350
+ - Order references are trusted to be locate-consistent (the order's stored locate wins
351
+ over the message header on E/X/D/U, so a corrupt feed cannot cross-corrupt books).
352
+ - Single-threaded by design; shard symbols across instances above the library if needed.
353
+ - Latency numbers above are from an unpinned desktop Windows box with boost clocks on.
354
+
355
+ ## What I would do differently in production
356
+
357
+ MoldUDP64 with A/B arbitration and gap-fill feeding `StreamParser`; pinned cores, huge
358
+ pages for the order pool, and an `io_uring` read path on Linux; per-symbol sharding with
359
+ an SPSC handoff per shard.
360
+
361
+ ## License
362
+
363
+ MIT