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

1475 lines
55 KiB
Python
Raw Permalink 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.
"""Equities loaders: market (OHLCV, options, microstructure), fundamentals (SEC filings, XBRL), and positioning (13F)."""
from pathlib import Path
from typing import Literal
import polars as pl
from data.exceptions import DataNotFoundError
from utils import ML4T_DATA_PATH
from utils.data_quality import apply_max_symbols
def load_sp500_index() -> pl.DataFrame:
"""Load S&P 500 index OHLCV data (bundled with repository).
This dataset is shipped with the ML4T repository and does not require
any download or API keys. It provides daily S&P 500 index data from 1980.
Returns:
DataFrame with columns: timestamp, open, high, low, close
Example:
>>> sp500 = load_sp500_index()
>>> sp500.head()
"""
path = ML4T_DATA_PATH / "equities" / "market" / "sp500" / "sp500.csv"
if not path.exists():
msg = f"S&P 500 index data not found at {path}."
raise FileNotFoundError(msg)
df = pl.read_csv(path, try_parse_dates=True)
if "date" in df.columns and "timestamp" not in df.columns:
df = df.rename({"date": "timestamp"})
return df
def load_us_equities(
symbols: list[str] | None = None,
start_date: str | None = None,
end_date: str | None = None,
max_symbols: int = 0,
) -> pl.DataFrame:
"""Load US equities dataset (NASDAQ Data Link, 1962-2018).
Survivorship-bias free dataset with 3,199 US companies including delisted stocks.
Args:
symbols: Optional list of symbols to filter (e.g., ["AAPL", "MSFT"])
start_date: Optional start date (YYYY-MM-DD format)
end_date: Optional end date (YYYY-MM-DD format)
max_symbols: Limit to N random symbols (0 = all). Seed-deterministic.
Returns:
DataFrame with columns: timestamp, symbol, open, high, low, close, volume, adj_close, etc.
"""
path = ML4T_DATA_PATH / "equities" / "market" / "us_equities" / "us_equities.parquet"
if not path.exists():
raise DataNotFoundError(
dataset_name="US Equities Dataset",
path=path,
download_script="data/equities/market/us_equities/download.py",
requires_api_key="QUANDL_API_KEY",
)
# Lazy scan with filter pushdown into parquet (row-group pruning).
lf = pl.scan_parquet(path)
schema_names = lf.collect_schema().names()
symbol_col = "ticker" if "ticker" in schema_names else "symbol"
# Prefer canonical `timestamp` if both are present (older snapshots carry
# both `date` and `timestamp`); rename-after-filter would otherwise collide
# with the existing `timestamp` column. Mirrors load_sp500_daily_bars.
if "timestamp" in schema_names:
time_col = "timestamp"
drop_date = "date" in schema_names
else:
time_col = "date"
drop_date = False
time_type = lf.collect_schema()[time_col]
if symbols:
lf = lf.filter(pl.col(symbol_col).is_in(symbols))
if start_date:
lit = (
pl.lit(start_date).str.to_date()
if time_type == pl.Date
else pl.lit(start_date).str.to_datetime()
)
lf = lf.filter(pl.col(time_col) >= lit)
if end_date:
if time_type == pl.Date:
lf = lf.filter(pl.col(time_col) <= pl.lit(end_date).str.to_date())
else:
# Use half-open interval for Datetime so non-midnight ticks on
# `end_date` are still included (matches the intraday loaders).
lf = lf.filter(
pl.col(time_col) < pl.lit(end_date).str.to_datetime() + pl.duration(days=1)
)
if drop_date:
lf = lf.drop("date")
renames = {}
if symbol_col != "symbol":
renames[symbol_col] = "symbol"
if time_col != "timestamp":
renames[time_col] = "timestamp"
if renames:
lf = lf.rename(renames)
if time_type != pl.Date:
lf = lf.with_columns(pl.col("timestamp").cast(pl.Date))
df = lf.collect()
return apply_max_symbols(df, max_symbols)
# Resampling aggregation specs for group_by_dynamic
_TRADE_OHLCV_AGGS = [
pl.col("open").first().alias("open"),
pl.col("high").max().alias("high"),
pl.col("low").min().alias("low"),
pl.col("close").last().alias("close"),
pl.col("volume").sum().alias("volume"),
]
_QUOTE_OHLCV_AGGS = [
pl.col("bid_open").first().alias("bid_open"),
pl.col("bid_high").max().alias("bid_high"),
pl.col("bid_low").min().alias("bid_low"),
pl.col("bid_close").last().alias("bid_close"),
pl.col("ask_open").first().alias("ask_open"),
pl.col("ask_high").max().alias("ask_high"),
pl.col("ask_low").min().alias("ask_low"),
pl.col("ask_close").last().alias("ask_close"),
]
_RESAMPLE_FREQUENCIES = {"5m": "5m", "15m": "15m", "30m": "30m", "1h": "1h", "4h": "4h"}
def load_nasdaq100_bars(
frequency: str = "1m",
symbols: list[str] | None = None,
start_date: str | None = None,
end_date: str | None = None,
include_quotes: bool = False,
include_microstructure: bool = False,
regular_hours: bool = True,
lazy: bool = False,
max_symbols: int = 0,
) -> pl.DataFrame | pl.LazyFrame:
"""Load AlgoSeek NASDAQ-100 bar data.
Default: minute-frequency trade OHLCV, filtered to regular trading hours
(09:30-16:00 ET). Supports resampling to coarser frequencies, optional
bid/ask quote OHLCV, and a raw 60-column microstructure mode.
Args:
frequency: Bar frequency. ``"1m"`` returns raw minute bars (no
resampling); ``"5m"``/``"15m"``/``"30m"``/``"1h"``/``"4h"``
resample via ``group_by_dynamic``. Ignored when
``include_microstructure=True``.
symbols: Optional list of symbols to filter.
start_date: Optional start date (YYYY-MM-DD).
end_date: Optional end date (YYYY-MM-DD).
include_quotes: If True, include bid/ask OHLCV columns alongside
trade OHLCV. Required for bid/ask-aware execution in the risk
layer. Ignored when ``include_microstructure=True``.
include_microstructure: If True, return all 60 raw AlgoSeek columns
without projection, regular-hours filtering, or resampling.
Mutually exclusive with ``frequency != "1m"`` and
``include_quotes``.
regular_hours: If True (default), filter to 09:30-16:00 ET. Ignored
when ``include_microstructure=True``.
lazy: If True, return a LazyFrame for deferred execution.
max_symbols: Limit to N random symbols (0 = all). Seed-deterministic.
Returns:
DataFrame (or LazyFrame if ``lazy=True``) with columns:
- Default: ``[timestamp, symbol, open, high, low, close, volume]``.
- With ``include_quotes``: adds ``bid_open, bid_high, bid_low,
bid_close, ask_open, ask_high, ask_low, ask_close``.
- With ``include_microstructure``: all 60 raw AlgoSeek columns.
"""
if include_microstructure and (frequency != "1m" or include_quotes):
msg = (
"include_microstructure=True returns the raw schema and cannot be "
"combined with resampling (frequency!='1m') or include_quotes."
)
raise ValueError(msg)
hive_path = ML4T_DATA_PATH / "equities" / "market" / "nasdaq100" / "minute_bars"
if not hive_path.exists() or not list(hive_path.glob("year=*")):
raise DataNotFoundError(
dataset_name="NASDAQ-100 Minute Bars",
path=hive_path,
instructions="""This dataset requires a commercial license from AlgoSeek.
To obtain the data:
1. Contact AlgoSeek: https://www.algoseek.com/
2. Request NASDAQ-100 Minute Bar data (2020-2021)
3. Download data to: $ML4T_DATA_PATH/algoseek/minute_nq100/
4. Run extraction: python data/_licensed/algoseek/nasdaq100_minute_bars.py
Note: Academic pricing may be available for educational use.""",
)
lf = pl.scan_parquet(hive_path / "**/*.parquet", hive_partitioning=True)
if start_date:
lf = lf.filter(pl.col("date") >= pl.lit(start_date).str.to_date())
if end_date:
lf = lf.filter(pl.col("date") <= pl.lit(end_date).str.to_date())
if symbols:
lf = lf.filter(pl.col("symbol").is_in(symbols))
if include_microstructure:
if max_symbols > 0:
lf = apply_max_symbols(lf, max_symbols)
return lf if lazy else lf.collect()
trade_cols = [
pl.col("timestamp"),
pl.col("symbol"),
pl.col("first_trade_price").alias("open"),
pl.col("high_trade_price").alias("high"),
pl.col("low_trade_price").alias("low"),
pl.col("last_trade_price").alias("close"),
pl.col("volume"),
]
quote_cols = [
pl.col("open_bid_price").alias("bid_open"),
pl.col("high_bid_price").alias("bid_high"),
pl.col("low_bid_price").alias("bid_low"),
pl.col("close_bid_price").alias("bid_close"),
pl.col("open_ask_price").alias("ask_open"),
pl.col("high_ask_price").alias("ask_high"),
pl.col("low_ask_price").alias("ask_low"),
pl.col("close_ask_price").alias("ask_close"),
]
select_cols = trade_cols + quote_cols if include_quotes else trade_cols
lf = lf.select(select_cols).filter(
pl.col("open").is_not_null()
& pl.col("high").is_not_null()
& pl.col("low").is_not_null()
& pl.col("close").is_not_null()
)
if regular_hours:
lf = lf.filter(
(pl.col("timestamp").dt.hour() >= 10)
| ((pl.col("timestamp").dt.hour() == 9) & (pl.col("timestamp").dt.minute() >= 30))
).filter(pl.col("timestamp").dt.hour() < 16)
lf = lf.sort("symbol", "timestamp")
if frequency != "1m":
every = _RESAMPLE_FREQUENCIES.get(frequency)
if every is None:
msg = (
f"Unsupported frequency {frequency!r}. Use: {list(_RESAMPLE_FREQUENCIES)} or '1m'."
)
raise ValueError(msg)
aggs = list(_TRADE_OHLCV_AGGS)
if include_quotes:
aggs.extend(_QUOTE_OHLCV_AGGS)
lf = (
lf.sort("timestamp")
.group_by_dynamic("timestamp", every=every, group_by="symbol")
.agg(aggs)
.filter(pl.col("open").is_not_null())
.sort("timestamp", "symbol")
)
if max_symbols > 0:
lf = apply_max_symbols(lf, max_symbols)
return lf if lazy else lf.collect()
def load_sp500_daily_bars(
start_date: str | None = None,
end_date: str | None = None,
symbols: list[str] | None = None,
max_symbols: int = 0,
) -> pl.DataFrame:
"""Load AlgoSeek daily OHLCV bars for S&P 500 constituents.
Args:
start_date: Optional start date (YYYY-MM-DD format)
end_date: Optional end date (YYYY-MM-DD format)
symbols: Optional list of symbols to filter
max_symbols: Limit to N random symbols (0 = all). Seed-deterministic.
Returns:
DataFrame with columns: timestamp, symbol, open, high, low, close, volume,
adj_factor (cumulative price factor for split adjustment)
Coverage: 2017-2021, ~638 symbols (S&P 500 + some changes)
"""
path = ML4T_DATA_PATH / "equities" / "market" / "sp500" / "daily_bars.parquet"
if not path.exists():
raise DataNotFoundError(
dataset_name="S&P 500 Daily Bars",
path=path,
instructions="""This dataset requires a commercial license from AlgoSeek.
To obtain the data:
1. Contact AlgoSeek: https://www.algoseek.com/
2. Request S&P 500 Daily OHLCV data (2017-2021)
3. Download data to: $ML4T_DATA_PATH/algoseek/sp500_daily/
4. Run extraction: python data/_licensed/algoseek/sp500_daily_bars.py
Note: Academic pricing may be available for educational use.""",
)
lf = pl.scan_parquet(path)
schema_names = lf.collect_schema().names()
time_col = "date" if "date" in schema_names else "timestamp"
time_type = lf.collect_schema()[time_col]
if symbols:
lf = lf.filter(pl.col("symbol").is_in(symbols))
if start_date:
lit = (
pl.lit(start_date).str.to_date()
if time_type == pl.Date
else pl.lit(start_date).str.to_datetime()
)
lf = lf.filter(pl.col(time_col) >= lit)
if end_date:
lit = (
pl.lit(end_date).str.to_date()
if time_type == pl.Date
else pl.lit(end_date).str.to_datetime()
)
lf = lf.filter(pl.col(time_col) <= lit)
# Canonical schema: rename date → timestamp (handle "date and timestamp both present")
if "date" in schema_names and "timestamp" not in schema_names:
lf = lf.rename({"date": "timestamp"})
elif "date" in schema_names and "timestamp" in schema_names:
lf = lf.drop("date")
# Cast surviving timestamp column to Date (post-filter; checks final dtype)
if lf.collect_schema()["timestamp"] != pl.Date:
lf = lf.with_columns(pl.col("timestamp").cast(pl.Date))
data = lf.collect()
return apply_max_symbols(data, max_symbols)
def load_sp500_options(
symbols: list[str] | None = None,
option_type: Literal["C", "P", "all"] = "all",
start_date: str | None = None,
end_date: str | None = None,
include_greeks: bool = True,
lazy: bool = False,
max_symbols: int = 0,
) -> pl.DataFrame | pl.LazyFrame:
"""Load RAW AlgoSeek options Greeks data for S&P 500 constituents.
WARNING: Raw data is 347M rows / 11GB on disk / ~30GB in RAM.
Do NOT collect the full dataset eagerly. For pipeline work, use:
- load_sp500_options_surface() — daily IV surface summary
- load_sp500_options_straddles() — daily ATM straddle data
This loader is for EDA and deep-dive analysis only.
Uses lazy scanning with Hive partition pruning for efficient loading.
Only reads data matching the requested date range and symbols.
Args:
symbols: Optional list of underlying symbols to filter (e.g., ["AAPL", "MSFT"])
option_type: "C" for calls only, "P" for puts only, "all" for both
start_date: Optional start date (YYYY-MM-DD format). Default: earliest available
end_date: Optional end date (YYYY-MM-DD format). Default: latest available
include_greeks: If True, include delta, gamma, theta, vega, rho columns
lazy: If True, return LazyFrame for deferred execution. Default: False
max_symbols: Limit to N random symbols (0 = all). Seed-deterministic.
Returns:
DataFrame (or LazyFrame if lazy=True) with columns:
date, symbol, call_put, option_style, strike, expiration,
years_to_maturity, days_to_maturity, underlying_price,
bid, ask, mid_price, implied_vol, theo_price,
delta, gamma, theta, vega, rho (if include_greeks=True),
iv_convergence
Coverage: 2017-2021, ~500 S&P 500 constituents, all listed options
Example:
>>> # Load AAPL calls for 2020
>>> calls = load_sp500_options(symbols=["AAPL"], option_type="C",
... start_date="2020-01-01", end_date="2020-12-31")
>>> # Lazy load for large queries
>>> lf = load_sp500_options(lazy=True)
>>> filtered = lf.filter(pl.col("implied_vol") > 0.5).collect()
"""
# Canonical path: Hive-partitioned options data
base_path = ML4T_DATA_PATH / "equities" / "market" / "sp500" / "options"
if not base_path.exists() or not list(base_path.glob("year=*")):
raise DataNotFoundError(
dataset_name="S&P 500 Options Greeks",
path=base_path,
instructions="""This dataset requires a commercial license from AlgoSeek.
To obtain the data:
1. Contact AlgoSeek: https://www.algoseek.com/
2. Request S&P 500 Options Greeks data (2017-2021)
3. Download data to: $ML4T_DATA_PATH/algoseek/options_sp500/
4. Run extraction: python data/_licensed/algoseek/sp500_options.py
Note: Academic pricing may be available for educational use.""",
)
# Use lazy scan with Hive partitioning
lf = pl.scan_parquet(
base_path / "**/*.parquet",
hive_partitioning=True,
)
# Normalize: rename date→timestamp for canonical schema, cast to Date
if "date" in lf.collect_schema().names():
lf = lf.with_columns(pl.col("date").cast(pl.Date).alias("timestamp")).drop("date")
elif lf.collect_schema()["timestamp"] != pl.Date:
lf = lf.with_columns(pl.col("timestamp").cast(pl.Date))
# Apply date filters using timestamp
if start_date:
lf = lf.filter(pl.col("timestamp") >= pl.lit(start_date).str.to_date())
if end_date:
lf = lf.filter(pl.col("timestamp") <= pl.lit(end_date).str.to_date())
# Apply symbol filter (predicate pushdown)
if symbols:
lf = lf.filter(pl.col("symbol").is_in(symbols))
# Apply option type filter
if option_type != "all":
lf = lf.filter(pl.col("call_put") == option_type)
# Remove Greeks if not requested
if not include_greeks:
greek_cols = ["delta", "gamma", "theta", "vega", "rho"]
existing = [c for c in greek_cols if c in lf.collect_schema().names()]
if existing:
lf = lf.drop(existing)
# Sort by timestamp, symbol, expiration, strike
lf = lf.sort(["timestamp", "symbol", "expiration", "strike"])
if max_symbols > 0:
lf = apply_max_symbols(lf, max_symbols)
return lf if lazy else lf.collect()
_SP500_OPTIONS_S3_BASE = "https://algoseek-public.s3.amazonaws.com/ml4t/sp500_options"
def load_sp500_options_eda(
symbols: list[str] | None = None,
option_type: Literal["C", "P", "all"] = "all",
start_date: str | None = None,
end_date: str | None = None,
include_greeks: bool = True,
max_symbols: int = 0,
) -> pl.DataFrame:
"""Load the S&P 500 options EDA dataset.
Daily option chains for AAPL, MSFT, GOOGL, AMZN, JPM, BA, XOM, KO over
2019-2020, partitioned by year so the files can be downloaded
independently. Full AlgoSeek schema (Greeks, implied vol, diagnostics).
Args:
symbols: Optional subset of the eight available symbols.
option_type: "C" for calls only, "P" for puts only, "all" for both.
start_date: Optional start date (YYYY-MM-DD).
end_date: Optional end date (YYYY-MM-DD).
include_greeks: If False, drop delta, gamma, theta, vega, rho.
max_symbols: Limit to N random symbols (0 = all).
Returns:
DataFrame with columns: timestamp, symbol, call_put, option_style,
strike, expiration, years_to_maturity, days_to_maturity,
underlying_price, bid, ask, mid_price, implied_vol, theo_price,
delta, gamma, theta, vega, rho, iv_convergence.
"""
base_path = ML4T_DATA_PATH / "equities" / "market" / "sp500" / "options_eda"
if not base_path.exists() or not list(base_path.glob("year=*.parquet")):
raise DataNotFoundError(
dataset_name="S&P 500 Options — EDA subset",
path=base_path,
download_url=f"{_SP500_OPTIONS_S3_BASE}/options_eda/",
)
lf = pl.scan_parquet(base_path / "year=*.parquet", hive_partitioning=True)
if "date" in lf.collect_schema().names():
lf = lf.with_columns(pl.col("date").cast(pl.Date).alias("timestamp")).drop("date")
elif lf.collect_schema()["timestamp"] != pl.Date:
lf = lf.with_columns(pl.col("timestamp").cast(pl.Date))
if start_date:
lf = lf.filter(pl.col("timestamp") >= pl.lit(start_date).str.to_date())
if end_date:
lf = lf.filter(pl.col("timestamp") <= pl.lit(end_date).str.to_date())
if symbols:
lf = lf.filter(pl.col("symbol").is_in(symbols))
if option_type != "all":
lf = lf.filter(pl.col("call_put") == option_type)
if not include_greeks:
greek_cols = [
c
for c in ("delta", "gamma", "theta", "vega", "rho")
if c in lf.collect_schema().names()
]
if greek_cols:
lf = lf.drop(greek_cols)
df = lf.sort(["timestamp", "symbol", "expiration", "call_put", "strike"]).collect()
return apply_max_symbols(df, max_symbols)
def load_sp500_options_straddles_raw(
symbols: list[str] | None = None,
start_date: str | None = None,
end_date: str | None = None,
lazy: bool = False,
max_symbols: int = 0,
) -> pl.DataFrame | pl.LazyFrame:
"""Load the lifecycle-preserving ATM-band raw option chains used by the
sp500_options straddle case study.
Contains every daily observation (both legs) of each (symbol, strike,
expiration) contract that enters the 30D ATM straddle candidate window
(DTE ∈ [25, 35], |delta| ∈ [0.35, 0.65], converged IV, tight spread) at
any point during 2017-2021 — from first listing through expiration.
Args:
symbols: Optional list of underlying symbols to filter.
start_date: Optional start date (YYYY-MM-DD).
end_date: Optional end date (YYYY-MM-DD).
lazy: If True, return a LazyFrame for deferred execution.
max_symbols: Limit to N random symbols (0 = all).
Returns:
DataFrame (or LazyFrame) with the full AlgoSeek option-chain schema.
"""
base_path = ML4T_DATA_PATH / "equities" / "market" / "sp500" / "options_straddles_raw"
if not base_path.exists() or not list(base_path.glob("year=*.parquet")):
raise DataNotFoundError(
dataset_name="S&P 500 Options — ATM-band straddle raw chains",
path=base_path,
download_url=f"{_SP500_OPTIONS_S3_BASE}/options_straddles_raw.tar.zst",
)
lf = pl.scan_parquet(base_path / "year=*.parquet", hive_partitioning=True)
if "date" in lf.collect_schema().names():
lf = lf.with_columns(pl.col("date").cast(pl.Date).alias("timestamp")).drop("date")
elif lf.collect_schema()["timestamp"] != pl.Date:
lf = lf.with_columns(pl.col("timestamp").cast(pl.Date))
if start_date:
lf = lf.filter(pl.col("timestamp") >= pl.lit(start_date).str.to_date())
if end_date:
lf = lf.filter(pl.col("timestamp") <= pl.lit(end_date).str.to_date())
if symbols:
lf = lf.filter(pl.col("symbol").is_in(symbols))
lf = lf.sort(["timestamp", "symbol", "expiration", "call_put", "strike"])
if max_symbols > 0:
lf = apply_max_symbols(lf, max_symbols)
return lf if lazy else lf.collect()
def load_sp500_options_surface(
symbols: list[str] | None = None,
start_date: str | None = None,
end_date: str | None = None,
max_symbols: int = 0,
) -> pl.DataFrame:
"""Load the daily IV surface summary for S&P 500 options.
One row per (symbol, date) with ATM implied vol at 7d/30d/90d tenors,
25-delta risk reversal, term structure slope/ratio, bid-ask spread, and
IV convergence quality.
Columns: timestamp, symbol, iv_30_atm, iv_7_atm, iv_90_atm,
iv_30_put_25d, iv_30_call_25d, skew_rr_30_25d, spread_atm_30,
qc_converged_share, term_slope_near_atm, term_slope_far_atm,
term_ratio_atm, term_convexity, skew_to_atm_ratio.
Used by sp500_equity_option_analytics/03_financial_features.py, which
also shows how this summary is computed from raw option chains.
"""
path = ML4T_DATA_PATH / "equities" / "market" / "sp500" / "options_surface_daily.parquet"
if not path.exists():
raise DataNotFoundError(
dataset_name="S&P 500 Options — Daily IV Surface",
path=path,
download_url=f"{_SP500_OPTIONS_S3_BASE}/options_surface_daily.parquet",
derivation_notebook=(
"case_studies/sp500_equity_option_analytics/03_financial_features.py"
),
)
df = pl.read_parquet(path)
if "date" in df.columns and "timestamp" not in df.columns:
df = df.rename({"date": "timestamp"})
if start_date:
df = df.filter(pl.col("timestamp") >= pl.lit(start_date).str.to_date())
if end_date:
df = df.filter(pl.col("timestamp") <= pl.lit(end_date).str.to_date())
if symbols:
df = df.filter(pl.col("symbol").is_in(symbols))
df = apply_max_symbols(df, max_symbols)
return df.sort(["timestamp", "symbol"])
def load_sp500_options_straddles(
symbols: list[str] | None = None,
start_date: str | None = None,
end_date: str | None = None,
max_symbols: int = 0,
) -> pl.DataFrame:
"""Load the daily 30D ATM straddle dataset for S&P 500 options.
One row per (symbol, date) with call and put leg details at matched
strike and expiration, plus straddle-level aggregates (mid, bid/ask,
spread, delta, gamma, theta, vega, IV, DTE). Used by
sp500_options/04_financial_features.py.
"""
path = ML4T_DATA_PATH / "equities" / "market" / "sp500" / "options_straddles_daily.parquet"
if not path.exists():
raise DataNotFoundError(
dataset_name="S&P 500 Options — Daily 30D ATM Straddles",
path=path,
download_url=f"{_SP500_OPTIONS_S3_BASE}/options_straddles_daily.parquet",
derivation_notebook="data/equities/market/sp500/materialize_options.py",
)
lf = pl.scan_parquet(path)
schema_names = lf.collect_schema().names()
time_col = "date" if "date" in schema_names else "timestamp"
time_type = lf.collect_schema()[time_col]
if symbols:
lf = lf.filter(pl.col("symbol").is_in(symbols))
if start_date:
lit = (
pl.lit(start_date).str.to_date()
if time_type == pl.Date
else pl.lit(start_date).str.to_datetime()
)
lf = lf.filter(pl.col(time_col) >= lit)
if end_date:
lit = (
pl.lit(end_date).str.to_date()
if time_type == pl.Date
else pl.lit(end_date).str.to_datetime()
)
lf = lf.filter(pl.col(time_col) <= lit)
if time_col == "date" and "timestamp" not in schema_names:
lf = lf.rename({"date": "timestamp"})
df = lf.collect()
df = apply_max_symbols(df, max_symbols)
return df.sort(["symbol", "timestamp"])
def load_nasdaq100_taq(
symbols: list[str] | None = None,
event_types: list[str] | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> pl.DataFrame:
"""Load AlgoSeek TAQ tick data for March 2020 (COVID crash period).
High-frequency tick data including trades and quotes with nanosecond
precision timestamps. Data covers AAPL, AMZN, MSFT during the
March 2020 market crash - ideal for studying market microstructure
during extreme volatility.
Args:
symbols: Optional list of symbols to filter (e.g., ["AAPL"])
Available: AAPL, AMZN, MSFT
event_types: Optional list of event types to filter. Available:
- "TRADE": Executed trades
- "TRADE NB": Non-binding trades
- "TRADE CANCELLED": Trade cancellations
- "QUOTE BID" / "QUOTE ASK": NBBO quotes
- "QUOTE BID NB" / "QUOTE ASK NB": Non-binding quotes
start_date: Optional start date (YYYY-MM-DD format)
end_date: Optional end date (YYYY-MM-DD format)
Returns:
DataFrame with columns:
timestamp (microsecond precision), symbol, event_type,
price, quantity, exchange, conditions
Coverage: 21 trading days in March 2020, 3 tickers (~500M rows total)
"""
base_path = ML4T_DATA_PATH / "equities" / "market" / "microstructure" / "trade_and_quotes"
algoseek_instructions = """This dataset requires a commercial license from AlgoSeek.
To obtain the data:
1. Contact AlgoSeek: https://www.algoseek.com/
2. Request Trade and Quote (TAQ) data for March 2020
3. Download data to: $ML4T_DATA_PATH/algoseek/taq/
4. Run extraction: python data/_licensed/algoseek/trade_and_quotes.py
Note: Academic pricing may be available for educational use."""
if not base_path.exists() or not list(base_path.glob("symbol=*")):
raise DataNotFoundError(
dataset_name="Trade and Quotes Tick Data",
path=base_path,
instructions=algoseek_instructions,
)
# Load data based on symbol filter
if symbols:
# Read specific symbol partitions
dfs = []
for s in symbols:
pattern = base_path / f"symbol={s}" / "*.parquet"
files = list(pattern.parent.glob(pattern.name))
if files:
df = pl.read_parquet(files).with_columns(pl.lit(s).alias("symbol"))
dfs.append(df)
if not dfs:
raise DataNotFoundError(
dataset_name="Trade and Quotes Tick Data",
path=base_path,
instructions=algoseek_instructions,
)
data = pl.concat(dfs, how="diagonal_relaxed")
else:
# Read all with hive partitioning (symbol only)
data = pl.read_parquet(
base_path / "**/*.parquet",
hive_partitioning=True,
)
# Apply filters
if event_types:
data = data.filter(pl.col("event_type").is_in(event_types))
if start_date:
data = data.filter(pl.col("timestamp").dt.date() >= pl.lit(start_date).str.to_date())
if end_date:
data = data.filter(pl.col("timestamp").dt.date() <= pl.lit(end_date).str.to_date())
return data.sort(["symbol", "timestamp"])
def load_mbo_data(
symbols: list[str] | None = None,
start_date: str | None = None,
end_date: str | None = None,
list_files: bool = False,
) -> pl.DataFrame | list[Path]:
"""Load DataBento MBO (Market-By-Order) tick data.
High-frequency order book data from NASDAQ ITCH via DataBento API.
Includes individual order messages (add, cancel, fill, modify, trade).
Args:
symbols: Optional list of symbols to filter (e.g., ["NVDA"])
Available: NVDA (Nov 2024, 10 trading days)
start_date: Optional start date (YYYY-MM-DD format)
end_date: Optional end date (YYYY-MM-DD format)
list_files: If True, return list of file paths instead of loading data.
Useful for notebooks that need to iterate files day-by-day.
Returns:
If list_files=False (default): DataFrame with columns:
ts_event, symbol, action, side, price, size, order_id, flags
If list_files=True: List of Path objects to parquet files
Coverage: 10 trading days in November 2024, NVDA only
Example:
>>> # Load all data
>>> df = load_mbo_data(symbols=["NVDA"])
>>> # Load date range
>>> df = load_mbo_data(symbols=["NVDA"], start_date="2024-11-04", end_date="2024-11-08")
>>> # Get file paths for iteration
>>> files = load_mbo_data(symbols=["NVDA"], list_files=True)
>>> for f in files:
... day_df = pl.read_parquet(f)
"""
base_path = ML4T_DATA_PATH / "equities" / "market" / "microstructure" / "market_by_order"
if not base_path.exists():
raise DataNotFoundError(
dataset_name="DataBento MBO Data",
path=base_path,
instructions=(
"Recommended: manual download from the Databento Download Center.\n"
" See data/equities/market/microstructure/MBO_DOWNLOAD.md for step-by-step\n"
" instructions (XNAS.ITCH, schema mbo, NVDA, 2024-11-04 to 2024-11-15,\n"
" Parquet output, ~$5).\n"
"\n"
"Alternative: API script (requires DATABENTO_API_KEY in .env):\n"
" uv run python data/equities/market/microstructure/mbo_download.py --estimate-only\n"
" uv run python data/equities/market/microstructure/mbo_download.py"
),
)
# Find available symbols
available_symbols = [d.name for d in base_path.iterdir() if d.is_dir()]
if not available_symbols:
raise DataNotFoundError(
dataset_name="DataBento MBO Data",
path=base_path,
instructions=(
"Recommended: manual download from the Databento Download Center.\n"
" See data/equities/market/microstructure/MBO_DOWNLOAD.md for step-by-step\n"
" instructions (XNAS.ITCH, schema mbo, NVDA, 2024-11-04 to 2024-11-15,\n"
" Parquet output, ~$5).\n"
"\n"
"Alternative: API script (requires DATABENTO_API_KEY in .env):\n"
" uv run python data/equities/market/microstructure/mbo_download.py --estimate-only\n"
" uv run python data/equities/market/microstructure/mbo_download.py"
),
)
# Filter symbols
load_symbols = symbols if symbols else available_symbols
load_symbols = [s for s in load_symbols if s in available_symbols]
if not load_symbols:
available = ", ".join(available_symbols)
msg = f"No matching symbols found. Available: {available}"
raise ValueError(msg)
# Collect files
all_files = []
for symbol in load_symbols:
symbol_dir = base_path / symbol
all_files.extend(sorted(symbol_dir.glob("*.parquet")))
if not all_files:
raise DataNotFoundError(
dataset_name="DataBento MBO Data",
path=base_path,
instructions=(
"Recommended: manual download from the Databento Download Center.\n"
" See data/equities/market/microstructure/MBO_DOWNLOAD.md for step-by-step\n"
" instructions (XNAS.ITCH, schema mbo, NVDA, 2024-11-04 to 2024-11-15,\n"
" Parquet output, ~$5).\n"
"\n"
"Alternative: API script (requires DATABENTO_API_KEY in .env):\n"
" uv run python data/equities/market/microstructure/mbo_download.py --estimate-only\n"
" uv run python data/equities/market/microstructure/mbo_download.py"
),
)
# Return file list if requested
if list_files:
return sorted(all_files)
# Load data
dfs = []
for f in all_files:
df = pl.read_parquet(f)
# Infer symbol from directory structure
symbol = f.parent.name
if "symbol" not in df.columns:
df = df.with_columns(pl.lit(symbol).alias("symbol"))
dfs.append(df)
data = pl.concat(dfs, how="diagonal_relaxed")
# Normalize: ts_event → timestamp for canonical schema
if "ts_event" in data.columns and "timestamp" not in data.columns:
data = data.rename({"ts_event": "timestamp"})
# Apply date filters
if start_date:
data = data.filter(pl.col("timestamp").dt.date() >= pl.lit(start_date).str.to_date())
if end_date:
data = data.filter(pl.col("timestamp").dt.date() <= pl.lit(end_date).str.to_date())
return data.sort(["symbol", "timestamp"])
def load_nasdaq_itch(
message_types: list[str] | None = None,
symbols: list[str] | None = None,
get_base_path: bool = False,
) -> pl.DataFrame | Path:
"""Load parsed NASDAQ ITCH message data.
Pre-parsed ITCH protocol messages from NASDAQ TotalView-ITCH feed.
Messages are organized by type in separate partitions.
Args:
message_types: Optional list of message types to filter. Available:
- "A": Add Order (no attribution)
- "F": Add Order (with attribution)
- "E": Order Executed
- "C": Order Executed with Price
- "X": Order Cancel
- "D": Order Delete
- "U": Order Replace
- "P": Trade (non-cross)
- "Q": Cross Trade
- "I": Imbalance
- "S": System Event
- "R": Stock Directory
- "H": Stock Trading Action
symbols: Optional list of stock symbols to filter (e.g., ["AAPL", "MSFT"])
get_base_path: If True, return the resolved base path instead of loading data.
Useful for notebooks that need direct access to message type directories.
Returns:
If get_base_path=False (default): DataFrame with message-type-specific columns.
Common columns include: timestamp, stock, order_reference_number, shares, price
If get_base_path=True: Path to ITCH messages directory
Note:
Raw ITCH files (~5GB each) must first be parsed using the Rust parser
or Python parser in Chapter 3 notebooks.
Example:
>>> # Load all add orders
>>> df = load_nasdaq_itch(message_types=["A", "F"])
>>> # Get base path for custom access
>>> itch_path = load_nasdaq_itch(get_base_path=True)
>>> add_orders = pl.read_parquet(itch_path / "A")
"""
base_path = (
ML4T_DATA_PATH / "equities" / "market" / "microstructure" / "nasdaq_itch" / "messages"
)
if not base_path.exists():
raise DataNotFoundError(
dataset_name="NASDAQ ITCH Parsed Messages",
path=base_path,
download_script="data/equities/market/microstructure/nasdaq_itch_download.py",
)
# Return base path if requested
if get_base_path:
return base_path
# Find available message types (single uppercase letter directories only)
available_types = [
d.name for d in base_path.iterdir() if d.is_dir() and len(d.name) == 1 and d.name.isupper()
]
if not available_types:
raise DataNotFoundError(
dataset_name="NASDAQ ITCH Parsed Messages",
path=base_path,
download_script="data/equities/market/microstructure/nasdaq_itch_download.py",
)
# Filter message types
load_types = message_types if message_types else available_types
load_types = [t for t in load_types if t in available_types]
if not load_types:
available = ", ".join(sorted(available_types))
msg = f"No matching message types found. Available: {available}"
raise ValueError(msg)
# Load data
dfs = []
for msg_type in load_types:
type_dir = base_path / msg_type
files = sorted(type_dir.glob("*.parquet"))
if files:
df = pl.read_parquet(files)
if "message_type" not in df.columns:
df = df.with_columns(pl.lit(msg_type).alias("message_type"))
dfs.append(df)
if not dfs:
raise DataNotFoundError(
dataset_name="NASDAQ ITCH Parsed Messages",
path=base_path,
download_script="data/equities/market/microstructure/nasdaq_itch_download.py",
)
data = pl.concat(dfs, how="diagonal_relaxed")
# Filter by symbol if requested
if symbols:
# ITCH uses 'stock' column
stock_col = "stock" if "stock" in data.columns else "symbol"
if stock_col in data.columns:
data = data.filter(pl.col(stock_col).is_in(symbols))
return data
def load_firm_characteristics(
split: Literal["all", "train", "valid", "test"] = "all",
include_macro: bool = False,
) -> pl.DataFrame:
"""Load firm characteristics dataset for ML-based asset pricing.
Chen-Pelger-Zhu (2020) anonymized dataset with ~180 firm characteristics
(accounting ratios, price-based measures) and monthly returns for US equities.
Standard benchmark for Chapters 10-16.
Args:
split: Which split to load ("all", "train", "valid", "test")
- train: 1967-1989 (~70%)
- valid: 1990-1999 (~15%)
- test: 2000-2016 (~15%)
include_macro: Whether to include macro columns
Returns:
DataFrame with ~180 firm characteristics and returns
"""
# Use canonical firm_characteristics_*.parquet naming
if split == "valid":
# Valid split derived from full dataset
filename = "firm_characteristics_all.parquet"
else:
filename = f"firm_characteristics_{split}.parquet"
path = ML4T_DATA_PATH / "equities" / "firm_characteristics" / filename
if not path.exists():
raise DataNotFoundError(
dataset_name="Firm Characteristics Dataset (Chen-Pelger-Zhu 2020)",
path=path,
download_script="data/equities/firm_characteristics/download.py",
)
df = pl.read_parquet(path)
# Normalize: date → timestamp for canonical schema
if "date" in df.columns and "timestamp" not in df.columns:
df = df.rename({"date": "timestamp"})
if df["timestamp"].dtype != pl.Date:
df = df.with_columns(pl.col("timestamp").cast(pl.Date))
# Add split column based on year (matches Chen-Pelger-Zhu methodology)
if "split" not in df.columns and "timestamp" in df.columns:
df = df.with_columns(
pl.when(pl.col("timestamp").dt.year() < 1990)
.then(pl.lit("train"))
.when(pl.col("timestamp").dt.year() < 2000)
.then(pl.lit("valid"))
.otherwise(pl.lit("test"))
.alias("split")
)
# Filter to requested split if not "all"
if split != "all" and "split" in df.columns:
df = df.filter(pl.col("split") == split)
if not include_macro:
# Filter out macro columns if present
macro_cols = [c for c in df.columns if c.startswith("macro_")]
if macro_cols:
df = df.drop(macro_cols)
return df
def load_iex_hist(
feed: Literal["tops", "deep"] = "deep",
data_type: Literal["quotes", "trades", "price_levels", "all"] = "all",
symbols: list[str] | None = None,
dates: list[str] | None = None,
get_raw_files: bool = False,
) -> pl.DataFrame | list[Path]:
"""Load IEX HIST market data (free public data from IEX Exchange).
IEX provides free historical market data with 12 months rolling history.
Data must be downloaded first using iex_hist.py, then parsed by the
iex_lob_reconstruction notebook.
Args:
feed: Which feed to load:
- "deep": Full depth of book (price level updates) - required for LOB
- "tops": Top of book only (BBO quotes and trades)
data_type: Which data type to load:
- "quotes": BBO quote updates (bid/ask prices and sizes)
- "trades": Trade executions
- "price_levels": Price level updates (DEEP only, for LOB reconstruction)
- "all": All available data types
symbols: Optional list of symbols to filter (e.g., ["AAPL", "SPY"])
dates: Optional list of dates to filter (YYYYMMDD format)
get_raw_files: If True, return list of raw pcap file paths instead of
loading parsed data. Useful for notebooks that need to parse data.
Returns:
If get_raw_files=False (default): DataFrame with requested data
If get_raw_files=True: List of Path objects to raw pcap.gz files
Note:
Raw pcap files must be parsed before use. The iex_lob_reconstruction
notebook handles parsing and saves results to the canonical location.
Attribution:
Data provided for free by IEX. By accessing or using IEX Historical Data,
you agree to the IEX Historical Data Terms of Use:
https://www.iexexchange.io/legal/hist-data-terms
Example:
>>> # Load all DEEP data
>>> df = load_iex_hist(feed="deep")
>>> # Load only trades for specific symbols
>>> trades = load_iex_hist(feed="tops", data_type="trades", symbols=["AAPL"])
>>> # Get raw files for custom parsing
>>> raw_files = load_iex_hist(feed="deep", get_raw_files=True)
"""
feed = feed.lower()
if feed not in ["tops", "deep"]:
raise ValueError("feed must be 'tops' or 'deep'")
base_path = ML4T_DATA_PATH / "equities" / "market" / "microstructure" / "iex" / feed
if not base_path.exists():
raise DataNotFoundError(
dataset_name=f"IEX HIST {feed.upper()} Data",
path=base_path,
download_script=f"data/equities/market/microstructure/iex_download.py --{'deep' if feed == 'deep' else 'smallest'}",
)
# Return raw pcap files if requested
if get_raw_files:
raw_files = sorted(base_path.glob("*.pcap.gz"))
if not raw_files:
raise DataNotFoundError(
dataset_name=f"IEX HIST {feed.upper()} Raw Files",
path=base_path,
download_script="data/equities/market/microstructure/iex_download.py",
)
return raw_files
# Look for parsed parquet files
parsed_path = base_path / "parsed"
if not parsed_path.exists() or not list(parsed_path.glob("**/*.parquet")):
# Check if raw files exist but aren't parsed
raw_files = list(base_path.glob("*.pcap.gz"))
if raw_files:
raise DataNotFoundError(
dataset_name=f"IEX HIST {feed.upper()} Parsed Data",
path=parsed_path,
download_script="See notebook: 16_iex_lob_reconstruction.py (run to parse raw files)",
)
raise DataNotFoundError(
dataset_name=f"IEX HIST {feed.upper()} Data",
path=base_path,
download_script="data/equities/market/microstructure/iex_download.py",
)
# Determine which data types to load
if data_type == "all":
if feed == "deep":
load_types = ["quotes", "trades", "price_levels"]
else:
load_types = ["quotes", "trades"]
else:
if data_type == "price_levels" and feed == "tops":
raise ValueError("price_levels only available with DEEP feed")
load_types = [data_type]
# Load data
dfs = []
for dtype in load_types:
type_path = parsed_path / dtype
if not type_path.exists():
continue
# Find parquet files, optionally filtering by date
if dates:
files = []
for d in dates:
files.extend(type_path.glob(f"{d}*.parquet"))
else:
files = sorted(type_path.glob("*.parquet"))
if files:
df = pl.read_parquet(files)
if "data_type" not in df.columns:
df = df.with_columns(pl.lit(dtype).alias("data_type"))
dfs.append(df)
if not dfs:
raise DataNotFoundError(
dataset_name=f"IEX HIST {feed.upper()} {data_type} Data",
path=parsed_path,
download_script="See notebook: 16_iex_lob_reconstruction.py",
)
data = pl.concat(dfs, how="diagonal_relaxed")
# Filter by symbol if requested
if symbols and "symbol" in data.columns:
data = data.filter(pl.col("symbol").is_in(symbols))
return data.sort("timestamp")
# --- Fundamentals: SEC filings (10-K, 10-Q, 8-K) + XBRL ---
def load_sec_filings(
form_type: str = "10-K",
universe: str = "sp100",
symbols: list[str] | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> pl.DataFrame:
"""Load SEC filing text from the consolidated aggregate parquet.
The same canonical schema is returned for every form type:
symbol, cik, form, filing_date, period_end, accession_no,
company_name, year, text, text_length
Args:
form_type: SEC form type — ``"10-K"``, ``"10-Q"``, or ``"8-K"``.
universe: Symbol universe — ``"sp100"`` (full-text 10-K/8-K) or
``"sp500"`` (10-Q MD&A).
symbols: Optional symbol filter.
start_date: Optional filing_date start (``YYYY-MM-DD``).
end_date: Optional filing_date end (``YYYY-MM-DD``).
Download first:
python data/equities/fundamentals/filings_download.py \\
--form {form_type} --universe {universe}
"""
form_slug = form_type.lower().replace("-", "")
path = (
ML4T_DATA_PATH
/ "equities"
/ "fundamentals"
/ form_slug
/ universe
/ "reference"
/ f"all_{form_slug}_filings.parquet"
)
if not path.exists():
raise DataNotFoundError(
dataset_name=f"SEC {form_type} Filings ({universe})",
path=path,
download_script=f"data/equities/fundamentals/filings_download.py --form {form_type} --universe {universe}",
readme="data/equities/fundamentals/README.md",
)
data = pl.read_parquet(path)
if symbols:
data = data.filter(pl.col("symbol").is_in(symbols))
if start_date:
data = data.filter(pl.col("filing_date") >= pl.lit(start_date).str.to_date())
if end_date:
data = data.filter(pl.col("filing_date") <= pl.lit(end_date).str.to_date())
return data
def load_sp500_10q_mda(
symbols: list[str] | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> pl.DataFrame:
"""Convenience wrapper for ``load_sec_filings("10-Q", "sp500")``.
The MD&A text lives in the canonical ``text`` column; pre-canonical
consumers that referenced ``mda_text`` should rename to ``text``.
Source: SEC EDGAR (public domain, redistributable).
Coverage: 2017-2021, ~600 S&P 500 constituents, ~9,000 quarterly filings.
"""
return load_sec_filings(
form_type="10-Q",
universe="sp500",
symbols=symbols,
start_date=start_date,
end_date=end_date,
)
def iter_sec_filings(
form_type: str = "10-K",
universe: str = "sp100",
symbols: list[str] | None = None,
):
"""Yield per-filing dict records for filesystem-style iteration.
Replaces the legacy per-ticker parquet directory layout. Each yielded
record has the canonical schema — readers that previously walked the
``{form}/{universe}/{TICKER}/{YEAR}.parquet`` tree should iterate this
generator instead.
Args:
form_type: SEC form type — ``"10-K"``, ``"10-Q"``, or ``"8-K"``.
universe: Symbol universe (``"sp100"`` or ``"sp500"``).
symbols: Optional symbol filter.
Yields:
dict with keys: symbol, cik, form, filing_date, period_end,
accession_no, company_name, year, text, text_length
"""
df = load_sec_filings(form_type=form_type, universe=universe, symbols=symbols)
yield from df.iter_rows(named=True)
def load_sec_xbrl_fundamentals(
concepts: list[str] | None = None,
years: list[int] | None = None,
symbols: list[str] | None = None,
ciks: list[int] | None = None,
) -> pl.DataFrame:
"""Load SEC XBRL fundamentals panel (CIK × quarter × concept).
Produced by `data/equities/fundamentals/xbrl_download.py`. Sourced from
the SEC XBRL Frames API (cross-sectional bulk snapshots) with
filing dates joined from the Submissions API for point-in-time
correctness.
Args:
concepts: Optional list of us-gaap concept names to keep as
columns (case-insensitive, e.g. ["Assets", "Revenues"]).
Default returns all fetched concepts.
years: Optional list of calendar years to filter on
`fiscal_quarter_end`.
symbols: Optional ticker filter.
ciks: Optional CIK filter.
Returns:
DataFrame with columns: symbol, cik, entity_name,
fiscal_quarter_end (Date), announcement_date (Date), accession,
and one lowercase column per requested concept.
Note:
Use `announcement_date` for point-in-time backtesting — not
`fiscal_quarter_end`, which introduces lookahead bias.
"""
path = ML4T_DATA_PATH / "equities" / "fundamentals" / "xbrl" / "fundamentals.parquet"
if not path.exists():
raise DataNotFoundError(
dataset_name="SEC XBRL Fundamentals",
path=path,
download_script="data/equities/fundamentals/xbrl_download.py",
readme="data/equities/fundamentals/README.md",
)
data = pl.read_parquet(path)
if symbols:
data = data.filter(pl.col("symbol").is_in(symbols))
if ciks:
data = data.filter(pl.col("cik").is_in(ciks))
if years:
data = data.filter(pl.col("fiscal_quarter_end").dt.year().is_in(years))
if concepts:
wanted = {c.lower() for c in concepts}
leading = [
c
for c in (
"symbol",
"cik",
"entity_name",
"fiscal_quarter_end",
"announcement_date",
"accession",
)
if c in data.columns
]
concept_cols = [c for c in data.columns if c not in leading and c in wanted]
data = data.select(leading + concept_cols)
return data
# --- Positioning: 13F institutional holdings ---
def load_institutional_holdings_13f(
start_date: str | None = None,
end_date: str | None = None,
) -> pl.DataFrame:
"""Load 13F institutional holdings for Chapter 10 / Chapter 22-23 notebooks.
Produced by `data/equities/positioning/13f_download.py`.
"""
path = ML4T_DATA_PATH / "equities" / "positioning" / "13f" / "institutional_holdings.parquet"
if not path.exists():
raise DataNotFoundError(
dataset_name="13F Institutional Holdings",
path=path,
download_script="data/equities/positioning/13f_download.py",
readme="data/equities/positioning/README.md",
)
data = pl.read_parquet(path)
if "filing_date" in data.columns and data["filing_date"].dtype == pl.String:
data = data.with_columns(pl.col("filing_date").str.to_date(strict=False))
if start_date and "filing_date" in data.columns:
data = data.filter(pl.col("filing_date") >= pl.lit(start_date).str.to_date())
if end_date and "filing_date" in data.columns:
data = data.filter(pl.col("filing_date") <= pl.lit(end_date).str.to_date())
return data
def load_13f_stock_features() -> pl.DataFrame:
"""Load stock-level features derived from 13F holdings."""
path = ML4T_DATA_PATH / "equities" / "positioning" / "13f" / "stock_features.parquet"
if not path.exists():
raise DataNotFoundError(
dataset_name="13F Stock Features",
path=path,
download_script="data/equities/positioning/13f_download.py",
readme="data/equities/positioning/README.md",
)
return pl.read_parquet(path)
def load_13f_edges() -> pl.DataFrame:
"""Load the institution-to-stock edge list for graph construction."""
path = ML4T_DATA_PATH / "equities" / "positioning" / "13f" / "institution_stock_edges.parquet"
if not path.exists():
raise DataNotFoundError(
dataset_name="13F Institution-Stock Edges",
path=path,
download_script="data/equities/positioning/13f_download.py",
readme="data/equities/positioning/README.md",
)
return pl.read_parquet(path)
def load_13f_bulk_holdings(quarter: str) -> pl.DataFrame:
"""Load one quarter of SEC bulk 13F holdings (full universe, ~3M rows).
Produced by `data/equities/positioning/13f_download.py --mode bulk`.
Same canonical schema as `load_institutional_holdings_13f` (cik,
accession_no, issuer, cusip, value_thousands, shares, filing_date,
company_name) but covers all ~5-7K filers in the window instead of the
curated institution list.
Args:
quarter: SEC filing-window label, e.g. '2024Q3'. SEC labels by
filing date: Q1=Mar-May, Q2=Jun-Aug, Q3=Sep-Nov, Q4=Dec-Feb.
"""
path = (
ML4T_DATA_PATH
/ "equities"
/ "positioning"
/ "13f"
/ "bulk"
/ quarter
/ "institutional_holdings.parquet"
)
if not path.exists():
raise DataNotFoundError(
dataset_name=f"13F Bulk Holdings ({quarter})",
path=path,
download_script=f"data/equities/positioning/13f_download.py --mode bulk --quarters {quarter}",
readme="data/equities/positioning/README.md",
)
return pl.read_parquet(path)