Files
2026-07-13 13:26:28 +08:00

577 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ---
# jupyter:
# jupytext:
# formats: ipynb,py:percent
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.19.1
# kernelspec:
# display_name: Python 3 (ipykernel)
# language: python
# name: python3
# ---
# %% [markdown]
# # NASDAQ TotalView-ITCH: Order Book Data Parsing
#
# **Chapter 3: Market Microstructure**
#
# **Docker image**: `ml4t`
#
# ## Purpose
#
# This notebook demonstrates how to parse NASDAQ's TotalView-ITCH binary protocol.
# Understanding MBO (message-by-order) data is foundational for microstructure-based ML features.
#
# ## Learning Objectives
#
# After completing this notebook, you will be able to:
# - Understand the ITCH 5.0 binary message format
# - Parse ITCH messages using Python's `struct` module
# - Load pre-parsed ITCH data for analysis
# - Choose between Python (educational) and Rust (production) parsers
#
# ## Cross-References
#
# - **Downstream**: `02_itch_lob_reconstruction` (builds order book from these messages)
# - **Related**: `09_databento_mbo_analysis` (alternative MBO data source)
#
# ## Data Requirements
#
# ITCH sample data can be downloaded using:
# ```bash
# python data/equities/market/microstructure/nasdaq_itch_download.py --list # List available files
# python data/equities/market/microstructure/nasdaq_itch_download.py # Download default sample
# ```
#
# Files are ~5GB compressed from: https://emi.nasdaq.com/ITCH/
# %% [markdown]
# ## ITCH Message Types
#
# The [ITCH v5.0 specification](https://www.nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/NQTVITCHSpecification.pdf) defines 20+ message types:
#
# | Type | Name | Description |
# |------|------|-------------|
# | **S** | System Event | Market open/close events |
# | **R** | Stock Directory | Ticker information and characteristics |
# | **H** | Trading Action | Trading halts, pauses, and resumptions |
# | **Y** | Reg SHO Restriction | Short sale price test restrictions |
# | **L** | Market Participant | Market maker positions |
# | **V** | MWCB Decline Level | Market-wide circuit breaker levels |
# | **W** | MWCB Status | Circuit breaker breach status |
# | **A** | Add Order | New limit order enters the book |
# | **F** | Add Order (MPID) | Same as A, with market participant ID |
# | **E** | Order Executed | Partial/full execution against standing order |
# | **C** | Order Executed w/Price | Execution at different price (hidden orders) |
# | **X** | Order Cancel | Partial cancellation |
# | **D** | Order Delete | Full removal from book |
# | **U** | Order Replace | Modify price/size (cancel + add) |
# | **P** | Trade | Non-displayed execution |
# | **Q** | Cross Trade | Opening/closing cross |
# | **B** | Broken Trade | Trade cancellation |
# | **I** | NOII | Net Order Imbalance Indicator (auction) |
# | **J** | LULD Auction Collar | Limit up-limit down price bands |
# | **K** | IPO Quoting Period | IPO quotation timing |
#
# By combining these messages chronologically, we can reconstruct the order book at any point in time.
# %%
"""NASDAQ TotalView-ITCH: Order Book Data Parsing — parse ITCH binary protocol into structured messages."""
import gzip
import os
import shutil
import struct
import warnings
from collections import Counter, defaultdict
from datetime import date, datetime
from pathlib import Path
from time import time
warnings.filterwarnings("ignore")
import polars as pl
from itch_message_specs import (
FMT_DICT,
MESSAGE_SPECS,
NT_DICT,
flush_to_parquet,
parse_price4,
parse_timestamp,
print_message_formats,
)
from tqdm.auto import tqdm
from data import load_nasdaq_itch
# %% tags=["parameters"]
SKIP_PARSING = False
# %%
# Data paths
# Canonical parsed messages location (both read and write target)
MESSAGE_DIR = load_nasdaq_itch(get_base_path=True)
MESSAGE_DIR.mkdir(parents=True, exist_ok=True)
# Raw ITCH binary input (from download script)
ITCH_RAW_DIR = MESSAGE_DIR.parent / "raw"
print(f"Raw ITCH data (input): {ITCH_RAW_DIR}")
print(f"Parsed messages (output): {MESSAGE_DIR}")
_raw_present = ITCH_RAW_DIR.exists() and next(ITCH_RAW_DIR.iterdir(), None) is not None
print(f"Raw data exists: {_raw_present}")
if not _raw_present:
print(" (run NB00_itch_download.py first to fetch the ITCH binary)")
# %% [markdown]
# ## 1. Message Specifications
#
# Each ITCH message has a fixed binary structure. The format is defined in `itch_message_specs.py`
# using Python's `struct` module. Format codes:
# - `H` = unsigned short (2 bytes)
# - `I` = unsigned int (4 bytes)
# - `Q` = unsigned long long (8 bytes)
# - `s` = char (1 byte), `Ns` = N chars
# - `>` = big-endian byte order
# %%
# Show message formats (loaded from utils/itch_message_specs.py)
print_message_formats()
# %% [markdown]
# ## 2. Binary Parsing Example
#
# Let's demonstrate how binary parsing works by creating and parsing a sample Add Order message.
# %%
# Create a sample Add Order message to demonstrate parsing
# Format: >HH6sQsI8sI (big-endian)
sample_add_order = struct.pack(
">HH6sQsI8sI",
1234, # stock_locate
5678, # tracking_number
b"\x00\x00\x00\x00\x00\x01", # timestamp (1 nanosecond)
9876543210, # order_reference_number
b"B", # buy_sell_indicator
100, # shares
b"AAPL ", # stock (padded to 8 chars)
1500000, # price (150.0000 in price4 format)
)
print(f"Raw Add Order message ({len(sample_add_order)} bytes):")
print(f" Hex: {sample_add_order.hex()}")
# %%
# Parse the binary data using our struct format
parsed = struct.unpack(FMT_DICT["A"], sample_add_order)
add_order = NT_DICT["A"]._make(parsed)
print("Parsed Add Order Message:")
print("-" * 40)
for field, value in add_order._asdict().items():
if isinstance(value, bytes):
value = value.decode("ascii").strip()
print(f" {field:25}: {value}")
# %%
# Apply conversions using helper functions from utils.itch_message_specs
ts_ns = parse_timestamp(add_order.timestamp)
price = parse_price4(add_order.price)
print(f"Timestamp: {ts_ns:,} nanoseconds = {ts_ns / 1e9:.9f} seconds after midnight")
print(f"Price: ${price:.4f}")
# %% [markdown]
# ## 3. Loading Pre-Parsed ITCH Data
#
# If you've already parsed ITCH data (using the Rust parser or Python parser), you can load
# the pre-parsed messages directly. This is the recommended approach for analysis.
# %%
# Check what data is available locally
print("ITCH Data Pipeline Status:")
print("-" * 50)
# Step 1: Raw binary from download
raw_files = []
if ITCH_RAW_DIR.exists():
raw_files = list(ITCH_RAW_DIR.glob("*.gz")) + list(ITCH_RAW_DIR.glob("*.bin"))
print(f"Raw binary files: {len(raw_files)}")
for f in raw_files:
print(f" {f.name} ({f.stat().st_size / 1e9:.2f} GB)")
# Step 2: Parsed messages (single uppercase letter = message type)
parsed_types = (
[
d
for d in sorted(MESSAGE_DIR.iterdir())
if d.is_dir() and len(d.name) == 1 and d.name.isupper()
]
if MESSAGE_DIR.exists()
else []
)
parsed_with_data = [d for d in parsed_types if list(d.glob("*.parquet"))]
print(f"Parsed message types: {len(parsed_with_data)}")
for msg_dir in parsed_with_data:
name = MESSAGE_SPECS.get(msg_dir.name, {}).get("name", "Unknown")
n_files = len(list(msg_dir.glob("*.parquet")))
print(f" {msg_dir.name} ({name}): {n_files} files")
# %%
# Validate: at minimum we need parsed data to continue
trade_dir = MESSAGE_DIR / "P"
assert trade_dir.exists() and list(trade_dir.glob("*.parquet")), (
f"No parsed ITCH data at {MESSAGE_DIR}.\n"
"To set up the data pipeline:\n"
" 1. Download raw data: uv run python data/equities/market/microstructure/nasdaq_itch_download.py\n"
" 2. Parse (this notebook, Section 4) or use Rust parser (Section 6)\n"
" 3. Parsed messages go to: data/equities/market/microstructure/nasdaq_itch/messages/"
)
trades = pl.read_parquet(trade_dir / "*.parquet")
print(f"\nLoaded {len(trades):,} trade messages")
print(f"Columns: {trades.columns}")
# %% [markdown]
# ## 4. Full Parser Implementation
#
# This Python parser is for **educational purposes**. For production use with large files,
# use the Rust parser (see Section 6) which provides order-of-magnitude speedups.
# %% [markdown]
# ### Parser Helpers
#
# We split the parser into three functions: `_read_frame` reads one binary message
# frame, `_decode_message` unpacks and converts it, and `parse_itch_file` orchestrates
# the loop with buffered Parquet writes.
# %%
def _read_frame(f, pbar) -> tuple[str, bytes] | None:
"""Read one ITCH message frame: 2-byte length + 1-byte type + payload.
Returns (msg_type, payload) on success, or None on EOF/truncation.
"""
# 2-byte big-endian length prefix (message size including type byte)
length_bytes = f.read(2)
if len(length_bytes) < 2:
return None
pbar.update(2)
msg_size = int.from_bytes(length_bytes, "big")
# 1-byte message type
msg_type_byte = f.read(1)
if len(msg_type_byte) < 1:
print(f"\nWarning: Truncated message at byte {f.tell()}, expected type byte")
return None
pbar.update(1)
msg_type = msg_type_byte.decode("ascii")
# Payload (msg_size includes type byte, so payload is msg_size - 1)
payload = f.read(msg_size - 1)
if len(payload) < msg_size - 1:
print(f"\nWarning: Truncated payload for message type {msg_type}")
return None
pbar.update(msg_size - 1)
return msg_type, payload
# %% [markdown]
# Decode binary payload into a Python dict, converting raw timestamp bytes
# to nanosecond integers and byte strings to stripped ASCII.
# %%
def _decode_message(msg_type: str, payload: bytes) -> dict | None:
"""Unpack binary payload into a dict, converting timestamps and strings.
Returns parsed message dict, or None on struct error.
"""
try:
parsed = struct.unpack(FMT_DICT[msg_type], payload)
msg = NT_DICT[msg_type]._make(parsed)._asdict()
except struct.error:
return None
# Convert timestamp: nanoseconds since midnight
if "timestamp" in msg:
msg["timestamp"] = int.from_bytes(msg["timestamp"], "big")
# Decode string fields
for field, value in msg.items():
if isinstance(value, bytes):
msg[field] = value.decode("ascii").strip()
return msg
# %% [markdown]
# The main parser reads the binary file sequentially, buffering decoded messages
# and flushing to Parquet periodically to bound memory usage.
# %% — single function body, helpers already extracted
def parse_itch_file(
itch_file: Path,
trading_day: date,
output_dir: Path,
max_buffered_messages: int = 10_000_000,
max_messages: int | None = None,
) -> dict[str, int]:
"""Parse ITCH binary file and store messages as Parquet.
Args:
itch_file: Path to binary ITCH file (.bin, not .gz).
trading_day: Trading date for timestamp construction.
output_dir: Directory for Parquet output (one subdir per message type).
max_buffered_messages: Flush threshold (total buffered messages).
max_messages: Optional limit for testing.
Returns:
Dictionary with message type counts.
"""
# Midnight timestamp for the trading day (ITCH timestamps are nanoseconds offset)
base_ts = datetime(trading_day.year, trading_day.month, trading_day.day)
file_counters: dict[str, int] = defaultdict(int)
buffers = defaultdict(list)
counts = Counter()
file_size = itch_file.stat().st_size
start_time = time()
with (
itch_file.open("rb") as f,
tqdm(total=file_size, desc="Parsing ITCH", unit="B", unit_scale=True) as pbar,
):
while True:
if max_messages and sum(counts.values()) >= max_messages:
print(f"\nLimit reached: {max_messages:,} messages")
break
frame = _read_frame(f, pbar)
if frame is None:
break
msg_type, payload = frame
counts[msg_type] += 1
if msg_type not in FMT_DICT:
continue
msg = _decode_message(msg_type, payload)
if msg is None:
continue
# Check for end of messages
if msg_type == "S" and msg.get("event_code") == "C":
print("\nEnd of Messages")
flush_to_parquet(buffers, output_dir, base_ts, file_counters)
break
buffers[msg_type].append(msg)
# Periodic flush
if sum(len(v) for v in buffers.values()) >= max_buffered_messages:
flush_to_parquet(buffers, output_dir, base_ts, file_counters)
# Final flush
if any(buffers.values()):
flush_to_parquet(buffers, output_dir, base_ts, file_counters)
elapsed = time() - start_time
total = sum(counts.values())
print(f"Parsed {total:,} messages in {elapsed:.1f}s ({total / elapsed:,.0f} msg/s)")
return dict(counts)
# %%
# Locate ITCH data file (skip if SKIP_PARSING is set)
if SKIP_PARSING:
print("SKIP_PARSING=True: skipping ITCH binary parsing (uses pre-parsed data)")
itch_file = None
counts = {}
else:
# Clear any existing parsed data to avoid schema conflicts
# (Different parser versions may produce different schemas)
# Set ITCH_KEEP_EXISTING=1 to skip cleanup and use existing data
clear_existing = os.environ.get("ITCH_KEEP_EXISTING", "0") != "1"
if clear_existing and MESSAGE_DIR.exists() and list(MESSAGE_DIR.glob("*/part-*.parquet")):
print(f"Clearing existing parsed data in {MESSAGE_DIR}")
print(" (Set ITCH_KEEP_EXISTING=1 to keep existing data)")
shutil.rmtree(MESSAGE_DIR)
MESSAGE_DIR.mkdir(parents=True, exist_ok=True)
# Find ITCH file (compressed or uncompressed)
gz_files = list(ITCH_RAW_DIR.glob("*.gz")) if ITCH_RAW_DIR.exists() else []
bin_files = list(ITCH_RAW_DIR.glob("*.bin")) if ITCH_RAW_DIR.exists() else []
if not gz_files and not bin_files:
raise FileNotFoundError(
f"No raw ITCH binary found at {ITCH_RAW_DIR}.\n"
"Download first:\n"
" uv run python data/equities/market/microstructure/nasdaq_itch_download.py"
)
# %%
# Decompress if needed and extract trading date
if not SKIP_PARSING and (gz_files or bin_files):
# Prefer uncompressed, otherwise decompress
if bin_files:
itch_file = bin_files[0]
print(f"Found uncompressed: {itch_file.name}")
else:
gz_file = gz_files[0]
itch_file = gz_file.with_suffix(".bin")
if not itch_file.exists():
print(f"Decompressing {gz_file.name}...")
with gzip.open(gz_file, "rb") as f_in, open(itch_file, "wb") as f_out:
shutil.copyfileobj(f_in, f_out)
print(f"Created: {itch_file.name} ({itch_file.stat().st_size / 1e9:.1f} GB)")
else:
print(f"Found: {itch_file.name}")
# Extract trading date from filename (format: MMDDYYYY.NASDAQ_ITCH50.bin)
date_str = itch_file.stem.split(".")[0]
if len(date_str) == 8 and date_str.isdigit():
trading_day = date(int(date_str[4:8]), int(date_str[:2]), int(date_str[2:4]))
else:
trading_day = date(2020, 1, 30) # Fallback
print(f"Trading day: {trading_day}")
# Parse ITCH file (full-day parse: ~22 min on the reference machine)
if itch_file and itch_file.exists():
counts = parse_itch_file(
itch_file=itch_file,
trading_day=trading_day,
output_dir=MESSAGE_DIR,
# max_messages=1_000_000, # Remove this line for full parse
)
print("\nMessage counts:")
for msg_type, count in sorted(counts.items(), key=lambda x: -x[1]):
name = MESSAGE_SPECS.get(msg_type, {}).get("name", "Unknown")
print(f" {msg_type} ({name:25}): {count:>10,}")
# %% [markdown]
# ## 5. Message Type Analysis
#
# After parsing, we can analyze message distributions.
# %%
# Message type distribution — use lazy scan to count without loading all data
print("Parsed Message Types:")
print("-" * 50)
for msg_dir in sorted(MESSAGE_DIR.iterdir()):
if (
msg_dir.is_dir()
and len(msg_dir.name) == 1
and msg_dir.name.isupper()
and list(msg_dir.glob("*.parquet"))
):
count = pl.scan_parquet(msg_dir / "*.parquet").select(pl.len()).collect().item()
name = MESSAGE_SPECS.get(msg_dir.name, {}).get("name", "Unknown")
print(f" {msg_dir.name} ({name:25}): {count:>12,} messages")
# %%
# Schema compatibility check — verify we can read each message type
print("Schema Compatibility Check:")
print("-" * 50)
for msg_dir in sorted(MESSAGE_DIR.iterdir()):
if (
msg_dir.is_dir()
and len(msg_dir.name) == 1
and msg_dir.name.isupper()
and list(msg_dir.glob("*.parquet"))
):
try:
sample = pl.scan_parquet(msg_dir / "*.parquet").head(5).collect()
name = MESSAGE_SPECS.get(msg_dir.name, {}).get("name", "Unknown")
print(f" [OK] {msg_dir.name} ({name:25}): cols={list(sample.columns)[:4]}...")
except Exception as e:
print(f" [FAIL] {msg_dir.name}: {e}")
# %% [markdown]
# ## 6. Production Parsing with Rust
#
# The Python parser above is educational but slow for full-day files.
# For production use, we provide a **Rust parser** that is an order of magnitude faster.
#
# **Repository**: [github.com/ml4t/itch-parser](https://github.com/ml4t/itch-parser)
#
# ### Performance Characteristics
#
# | Aspect | Python | Rust |
# |--------|--------|------|
# | Speed | Baseline | **10-20× faster** |
# | Memory | High (buffers in RAM) | Low (streaming) |
# | Use case | Learning, debugging | Production pipelines |
#
# Actual speedups depend on disk I/O and CPU. The Rust parser uses memory-mapped
# I/O and zero-copy parsing, which provides substantial gains on modern hardware.
#
# ### Installation
#
# ```bash
# # Clone the repository
# git clone https://github.com/ml4t/itch-parser.git
# cd itch-parser
#
# # Build release binary
# cargo build --release
# ```
#
# ### Usage
#
# ```bash
# # Parse ITCH file (works with .gz or uncompressed)
# ./target/release/itch_parser <input_file> <output_dir> <MMDDYYYY>
#
# # Example
# ./target/release/itch_parser data/01302020.NASDAQ_ITCH50.gz ./messages 01302020
# ```
#
# Output is identical Parquet files partitioned by message type, compatible with
# the Python code in this notebook and downstream analysis.
#
# ### When to Use Which
#
# | Use Case | Recommendation |
# |----------|---------------|
# | Learning the protocol | Python (this notebook) |
# | Debugging parse issues | Python |
# | Processing a single day | Either |
# | Multi-day backtesting | **Rust** |
# | Production pipeline | **Rust** |
# %% [markdown]
# ## Key Takeaways
#
# 1. **ITCH Protocol**: Binary message-by-order format with nanosecond precision
# 2. **Message Types**: A/F (add), E/C (execute), X (cancel), D (delete), U (replace)
# 3. **Price Format**: Integers with 4 implied decimals (150.0000 → 1500000)
# 4. **Parser Choice**: Python for learning, Rust for production (20× faster)
#
# ### Next Steps
#
# - **Order Book Reconstruction**: `02_itch_lob_reconstruction`
# - **Trading Activity Overview**: `05_itch_trading_activity` (includes E/C enrichment)
#
# ---
#
# ## Reference
#
# Bouchaud, J.-P., Bonart, J., Donier, J., & Gould, M. (2018).
# *Trades, Quotes and Prices: Financial Markets Under the Microscope*.
# Cambridge University Press.
# [https://doi.org/10.1017/9781009028943](https://doi.org/10.1017/9781009028943)