Files
stefan-jansen--machine-lear…/tests/generate_test_microstructure.py
2026-07-13 13:26:28 +08:00

866 lines
37 KiB
Python

"""Generate minimal synthetic test data for Ch03 microstructure notebooks.
Creates test-sized datasets that match the schemas expected by Ch03 notebooks
and related notebooks (Ch02 futures individual, Ch04 prediction markets).
Writes to ~/ml4t/test-data/data/ which serves as ML4T_DATA_PATH
in CI.
Usage:
uv run python tests/generate_test_microstructure.py
"""
from datetime import date, datetime, time, timedelta
from pathlib import Path
import numpy as np
import polars as pl
# ── Output root ──────────────────────────────────────────────────────────────
TEST_DATA_ROOT = Path.home() / "ml4t" / "test-data" / "data"
# Seed for reproducibility
RNG = np.random.default_rng(42)
# ═════════════════════════════════════════════════════════════════════════════
# 1. ITCH Parsed Messages (for NB 02-10)
# ═════════════════════════════════════════════════════════════════════════════
# These go into the ITCH messages path that load_nasdaq_itch() resolves:
# ML4T_DATA_PATH / "equities" / "market" / "microstructure" / "nasdaq_itch" / "messages"
# Notebooks 02-10 read via utils.limit_orderbook.load_itch_messages(itch_dir, msg_type, symbol)
def _ns_timestamp(hour: int, minute: int, second: int = 0, micro: int = 0) -> datetime:
"""Create a nanosecond-precision datetime on the ITCH trading day (2020-01-30)."""
return datetime(2020, 1, 30, hour, minute, second, micro)
def generate_itch_messages() -> None:
"""Generate all ITCH message type parquet files."""
itch_dir = (
TEST_DATA_ROOT / "equities" / "market" / "microstructure" / "nasdaq_itch" / "messages"
)
# ── R (Stock Directory) ──────────────────────────────────────────────
r_dir = itch_dir / "R"
r_dir.mkdir(parents=True, exist_ok=True)
r_df = pl.DataFrame(
{
"stock_locate": pl.Series([1, 2, 3], dtype=pl.UInt16),
"tracking_number": pl.Series([0, 0, 0], dtype=pl.UInt16),
"timestamp": [
_ns_timestamp(4, 0, 0),
_ns_timestamp(4, 0, 0),
_ns_timestamp(4, 0, 0),
],
"stock": ["AAPL", "MSFT", "NVDA"],
"market_category": ["Q", "Q", "Q"],
"financial_status": ["N", "N", "N"],
"round_lot_size": pl.Series([100, 100, 100], dtype=pl.UInt32),
"round_lots_only": ["N", "N", "N"],
"issue_classification": ["C", "C", "C"],
"issue_subtype": ["Z", "Z", "Z"],
"authenticity": ["P", "P", "P"],
"short_sale_threshold": ["N", "N", "N"],
"ipo_flag": ["N", "N", "N"],
"luld_reference_price_tier": ["1", "1", "1"],
"etp_flag": ["N", "N", "N"],
"etp_leverage_factor": pl.Series([0, 0, 0], dtype=pl.UInt32),
"inverse_indicator": ["N", "N", "N"],
}
).cast({"timestamp": pl.Datetime("ns")})
r_df.write_parquet(r_dir / "part-000000.parquet")
# ── S (System Event) ─────────────────────────────────────────────────
s_dir = itch_dir / "S"
s_dir.mkdir(parents=True, exist_ok=True)
s_df = pl.DataFrame(
{
"stock_locate": pl.Series([0, 0, 0, 0], dtype=pl.UInt16),
"tracking_number": pl.Series([0, 0, 0, 0], dtype=pl.UInt16),
"timestamp": [
_ns_timestamp(4, 0, 0),
_ns_timestamp(9, 30, 0),
_ns_timestamp(16, 0, 0),
_ns_timestamp(20, 0, 0),
],
"event_code": ["O", "Q", "M", "C"],
}
).cast({"timestamp": pl.Datetime("ns")})
s_df.write_parquet(s_dir / "part-000000.parquet")
# ── A (Add Order) ────────────────────────────────────────────────────
# 20 orders for AAPL (stock_locate=1), spanning 10:00 to 15:00
a_dir = itch_dir / "A"
a_dir.mkdir(parents=True, exist_ok=True)
n_orders = 20
base_price_aapl = 320.0 # AAPL price circa Jan 2020
order_refs = list(range(1001, 1001 + n_orders))
sides = ["B" if i % 2 == 0 else "S" for i in range(n_orders)]
shares = [int(RNG.integers(100, 1001)) for _ in range(n_orders)]
# Prices: bids slightly below base, asks slightly above
prices = []
for i, side in enumerate(sides):
offset = RNG.uniform(0.01, 0.50)
if side == "B":
prices.append(round(base_price_aapl - offset, 4))
else:
prices.append(round(base_price_aapl + offset, 4))
# Timestamps spaced across 10:00-15:00 (300 minutes = 18000 seconds)
a_timestamps = [
_ns_timestamp(10, 0) + timedelta(seconds=int(i * 18000 / n_orders)) for i in range(n_orders)
]
# Prices stored as ITCH price4 integers (multiply by 10000) per spec
a_df = pl.DataFrame(
{
"stock_locate": pl.Series([1] * n_orders, dtype=pl.UInt16),
"tracking_number": pl.Series([0] * n_orders, dtype=pl.UInt16),
"timestamp": a_timestamps,
"order_reference_number": pl.Series(order_refs, dtype=pl.UInt64),
"buy_sell_indicator": sides,
"shares": pl.Series(shares, dtype=pl.UInt32),
"stock": ["AAPL"] * n_orders,
"price": pl.Series([int(p * 10000) for p in prices], dtype=pl.UInt32),
}
).cast({"timestamp": pl.Datetime("ns")})
a_df.write_parquet(a_dir / "part-000000.parquet")
# ── D (Order Delete) ─────────────────────────────────────────────────
d_dir = itch_dir / "D"
d_dir.mkdir(parents=True, exist_ok=True)
delete_refs = [1001, 1003, 1005, 1007, 1009]
d_df = pl.DataFrame(
{
"stock_locate": pl.Series([1] * 5, dtype=pl.UInt16),
"tracking_number": pl.Series([0] * 5, dtype=pl.UInt16),
"timestamp": [
a_timestamps[0] + timedelta(seconds=30),
a_timestamps[2] + timedelta(seconds=30),
a_timestamps[4] + timedelta(seconds=30),
a_timestamps[6] + timedelta(seconds=30),
a_timestamps[8] + timedelta(seconds=30),
],
"order_reference_number": pl.Series(delete_refs, dtype=pl.UInt64),
}
).cast({"timestamp": pl.Datetime("ns")})
d_df.write_parquet(d_dir / "part-000000.parquet")
# ── E (Order Executed) ───────────────────────────────────────────────
e_dir = itch_dir / "E"
e_dir.mkdir(parents=True, exist_ok=True)
exec_refs = [1002, 1004, 1006, 1008, 1010, 1012, 1014, 1016]
exec_shares = [min(shares[r - 1001] // 2, 200) for r in exec_refs]
e_df = pl.DataFrame(
{
"stock_locate": pl.Series([1] * 8, dtype=pl.UInt16),
"tracking_number": pl.Series([0] * 8, dtype=pl.UInt16),
"timestamp": [a_timestamps[r - 1001] + timedelta(seconds=60) for r in exec_refs],
"order_reference_number": pl.Series(exec_refs, dtype=pl.UInt64),
"executed_shares": pl.Series(exec_shares, dtype=pl.UInt32),
"match_number": pl.Series(list(range(5001, 5009)), dtype=pl.UInt64),
}
).cast({"timestamp": pl.Datetime("ns")})
e_df.write_parquet(e_dir / "part-000000.parquet")
# ── X (Order Cancel) ─────────────────────────────────────────────────
x_dir = itch_dir / "X"
x_dir.mkdir(parents=True, exist_ok=True)
cancel_refs = [1011, 1013, 1015]
cancel_shares = [shares[r - 1001] // 3 for r in cancel_refs]
x_df = pl.DataFrame(
{
"stock_locate": pl.Series([1] * 3, dtype=pl.UInt16),
"tracking_number": pl.Series([0] * 3, dtype=pl.UInt16),
"timestamp": [a_timestamps[r - 1001] + timedelta(seconds=45) for r in cancel_refs],
"order_reference_number": pl.Series(cancel_refs, dtype=pl.UInt64),
"cancelled_shares": pl.Series(cancel_shares, dtype=pl.UInt32),
}
).cast({"timestamp": pl.Datetime("ns")})
x_df.write_parquet(x_dir / "part-000000.parquet")
# ── C (Order Executed with Price) ────────────────────────────────────
c_dir = itch_dir / "C"
c_dir.mkdir(parents=True, exist_ok=True)
c_refs = [1017, 1018]
c_df = pl.DataFrame(
{
"stock_locate": pl.Series([1] * 2, dtype=pl.UInt16),
"tracking_number": pl.Series([0] * 2, dtype=pl.UInt16),
"timestamp": [
a_timestamps[16] + timedelta(seconds=90),
a_timestamps[17] + timedelta(seconds=90),
],
"order_reference_number": pl.Series(c_refs, dtype=pl.UInt64),
"executed_shares": pl.Series([shares[16] // 4, shares[17] // 4], dtype=pl.UInt32),
"match_number": pl.Series([6001, 6002], dtype=pl.UInt64),
"printable": ["Y", "Y"],
"execution_price": pl.Series(
[int(prices[16] * 10000), int(prices[17] * 10000)], dtype=pl.UInt32
),
}
).cast({"timestamp": pl.Datetime("ns")})
c_df.write_parquet(c_dir / "part-000000.parquet")
# ── P (Non-Cross Trade) ──────────────────────────────────────────────
p_dir = itch_dir / "P"
p_dir.mkdir(parents=True, exist_ok=True)
p_df = pl.DataFrame(
{
"stock_locate": pl.Series([1, 1, 1], dtype=pl.UInt16),
"tracking_number": pl.Series([0, 0, 0], dtype=pl.UInt16),
"timestamp": [
_ns_timestamp(11, 30, 0),
_ns_timestamp(13, 0, 0),
_ns_timestamp(14, 30, 0),
],
"order_reference_number": pl.Series([2001, 2002, 2003], dtype=pl.UInt64),
"buy_sell_indicator": ["B", "S", "B"],
"shares": pl.Series([200, 150, 300], dtype=pl.UInt32),
"stock": ["AAPL", "AAPL", "AAPL"],
"price": pl.Series([int(base_price_aapl * 10000)] * 3, dtype=pl.UInt32),
"match_number": pl.Series([7001, 7002, 7003], dtype=pl.UInt64),
}
).cast({"timestamp": pl.Datetime("ns")})
p_df.write_parquet(p_dir / "part-000000.parquet")
# ── U (Order Replace) ────────────────────────────────────────────────
u_dir = itch_dir / "U"
u_dir.mkdir(parents=True, exist_ok=True)
u_df = pl.DataFrame(
{
"stock_locate": pl.Series([1, 1], dtype=pl.UInt16),
"tracking_number": pl.Series([0, 0], dtype=pl.UInt16),
"timestamp": [
a_timestamps[18] + timedelta(seconds=20),
a_timestamps[19] + timedelta(seconds=20),
],
"original_order_reference_number": pl.Series([1019, 1020], dtype=pl.UInt64),
"new_order_reference_number": pl.Series([3001, 3002], dtype=pl.UInt64),
"shares": pl.Series([500, 600], dtype=pl.UInt32),
"price": pl.Series(
[int((base_price_aapl - 0.10) * 10000), int((base_price_aapl + 0.10) * 10000)],
dtype=pl.UInt32,
),
}
).cast({"timestamp": pl.Datetime("ns")})
u_df.write_parquet(u_dir / "part-000000.parquet")
print(f" ITCH messages written to {itch_dir}")
for sub in sorted(itch_dir.iterdir()):
if sub.is_dir() and sub.name != "enriched":
n = pl.scan_parquet(sub / "*.parquet").select(pl.len()).collect().item()
print(f" {sub.name}/: {n} rows")
# ═════════════════════════════════════════════════════════════════════════════
# 2. DataBento MBO (for NB 09-13)
# ═════════════════════════════════════════════════════════════════════════════
# Path: ML4T_DATA_PATH / "equities" / "market" / "microstructure" / "market_by_order" / "NVDA"
# File naming: xnas-itch-YYYYMMDD.mbo.dbn.parquet (DataBento convention)
# NB09, NB12 expect "timestamp" column. NB10, NB11, NB13 also need it.
# We provide BOTH ts_event and timestamp (same values) for compatibility.
def _generate_mbo_day(base_date: datetime, base_price_nano: int, start_order_id: int) -> list[dict]:
"""Generate one day of MBO messages with realistic bid/ask structure.
Returns a list of row dicts (not yet a DataFrame).
"""
rows: list[dict] = []
order_id = start_order_id
n_cycles = 50 # 50 cycles spread across 6.5 hours of trading
for cycle in range(n_cycles):
cycle_start_ms = cycle * 468_000 # ~7.8 min per cycle
# Phase 1: Adds (build book) - 15 orders per cycle
for i in range(15):
ts = base_date + timedelta(milliseconds=cycle_start_ms + i * 100)
side = "B" if i % 2 == 0 else "A"
if side == "B":
price_offset = -RNG.integers(1, 51) * 10_000_000
else:
price_offset = RNG.integers(1, 51) * 10_000_000
price = base_price_nano + price_offset
size = int(RNG.integers(1, 501))
rows.append(
{
"ts_event": ts,
"ts_recv": ts + timedelta(microseconds=int(RNG.integers(1, 100))),
"action": "A",
"side": side,
"price": price,
"size": size,
"order_id": order_id,
"flags": 0,
"publisher_id": 39,
}
)
order_id += 1
# Phase 2: Modifications - 3 per cycle
for i in range(3):
ts = base_date + timedelta(milliseconds=cycle_start_ms + 1500 + i * 200)
mod_order = order_id - 15 + i * 5
mod_side = "B" if i % 2 == 0 else "A"
if mod_side == "B":
price_offset = -RNG.integers(1, 31) * 10_000_000
else:
price_offset = RNG.integers(1, 31) * 10_000_000
rows.append(
{
"ts_event": ts,
"ts_recv": ts + timedelta(microseconds=int(RNG.integers(1, 100))),
"action": "M",
"side": mod_side,
"price": base_price_nano + price_offset,
"size": int(RNG.integers(1, 300)),
"order_id": mod_order,
"flags": 0,
"publisher_id": 39,
}
)
# Phase 3: Cancels - 3 per cycle
for i in range(3):
ts = base_date + timedelta(milliseconds=cycle_start_ms + 2100 + i * 200)
cancel_order = order_id - 14 + i * 5
rows.append(
{
"ts_event": ts,
"ts_recv": ts + timedelta(microseconds=int(RNG.integers(1, 100))),
"action": "C",
"side": "A" if i % 2 == 0 else "B",
"price": base_price_nano,
"size": 0,
"order_id": cancel_order,
"flags": 0,
"publisher_id": 39,
}
)
# Phase 4: Fills (F) and Trades (T) - 10 per cycle
for i in range(10):
ts = base_date + timedelta(milliseconds=cycle_start_ms + 2700 + i * 300)
fill_order = order_id - 13 + i
fill_size = int(RNG.integers(1, 200))
# Biased aggressor side for realistic imbalance runs.
# Runs of 10 consecutive cycles (~100 trades) with 95% bias,
# creating sustained imbalance that triggers bar boundaries.
# This mimics real institutional order flow patterns.
run_idx = cycle // 10
if run_idx % 2 == 0:
aggressor = "B" if RNG.random() < 0.95 else "A"
else:
aggressor = "A" if RNG.random() < 0.95 else "B"
trade_price = base_price_nano + RNG.integers(-5, 6) * 10_000_000
fill_side = "A" if aggressor == "B" else "B"
rows.append(
{
"ts_event": ts,
"ts_recv": ts + timedelta(microseconds=int(RNG.integers(1, 100))),
"action": "F",
"side": fill_side,
"price": trade_price,
"size": fill_size,
"order_id": fill_order,
"flags": 128,
"publisher_id": 39,
}
)
rows.append(
{
"ts_event": ts + timedelta(microseconds=1),
"ts_recv": ts + timedelta(microseconds=int(RNG.integers(2, 150))),
"action": "T",
"side": aggressor,
"price": trade_price,
"size": fill_size,
"order_id": fill_order,
"flags": 128,
"publisher_id": 39,
}
)
return rows
def generate_mbo_data() -> None:
"""Generate synthetic DataBento MBO tick data for NVDA.
Key schema requirements from notebooks:
- NB09 (lee_ready): expects "timestamp" column, reads parquet directly
- NB10 (information_bars): expects filename like xnas-itch-YYYYMMDD.mbo.dbn.parquet
- NB11 (lob_reconstruction): expects "ts_event" column, reads parquet directly
- NB12 (mbo_analysis): expects "timestamp" column, reads parquet directly
- NB13 (bar_sampling): expects "timestamp" column, filename like xnas-itch-*
We include both ts_event and timestamp columns, and use DataBento file naming.
We also generate enough data (spread across hours) for meaningful analysis.
"""
mbo_dir = TEST_DATA_ROOT / "equities" / "market" / "microstructure" / "market_by_order" / "NVDA"
mbo_dir.mkdir(parents=True, exist_ok=True)
# Remove old file if it exists (was named 20241104.parquet before)
old_file = mbo_dir / "20241104.parquet"
if old_file.exists():
old_file.unlink()
base_price_nano = 140_000_000_000 # $140 in nanodollars
# Generate 3 days of data. NB13 (bar_sampling) computes day-to-day CV which
# needs >= 2 days. NB10 (information_bars) also benefits from more trades.
trading_days = [
datetime(2024, 11, 4, 14, 30, 0), # Monday 9:30 AM ET in UTC
datetime(2024, 11, 5, 14, 30, 0), # Tuesday
datetime(2024, 11, 6, 14, 30, 0), # Wednesday
]
for day_idx, base_date in enumerate(trading_days):
rows = _generate_mbo_day(base_date, base_price_nano, 100_000 + day_idx * 10_000)
df = (
pl.DataFrame(rows)
.cast(
{
"ts_event": pl.Datetime("ns"),
"ts_recv": pl.Datetime("ns"),
"price": pl.Int64,
"size": pl.Int64,
"order_id": pl.Int64,
"flags": pl.Int64,
"publisher_id": pl.Int64,
}
)
.sort("ts_event")
)
# Add canonical "timestamp" column (same as ts_event) for notebooks that expect it.
# NB09, NB12, NB13 use "timestamp"; NB10, NB11 use "ts_event".
df = df.with_columns(pl.col("ts_event").alias("timestamp"))
# Write with DataBento filename convention: xnas-itch-YYYYMMDD.mbo.dbn.parquet
# NB10 and NB13 parse the filename: file_path.name.split("-")[2].split(".")[0]
date_str = base_date.strftime("%Y%m%d")
out_file = mbo_dir / f"xnas-itch-{date_str}.mbo.dbn.parquet"
df.write_parquet(out_file)
print(f" MBO day {date_str}: {len(df)} rows -> {out_file}")
# ═════════════════════════════════════════════════════════════════════════════
# 3. AlgoSeek TAQ (for NB 15-16)
# ═════════════════════════════════════════════════════════════════════════════
# Path: ML4T_DATA_PATH / "equities" / "market" / "microstructure" / "trade_and_quotes" / "symbol=AAPL" / "data.parquet"
# NB15 expects: TRADE, QUOTE BID, QUOTE ASK, QUOTE BID NB, QUOTE ASK NB event types
# NB15 does spread analysis using NBBO quotes and trade size distribution
def generate_taq_data() -> None:
"""Generate synthetic AlgoSeek TAQ tick data for AAPL on 2020-03-16.
Key schema requirements from notebooks:
- NB15 (taq_eda): Needs TRADE, QUOTE BID NB, QUOTE ASK NB event types
for spread analysis. Needs enough trades for size distribution.
- NB16 (taq_lob): Needs QUOTE BID/ASK for LOB reconstruction.
We generate ~600 events with realistic distributions.
"""
taq_dir = (
TEST_DATA_ROOT
/ "equities"
/ "market"
/ "microstructure"
/ "trade_and_quotes"
/ "symbol=AAPL"
)
taq_dir.mkdir(parents=True, exist_ok=True)
# March 16, 2020: AAPL around $250, huge volatility day
base_date = datetime(2020, 3, 16)
base_price = 250.0
exchanges = ["Q", "N", "Z", "P", "K"]
rows = []
# Generate ~600 rows: mix of trades, exchange quotes, and NBBO quotes
# NB15 needs: TRADE events for trade analysis, QUOTE BID NB / QUOTE ASK NB for spread
for i in range(600):
# Random time between 9:30 and 16:00 (6.5 hours = 23400 seconds)
seconds_offset = int(RNG.integers(0, 23400))
ts = base_date + timedelta(
hours=9, minutes=30, seconds=seconds_offset, microseconds=int(RNG.integers(0, 999999))
)
# Event type distribution:
# ~15% trades, ~20% NBBO bids, ~20% NBBO asks, ~20% exchange bids, ~20% exchange asks
# We need QUOTE BID NB and QUOTE ASK NB for NB15's spread analysis
r = RNG.random()
if r < 0.15:
event_type = "TRADE"
price = round(base_price + RNG.normal(0, 5), 2)
quantity = int(RNG.integers(10, 10001))
elif r < 0.35:
event_type = "QUOTE BID NB"
price = round(base_price - abs(RNG.normal(0.03, 0.10)), 2)
quantity = int(RNG.integers(100, 5001))
elif r < 0.55:
event_type = "QUOTE ASK NB"
price = round(base_price + abs(RNG.normal(0.03, 0.10)), 2)
quantity = int(RNG.integers(100, 5001))
elif r < 0.75:
event_type = "QUOTE BID"
price = round(base_price - abs(RNG.normal(0.05, 0.20)), 2)
quantity = int(RNG.integers(100, 5001))
else:
event_type = "QUOTE ASK"
price = round(base_price + abs(RNG.normal(0.05, 0.20)), 2)
quantity = int(RNG.integers(100, 5001))
rows.append(
{
"timestamp": ts,
"event_type": event_type,
"price": price,
"quantity": quantity,
"exchange": exchanges[int(RNG.integers(0, len(exchanges)))],
"conditions": "00000000",
}
)
df = (
pl.DataFrame(rows)
.cast(
{
"timestamp": pl.Datetime("us"),
"price": pl.Float64,
"quantity": pl.Int64,
}
)
.sort("timestamp")
)
df.write_parquet(taq_dir / "data.parquet")
print(f" TAQ data: {len(df)} rows -> {taq_dir / 'data.parquet'}")
# ═════════════════════════════════════════════════════════════════════════════
# 4. IEX Parsed Data (for NB 14)
# ═════════════════════════════════════════════════════════════════════════════
# Path: ML4T_DATA_PATH / "equities" / "market" / "microstructure" / "iex" / "deep" / "parsed" / {type}/
def generate_iex_data() -> None:
"""Generate synthetic IEX DEEP parsed data."""
parsed_dir = (
TEST_DATA_ROOT / "equities" / "market" / "microstructure" / "iex" / "deep" / "parsed"
)
base_date = datetime(2025, 1, 15, 14, 30, 0) # 9:30 AM ET in UTC
base_price = 240.0 # AAPL-ish
# ── Quotes ───────────────────────────────────────────────────────────
quotes_dir = parsed_dir / "quotes"
quotes_dir.mkdir(parents=True, exist_ok=True)
quote_rows = []
for i in range(30):
ts = base_date + timedelta(seconds=i * 60)
spread = round(abs(RNG.normal(0.02, 0.01)), 4)
mid = base_price + RNG.normal(0, 0.5)
quote_rows.append(
{
"timestamp": ts,
"symbol": "AAPL",
"bid_price": round(mid - spread / 2, 2),
"bid_size": int(RNG.integers(100, 5001)),
"ask_price": round(mid + spread / 2, 2),
"ask_size": int(RNG.integers(100, 5001)),
}
)
quotes_df = pl.DataFrame(quote_rows).cast(
{
"timestamp": pl.Datetime("ns"),
"bid_price": pl.Float64,
"ask_price": pl.Float64,
"bid_size": pl.Int64,
"ask_size": pl.Int64,
}
)
quotes_df.write_parquet(quotes_dir / "data.parquet")
print(f" IEX quotes: {len(quotes_df)} rows -> {quotes_dir / 'data.parquet'}")
# ── Trades ───────────────────────────────────────────────────────────
trades_dir = parsed_dir / "trades"
trades_dir.mkdir(parents=True, exist_ok=True)
trade_rows = []
for i in range(20):
ts = base_date + timedelta(seconds=i * 90 + int(RNG.integers(0, 30)))
trade_rows.append(
{
"timestamp": ts,
"symbol": "AAPL",
"price": round(base_price + RNG.normal(0, 0.3), 2),
"size": int(RNG.integers(1, 501)),
}
)
trades_df = pl.DataFrame(trade_rows).cast(
{
"timestamp": pl.Datetime("ns"),
"price": pl.Float64,
"size": pl.Int64,
}
)
trades_df.write_parquet(trades_dir / "data.parquet")
print(f" IEX trades: {len(trades_df)} rows -> {trades_dir / 'data.parquet'}")
# ── Price Levels ─────────────────────────────────────────────────────
price_levels_dir = parsed_dir / "price_levels"
price_levels_dir.mkdir(parents=True, exist_ok=True)
pl_rows = []
for i in range(40):
ts = base_date + timedelta(seconds=i * 45)
side = "bid" if i % 2 == 0 else "ask"
offset = RNG.uniform(0.01, 0.50)
price = round(base_price - offset if side == "bid" else base_price + offset, 2)
pl_rows.append(
{
"timestamp": ts,
"symbol": "AAPL",
"side": side,
"price": price,
"size": int(RNG.integers(100, 3001)),
}
)
pl_df = pl.DataFrame(pl_rows).cast(
{
"timestamp": pl.Datetime("ns"),
"price": pl.Float64,
"size": pl.Int64,
}
)
pl_df.write_parquet(price_levels_dir / "data.parquet")
print(f" IEX price_levels: {len(pl_df)} rows -> {price_levels_dir / 'data.parquet'}")
# ═════════════════════════════════════════════════════════════════════════════
# 5. CME Individual Contracts (for Ch02 NB 04-06)
# ═════════════════════════════════════════════════════════════════════════════
# Path: ML4T_DATA_PATH / "futures" / "market" / "individual" / "{PRODUCT}" / "data.parquet"
# Schema matches what load_cme_futures(continuous=False) returns:
# timestamp (datetime[ns, UTC]), rtype, publisher_id, instrument_id,
# open, high, low, close, volume, product
#
# NB06 (futures_continuous) needs:
# - Multiple contracts with OVERLAPPING date ranges
# - Volume patterns that make front-month detection possible
# - Enough contracts for roll detection to produce adj_close
def generate_individual_futures() -> None:
"""Generate synthetic CME individual contract data for ES, NQ, CL.
Key requirements from NB06 (continuous construction):
- Contracts must overlap in time (concurrent trading)
- Front month should have highest volume (for volume-based roll detection)
- Need at least 3 contracts with clear roll transitions
- Need enough data points for roll gaps to produce adj_close
"""
individual_dir = TEST_DATA_ROOT / "futures" / "market" / "individual"
products = {
"ES": {"base_price": 4500.0, "tick": 0.25},
"NQ": {"base_price": 15500.0, "tick": 0.25},
"CL": {"base_price": 75.0, "tick": 0.01},
}
# Contract months: H=March, M=June, U=Sep, Z=Dec
# Instrument IDs encode contract month. Simulate 4 quarterly contracts
# overlapping across 2024, with volume-based rolls.
contract_specs = [
# (instrument_id, start_day_offset, end_day_offset, is_front_until_day)
# Contract 1 (H24): front month days 0-29, then rolls to contract 2
(49701, 0, 59, 29),
# Contract 2 (M24): front month days 30-89, then rolls to contract 3
(49702, 15, 119, 89),
# Contract 3 (U24): front month days 90-149, then rolls to contract 4
(49703, 75, 179, 149),
# Contract 4 (Z24): front month from day 150 onward
(49704, 135, 209, 209),
]
for product, cfg in products.items():
prod_dir = individual_dir / product
prod_dir.mkdir(parents=True, exist_ok=True)
rows = []
start = datetime(2024, 1, 2, 0, 0, 0)
for inst_id, start_day, end_day, front_until in contract_specs:
# Adjust instrument_id per product to be unique
if product == "NQ":
inst_id += 1000
elif product == "CL":
inst_id += 2000
for day_offset in range(start_day, end_day + 1):
# Generate one bar per day (24 hours apart for hourly-like data)
ts = start + timedelta(days=day_offset)
# Price drifts slightly
base = cfg["base_price"] + RNG.normal(0, cfg["base_price"] * 0.002)
o = round(base, 2)
h = round(base + abs(RNG.normal(0, cfg["base_price"] * 0.001)), 2)
l = round(base - abs(RNG.normal(0, cfg["base_price"] * 0.001)), 2)
c = round(base + RNG.normal(0, cfg["base_price"] * 0.0005), 2)
# Volume: high when front month, low when back month
if day_offset <= front_until:
vol = int(RNG.integers(10000, 50001)) # Front month: high volume
else:
vol = int(RNG.integers(100, 3001)) # Back month: low volume
rows.append(
{
"timestamp": ts,
"rtype": 35,
"publisher_id": 1,
"instrument_id": inst_id,
"open": o,
"high": h,
"low": l,
"close": c,
"volume": vol,
"product": product,
}
)
df = (
pl.DataFrame(rows)
.cast(
{
"timestamp": pl.Datetime("ns", time_zone="UTC"),
"rtype": pl.UInt8,
"publisher_id": pl.UInt16,
"instrument_id": pl.UInt32,
"open": pl.Float64,
"high": pl.Float64,
"low": pl.Float64,
"close": pl.Float64,
"volume": pl.UInt64,
}
)
.sort("timestamp")
)
df.write_parquet(prod_dir / "data.parquet")
print(f" Futures individual {product}: {len(df)} rows -> {prod_dir / 'data.parquet'}")
# ═════════════════════════════════════════════════════════════════════════════
# 6. Kalshi Events (for Ch04 NB 13)
# ═════════════════════════════════════════════════════════════════════════════
# Path: ML4T_DATA_PATH / "prediction_markets" / "kalshi_events.parquet"
# Schema: timestamp (Date), symbol (str), open/high/low/close (Float64), volume (Int64)
def generate_kalshi_data() -> None:
"""Generate synthetic Kalshi prediction market data."""
pm_dir = TEST_DATA_ROOT / "prediction_markets"
pm_dir.mkdir(parents=True, exist_ok=True)
# 5 contracts, ~10 days each = ~50 rows
contracts = [
"KXFED-27APR-T4.25",
"KXFED-27APR-T4.50",
"KXFED-27JUN-T4.00",
"KXINFL-27MAR-T3.0",
"KXGDP-27Q1-T2.0",
]
rows = []
base_date = date(2027, 3, 1)
for contract in contracts:
# Each contract gets a base probability and drifts
base_prob = RNG.uniform(0.2, 0.8)
for day in range(10):
d = base_date + timedelta(days=day)
# Random walk for probability
base_prob = max(0.01, min(0.99, base_prob + RNG.normal(0, 0.03)))
o = round(base_prob, 2)
h = round(min(0.99, base_prob + abs(RNG.normal(0, 0.02))), 2)
l = round(max(0.01, base_prob - abs(RNG.normal(0, 0.02))), 2)
c = round(max(0.01, min(0.99, base_prob + RNG.normal(0, 0.01))), 2)
vol = int(RNG.integers(50, 5001))
rows.append(
{
"timestamp": d,
"symbol": contract,
"open": o,
"high": h,
"low": l,
"close": c,
"volume": vol,
}
)
df = (
pl.DataFrame(rows)
.cast(
{
"timestamp": pl.Date,
"open": pl.Float64,
"high": pl.Float64,
"low": pl.Float64,
"close": pl.Float64,
"volume": pl.Int64,
}
)
.sort(["symbol", "timestamp"])
)
df.write_parquet(pm_dir / "kalshi_events.parquet")
print(f" Kalshi events: {len(df)} rows -> {pm_dir / 'kalshi_events.parquet'}")
# ═════════════════════════════════════════════════════════════════════════════
# Main
# ═════════════════════════════════════════════════════════════════════════════
def main() -> None:
print(f"Generating test microstructure data in {TEST_DATA_ROOT}\n")
print("1. ITCH Parsed Messages")
generate_itch_messages()
print("\n2. DataBento MBO")
generate_mbo_data()
print("\n3. AlgoSeek TAQ")
generate_taq_data()
print("\n4. IEX Parsed Data")
generate_iex_data()
print("\n5. CME Individual Futures")
generate_individual_futures()
print("\n6. Kalshi Prediction Markets")
generate_kalshi_data()
print("\nDone.")
if __name__ == "__main__":
main()