410 lines
13 KiB
Python
410 lines
13 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]
|
|
# # Cross-Case-Study Feature Evaluation Summary
|
|
#
|
|
# **Chapter 8: Feature Engineering**
|
|
# **Section Reference**: 8.6 - Combining Features and Controlling Search
|
|
# **Docker image**: `ml4t`
|
|
#
|
|
# ## Purpose
|
|
#
|
|
# This notebook is the cross-case-study inventory and presentation layer: it
|
|
# aggregates engineered features and the best registry IC per case study across
|
|
# all 9 asset classes. It surfaces:
|
|
# - **Feature counts and families**: how large each case study's feature space is
|
|
# - **Best IC per case study** (from the model registry): how predictive the
|
|
# strongest family is, by asset class
|
|
# - **Cross-asset patterns**: which feature families generalize vs which are asset-specific
|
|
#
|
|
# The HAC-adjusted significance and BH-FDR survival counts themselves are computed
|
|
# upstream, in each case study's `13_model_analysis.py`; this notebook reads and
|
|
# presents their results rather than recomputing them.
|
|
#
|
|
# ## Learning Objectives
|
|
#
|
|
# 1. Compare feature predictability across diverse asset classes
|
|
# 2. Read off each case study's best registry IC and feature-space size
|
|
# 3. Identify feature families that generalize vs those that are asset-specific
|
|
# 4. Understand how universe size (breadth) interacts with IC magnitude
|
|
#
|
|
# ## Prerequisites
|
|
#
|
|
# - Case study feature notebooks must have produced `data/features/financial.parquet`
|
|
# - If feature data is missing for some case studies, they show as "no features"
|
|
|
|
# %%
|
|
"""Cross-case-study feature evaluation summary."""
|
|
|
|
import warnings
|
|
|
|
import numpy as np
|
|
import plotly.graph_objects as go
|
|
import polars as pl
|
|
from IPython.display import display
|
|
|
|
warnings.filterwarnings("ignore")
|
|
|
|
from utils.paths import get_case_study_dir
|
|
|
|
# %% tags=["parameters"]
|
|
# Scale parameters (Papermill overrides for testing; readers see production values)
|
|
START_DATE = None # use full dataset
|
|
|
|
# %% [markdown]
|
|
# ## 1. Load Feature Data
|
|
#
|
|
# Scan all case study `data/features/` directories for the `financial.parquet`
|
|
# produced by the feature engineering notebooks. We introspect schemas to count
|
|
# features and compare across case studies.
|
|
|
|
# %%
|
|
CASE_STUDIES = [
|
|
"etfs",
|
|
"crypto_perps_funding",
|
|
"nasdaq100_microstructure",
|
|
"sp500_equity_option_analytics",
|
|
"us_firm_characteristics",
|
|
"fx_pairs",
|
|
"cme_futures",
|
|
"sp500_options",
|
|
"us_equities_panel",
|
|
]
|
|
|
|
DISPLAY_NAMES = {
|
|
"etfs": "ETFs",
|
|
"crypto_perps_funding": "Crypto Perps",
|
|
"nasdaq100_microstructure": "NASDAQ-100",
|
|
"sp500_equity_option_analytics": "S&P 500 Eq+Opt",
|
|
"us_firm_characteristics": "US Firm Chars",
|
|
"fx_pairs": "FX Pairs",
|
|
"cme_futures": "CME Futures",
|
|
"sp500_options": "S&P 500 Options",
|
|
"us_equities_panel": "US Equities",
|
|
}
|
|
|
|
# Columns that are identifiers, not features
|
|
_ID_COLS = {"timestamp", "symbol", "product", "stock_id", "instrument_id", "date", "asset"}
|
|
|
|
|
|
def load_feature_info(case_study_id: str) -> dict | None:
|
|
"""Load feature summary by introspecting financial.parquet schema."""
|
|
case_dir = get_case_study_dir(case_study_id)
|
|
# Case studies materialize features under <case_dir>/features/ (the
|
|
# naming-conventions doc lists this under data/features/, but the
|
|
# current case-study layout writes directly under features/).
|
|
features_path = case_dir / "features" / "financial.parquet"
|
|
if not features_path.exists():
|
|
return None
|
|
|
|
schema = pl.scan_parquet(features_path).collect_schema()
|
|
feature_names = [c for c in schema.names() if c not in _ID_COLS]
|
|
n_features = len(feature_names)
|
|
|
|
# Group features into families by prefix (e.g. "mom_", "vol_", "carry_")
|
|
family_counts: dict[str, int] = {}
|
|
for name in feature_names:
|
|
parts = name.split("_")
|
|
family = parts[0] if len(parts) > 1 else "other"
|
|
family_counts[family] = family_counts.get(family, 0) + 1
|
|
|
|
return {
|
|
"n_features": n_features,
|
|
"feature_names": feature_names,
|
|
"family_counts": family_counts,
|
|
}
|
|
|
|
|
|
# %%
|
|
# Load all feature info
|
|
all_results: dict[str, dict] = {}
|
|
evaluated: dict[str, dict] = {}
|
|
awaiting: list[str] = []
|
|
|
|
for cs in CASE_STUDIES:
|
|
result = load_feature_info(cs)
|
|
if result is None:
|
|
awaiting.append(cs)
|
|
continue
|
|
all_results[cs] = result
|
|
evaluated[cs] = result
|
|
|
|
print(f"Case studies with features: {len(evaluated)}/{len(CASE_STUDIES)}")
|
|
if evaluated:
|
|
print(f" Available: {', '.join(DISPLAY_NAMES[cs] for cs in evaluated)}")
|
|
if awaiting:
|
|
print(f" No features: {', '.join(DISPLAY_NAMES.get(cs, cs) for cs in awaiting)}")
|
|
|
|
# %% [markdown]
|
|
# ## 2. Feature Count Comparison
|
|
#
|
|
# How many features and feature families does each case study engineer? (The
|
|
# multiple-testing survival counts are produced upstream in each case study's
|
|
# `13_model_analysis.py`; here we inventory the feature space.)
|
|
|
|
# %%
|
|
if evaluated:
|
|
summary_rows = []
|
|
for cs, result in evaluated.items():
|
|
summary_rows.append(
|
|
{
|
|
"case_study": DISPLAY_NAMES[cs],
|
|
"n_features": result["n_features"],
|
|
"n_families": len(result["family_counts"]),
|
|
"top_families": ", ".join(
|
|
f"{k}({v})"
|
|
for k, v in sorted(result["family_counts"].items(), key=lambda x: -x[1])[:5]
|
|
),
|
|
}
|
|
)
|
|
|
|
summary_df = pl.DataFrame(summary_rows)
|
|
display(summary_df)
|
|
else:
|
|
print("No feature data available yet. Run case study feature notebooks first.")
|
|
|
|
# %% [markdown]
|
|
# ## 3. Feature Count Comparison
|
|
#
|
|
# How does feature set size vary across case studies? More features provide
|
|
# a richer signal space but also increase the multiple testing burden.
|
|
|
|
# %%
|
|
if evaluated:
|
|
cs_names = [DISPLAY_NAMES[cs] for cs in evaluated]
|
|
n_features = [evaluated[cs]["n_features"] for cs in evaluated]
|
|
|
|
fig = go.Figure()
|
|
fig.add_trace(
|
|
go.Bar(
|
|
x=cs_names,
|
|
y=n_features,
|
|
marker_color="#3498db",
|
|
text=[str(n) for n in n_features],
|
|
textposition="outside",
|
|
)
|
|
)
|
|
fig.update_layout(
|
|
title="Number of Financial Features by Case Study",
|
|
yaxis_title="Number of Features",
|
|
template="plotly_white",
|
|
height=450,
|
|
)
|
|
fig.show()
|
|
else:
|
|
print("No feature data available.")
|
|
|
|
# %% [markdown]
|
|
# ## 4. Feature Family Distribution
|
|
#
|
|
# Which feature families are used across asset classes? This heatmap shows
|
|
# the number of features per family per case study, revealing cross-asset
|
|
# patterns (e.g., momentum features everywhere) vs asset-specific features
|
|
# (e.g., carry only in futures/FX).
|
|
|
|
# %%
|
|
if evaluated:
|
|
# Collect all family names across case studies
|
|
all_families: set[str] = set()
|
|
for cs in evaluated:
|
|
all_families.update(evaluated[cs]["family_counts"].keys())
|
|
|
|
all_families_sorted = sorted(all_families)
|
|
|
|
if all_families_sorted:
|
|
heatmap_data = []
|
|
for family in all_families_sorted:
|
|
row = []
|
|
for cs in evaluated:
|
|
count = evaluated[cs]["family_counts"].get(family, 0)
|
|
row.append(count if count > 0 else float("nan"))
|
|
heatmap_data.append(row)
|
|
|
|
fig = go.Figure(
|
|
data=go.Heatmap(
|
|
z=heatmap_data,
|
|
x=[DISPLAY_NAMES[cs] for cs in evaluated],
|
|
y=all_families_sorted,
|
|
colorscale="Blues",
|
|
text=[
|
|
[f"{int(v)}" if not np.isnan(v) else "" for v in row] for row in heatmap_data
|
|
],
|
|
texttemplate="%{text}",
|
|
textfont={"size": 9},
|
|
)
|
|
)
|
|
fig.update_layout(
|
|
title="Feature Family Count by Case Study",
|
|
template="plotly_white",
|
|
height=max(400, len(all_families_sorted) * 30 + 100),
|
|
width=max(600, len(evaluated) * 100 + 200),
|
|
)
|
|
fig.show()
|
|
else:
|
|
print("No family-level data available.")
|
|
else:
|
|
print("No feature data available.")
|
|
|
|
# %% [markdown]
|
|
# ## 5. Representative Features Across Case Studies
|
|
#
|
|
# A sample of each case study's feature space — the first few feature names in
|
|
# schema order — to illustrate the engineered inputs. This is an inventory view,
|
|
# not an IC ranking (per-feature IC is computed in each case study's evaluation
|
|
# notebook).
|
|
|
|
# %%
|
|
if evaluated:
|
|
top_features_all = []
|
|
for cs in evaluated:
|
|
# Show first 5 feature names per case study
|
|
for feat_name in evaluated[cs]["feature_names"][:5]:
|
|
top_features_all.append(
|
|
{
|
|
"case_study": DISPLAY_NAMES[cs],
|
|
"feature": feat_name,
|
|
}
|
|
)
|
|
|
|
if top_features_all:
|
|
top_df = pl.DataFrame(top_features_all)
|
|
display(top_df)
|
|
else:
|
|
print("No feature data available.")
|
|
|
|
# %% [markdown]
|
|
# ## 6. Correlation Structure Summary
|
|
#
|
|
# Feature redundancy across case studies. How many feature pairs
|
|
# have correlation above 0.7? High redundancy wastes model capacity.
|
|
|
|
# %%
|
|
if evaluated:
|
|
corr_data = []
|
|
for cs in evaluated:
|
|
corr_data.append(
|
|
{
|
|
"case_study": DISPLAY_NAMES[cs],
|
|
"n_features": evaluated[cs]["n_features"],
|
|
"n_families": len(evaluated[cs]["family_counts"]),
|
|
}
|
|
)
|
|
|
|
corr_df = pl.DataFrame(corr_data)
|
|
display(corr_df)
|
|
print("\nNote: For full correlation analysis, run the per-case-study evaluation notebooks.")
|
|
else:
|
|
print("No feature data available.")
|
|
|
|
# %% [markdown]
|
|
# ## 7. Breadth vs IC: The Fundamental Law Perspective
|
|
#
|
|
# The Fundamental Law of Active Management says:
|
|
#
|
|
# $$IR \approx IC \times \sqrt{BR}$$
|
|
#
|
|
# where $BR$ is the number of independent bets (roughly the universe size).
|
|
# A case study with IC = 0.01 and 3,000 stocks achieves IR = 0.55,
|
|
# while IC = 0.03 with 20 pairs gives IR = 0.13. Breadth matters enormously.
|
|
|
|
# %%
|
|
from case_studies.utils.analytics import DATASET_META, load_best_ic_per_family
|
|
|
|
if evaluated:
|
|
# Load best IC per family from registry to combine with universe metadata
|
|
best_ic_df = load_best_ic_per_family()
|
|
|
|
if not best_ic_df.is_empty():
|
|
# Get best IC per case study (across all families)
|
|
best_per_cs = (
|
|
best_ic_df.sort("ic_mean", descending=True, nulls_last=True)
|
|
.group_by("case_study")
|
|
.first()
|
|
.select("case_study", "ic_mean")
|
|
)
|
|
|
|
breadth_data = []
|
|
for row in best_per_cs.iter_rows(named=True):
|
|
cs = row["case_study"]
|
|
meta = DATASET_META.get(cs, {})
|
|
n_entities = meta.get("entities", 0)
|
|
ic = abs(row["ic_mean"]) if row["ic_mean"] is not None else 0.0
|
|
if n_entities == 0 or ic == 0.0:
|
|
continue
|
|
ir_estimate = ic * np.sqrt(n_entities)
|
|
breadth_data.append(
|
|
{
|
|
"case_study": DISPLAY_NAMES.get(cs, cs),
|
|
"universe_size": n_entities,
|
|
"best_abs_ic": round(ic, 4),
|
|
"estimated_ir": round(ir_estimate, 2),
|
|
}
|
|
)
|
|
|
|
if breadth_data:
|
|
breadth_df = pl.DataFrame(breadth_data).sort("estimated_ir", descending=True)
|
|
display(breadth_df)
|
|
else:
|
|
print("No IC data available from registry.")
|
|
else:
|
|
print("No model IC data in registry yet.")
|
|
else:
|
|
print("No feature data available.")
|
|
|
|
# %%
|
|
# Visualize breadth vs IC
|
|
if evaluated and "breadth_data" in dir() and breadth_data:
|
|
fig = go.Figure()
|
|
for brow in breadth_data:
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=[brow["universe_size"]],
|
|
y=[brow["best_abs_ic"]],
|
|
mode="markers+text",
|
|
text=[brow["case_study"]],
|
|
textposition="top center",
|
|
marker=dict(size=brow["estimated_ir"] * 20 + 5),
|
|
showlegend=False,
|
|
)
|
|
)
|
|
|
|
fig.update_layout(
|
|
title="Universe Size vs Best Model IC (bubble size = estimated IR)",
|
|
xaxis_title="Universe Size (number of instruments)",
|
|
yaxis_title="Best Model |IC|",
|
|
xaxis_type="log",
|
|
template="plotly_white",
|
|
height=450,
|
|
)
|
|
fig.show()
|
|
|
|
# %% [markdown]
|
|
# ## What the Panels Above Show
|
|
#
|
|
# The notebook aggregates whatever is present in each case study's
|
|
# `data/features/financial.parquet` and the model registry. The substantive
|
|
# findings — which feature families have predictive content for which label
|
|
# and horizon, how many features survive HAC + BH-FDR, and how breadth
|
|
# interacts with IC magnitude — are produced by the per-case-study evaluation
|
|
# notebooks (`13_model_analysis.py` in each case study). This summary
|
|
# notebook is a cross-case-study inventory and presentation layer; it does
|
|
# not itself compute IC or run multiple-testing correction.
|
|
#
|
|
# **Next**: See `09_model_based_features/case_study_temporal_summary` for the
|
|
# temporal/model-based feature companion view.
|
|
# **Book**: Chapter 8.6 discusses combining features and controlling the
|
|
# search space to avoid data mining.
|