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

312 lines
9.9 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]
# # ETFs — Exploratory Data Analysis
#
# **Docker image**: `ml4t`
#
# **Purpose**: Profile the 100-ETF candidate universe sourced from Yahoo Finance
# and confirm the category coverage, history, and data-quality characteristics
# that drive the ETF rotation case study.
#
# **Learning objectives**:
#
# - Load the ETF panel via `data.load_etfs` and inspect its canonical schema.
# - Quantify per-symbol coverage and identify ETFs with shorter history.
# - Check OHLC invariants and null rates across the full panel.
# - Compare liquidity and price ranges across asset-class categories.
#
# **Book reference**: §2.2 ("The Asset-Class Market Data Landscape" — ETPs).
#
# **Prerequisites**: `data` package on `PYTHONPATH`; ETF parquet present at
# `ML4T_DATA_PATH/etfs/market/`. Run `python data/etfs/market/download.py` if
# missing.
# %%
"""ETFs — Exploratory data analysis of the multi-asset ETF universe."""
import polars as pl
from ml4t.data.etfs import ETFDataManager
from data import load_etfs
from utils.data_quality import check_ohlc_invariants, per_asset_stats
from utils.paths import REPO_ROOT
# %% tags=["parameters"]
# Production defaults — Papermill injects overrides for CI
MAX_SYMBOLS = 0 # 0 = all
# %% [markdown]
# ## 1. Load and Inspect
#
# The ETF universe is stored as a single Parquet file containing daily OHLCV data
# for 50 ETFs spanning multiple asset classes.
# %%
etfs = load_etfs()
print("=== ETF Dataset ===")
print(f"Shape: {etfs.shape}")
print(f"Columns: {etfs.columns}")
# %%
# Schema overview
print("\nSchema:")
for col, dtype in etfs.schema.items():
print(f" {col}: {dtype}")
# %% [markdown]
# ### Adjusted Prices
#
# Yahoo Finance returns split- and dividend-adjusted OHLC. The `close` column is
# the adjusted close, so returns can be computed directly without a separate
# `adj_close` column.
# %% [markdown]
# ## 2. Coverage Summary
# %%
# Unique symbols
symbols = etfs["symbol"].unique().sort().to_list()
print("=== Coverage ===")
print(f"Number of ETFs: {len(symbols)}")
print(f"\nSymbols: {', '.join(symbols)}")
# %%
# Overall date range
date_range = etfs.select(
[
pl.col("timestamp").min().alias("start"),
pl.col("timestamp").max().alias("end"),
pl.col("timestamp").n_unique().alias("unique_dates"),
]
)
print(f"\nDate range: {date_range['start'][0]} to {date_range['end'][0]}")
print(f"Trading days: {date_range['unique_dates'][0]}")
# %%
symbol_stats = per_asset_stats(
etfs,
time_col="timestamp",
asset_col="symbol",
price_col="close",
volume_col="volume",
)
full_start = date_range["start"][0]
full_end = date_range["end"][0]
partial = symbol_stats.filter(
(pl.col("start").cast(pl.Date) != pl.lit(full_start).cast(pl.Date))
| (pl.col("end").cast(pl.Date) != pl.lit(full_end).cast(pl.Date))
)
print(f"Symbols with full coverage: {len(symbol_stats) - len(partial)}")
print(f"Symbols with partial coverage: {len(partial)}")
# %% [markdown]
# Most ETFs predate the 2006 start of the dataset, but a sizeable minority were
# launched later — visible below as ETFs whose first observation is after
# 2006-01-03.
# %%
partial.select(["symbol", "start", "end", "rows"]).sort("start")
# %% [markdown]
# ## 3. ETF Categories
#
# Six categories cover 50 ETFs that span the major asset-class buckets used by
# the rotation case study. The full universe contains 100 ETFs; the remaining
# 50 are kept as candidates for the universe-construction work in Chapter 6.
# %%
ETF_CATEGORIES = {
"US Equity Broad": ["SPY", "QQQ", "IWM", "DIA", "VTI", "MDY", "IJR"],
"US Sectors": ["XLB", "XLE", "XLF", "XLI", "XLK", "XLP", "XLU", "XLV", "XLY", "VNQ", "IYR"],
"International": [
"EFA",
"EEM",
"VEA",
"VWO",
"EWJ",
"EWG",
"FXI",
"EWZ",
"EWY",
"EWC",
"EWA",
"ACWI",
],
"Fixed Income": ["AGG", "BND", "TLT", "IEF", "SHY", "LQD", "HYG", "TIP", "EMB", "MUB"],
"Commodities": ["GLD", "SLV", "USO", "UNG", "DBC", "GSG"],
"Specialty": ["IBB", "SMH", "KRE", "OIH"],
}
print("=== ETF Categories ===")
for category, tickers in ETF_CATEGORIES.items():
available = [t for t in tickers if t in symbols]
print(f" {category}: {len(available)}/{len(tickers)} ETFs")
# %% [markdown]
# ## 4. Data Quality
# %%
# Check for nulls
null_counts = etfs.null_count()
total_nulls = null_counts.sum_horizontal()[0]
print("=== Data Quality ===")
print(f"Total null values: {total_nulls}")
# %%
# Check for zero volume days
zero_volume = etfs.filter(pl.col("volume") == 0)
print(f"Zero volume rows: {len(zero_volume)} ({100 * len(zero_volume) / len(etfs):.2f}%)")
# %%
# OHLC invariants
invariants = check_ohlc_invariants(etfs)
print("\nOHLC Invariants:")
for row in invariants.iter_rows(named=True):
status = "[OK]" if row["valid_pct"] >= 99.99 else "[WARN]"
print(f" {status} {row['check']}: {row['valid_pct']:.2f}%")
# Count violations
violations = etfs.filter(
(pl.col("high") < pl.col("low"))
| (pl.col("high") < pl.col("open"))
| (pl.col("high") < pl.col("close"))
)
print(f"\nTotal OHLC violations: {len(violations)}")
# %% [markdown]
# Violations occur where `high < close` or `low > close` after price adjustment.
# These arise from floating-point/rounding precision in the stored adjusted OHLC
# values (the same cumulative ratio is applied across all four fields, but small
# per-field rounding can break the strict invariants the raw quotes satisfied).
# At a tenth of a percent of rows they are immaterial for return and feature
# calculations but worth being aware of when computing intraday range statistics.
# %% [markdown]
# ## 5. Volume and Liquidity Distribution
#
# Understanding volume distributions helps identify liquidity constraints for trading.
# %%
# Average daily volume by ETF
volume_stats = (
etfs.group_by("symbol")
.agg(
[
pl.col("volume").mean().alias("avg_volume"),
pl.col("volume").median().alias("median_volume"),
pl.col("volume").std().alias("volume_std"),
]
)
.sort("avg_volume", descending=True)
)
print("=== Volume Distribution (Top 10 Most Liquid) ===")
volume_stats.head(10)
# %%
# Volume by category
print("\n=== Average Daily Volume by Category ===")
for category, tickers in ETF_CATEGORIES.items():
category_vol = (
etfs.filter(pl.col("symbol").is_in(tickers)).select(pl.col("volume").mean()).item()
)
if category_vol is not None:
print(f" {category}: {category_vol:,.0f} shares/day")
# %% [markdown]
# ## 6. Price Distribution
#
# Price levels across the ETF universe span a wide range.
# %%
# Current price levels (most recent date)
latest = etfs.filter(pl.col("timestamp") == etfs["timestamp"].max())
price_dist = latest.select(
[
pl.col("close").min().alias("min_price"),
pl.col("close").max().alias("max_price"),
pl.col("close").median().alias("median_price"),
pl.col("close").mean().alias("mean_price"),
]
)
print("=== Price Distribution (Latest Date) ===")
price_dist
# %%
# Price range by category
print("=== Price Range by Category ===")
for category, tickers in ETF_CATEGORIES.items():
category_prices = latest.filter(pl.col("symbol").is_in(tickers))
min_p = category_prices["close"].min()
max_p = category_prices["close"].max()
print(f" {category}: ${min_p:.2f} - ${max_p:.2f}")
# %% [markdown]
# ## 7. Loading by Symbol or via the ml4t-data Library
#
# `load_etfs(symbols=[...])` filters the panel to a subset; `ETFDataManager` is
# the config-driven entry point used by the production download/refresh workflow
# in `data/etfs/market/`.
# %%
spy = load_etfs(symbols=["SPY"])
print(f"SPY via loader: {spy.shape}")
# %%
config_path = REPO_ROOT / "data" / "etfs" / "market" / "config.yaml"
etf_mgr = ETFDataManager.from_config(str(config_path))
configured = sum(len(group["symbols"]) for group in etf_mgr.config.tickers.values())
print(f"ETFDataManager loaded from {config_path}")
print(f" Provider: {etf_mgr.config.provider}")
print(f" Date range: {etf_mgr.config.start} to {etf_mgr.config.end}")
print(f" Configured symbols: {configured} across {len(etf_mgr.config.tickers)} categories")
# %% [markdown]
# ## Key Takeaways
#
# 1. **Pre-adjusted prices**: Yahoo Finance returns split- and dividend-adjusted
# OHLC. The `close` column is the adjusted close — return calculations need
# no further adjustment.
# 2. **Coverage**: 100 ETFs across daily history from 2006-01-03 to 2025-12-31
# (5,031 trading days), with 59 ETFs spanning the full window and 41 starting
# later as new products were launched.
# 3. **Categorization**: Six categories (US Equity Broad, US Sectors,
# International, Fixed Income, Commodities, Specialty) cover 50 of the 100
# ETFs and form the candidate set for the rotation case study; the other 50
# are reserved for the universe-construction work in Chapter 6.
# 4. **Mostly clean data**: Zero nulls and 473 OHLC violations
# (`high < close` or `low > close`) — about 0.1% of rows, immaterial for
# return and feature calculations.
# 5. **Liquidity variation**: SPY averages 126M shares/day; the Commodities
# bucket averages 5.5M — a 23× difference that matters for transaction-cost
# modeling in later chapters.
#
# ### Next Steps
#
# - **§2.6 / `13_data_quality_framework`**: Systematic data-quality checks across
# the seven datasets.
# - **§2.7 / `15_survivorship_bias_detection`**: Survivorship and selection bias
# in equity panels — a contrast to the ETF universe shown here.
# - **Chapter 6**: Universe construction filters this 100-ETF candidate pool down
# to the trading universe used by the ETF rotation case study.