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

884 lines
30 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.
"""Shared utilities for storage benchmarks.
This module provides common functionality used by both local and server benchmarks:
- Synthetic data generation (OHLCV, tick data)
- Timing infrastructure with warm-up and percentiles
- Result validation with forced materialization
- Configuration via YAML or environment variables
- Memory estimation utilities
"""
import gc
import os
import time
import warnings
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import polars as pl
import yaml
warnings.filterwarnings("ignore")
# =============================================================================
# CONFIGURATION
# =============================================================================
# Benchmark scale configuration
# BENCHMARK_SCALE: XS, S, M, L, XL, XXL
BENCHMARK_SCALE = os.environ.get("BENCHMARK_SCALE", "").upper()
# Chapter directory paths
from utils.paths import get_chapter_dir
CHAPTER_DIR = get_chapter_dir(2)
CODE_DIR = CHAPTER_DIR
# Load configuration from YAML if available
CONFIG_PATH = CODE_DIR / "benchmark_config.yaml"
def load_config() -> dict:
"""Load benchmark configuration from YAML file."""
if CONFIG_PATH.exists():
with open(CONFIG_PATH) as f:
return yaml.safe_load(f)
return {}
CONFIG = load_config()
# New scale configurations (from YAML or defaults)
# Format: {scale: {ohlcv: {symbols, rows_per_symbol, total_rows}, tick: {...}}}
SCALE_CONFIGS_NEW = {}
if CONFIG.get("scales"):
for scale_name, scale_cfg in CONFIG["scales"].items():
ohlcv = scale_cfg.get("ohlcv", {})
tick = scale_cfg.get("tick", {})
SCALE_CONFIGS_NEW[scale_name] = {
"ohlcv": {
"symbols": ohlcv.get("symbols", 10),
"rows_per_symbol": ohlcv.get("rows_per_symbol", 1000),
"total_rows": ohlcv.get("total_rows", 10000),
},
"tick": {
"symbols": tick.get("symbols", 5),
"trades": tick.get("trades", 5000),
"quotes": tick.get("quotes", 25000),
},
"target_memory": scale_cfg.get("target_memory", "1MB"),
"description": scale_cfg.get("description", ""),
}
else:
# Default scale configs if YAML not present
SCALE_CONFIGS_NEW = {
"XS": {
"ohlcv": {"symbols": 5, "rows_per_symbol": 200, "total_rows": 1000},
"tick": {"symbols": 3, "trades": 500, "quotes": 2500},
"target_memory": "100KB",
"description": "Unit tests",
},
"S": {
"ohlcv": {"symbols": 10, "rows_per_symbol": 1000, "total_rows": 10000},
"tick": {"symbols": 5, "trades": 5000, "quotes": 25000},
"target_memory": "1MB",
"description": "Quick validation",
},
"M": {
"ohlcv": {"symbols": 50, "rows_per_symbol": 2000, "total_rows": 100000},
"tick": {"symbols": 10, "trades": 50000, "quotes": 250000},
"target_memory": "10MB",
"description": "Development",
},
"L": {
"ohlcv": {"symbols": 100, "rows_per_symbol": 10000, "total_rows": 1000000},
"tick": {"symbols": 50, "trades": 500000, "quotes": 2500000},
"target_memory": "100MB",
"description": "Standard benchmark",
},
"XL": {
"ohlcv": {"symbols": 500, "rows_per_symbol": 20000, "total_rows": 10000000},
"tick": {"symbols": 100, "trades": 5000000, "quotes": 25000000},
"target_memory": "1GB",
"description": "Scale testing",
},
"XXL": {
"ohlcv": {"symbols": 1000, "rows_per_symbol": 100000, "total_rows": 100000000},
"tick": {"symbols": 500, "trades": 50000000, "quotes": 250000000},
"target_memory": "10GB",
"description": "Production-scale",
},
}
def get_scale_config(scale: str) -> dict:
"""Get configuration for a scale level.
Args:
scale: Scale name (XS, S, M, L, XL, XXL)
Returns:
Dict with ohlcv and tick configuration
"""
if scale not in SCALE_CONFIGS_NEW:
raise ValueError(f"Unknown scale {scale!r}, expected one of {list(SCALE_CONFIGS_NEW)}")
return SCALE_CONFIGS_NEW[scale]
# Determine active scale
# BENCHMARK_VERBOSE controls whether to print on import (default: False for clean imports)
BENCHMARK_VERBOSE = os.environ.get("BENCHMARK_VERBOSE", "0") == "1"
if BENCHMARK_SCALE and BENCHMARK_SCALE in SCALE_CONFIGS_NEW:
ACTIVE_SCALE = BENCHMARK_SCALE
scale_cfg = get_scale_config(BENCHMARK_SCALE)
N_SYMBOLS = scale_cfg["ohlcv"]["symbols"]
N_ROWS_PER_SYMBOL = scale_cfg["ohlcv"]["rows_per_symbol"]
N_TICKS_TRADES = scale_cfg["tick"]["trades"]
N_TICKS_QUOTES = scale_cfg["tick"]["quotes"]
TIMING_RUNS = CONFIG.get("execution", {}).get("iterations", {}).get(ACTIVE_SCALE, 3)
else:
# Default: S scale for quick iteration
ACTIVE_SCALE = "S"
scale_cfg = get_scale_config("S")
N_SYMBOLS = scale_cfg["ohlcv"]["symbols"]
N_ROWS_PER_SYMBOL = scale_cfg["ohlcv"]["rows_per_symbol"]
N_TICKS_TRADES = scale_cfg["tick"]["trades"]
N_TICKS_QUOTES = scale_cfg["tick"]["quotes"]
TIMING_RUNS = 3
# Database connection configuration (environment variable overrides)
DB_CONFIG = {
"clickhouse": {
"host": os.environ.get("CLICKHOUSE_HOST", "localhost"),
"port": int(os.environ.get("CLICKHOUSE_PORT", "8123")),
},
"questdb": {
"host": os.environ.get("QUESTDB_HOST", "localhost"),
"http_port": int(os.environ.get("QUESTDB_HTTP_PORT", "9000")),
"ilp_port": int(os.environ.get("QUESTDB_ILP_PORT", "9009")),
"pg_port": int(os.environ.get("QUESTDB_PG_PORT", "8812")),
},
"timescaledb": {
"host": os.environ.get("TIMESCALE_HOST", "localhost"),
"port": int(os.environ.get("TIMESCALE_PORT", "5437")),
"user": os.environ.get("TIMESCALE_USER", "postgres"),
"password": os.environ.get("TIMESCALE_PASSWORD", "benchmark"),
"database": os.environ.get("TIMESCALE_DB", "postgres"),
},
"influxdb": {
"host": os.environ.get("INFLUXDB_HOST", "localhost"),
"port": int(os.environ.get("INFLUXDB_PORT", "8086")),
"org": os.environ.get("INFLUXDB_ORG", "benchmark"),
"token": os.environ.get("INFLUXDB_TOKEN", "benchmark-token"),
"bucket": os.environ.get("INFLUXDB_BUCKET", "benchmark"),
},
"postgres": {
"host": os.environ.get("POSTGRES_HOST", "localhost"),
"port": int(os.environ.get("POSTGRES_PORT", "5436")),
"user": os.environ.get("POSTGRES_USER", "postgres"),
"password": os.environ.get("POSTGRES_PASSWORD", "benchmark"),
"database": os.environ.get("POSTGRES_DB", "ml4t"),
},
}
# WAL flush timeout (seconds) - adjustable for slower systems
WAL_FLUSH_TIMEOUT = int(os.environ.get("WAL_FLUSH_TIMEOUT", "3"))
# =============================================================================
# OUTPUT DIRECTORIES
# =============================================================================
# Directory structure:
# .tmp/ - Transient data (gitignored), regenerated each run
# ../output/benchmark/ - Results CSVs for book tables and citation
# ../figures/ - Book figures (numbered: figure_3_N_slug.png)
# Transient benchmark data (synthetic OHLCV, trades, quotes)
TMP_DIR = CHAPTER_DIR / ".tmp"
TMP_DIR.mkdir(exist_ok=True)
BENCHMARK_DIR = TMP_DIR # Alias used by benchmark notebooks
# Working charts (transient, not book figures)
CHARTS_DIR = TMP_DIR / "charts"
CHARTS_DIR.mkdir(exist_ok=True)
# Results for book tables (CSV files for citation in prose)
RESULTS_DIR = CHAPTER_DIR / "output" / "benchmark"
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
# =============================================================================
# DATA CLASSES
# =============================================================================
@dataclass
class BenchmarkResult:
"""Container for benchmark results."""
name: str
operation: str
time_seconds: float
size_bytes: int = 0
rows: int = 0
@property
def rows_per_second(self) -> float:
return self.rows / self.time_seconds if self.time_seconds > 0 else 0
@property
def mb_per_second(self) -> float:
return (self.size_bytes / 1e6) / self.time_seconds if self.time_seconds > 0 else 0
# =============================================================================
# TIMING & VALIDATION
# =============================================================================
def drop_os_caches() -> bool:
"""Drop OS page caches for accurate cold-cache benchmarking.
Requires sudo access. Returns True if successful, False otherwise.
On Linux: sync; echo 3 > /proc/sys/vm/drop_caches
"""
import subprocess
try:
# Sync first to flush dirty pages
subprocess.run(["sync"], check=True, timeout=30)
# Drop caches (requires sudo or appropriate permissions)
result = subprocess.run(
["sudo", "-n", "sh", "-c", "echo 3 > /proc/sys/vm/drop_caches"],
capture_output=True,
timeout=10,
)
return result.returncode == 0
except Exception:
return False
def time_operation(func, n_runs: int = TIMING_RUNS, warmup: bool = True) -> tuple[float, Any]:
"""Time a function with warm-up and percentile tracking.
Args:
func: Function to time
n_runs: Number of timing runs (default: TIMING_RUNS)
warmup: Whether to run once before timing to warm up caches/JIT (default: True)
Returns:
(mean_time, result): Mean execution time and last result
Note: Timing stats (percentiles) are stored in result.timing_stats if available
"""
# Warm-up run (exclude from timing)
if warmup:
from contextlib import suppress
with suppress(Exception): # Warm-up failure is non-critical
func()
# Timing runs
times = []
result = None
for _ in range(n_runs):
start = time.perf_counter()
result = func()
elapsed = time.perf_counter() - start
times.append(elapsed)
# Calculate statistics
times_array = np.array(times)
mean_time = float(np.mean(times_array))
# Store timing stats as metadata (if result supports it)
try:
if hasattr(result, "__dict__"):
result.timing_stats = {
"mean": mean_time,
"std": float(np.std(times_array)),
"min": float(np.min(times_array)),
"max": float(np.max(times_array)),
"p50": float(np.percentile(times_array, 50)),
"p95": float(np.percentile(times_array, 95)),
"p99": float(np.percentile(times_array, 99)),
}
except Exception:
pass # Not all result types support metadata
return mean_time, result
def time_cold_cache(func, drop_caches: bool = True) -> tuple[float, Any]:
"""Time a single cold-cache read operation.
For fair comparison of file formats, drops OS page caches before reading.
This ensures memory-mapped formats (Feather) don't benefit from cached pages.
Args:
func: Function to time (should be a file read operation)
drop_caches: Whether to drop OS caches first (requires sudo)
Returns:
(time, result): Execution time and result
"""
if drop_caches:
cache_dropped = drop_os_caches()
if not cache_dropped:
print("Warning: Could not drop OS caches (requires sudo)")
gc.collect()
start = time.perf_counter()
result = func()
elapsed = time.perf_counter() - start
return elapsed, result
def validate_result(
result: Any, expected_rows: int, operation: str, tolerance: float = 0.1
) -> None:
"""Validate benchmark result has reasonable row count.
Args:
result: Result to validate (DataFrame, list, dict with 'dataset', or None)
expected_rows: Expected number of rows
operation: Operation name for error messages
tolerance: Fraction tolerance (0.1 = 10% deviation acceptable)
Raises:
AssertionError: If row count is unreasonable
"""
if result is None:
return # Skip validation for None results (optional databases)
# Extract row count based on result type
if hasattr(result, "shape"): # DataFrame
actual_rows = result.shape[0]
elif isinstance(result, dict) and "dataset" in result: # QuestDB result
actual_rows = len(result["dataset"])
elif isinstance(result, list):
actual_rows = len(result)
else:
return # Unknown type, skip validation
# Check row count is within tolerance
min_rows = int(expected_rows * (1 - tolerance))
max_rows = int(expected_rows * (1 + tolerance))
if not (min_rows <= actual_rows <= max_rows):
raise AssertionError(
f"{operation}: Expected {expected_rows:,} rows (±{tolerance:.0%}), got {actual_rows:,}"
)
# =============================================================================
# MATERIALIZATION HELPERS
# =============================================================================
def force_materialize_polars(df: pl.DataFrame | pl.LazyFrame) -> pl.DataFrame:
"""Force full materialization of a Polars DataFrame by scanning ALL columns.
Memory-mapped formats (like Feather/Arrow IPC) may return handles without
loading data. This function forces actual data access by touching every column.
Args:
df: Polars DataFrame or LazyFrame
Returns:
Materialized DataFrame with data actually loaded into memory
"""
if isinstance(df, pl.LazyFrame):
df = df.collect()
# Force materialization by touching EVERY column (not just first 3 numeric)
# This ensures all data is actually loaded, not just memory-mapped
exprs = []
for col_name in df.columns:
dtype = df[col_name].dtype
if dtype in (pl.Float64, pl.Float32, pl.Int64, pl.Int32, pl.Int16, pl.Int8):
# Numeric: compute sum
exprs.append(pl.col(col_name).sum().alias(f"{col_name}_sum"))
elif dtype == pl.String:
# String: compute length sum (forces read of all string data)
exprs.append(pl.col(col_name).str.len_bytes().sum().alias(f"{col_name}_len"))
elif dtype in (pl.Datetime, pl.Date):
# Datetime: compute min/max (forces read)
exprs.append(pl.col(col_name).min().alias(f"{col_name}_min"))
else:
# Other types: count non-null (forces read)
exprs.append(pl.col(col_name).count().alias(f"{col_name}_count"))
if exprs:
_ = df.select(exprs).to_dict()
return df
def force_materialize_pandas(df: pd.DataFrame) -> pd.DataFrame:
"""Force full materialization of a pandas DataFrame by scanning ALL columns.
Args:
df: pandas DataFrame
Returns:
Materialized DataFrame with all data accessed
"""
# Force read of ALL columns, not just first 3 numeric
result = {}
# Numeric columns: compute sum
numeric_cols = df.select_dtypes(include=[np.number]).columns
if len(numeric_cols) > 0:
result["numeric_sums"] = df[numeric_cols].sum().to_dict()
# String/object columns: compute total string length
object_cols = df.select_dtypes(include=["object", "string"]).columns
if len(object_cols) > 0:
result["string_lens"] = {col: df[col].astype(str).str.len().sum() for col in object_cols}
# Datetime columns: compute min
datetime_cols = df.select_dtypes(include=["datetime64"]).columns
if len(datetime_cols) > 0:
result["datetime_mins"] = {col: df[col].min() for col in datetime_cols}
# Force evaluation
_ = result
return df
def read_with_materialization(
read_func,
path: Path,
library: str = "polars",
) -> tuple[float, Any]:
"""Time a read operation with forced materialization.
Args:
read_func: Function to read data (e.g., pl.read_parquet, pd.read_csv)
path: Path to file
library: "polars" or "pandas"
Returns:
(time_seconds, result): Tuple of read time and DataFrame
"""
gc.collect()
start = time.perf_counter()
df = read_func(path)
# Force materialization
if library == "polars":
df = force_materialize_polars(df)
else:
df = force_materialize_pandas(df)
elapsed = time.perf_counter() - start
return elapsed, df
# =============================================================================
# MEMORY UTILITIES
# =============================================================================
def estimate_memory_mb(df: pl.DataFrame | pd.DataFrame) -> float:
"""Estimate memory usage of a DataFrame in MB.
Args:
df: Polars or pandas DataFrame
Returns:
Estimated memory in megabytes
"""
if isinstance(df, pl.DataFrame):
return df.estimated_size("mb")
else:
return df.memory_usage(deep=True).sum() / 1_000_000
def get_memory_usage_mb() -> float:
"""Get current process memory usage in MB.
Returns:
Memory usage in megabytes
"""
import psutil
process = psutil.Process()
return process.memory_info().rss / 1_000_000
def run_with_gc(func):
"""Run function with garbage collection before and after.
Args:
func: Function to run
Returns:
Function result
"""
gc.collect()
result = func()
gc.collect()
return result
def save_chart(fig: go.Figure, name: str) -> None:
"""Save chart to HTML file instead of opening browser."""
path = CHARTS_DIR / f"{name}.html"
fig.write_html(str(path), include_plotlyjs="cdn")
print(f"Chart saved: {path}")
# =============================================================================
# DATA GENERATION
# =============================================================================
def generate_ohlcv_data(
n_symbols: int = N_SYMBOLS,
n_rows: int = N_ROWS_PER_SYMBOL,
seed: int = 42,
) -> pl.DataFrame:
"""Generate realistic synthetic OHLCV panel data (fully vectorized).
Creates a panel dataset with multiple symbols and time-series bars.
OHLCV constraints are enforced: H >= max(O,C), L <= min(O,C).
This implementation is fully vectorized using numpy arrays, enabling
generation of 10M+ rows in seconds (required for XL/XXL scales).
Args:
n_symbols: Number of unique symbols
n_rows: Number of rows per symbol
seed: Random seed for reproducibility
Returns:
Polars DataFrame with columns: timestamp, symbol, open, high, low, close, volume, vwap, num_trades
"""
np.random.seed(seed)
total_rows = n_symbols * n_rows
# Generate all timestamps at once (vectorized)
base_time = np.datetime64("2024-01-01T00:00:00", "us")
minute_offsets = np.arange(n_rows, dtype="timedelta64[m]")
single_symbol_times = base_time + minute_offsets
# Tile timestamps for all symbols
timestamps = np.tile(single_symbol_times, n_symbols)
# Generate symbol array (repeat each symbol n_rows times)
symbol_names = np.array([f"SYM_{i:03d}" for i in range(n_symbols)])
symbols = np.repeat(symbol_names, n_rows)
# Generate base prices per symbol, then broadcast to all rows
base_prices = 100 + np.random.randn(n_symbols) * 10 # (n_symbols,)
# Generate returns for all rows at once: (n_symbols, n_rows)
returns = np.random.randn(n_symbols, n_rows) * 0.001
# Cumulative sum along rows axis, then flatten
cumret = np.cumsum(returns, axis=1) # (n_symbols, n_rows)
prices = (base_prices[:, np.newaxis] * np.exp(cumret)).flatten() # (total_rows,)
# Generate intrabar noise for all rows
noise = np.abs(np.random.randn(total_rows)) * 0.002
# Generate OHLC
opens = prices * (1 - noise * 0.5)
closes = prices * (1 + noise * 0.5)
highs_raw = prices * (1 + noise)
lows_raw = prices * (1 - noise)
# Enforce OHLC constraints: H >= max(O,C), L <= min(O,C)
highs = np.maximum(np.maximum(opens, closes), highs_raw)
lows = np.minimum(np.minimum(opens, closes), lows_raw)
# Generate volume (lognormal distribution)
volumes = np.exp(np.random.randn(total_rows) * 0.5 + 10).astype(np.int64)
# VWAP approximation: typical price (H+L+C)/3
vwap = (highs + lows + closes) / 3
# Number of trades (proportional to volume with noise)
num_trades = (volumes / 1000 + np.random.randint(10, 100, total_rows)).astype(np.int32)
# Build DataFrame directly from numpy arrays (zero-copy where possible)
df = pl.DataFrame(
{
"timestamp": timestamps,
"symbol": symbols,
"open": opens,
"high": highs,
"low": lows,
"close": closes,
"volume": volumes,
"vwap": vwap,
"num_trades": num_trades,
},
schema={
"timestamp": pl.Datetime("us"),
"symbol": pl.String,
"open": pl.Float64,
"high": pl.Float64,
"low": pl.Float64,
"close": pl.Float64,
"volume": pl.Int64,
"vwap": pl.Float64,
"num_trades": pl.Int32,
},
)
return df
def generate_tick_data(
n_trades: int = N_TICKS_TRADES,
n_quotes: int = N_TICKS_QUOTES,
n_symbols: int = N_SYMBOLS,
seed: int = 42,
) -> tuple[pl.DataFrame, pl.DataFrame]:
"""Generate realistic synthetic tick data (fully vectorized).
Trades are correlated with quotes (occur near bid/ask) for realistic simulation.
This implementation is fully vectorized using numpy arrays, enabling
generation of millions of ticks in seconds (required for XL/XXL scales).
Args:
n_trades: Total number of trade ticks
n_quotes: Total number of quote ticks
n_symbols: Number of unique symbols
seed: Random seed for reproducibility
Returns:
(trades_df, quotes_df): Tuple of Polars DataFrames
"""
np.random.seed(seed)
symbol_names = np.array([f"SYM_{i:03d}" for i in range(n_symbols)])
base_time = np.datetime64("2024-01-01T09:30:00", "us")
# =========================================================================
# QUOTES: Fully vectorized generation
# =========================================================================
# Assign quotes to symbols (weighted: some symbols more active)
# Use exponential weights so first symbols get more quotes
weights = np.exp(-np.arange(n_symbols) * 0.1)
weights = weights / weights.sum()
quote_symbol_indices = np.random.choice(n_symbols, size=n_quotes, p=weights)
quote_symbols = symbol_names[quote_symbol_indices]
# Generate timestamps (100 microseconds apart)
quote_timestamps = base_time + (np.arange(n_quotes) * 100).astype("timedelta64[us]")
# Generate mid prices per symbol using random walk
# Strategy: for each symbol, generate cumulative random walk, then gather
quotes_per_symbol = np.bincount(quote_symbol_indices, minlength=n_symbols)
max_quotes_per_symbol = quotes_per_symbol.max()
# Generate random walk increments for all symbols at once
base_mids = 150 + np.random.randn(n_symbols) * 20 # Starting mid price per symbol
walk_increments = np.random.randn(n_symbols, max_quotes_per_symbol) * 0.01
walk_paths = base_mids[:, np.newaxis] + np.cumsum(walk_increments, axis=1)
# For each quote, look up the appropriate price from that symbol's walk
# Track position within each symbol's sequence
symbol_counters = np.zeros(n_symbols, dtype=np.int64)
mid_prices = np.empty(n_quotes, dtype=np.float64)
# Vectorized lookup using cumulative counts
for sym_idx in range(n_symbols):
mask = quote_symbol_indices == sym_idx
count = mask.sum()
if count > 0:
mid_prices[mask] = walk_paths[sym_idx, :count]
# Generate spreads (0.01-0.05% of mid)
spread_pct = 0.0001 + np.abs(np.random.randn(n_quotes)) * 0.0002
spreads = mid_prices * spread_pct
bids = mid_prices - spreads / 2
asks = mid_prices + spreads / 2
# Generate sizes (lognormal)
bid_sizes = np.exp(np.random.randn(n_quotes) * 0.3 + 5).astype(np.int64)
ask_sizes = np.exp(np.random.randn(n_quotes) * 0.3 + 5).astype(np.int64)
quotes_df = pl.DataFrame(
{
"timestamp": quote_timestamps,
"symbol": quote_symbols,
"bid": bids,
"ask": asks,
"bid_size": bid_sizes,
"ask_size": ask_sizes,
},
schema={
"timestamp": pl.Datetime("us"),
"symbol": pl.String,
"bid": pl.Float64,
"ask": pl.Float64,
"bid_size": pl.Int64,
"ask_size": pl.Int64,
},
)
# =========================================================================
# TRADES: Fully vectorized generation
# =========================================================================
# Assign trades to symbols (same distribution as quotes)
trade_symbol_indices = np.random.choice(n_symbols, size=n_trades, p=weights)
trade_symbols = symbol_names[trade_symbol_indices]
# Generate timestamps (100 microseconds apart, offset by 50us from quotes)
trade_timestamps = base_time + (np.arange(n_trades) * 100 + 50).astype("timedelta64[us]")
# Generate sizes
trade_sizes = np.exp(np.random.randn(n_trades) * 0.3 + 5).astype(np.int64)
# Build preliminary trades DataFrame
trades_prelim = pl.DataFrame(
{
"timestamp": trade_timestamps,
"symbol": trade_symbols,
"price": np.zeros(n_trades), # Placeholder
"size": trade_sizes,
},
schema={
"timestamp": pl.Datetime("us"),
"symbol": pl.String,
"price": pl.Float64,
"size": pl.Int64,
},
)
# Use ASOF join to match each trade with most recent quote
trades_with_quotes = trades_prelim.join_asof(
quotes_df.sort(["symbol", "timestamp"]),
on="timestamp",
by="symbol",
strategy="backward",
)
# Calculate trade prices: 25% at ask, 25% at bid, 50% at mid
trade_sides = np.random.rand(n_trades)
bid_arr = trades_with_quotes["bid"].to_numpy()
ask_arr = trades_with_quotes["ask"].to_numpy()
trade_prices = np.where(
trade_sides < 0.25,
ask_arr, # Buy at ask (25%)
np.where(
trade_sides < 0.5,
bid_arr, # Sell at bid (25%)
(bid_arr + ask_arr) / 2, # Mid (50%)
),
)
# Handle NaN prices (trades before first quote)
trade_prices = np.where(np.isnan(trade_prices), 150.0, trade_prices)
# Create final trades DataFrame
trades_df = pl.DataFrame(
{
"timestamp": trades_with_quotes["timestamp"],
"symbol": trades_with_quotes["symbol"],
"price": trade_prices,
"size": trades_with_quotes["size"],
}
)
return trades_df, quotes_df
# =============================================================================
# RESULTS STORAGE
# =============================================================================
# Map benchmark_type to clean filename prefixes
BENCHMARK_TYPE_TO_FILENAME = {
"formats": "file_formats",
"embedded": "embedded_dbs",
"pandas_polars": "pandas_polars",
"servers": "server_dbs",
# Legacy mappings
"local": "local",
"server": "server",
}
def save_benchmark_results(
results: list[BenchmarkResult], benchmark_type: str, scale: str | None = None
) -> Path:
"""Save benchmark results to CSV for book publication.
Results are saved to output/benchmark/ with clean filenames for citation in prose.
Example: file_formats_l.csv, embedded_dbs_xl.csv
Args:
results: List of BenchmarkResult objects
benchmark_type: "formats", "embedded", "pandas_polars", "servers"
scale: Scale level (S, L, XL, etc.) or None for auto-detect
Returns:
Path to saved CSV file
"""
if scale is None:
scale = ACTIVE_SCALE
# Clean filename prefix
filename_prefix = BENCHMARK_TYPE_TO_FILENAME.get(benchmark_type, benchmark_type)
# Create results DataFrame
df = pl.DataFrame(
[
{
"benchmark_type": benchmark_type,
"scale": scale,
"technology": r.name,
"operation": r.operation,
"time_seconds": r.time_seconds,
"size_mb": r.size_bytes / 1_000_000 if r.size_bytes else None,
"rows": r.rows if r.rows else None,
"rows_per_second": r.rows_per_second if r.rows else None,
"mb_per_second": r.mb_per_second if r.size_bytes else None,
"timestamp": pd.Timestamp.now().isoformat(),
"n_symbols": N_SYMBOLS,
"n_rows_per_symbol": N_ROWS_PER_SYMBOL,
}
for r in results
]
)
# Save as CSV (for book tables and prose citation)
csv_path = RESULTS_DIR / f"{filename_prefix}_{scale.lower()}.csv"
df.write_csv(csv_path)
print(f"\n📁 Results saved to: {csv_path}")
return csv_path
# =============================================================================
# PRINT CONFIGURATION (only when BENCHMARK_VERBOSE=1 or running as main)
# =============================================================================
def print_config() -> None:
"""Print current benchmark configuration."""
print(f"📊 Scale: {ACTIVE_SCALE} ({scale_cfg['target_memory']} target)")
print(f" OHLCV: {N_SYMBOLS} symbols × {N_ROWS_PER_SYMBOL:,} rows/symbol")
print(f" Ticks: {N_TICKS_TRADES:,} trades, {N_TICKS_QUOTES:,} quotes")
print(f" Timing runs: {TIMING_RUNS}")
# Only print on import if explicitly requested
if BENCHMARK_VERBOSE:
print_config()