Files
stefan-jansen--machine-lear…/02_financial_data_universe/19_incremental_updates.py
T
2026-07-13 13:26:28 +08:00

373 lines
13 KiB
Python
Raw 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.
# ---
# 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]
# # Incremental Updates: Keeping Market Data Fresh
#
# **Docker image**: `ml4t`
#
# **Purpose**: Walk through the update lifecycle — initial load, daily delta,
# gap detection, and a small health dashboard — so the same flow that runs
# in `data/download_all.py --update` is visible end-to-end.
#
# **Learning objectives**:
# 1. Compare full-refresh and incremental update costs.
# 2. Drive a daily delta with `DataManager.update(..., fill_gaps=False)` and
# understand why the `fill_gaps=True` default is unsafe for OHLCV data.
# 3. Verify completeness with `GapDetector(exclude_weekends=True)`.
# 4. Render a 3-panel health dashboard (volume, freshness, issues).
#
# **Book reference**: §2.4 (storing data) — operational counterpart to the
# storage benchmarks in notebooks 20 and 21.
#
# **Prerequisites**: ml4t-data installed; live network access for Yahoo Finance.
#
# **Why incremental updates**: a full refresh of 500 symbols × 10 years pulls
# ~1.26M rows; an incremental run pulls roughly the trading days since the
# last fetch. The gap is two orders of magnitude in network and disk I/O.
# %% [markdown]
# ## Setup
# %%
"""Incremental Updates — Keeping market data fresh with daily delta fetches."""
import logging
import shutil
from datetime import datetime
import matplotlib.pyplot as plt
import polars as pl
import structlog
# Quiet ml4t-data's structured logger so the notebook focuses on demo output.
structlog.configure(
wrapper_class=structlog.make_filtering_bound_logger(logging.WARNING),
)
from ml4t.data import DataManager
from ml4t.data.storage import HiveStorage
from ml4t.data.storage.backend import StorageConfig
from ml4t.data.update_manager import GapDetector
from ml4t.data.validation import OHLCVValidator
from utils.paths import get_output_dir
from utils.style import COLORS
# Storage for this notebook's demos. Wipe any prior-run artifacts so the
# initial-load + update sequence is reproducible.
DEMO_DIR = get_output_dir(2, "incremental_updates")
if DEMO_DIR.exists():
shutil.rmtree(DEMO_DIR)
DEMO_DIR.mkdir(parents=True, exist_ok=True)
print(f"Demo storage: {DEMO_DIR}")
# %% tags=["parameters"]
# Production defaults — Papermill injects overrides for CI
# %% [markdown]
# ---
#
# ## 1. Full Refresh vs Incremental Update
#
# Let's demonstrate the difference with a concrete example.
# %% [markdown]
# ### Step 1: Initial Load (Full History)
# %%
# Initialize storage and DataManager
config = StorageConfig(base_path=DEMO_DIR / "updates_demo", compression="zstd")
storage = HiveStorage(config=config)
dm = DataManager(storage=storage)
symbols = ["AAPL", "MSFT", "GOOGL"]
print("=== Initial Load (Full History) ===")
for symbol in symbols:
dm.load(symbol, "2023-01-01", "2024-12-31", provider="yahoo")
meta = dm.get_metadata(symbol)
print(f" {symbol}: stored ({meta['row_count']} rows)")
# %% [markdown]
# ### Step 2: Incremental Update (Only New Data)
#
# `DataManager.update()` reads the last timestamp in storage, refetches a small
# overlap (`lookback_days=7`) plus everything since, and merges. We pass
# `fill_gaps=False` deliberately — the library default `fill_gaps=True` runs a
# calendar-unaware gap detector that treats every weekend and US holiday as a
# missing trading day and forward-fills it, silently inflating each symbol's
# row count by hundreds of phantom bars. For OHLCV data, the right behavior is
# to leave non-trading days absent and rely on a calendar-aware completeness
# check downstream.
# %%
print("=== Incremental Update ===")
for symbol in symbols:
dm.update(symbol, lookback_days=7, provider="yahoo", fill_gaps=False)
meta = dm.get_metadata(symbol)
print(f" {symbol}: updated ({meta['row_count']} rows)")
# %% [markdown]
# Each symbol started with 502 rows (2023-01 through 2024-12). The update
# fetches the 7-day overlap plus all sessions through today and merges; the
# resulting row count equals the original 502 plus exactly the new trading
# sessions — no synthetic weekend/holiday rows.
# %% [markdown]
# ---
#
# ## 2. Update Strategies
#
# ml4t-data supports four strategies for different scenarios:
# %%
pl.DataFrame(
{
"strategy": ["INCREMENTAL", "APPEND_ONLY", "FULL_REFRESH", "BACKFILL"],
"behavior": [
"Fetch data after last stored timestamp",
"Add new rows; never modify existing",
"Re-download and replace all data",
"Fetch missing periods inside existing range",
],
"use_case": [
"Daily updates (default, fastest)",
"Audit-safe archives",
"Recovery after corruption",
"Patch holes in historical data",
],
}
)
# %% [markdown]
# The `IncrementalUpdater` class provides low-level control over these strategies.
# For most use cases, `DataManager.update()` (which uses `INCREMENTAL`) is sufficient.
# %% [markdown]
# ---
#
# ## 3. Gap Detection
#
# Before running a backtest, verify that your data is complete.
# Gaps can occur from failed downloads, provider outages, or holiday handling.
# %%
gap_detector = GapDetector(exclude_weekends=True)
print("=== Gap Detection ===")
for symbol in symbols:
key = f"equities/daily/{symbol}"
df = storage.read(key).collect()
gaps = gap_detector.detect_gaps(df, frequency="daily")
if gaps:
print(f"{symbol}: {len(gaps)} gap(s) found")
for gap in gaps[:5]:
print(f" {gap['start'].date()} -> {gap['end'].date()} ({gap['size_days']} days)")
if len(gaps) > 5:
print(f" ... and {len(gaps) - 5} more")
else:
print(f"{symbol}: complete (no gaps)")
# %% [markdown]
# `exclude_weekends=True` filters Saturdays and Sundays. US market holidays
# (MLK Day, Good Friday, Thanksgiving, etc.) still register as one-day gaps
# because the detector has no exchange calendar — fine for routine update
# health checks, but pair with a calendar-aware completeness check before
# trusting the result for backtest panels.
# %% [markdown]
# ---
#
# ## 4. Data Health Dashboard
#
# In production, monitor data health across your entire universe.
# %%
def data_health_report(storage: HiveStorage, symbols: list[str]) -> pl.DataFrame:
"""Per-symbol freshness, gap count, and validation issue count."""
validator = OHLCVValidator(max_return_threshold=0.5)
detector = GapDetector(exclude_weekends=True)
now = datetime.now()
rows = []
for symbol in symbols:
df = storage.read(f"equities/daily/{symbol}").collect()
last_date = df["timestamp"].max().replace(tzinfo=None)
days_stale = (now - last_date).days
gaps = detector.detect_gaps(df, frequency="daily")
result = validator.validate(df)
rows.append(
{
"symbol": symbol,
"status": "stale" if days_stale > 5 else "fresh",
"rows": len(df),
"last_date": last_date.date(),
"days_stale": days_stale,
"gaps": len(gaps),
"issues": result.error_count if not result.passed else 0,
}
)
return pl.DataFrame(rows)
# %%
report = data_health_report(storage, symbols)
report
# %%
fig, axes = plt.subplots(1, 3, figsize=(14, 4), constrained_layout=True)
axes[0].barh(report["symbol"].to_list(), report["rows"].to_list(), color=COLORS["blue"])
axes[0].set_xlabel("Rows")
axes[0].set_title("Data Volume")
stale_days = report["days_stale"].to_list()
freshness_color = [
COLORS["positive"] if d <= 3 else COLORS["amber"] if d <= 7 else COLORS["negative"]
for d in stale_days
]
# Plot bars; zero-width bars (days_stale == 0) get a small marker so the
# panel is not blank when every symbol is fresh.
axes[1].barh(report["symbol"].to_list(), stale_days, color=freshness_color)
zero_mask = [i for i, d in enumerate(stale_days) if d == 0]
if zero_mask:
axes[1].scatter(
[0] * len(zero_mask),
[report["symbol"].to_list()[i] for i in zero_mask],
color=[freshness_color[i] for i in zero_mask],
s=80,
marker="o",
zorder=3,
label="Fresh (0 days)",
)
axes[1].axvline(3, color=COLORS["positive"], linestyle="--", alpha=0.6, label="3-day threshold")
axes[1].axvline(7, color=COLORS["amber"], linestyle="--", alpha=0.6, label="7-day threshold")
axes[1].set_xlim(left=-0.5)
axes[1].set_xlabel("Days Since Update")
axes[1].set_title("Data Freshness")
axes[1].legend(fontsize=8, loc="lower right")
gaps = report["gaps"].to_list()
issues = report["issues"].to_list()
axes[2].barh(report["symbol"].to_list(), gaps, color=COLORS["negative"], label="Gaps")
axes[2].barh(
report["symbol"].to_list(), issues, left=gaps, color=COLORS["amber"], label="Validation"
)
axes[2].set_xlabel("Count")
axes[2].set_title("Data Issues")
axes[2].legend(fontsize=8, loc="lower right")
fig.suptitle("Data Health Dashboard")
plt.show()
# %% [markdown]
# ---
#
# ## 5. The Book's Update Workflow
#
# The book's `data/download_all.py` script implements exactly this pattern
# for all asset classes. Run it with `--update` to extend datasets to the present:
#
# ```bash
# # Initial download (run once)
# python data/download_all.py
#
# # Update to present (run daily/weekly)
# python data/download_all.py --update
# ```
#
# Under the hood, `download_all.py` uses:
# - `ETFDataManager.from_config("data/etfs/config.yaml")` → `manager.update()`
# - `CryptoDataManager.from_config("data/crypto/config.yaml")` → `manager.update()`
# - `MacroDataManager.from_config("data/macro/config.yaml")` → `manager.download_treasury_yields()`
# - `FuturesDataManager.from_config("data/futures/config.yaml")` → `manager.download_all()`
#
# Each manager reads its YAML config for symbols, date ranges, and provider settings,
# then updates only what's new.
#
# ### Config-Driven Downloads
#
# The ETF config at `data/etfs/config.yaml` defines:
# ```yaml
# etfs:
# provider: yahoo
# start: '2006-01-01'
# end: '2025-12-31'
# frequency: daily
# tickers:
# us_equity_broad:
# symbols: [SPY, QQQ, IWM, ...]
# us_sectors:
# symbols: [XLB, XLC, XLE, ...]
# # ... 9 categories, 100 ETFs total
# ```
#
# When you run `--update` in 2026+, it extends data beyond the configured end date
# to the present — no config changes needed.
# %% [markdown]
# ---
#
# ## Summary
#
# ### Key Patterns
#
# | Pattern | Command | When |
# |---------|---------|------|
# | Initial load | `dm.load(symbol, start, end)` | First time |
# | Daily update | `dm.update(symbol, lookback_days=7)` | Every trading day |
# | Gap check | `updater.detect_gaps(df, "daily")` | Before backtesting |
# | Full refresh | `UpdateStrategy.FULL_REFRESH` | After data corruption |
# | Batch update | `download_all.py --update` | Cron job |
#
# ### Production Checklist
#
# 1. **Initial download**: `python data/download_all.py` (run once, ~10 min)
# 2. **Schedule updates**: Add `download_all.py --update` to cron (daily at 6 PM)
# 3. **Monitor health**: Check freshness, gaps, and validation before backtests
# 4. **Validate data**: Use `OHLCVValidator` on every load (see `13_data_quality_framework`)
#
# ### Key Takeaways
#
# - **`lookback_days` is the operating lever**, not the symbol set. Set it once
# to cover provider revision windows (Yahoo retro-adjusts ~5 trading days; CRSP
# ~30) and the strategy generalizes across instruments.
# - **Gap detection runs against stored data, not stream data.** A daily cron
# that finishes with `detect_gaps()` catches missed trading days before any
# backtest reads stale partitions.
# - **Full refresh is the recovery path.** Use `UpdateStrategy.FULL_REFRESH`
# when validation flags a regression; never patch a corrupted parquet in place.
# - **Configurable end dates eliminate config drift.** Symbol-list YAMLs use
# today-as-default; `--update` extends data beyond the file's `end:` field
# without needing edits each calendar year.
# - **Cron + idempotent CLI is the production interface.** The same
# `download_all.py --update` works in a notebook, a CI run, and a 6 PM cron.
#
# Next: `20_storage_benchmark_file` compares file-format throughput for the
# parquet write path implicit in every update; `21_storage_benchmark_database`
# extends the same comparison to database engines.
#
# ### Cross-References
#
# - **Data quality**: `13_data_quality_framework` — validation and anomaly detection.
# - **DataManager basics**: `18_data_management` — fetch, batch, Universe, storage.
# - **Storage benchmarks**: `20_storage_benchmark_file` (file formats) and
# `21_storage_benchmark_database` (database engines).
# - **Download scripts**: `data/download_all.py` — the book's orchestrator.
# - **ml4t-data docs**: [ml4trading.io/docs/data/user-guide/incremental-updates/](https://ml4trading.io/docs/data/user-guide/incremental-updates/)