# --- # 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/)