Chapter 20
Data Management: From Download to Production Pipeline
Data Management: From Download to Production Pipeline
Docker image: ml4t
Chapter 2: The Financial Data Universe
Previous notebooks fetched and validated data. This notebook shows how to manage it at scale using ml4t-data's production features:
- DataManager: Unified entry point for fetching, storing, and updating
- Universe: Predefined symbol lists (S&P 500, NASDAQ 100, etc.)
- HiveStorage: Partitioned Parquet for fast queries and incremental writes
- Incremental Updates: Keep data fresh without re-downloading history
- CLI: Command-line interface for scripted workflows
Learning Objectives
By completing this notebook, you will:
- Use
DataManageras a single entry point for all data operations - Load predefined universes with the
Universeclass - Store and query data with Hive-partitioned Parquet
- Perform incremental updates and detect gaps
- Use the
ml4t-dataCLI for scripted workflows
Why This Matters
A one-time download is fine for a tutorial. A trading system needs:
- Daily updates that only fetch new data (10x faster than full refresh)
- Partitioned storage that supports fast date-range queries
- Gap detection to ensure completeness before backtesting
- Metadata tracking so you know what you have and when it was updated
ml4t-data docs: See the Incremental Updates Guide and Storage Guide for full reference.
Prerequisites: ml4t-data installed; live network access for Yahoo Finance.
Setup
"""Data Management — DataManager, Universe, HiveStorage, and incremental updates."""
import logging
import shutil
from datetime import datetime
from pathlib import Path
import plotly.graph_objects as go
import polars as pl
import structlog
# ml4t-data emits structured debug logs on every fetch/store; route them
# through stdlib logging at WARNING so the notebook output stays focused on
# the demonstration.
structlog.configure(
wrapper_class=structlog.make_filtering_bound_logger(logging.WARNING),
)
# ml4t-data core imports
from ml4t.data import DataManager
from ml4t.data.storage import HiveStorage
from ml4t.data.storage.backend import StorageConfig
from ml4t.data.universe import Universe
from utils.paths import REPO_ROOT, get_output_dir
from utils.style import COLORS
def _rel(path):
"""Repo-relative display path (keeps absolute machine paths out of outputs)."""
try:
return path.relative_to(REPO_ROOT)
except ValueError:
return path
# Working directory for this notebook's storage examples. Wipe any artifacts
# from a previous run so the demo is fully reproducible.
STORAGE_DIR = get_output_dir(2, "data_management")
if STORAGE_DIR.exists():
shutil.rmtree(STORAGE_DIR)
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
print(f"Storage directory: {_rel(STORAGE_DIR)}")Output
Storage directory: 02_financial_data_universe/output/data_management
# Production defaults — Papermill injects overrides for CI1. DataManager: The Unified Entry Point
DataManager abstracts away provider selection, storage, and updates
behind a single interface. Compare:
# Without DataManager (manual)
provider = YahooFinanceProvider()
df = provider.fetch_ohlcv("AAPL", "2024-01-01", "2024-12-31", "daily")
# With DataManager (unified)
dm = DataManager()
df = dm.fetch("AAPL", "2024-01-01", "2024-12-31")The real power shows with batch operations, storage integration, and updates.
Fetch: Single Symbol
# DataManager without storage — pure fetch mode
dm = DataManager()
# Fetch a single symbol (defaults to Yahoo Finance for equities)
aapl = dm.fetch("AAPL", "2024-01-01", "2024-12-31", provider="yahoo")
print(f"AAPL: {aapl.shape[0]} rows, {aapl.shape[1]} columns")
print(f"Date range: {aapl['timestamp'].min().date()} to {aapl['timestamp'].max().date()}")
print(f"Columns: {aapl.columns}")
aapl.head(3)Output
AAPL: 252 rows, 7 columns Date range: 2024-01-02 to 2024-12-31 Columns: ['timestamp', 'symbol', 'open', 'high', 'low', 'close', 'volume']
shape: (3, 7) ┌─────────────────────────┬────────┬────────────┬────────────┬────────────┬────────────┬───────────┐ │ timestamp ┆ symbol ┆ open ┆ high ┆ low ┆ close ┆ volume │ │ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │ │ datetime[μs, UTC] ┆ str ┆ f64 ┆ f64 ┆ f64 ┆ f64 ┆ f64 │ ╞═════════════════════════╪════════╪════════════╪════════════╪════════════╪════════════╪═══════════╡ │ 2024-01-02 00:00:00 UTC ┆ AAPL ┆ 185.055273 ┆ 186.330843 ┆ 181.831767 ┆ 183.56218 ┆ 8.24887e7 │ │ 2024-01-03 00:00:00 UTC ┆ AAPL ┆ 182.158066 ┆ 183.799489 ┆ 181.3769 ┆ 182.187729 ┆ 5.84145e7 │ │ 2024-01-04 00:00:00 UTC ┆ AAPL ┆ 180.111251 ┆ 181.040733 ┆ 178.855477 ┆ 179.873947 ┆ 7.19836e7 │ └─────────────────────────┴────────┴────────────┴────────────┴────────────┴────────────┴───────────┘
| timestamp | symbol | open | high | low | close | volume |
|---|---|---|---|---|---|---|
| datetime[μs, UTC] | str | f64 | f64 | f64 | f64 | f64 |
| 2024-01-02 00:00:00 UTC | "AAPL" | 185.055273 | 186.330843 | 181.831767 | 183.56218 | 8.24887e7 |
| 2024-01-03 00:00:00 UTC | "AAPL" | 182.158066 | 183.799489 | 181.3769 | 182.187729 | 5.84145e7 |
| 2024-01-04 00:00:00 UTC | "AAPL" | 180.111251 | 181.040733 | 178.855477 | 179.873947 | 7.19836e7 |
Batch Fetch: Multiple Symbols
batch_load fetches multiple symbols in parallel and returns a single
stacked DataFrame — the standard multi-asset format used throughout the book.
# Fetch 5 ETFs in parallel
etf_symbols = ["SPY", "QQQ", "IWM", "TLT", "GLD"]
etf_data = dm.batch_load(
symbols=etf_symbols,
start="2024-01-01",
end="2024-12-31",
provider="yahoo",
max_workers=4,
)
print(f"Combined: {etf_data.shape[0]:,} rows across {etf_data['symbol'].n_unique()} symbols")
# The stacked frame is easier to read as a picture than as a row count. Rebase
# each ETF's close to 100 at the first 2024 session and color by asset class:
# the batch is one call, but the panel spans equities (SPY/QQQ/IWM), long bonds
# (TLT), and gold (GLD). Color carries the asset class, not the ticker — the
# three equity lines move as a bundle while bonds and gold pull away, which is
# exactly the cross-asset dispersion a multi-asset loader exists to capture.
etf_rebased = etf_data.sort("timestamp").with_columns(
(pl.col("close") / pl.col("close").first().over("symbol") * 100).alias("rebased")
)
etf_class = {
"SPY": "Equities",
"QQQ": "Equities",
"IWM": "Equities",
"TLT": "Long bonds",
"GLD": "Gold",
}
class_color = {"Equities": COLORS["blue"], "Long bonds": COLORS["copper"], "Gold": COLORS["amber"]}
fig = go.Figure()
seen: set[str] = set()
for sym in etf_symbols:
s = etf_rebased.filter(pl.col("symbol") == sym)
cls = etf_class[sym]
fig.add_trace(
go.Scatter(
x=s["timestamp"].to_list(),
y=s["rebased"].to_list(),
mode="lines",
line=dict(color=class_color[cls], width=1.5),
name=cls,
legendgroup=cls,
showlegend=cls not in seen,
text=sym,
hovertemplate="%{text}: %{y:.1f}<extra></extra>",
)
)
seen.add(cls)
fig.add_hline(y=100, line=dict(color=COLORS["neutral"], width=1, dash="dot"))
fig.update_layout(
title="One batch_load call returns three asset classes on one axis",
xaxis_title="Date",
yaxis_title="Rebased close (Jan 2 2024 = 100)",
height=420,
legend_title="Asset class",
)
fig.show()Output
Combined: 1,260 rows across 5 symbols
[省略较大 image/png 输出]
2. Universe: Predefined Symbol Lists
Instead of maintaining symbol lists in YAML or hardcoding them, ml4t-data ships curated universes that stay current with index rebalances.
# List available universes
print("Available universes:")
for name in Universe.list_universes():
symbols = Universe.get(name)
print(f" {name}: {len(symbols)} symbols")Output
Available universes: CRYPTO_TOP_100: 100 symbols FOREX_MAJORS: 28 symbols NASDAQ100: 100 symbols SP500: 503 symbols
# Access a universe directly
sp500 = Universe.SP500
print(f"\nS&P 500: {len(sp500)} symbols")
print(f"First 10: {sp500[:10]}")
print(f"Last 10: {sp500[-10:]}")Output
S&P 500: 503 symbols First 10: ['AAPL', 'MSFT', 'NVDA', 'GOOGL', 'GOOG', 'AMZN', 'META', 'TSLA', 'AVGO', 'ORCL'] Last 10: ['PKG', 'ETFC', 'FITB', 'KEY', 'MTB', 'HBAN', 'CMA', 'ZION', 'WBS', 'EWBC']
# Use with DataManager.batch_load_universe for one-line loading
# (fetches all 503 S&P 500 symbols — use a smaller slice for demo)
sp500_sample = dm.batch_load(
symbols=sp500[:5],
start="2024-06-01",
end="2024-12-31",
provider="yahoo",
)
print(
f"S&P 500 sample: {sp500_sample.shape[0]:,} rows, {sp500_sample['symbol'].n_unique()} symbols"
)Output
S&P 500 sample: 735 rows, 5 symbols
# Custom universes for your strategy
Universe.add_custom("etf_momentum", ["SPY", "QQQ", "IWM", "EFA", "EEM", "TLT", "GLD"])
Universe.add_custom("crypto_arb", ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT"])
print("\nCustom universes registered:")
for name in ["etf_momentum", "crypto_arb"]:
print(f" {name}: {Universe.get(name)}")Output
Custom universes registered: etf_momentum: ['SPY', 'QQQ', 'IWM', 'EFA', 'EEM', 'TLT', 'GLD'] crypto_arb: ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT']
3. HiveStorage: Partitioned Parquet
For data you'll query repeatedly, Hive-partitioned Parquet is the storage
layer used throughout ml4t-data. The HiveStorage backend collapses the
logical key equities/daily/AAPL to a filesystem-safe directory name and
nests Hive-style year/month partitions underneath:
hive_demo/
├── .metadata/
│ └── equities_daily_AAPL.json
└── equities_daily_AAPL/
├── year=2024/month=1/data.parquet
├── year=2024/month=2/data.parquet
└── ...Benefits over flat files:
- Partition pruning: Query "last 30 days" reads 1 file, not all of history
- Incremental writes: New data appends without rewriting existing partitions
- Metadata tracking: Know when each symbol was last updated
DataManager with Storage
# Initialize storage
storage_config = StorageConfig(
base_path=STORAGE_DIR / "hive_demo",
compression="zstd",
partition_granularity="month",
)
storage = HiveStorage(config=storage_config)
# DataManager with storage — enables load/update/metadata operations
dm_stored = DataManager(storage=storage)Load and Store
DataManager.load() fetches from the provider and writes to Hive
partitions in one call. The storage key encodes the asset class, frequency,
and symbol.
symbols = ["AAPL", "MSFT", "GOOGL"]
stored_keys = {}
for symbol in symbols:
key = dm_stored.load(symbol, "2023-01-01", "2024-12-31", provider="yahoo")
stored_keys[symbol] = key
print(f" Stored {symbol} → key: {key}")Output
Stored AAPL → key: equities/daily/AAPL
Stored MSFT → key: equities/daily/MSFT
Stored GOOGL → key: equities/daily/GOOGL
Query Stored Data
# List what's in storage. `storage.list_keys()` walks the on-disk layout, so it
# reports every symbol regardless of metadata-file contents.
stored_symbols = sorted(storage.list_keys())
print(f"Symbols in storage: {stored_symbols}")
# Read back with a date-range filter — only the matching month=k partitions
# touch disk, so reading 2024 from a 2-year archive halves the I/O.
aapl_2024 = storage.read(
stored_keys["AAPL"],
start_date=datetime(2024, 1, 1),
end_date=datetime(2024, 12, 31),
).collect()
print(f"\nAAPL 2024 only: {len(aapl_2024)} rows (partition-pruned)")
print(f"Date range: {aapl_2024['timestamp'].min().date()} to {aapl_2024['timestamp'].max().date()}")Output
Symbols in storage: ['equities/daily/AAPL', 'equities/daily/GOOGL', 'equities/daily/MSFT'] AAPL 2024 only: 251 rows (partition-pruned) Date range: 2024-01-02 to 2024-12-30
Metadata
# Check metadata for stored symbols
for symbol in symbols:
meta = dm_stored.get_metadata(symbol)
if meta:
print(f"\n{symbol}:")
for k, v in list(meta.items())[:5]:
print(f" {k}: {v}")Output
AAPL:
last_updated: 2026-08-10 16:49:27.884148+00:00
partitions: ['year=2023/month=1', 'year=2023/month=2', 'year=2023/month=3', 'year=2023/month=4', 'year=2023/month=5', 'year=2023/month=6', 'year=2023/month=7', 'year=2023/month=8', 'year=2023/month=9', 'year=2023/month=10', 'year=2023/month=11', 'year=2023/month=12', 'year=2024/month=1', 'year=2024/month=2', 'year=2024/month=3', 'year=2024/month=4', 'year=2024/month=5', 'year=2024/month=6', 'year=2024/month=7', 'year=2024/month=8', 'year=2024/month=9', 'year=2024/month=10', 'year=2024/month=11', 'year=2024/month=12']
row_count: 502
schema: ['timestamp', 'symbol', 'open', 'high', 'low', 'close', 'volume', 'year', 'month']
custom: {'provider': 'yahoo', 'symbol': 'AAPL', 'asset_class': 'equities', 'bar_type': 'time', 'bar_params': {'frequency': 'daily'}, 'exchange': 'UNKNOWN', 'calendar': None, 'start_date': '2023-01-03 00:00:00+00:00', 'end_date': '2024-12-31 00:00:00+00:00', 'last_updated': '2026-08-10 16:49:27.884148+00:00', 'schema_version': '1.0', 'download_utc_timestamp': '2026-08-10 16:49:27.884169+00:00', 'data_range': {'start': '2023-01-03 00:00:00+00:00', 'end': '2024-12-31 00:00:00+00:00'}, 'provider_params': {}, 'attributes': {}}
MSFT:
last_updated: 2026-08-10 16:49:28.313477+00:00
partitions: ['year=2023/month=1', 'year=2023/month=2', 'year=2023/month=3', 'year=2023/month=4', 'year=2023/month=5', 'year=2023/month=6', 'year=2023/month=7', 'year=2023/month=8', 'year=2023/month=9', 'year=2023/month=10', 'year=2023/month=11', 'year=2023/month=12', 'year=2024/month=1', 'year=2024/month=2', 'year=2024/month=3', 'year=2024/month=4', 'year=2024/month=5', 'year=2024/month=6', 'year=2024/month=7', 'year=2024/month=8', 'year=2024/month=9', 'year=2024/month=10', 'year=2024/month=11', 'year=2024/month=12']
row_count: 502
schema: ['timestamp', 'symbol', 'open', 'high', 'low', 'close', 'volume', 'year', 'month']
custom: {'provider': 'yahoo', 'symbol': 'MSFT', 'asset_class': 'equities', 'bar_type': 'time', 'bar_params': {'frequency': 'daily'}, 'exchange': 'UNKNOWN', 'calendar': None, 'start_date': '2023-01-03 00:00:00+00:00', 'end_date': '2024-12-31 00:00:00+00:00', 'last_updated': '2026-08-10 16:49:28.313477+00:00', 'schema_version': '1.0', 'download_utc_timestamp': '2026-08-10 16:49:28.313497+00:00', 'data_range': {'start': '2023-01-03 00:00:00+00:00', 'end': '2024-12-31 00:00:00+00:00'}, 'provider_params': {}, 'attributes': {}}
GOOGL:
last_updated: 2026-08-10 16:49:28.704136+00:00
partitions: ['year=2023/month=1', 'year=2023/month=2', 'year=2023/month=3', 'year=2023/month=4', 'year=2023/month=5', 'year=2023/month=6', 'year=2023/month=7', 'year=2023/month=8', 'year=2023/month=9', 'year=2023/month=10', 'year=2023/month=11', 'year=2023/month=12', 'year=2024/month=1', 'year=2024/month=2', 'year=2024/month=3', 'year=2024/month=4', 'year=2024/month=5', 'year=2024/month=6', 'year=2024/month=7', 'year=2024/month=8', 'year=2024/month=9', 'year=2024/month=10', 'year=2024/month=11', 'year=2024/month=12']
row_count: 502
schema: ['timestamp', 'symbol', 'open', 'high', 'low', 'close', 'volume', 'year', 'month']
custom: {'provider': 'yahoo', 'symbol': 'GOOGL', 'asset_class': 'equities', 'bar_type': 'time', 'bar_params': {'frequency': 'daily'}, 'exchange': 'UNKNOWN', 'calendar': None, 'start_date': '2023-01-03 00:00:00+00:00', 'end_date': '2024-12-31 00:00:00+00:00', 'last_updated': '2026-08-10 16:49:28.704136+00:00', 'schema_version': '1.0', 'download_utc_timestamp': '2026-08-10 16:49:28.704164+00:00', 'data_range': {'start': '2023-01-03 00:00:00+00:00', 'end': '2024-12-31 00:00:00+00:00'}, 'provider_params': {}, 'attributes': {}}
Inspect Partition Structure
# Ask the store what it wrote. The directory names are not addressable from outside -
# the key is encoded for filesystem safety, and each write commits into a new generation
# directory so a failed write cannot leave a half-written partition visible - so
# `partitions()` is how a caller reports the layout.
for symbol in symbols:
parts = storage.partitions(stored_keys[symbol])
print(f"{symbol}: {len(parts)} partitions, {sum(p.size_bytes for p in parts) / 1024:.1f} KB")
print("\nAAPL partitions (first 8):")
for part in storage.partitions(stored_keys["AAPL"])[:8]:
print(f" {part.label} ({part.size_bytes / 1024:.1f} KB)")Output
AAPL: 24 partitions, 75.8 KB MSFT: 24 partitions, 75.7 KB GOOGL: 24 partitions, 76.0 KB AAPL partitions (first 8): 2023-01 (3.1 KB) 2023-02 (3.1 KB) 2023-03 (3.2 KB) 2023-04 (3.1 KB) 2023-05 (3.2 KB) 2023-06 (3.2 KB) 2023-07 (3.1 KB) 2023-08 (3.2 KB)
The two-year AAPL load lands as one Parquet file per calendar month — the
partition_granularity="month" setting above. A date-range query reads only
the months it needs (partition pruning); an incremental update writes only the
newest month. Each monthly file below holds ~21 trading days, so the sizes are
near-uniform, and every new month is a new partition, never a rewrite.
aapl_sizes = pl.DataFrame(
[
{"period": part.label, "size_kb": part.size_bytes / 1024}
for part in storage.partitions(stored_keys["AAPL"])
]
)
fig = go.Figure(
go.Bar(
x=aapl_sizes["period"].to_list(),
y=aapl_sizes["size_kb"].to_list(),
marker_color=COLORS["blue"],
)
)
fig.update_layout(
title="AAPL Hive storage: one Parquet partition per month",
xaxis_title="Partition (year-month)",
yaxis_title="Partition size (KB)",
height=420,
showlegend=False,
)
fig.show()Output
4. Incremental Updates
The key workflow: download history once, then update daily with only new data.
Update a Symbol
# update() checks what's already stored and only fetches new data
for symbol in symbols:
key = dm_stored.update(symbol, lookback_days=7, provider="yahoo")
print(f" Updated {symbol} → {key}")
# Verify data is current
for symbol in symbols:
meta = dm_stored.get_metadata(symbol)
if meta and "last_updated" in meta:
print(f" {symbol} last updated: {meta['last_updated']}")Output
[2m2026-08-10 12:49:30[0m [[33m[1mwarning [0m] [1mGaps detected in data [0m [36mgap_count[0m=[35m200[0m [36mtotal_missing[0m=[35m413[0m
Updated AAPL → equities/daily/AAPL [2m2026-08-10 12:49:31[0m [[33m[1mwarning [0m] [1mGaps detected in data [0m [36mgap_count[0m=[35m200[0m [36mtotal_missing[0m=[35m413[0m
Updated MSFT → equities/daily/MSFT [2m2026-08-10 12:49:31[0m [[33m[1mwarning [0m] [1mGaps detected in data [0m [36mgap_count[0m=[35m200[0m [36mtotal_missing[0m=[35m413[0m
Updated GOOGL → equities/daily/GOOGL AAPL last updated: 2026-08-10 16:49:30.467769+00:00 MSFT last updated: 2026-08-10 16:49:31.170910+00:00 GOOGL last updated: 2026-08-10 16:49:31.785906+00:00
Understanding Update Strategies
ml4t-data supports four update strategies:
| Strategy | Behavior | Use Case |
|---|---|---|
INCREMENTAL | Only fetch data after last stored timestamp | Daily updates (default) |
APPEND_ONLY | Never modify existing rows | Audit-safe archives |
FULL_REFRESH | Replace all data for the symbol | Recovery after corruption |
BACKFILL | Fill gaps in historical data | Fix missing periods |
The default INCREMENTAL strategy is correct for most workflows.
DataManager.update() uses it automatically.
Gap Detection
Before backtesting, verify data completeness. The IncrementalUpdater can detect missing trading days.
from ml4t.data.update_manager import GapDetector
# Pass `exclude_weekends=True` so Saturdays and Sundays don't count as gaps.
# The cached series here is calendar-dense (each non-trading day carries the
# prior close forward), so the detector reports no gaps. For a sparse,
# trading-days-only feed it would instead flag every missing session, including
# holidays — without an exchange calendar it cannot tell a holiday from a true
# gap, so pair it with a calendar-aware check for end-of-day pipelines.
gap_detector = GapDetector(exclude_weekends=True)
for symbol, key in stored_keys.items():
df = storage.read(key).collect()
gaps = gap_detector.detect_gaps(df, frequency="daily")
if gaps:
print(f"{symbol}: {len(gaps)} gap(s) detected")
for gap in gaps[:3]:
print(f" {gap['start'].date()} to {gap['end'].date()} ({gap['size_days']} days)")
else:
print(f"{symbol}: No gaps (complete)")Output
AAPL: No gaps (complete) MSFT: No gaps (complete) GOOGL: No gaps (complete)
5. Command-Line Interface
ml4t-data includes a CLI for scripted workflows and cron jobs. Here are the key commands:
Fetch Data
# Single symbol
ml4t-data fetch AAPL --start 2024-01-01 --end 2024-12-31
# Multiple symbols
ml4t-data fetch SPY QQQ IWM TLT --provider yahoo --output data/etfs.parquetUpdate Stored Data
# Update a symbol (incremental — only fetches new data)
ml4t-data update AAPL --storage-path ./data
# Update all stored symbols
ml4t-data update --all --storage-path ./dataValidate Data Quality
# Run OHLCV validation on stored data
ml4t-data validate ./data/etfs.parquetList Available Data
# List symbols in storage
ml4t-data list --storage-path ./data
# List available providers
ml4t-data info --providersAutomated Daily Updates (Cron)
# Daily at 6 PM EST (after US market close), Monday-Friday
0 18 * * 1-5 cd ~/ml4t && ml4t-data update --all --storage-path ./data >> logs/update.log 2>&16. Putting It Together: Production Data Pipeline
Here's the complete workflow combining everything above — the pattern
used by the book's data/download_all.py orchestrator.
def production_pipeline(
universe_name: str,
start: str,
end: str,
storage_path: Path,
) -> pl.DataFrame:
"""Fetch, store, validate, and assemble a stacked DataFrame for a universe.
The same pattern drives `data/download_all.py` for every asset class —
only the universe and provider differ.
"""
from ml4t.data.validation import OHLCVValidator
symbols = Universe.get(universe_name)
print(f"Universe '{universe_name}': {len(symbols)} symbols")
config = StorageConfig(base_path=storage_path, compression="zstd")
store = HiveStorage(config=config)
manager = DataManager(storage=store, enable_validation=True)
stored = {}
for symbol in symbols:
stored[symbol] = manager.load(symbol, start, end, provider="yahoo")
print(f"Fetched: {len(stored)} symbols")
validator = OHLCVValidator(max_return_threshold=0.5)
issues = 0
for symbol, key in stored.items():
df = store.read(key).collect()
result = validator.validate(df)
if not result.passed:
issues += result.error_count
print(f" {symbol}: {result.error_count} validation issues")
print(f"Validated: {issues} total issue(s) across {len(stored)} symbols")
frames = [
store.read(key).collect().with_columns(pl.lit(symbol).alias("symbol"))
for symbol, key in stored.items()
]
combined = pl.concat(frames)
print(f"Result: {combined.shape[0]:,} rows, {combined['symbol'].n_unique()} symbols")
return combined# Run pipeline on a small universe
pipeline_output = production_pipeline(
universe_name="etf_momentum",
start="2024-01-01",
end="2024-12-31",
storage_path=STORAGE_DIR / "pipeline_demo",
)
pipeline_output.head()Output
Universe 'etf_momentum': 7 symbols
Fetched: 7 symbols SPY: 1 validation issues QQQ: 1 validation issues IWM: 1 validation issues EFA: 1 validation issues EEM: 1 validation issues TLT: 1 validation issues GLD: 1 validation issues Validated: 7 total issue(s) across 7 symbols Result: 1,764 rows, 7 symbols
shape: (5, 7) ┌─────────────────────┬────────┬────────────┬────────────┬────────────┬────────────┬────────────┐ │ timestamp ┆ symbol ┆ open ┆ high ┆ low ┆ close ┆ volume │ │ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │ │ datetime[μs, UTC] ┆ str ┆ f64 ┆ f64 ┆ f64 ┆ f64 ┆ f64 │ ╞═════════════════════╪════════╪════════════╪════════════╪════════════╪════════════╪════════════╡ │ 2024-01-02 00:00:00 ┆ SPY ┆ 458.333593 ┆ 459.799385 ┆ 456.712483 ┆ 458.809235 ┆ 1.236237e8 │ │ UTC ┆ ┆ ┆ ┆ ┆ ┆ │ │ 2024-01-03 00:00:00 ┆ SPY ┆ 456.654216 ┆ 457.39197 ┆ 454.460416 ┆ 455.062256 ┆ 1.035859e8 │ │ UTC ┆ ┆ ┆ ┆ ┆ ┆ │ │ 2024-01-04 00:00:00 ┆ SPY ┆ 454.586556 ┆ 457.168665 ┆ 453.37316 ┆ 453.596436 ┆ 8.42322e7 │ │ UTC ┆ ┆ ┆ ┆ ┆ ┆ │ │ 2024-01-05 00:00:00 ┆ SPY ┆ 453.800312 ┆ 456.663938 ┆ 452.771355 ┆ 454.217743 ┆ 8.61189e7 │ │ UTC ┆ ┆ ┆ ┆ ┆ ┆ │ │ 2024-01-08 00:00:00 ┆ SPY ┆ 454.712783 ┆ 460.847719 ┆ 454.586585 ┆ 460.702118 ┆ 7.48791e7 │ │ UTC ┆ ┆ ┆ ┆ ┆ ┆ │ └─────────────────────┴────────┴────────────┴────────────┴────────────┴────────────┴────────────┘
| timestamp | symbol | open | high | low | close | volume |
|---|---|---|---|---|---|---|
| datetime[μs, UTC] | str | f64 | f64 | f64 | f64 | f64 |
| 2024-01-02 00:00:00 UTC | "SPY" | 458.333593 | 459.799385 | 456.712483 | 458.809235 | 1.236237e8 |
| 2024-01-03 00:00:00 UTC | "SPY" | 456.654216 | 457.39197 | 454.460416 | 455.062256 | 1.035859e8 |
| 2024-01-04 00:00:00 UTC | "SPY" | 454.586556 | 457.168665 | 453.37316 | 453.596436 | 8.42322e7 |
| 2024-01-05 00:00:00 UTC | "SPY" | 453.800312 | 456.663938 | 452.771355 | 454.217743 | 8.61189e7 |
| 2024-01-08 00:00:00 UTC | "SPY" | 454.712783 | 460.847719 | 454.586585 | 460.702118 | 7.48791e7 |
A single validation issue per symbol on this 2024 ETF panel comes from the
OHLCVValidator(max_return_threshold=0.5) flagging the largest 1-day move
in each series — a sanity check, not a data error. The validator surfaces
candidates; downstream code decides whether to drop, winsorize, or pass
through. Section 2.6 (data quality) covers the trade-offs.
Summary
| Component | Purpose | Key Method |
|---|---|---|
| DataManager | Unified entry point | fetch(), batch_load(), load(), update() |
| Universe | Predefined symbol lists | Universe.SP500, Universe.get("nasdaq100") |
| HiveStorage | Partitioned Parquet | read(), write(), partition pruning |
| GapDetector | Gap detection in time series | detect_gaps(), detect_gaps_in_storage() |
| CLI | Scripted workflows & cron | ml4t-data fetch, ml4t-data update |
The ml4t-data Workflow
1. Initial load: dm.load("AAPL", "2020-01-01", "2024-12-31")
2. Daily update: dm.update("AAPL", lookback_days=7)
3. Gap check: gap_detector.detect_gaps(df, frequency="daily")
4. Batch load: dm.batch_load_universe("sp500", start, end)
5. Automate: cron + ml4t-data update --allKey Takeaways
- One entry point, many providers.
DataManager.fetch()hides whether the bytes come from Yahoo, Binance, AlgoSeek, or local Hive parquet; the user code does not change when providers do. load()is cache-first,fetch()is provider-first. Useload()for research / backtesting (fast, offline, deterministic); usefetch()only when the cache must be refreshed.- Universes are first-class.
Universe.SP500and friends keep symbol lists out of notebook code and version-controlled in the library. - Gap detection is a separate concern.
GapDetectorruns against already-stored data; missing trading days surface as findings, not silent nulls. - The CLI is the production surface. Cron-driven
ml4t-data update --allis the same code path the notebook exercises.
Further Reading
- Incremental updates:
19_incremental_updateswalks the update strategies from this notebook in detail and shows how to schedule them. - Storage formats:
20_storage_benchmark_filecompares Parquet, CSV, and HDF5;21_storage_benchmark_databasebenchmarks Postgres-backed alternatives. - Data quality:
13_data_quality_frameworkcovers validation and anomaly detection. - Provider comparison:
16_provider_comparisondemonstrates multi-source acquisition. - ml4t-data docs: ml4trading.io/docs/data/
