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

527 lines
17 KiB
Python

# ---
# jupyter:
# jupytext:
# cell_metadata_filter: tags,-all
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.19.3
# kernelspec:
# display_name: Python 3 (ipykernel)
# language: python
# name: python3
# ---
# %% [markdown]
# # 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:
# 1. Use `DataManager` as a single entry point for all data operations
# 2. Load predefined universes with the `Universe` class
# 3. Store and query data with Hive-partitioned Parquet
# 4. Perform incremental updates and detect gaps
# 5. Use the `ml4t-data` CLI 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](https://ml4trading.io/docs/data/user-guide/incremental-updates/)
# > and [Storage Guide](https://ml4trading.io/docs/data/user-guide/storage/) for full reference.
#
# **Prerequisites**: ml4t-data installed; live network access for Yahoo Finance.
# %% [markdown]
# ## Setup
# %%
"""Data Management — DataManager, Universe, HiveStorage, and incremental updates."""
import logging
import shutil
from datetime import datetime
from pathlib import Path
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 get_output_dir
# 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: {STORAGE_DIR}")
# %% tags=["parameters"]
# Production defaults — Papermill injects overrides for CI
# %% [markdown]
# ---
#
# ## 1. DataManager: The Unified Entry Point
#
# `DataManager` abstracts away provider selection, storage, and updates
# behind a single interface. Compare:
#
# ```python
# # 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.
# %% [markdown]
# ### 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)
# %% [markdown]
# ### 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")
etf_data.group_by("symbol").len().sort("symbol")
# %% [markdown]
# ---
#
# ## 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")
# %%
# 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:]}")
# %%
# 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"
)
# %%
# 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)}")
# %% [markdown]
# ---
#
# ## 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
# %% [markdown]
# ### 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)
# %% [markdown]
# ### 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}")
# %% [markdown]
# ### 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()}")
# %% [markdown]
# ### 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}")
# %% [markdown]
# ### Inspect Partition Structure
# %%
# See the actual file layout
hive_root = STORAGE_DIR / "hive_demo"
parquet_files = sorted(hive_root.rglob("*.parquet"))
print(f"Total Parquet files: {len(parquet_files)}")
print("\nExample partition paths (first 8):")
for f in parquet_files[:8]:
rel = f.relative_to(hive_root)
size_kb = f.stat().st_size / 1024
print(f" {rel} ({size_kb:.1f} KB)")
# %% [markdown]
# ---
#
# ## 4. Incremental Updates
#
# The key workflow: download history once, then **update daily** with only new data.
# %% [markdown]
# ### 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']}")
# %% [markdown]
# ### 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.
# %% [markdown]
# ### 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)")
# %% [markdown]
# ---
#
# ## 5. Command-Line Interface
#
# ml4t-data includes a CLI for scripted workflows and cron jobs.
# Here are the key commands:
#
# ### Fetch Data
# ```bash
# # 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.parquet
# ```
#
# ### Update Stored Data
# ```bash
# # 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 ./data
# ```
#
# ### Validate Data Quality
# ```bash
# # Run OHLCV validation on stored data
# ml4t-data validate ./data/etfs.parquet
# ```
#
# ### List Available Data
# ```bash
# # List symbols in storage
# ml4t-data list --storage-path ./data
#
# # List available providers
# ml4t-data info --providers
# ```
#
# ### Automated Daily Updates (Cron)
# ```bash
# # 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>&1
# ```
# %% [markdown]
# ---
#
# ## 6. 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()
# %% [markdown]
# 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.
# %% [markdown]
# ---
#
# ## 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 --all
# ```
#
# ### Key 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.** Use `load()` for
# research / backtesting (fast, offline, deterministic); use `fetch()` only
# when the cache must be refreshed.
# - **Universes are first-class.** `Universe.SP500` and friends keep symbol
# lists out of notebook code and version-controlled in the library.
# - **Gap detection is a separate concern.** `GapDetector` runs against
# already-stored data; missing trading days surface as findings, not silent
# nulls.
# - **The CLI is the production surface.** Cron-driven `ml4t-data update --all`
# is the same code path the notebook exercises.
#
# ### Further Reading
#
# - **Incremental updates**: `19_incremental_updates` walks the update strategies
# from this notebook in detail and shows how to schedule them.
# - **Storage formats**: `20_storage_benchmark_file` compares Parquet, CSV, and HDF5;
# `21_storage_benchmark_database` benchmarks Postgres-backed alternatives.
# - **Data quality**: `13_data_quality_framework` covers validation and anomaly detection.
# - **Provider comparison**: `16_provider_comparison` demonstrates multi-source acquisition.
# - **ml4t-data docs**: [ml4trading.io/docs/data/](https://ml4trading.io/docs/data/)