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

565 lines
19 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.
# ---
# jupyter:
# jupytext:
# cell_metadata_filter: tags,-all
# formats: ipynb,py:percent
# 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]
# # Text Feature Signal Evaluation
#
# **Chapter 10: Text Feature Engineering**
# **Section Reference**: See Section 10.5 for practitioner workflow and alpha factor design
#
# **Docker image**: `ml4t`
#
# ## Purpose
# This notebook evaluates **text-derived alpha signals** produced by
# [`07_news_return_signals`](07_news_return_signals.ipynb) using standard factor diagnostics:
# - Daily cross-sectional Information Coefficient (Spearman rank correlation)
# - ICIR and t-stats for robustness
# - Quintile and long-short (Q5 - Q1) spread analysis
#
# ## Data Contract
# - **Input**: `output/fnspid/news_features.parquet` (from notebook 07)
# - Contains forward returns (fwd_ret_1d, fwd_ret_5d, fwd_ret_20d) already aligned
# - No additional price data or label computation needed
# - **Output**: `output/text_evaluation/text_signal_summary.parquet`
#
# ## Learning Objectives
# After completing this notebook, you will be able to:
# - Evaluate NLP-derived alpha signals using standard factor analysis
# - Compute daily IC, ICIR, and t-statistics for text signals
# - Analyze quintile spreads and long-short returns
# - Understand the predictive power of text-based factors
#
# ## Prerequisites
# - Section 10.5 of the chapter (alpha-factor evaluation: IC, ICIR, quintile spread).
# - `news_features.parquet` produced by `07_news_return_signals.py`.
#
# ## Related Notebooks
# - `07_news_return_signals.py` — produces the text features evaluated here.
# - `09_filing_text_signals.py` — analogous IC analysis for 10-Q filing signals.
#
# ## Signals Evaluated
# | Signal | Description | Expected Effect |
# |--------|-------------|-----------------|
# | weighted_surprise | News surprise × sentiment direction | Positive (directional information) |
# | sentiment_mean | Average daily sentiment | Positive (bullish → positive returns) |
# | sentiment_momentum | Change in sentiment vs baseline | Positive (improving sentiment) |
# | coverage_count | Article frequency | Ambiguous (attention effect) |
# %%
"""Text Feature Signal Evaluation - Evaluate NLP-derived alpha signals."""
import json
import warnings
from collections.abc import Iterator
from dataclasses import dataclass
import matplotlib.pyplot as plt
import numpy as np
import polars as pl
from scipy.stats import spearmanr
warnings.filterwarnings("ignore")
from utils.paths import get_output_dir
from utils.reproducibility import set_global_seeds
# %% tags=["parameters"]
SEED = 42
MIN_ASSETS_PER_DAY = 5
# %%
# Reproducibility — set_global_seeds covers Python random / NumPy / Torch.
set_global_seeds(SEED)
@dataclass(frozen=True)
class EvalConfig:
"""Configuration for text signal evaluation."""
horizons: tuple[int, ...] = (1, 5, 20)
min_assets_per_day: int = 20
quantiles: int = 5
# Paths using standard utilities
TEXT_INPUT_DIR = get_output_dir(8, "fnspid")
OUTPUT_DIR = get_output_dir(8, "text_evaluation")
CONFIG = EvalConfig(min_assets_per_day=MIN_ASSETS_PER_DAY)
# %% [markdown]
# ## 1. Load Text Features from Notebook 07
#
# Notebook 07 produces a complete dataset with:
# - Text signals (weighted_surprise, sentiment_mean, etc.)
# - Forward returns (fwd_ret_1d, fwd_ret_5d, fwd_ret_20d) already computed
# - Proper look-ahead bias handling (signals lag returns by 1 day)
#
# We do NOT recompute labels here—we use the dataset contract from notebook 07.
# %%
# Load text-derived features
text_features_path = TEXT_INPUT_DIR / "news_features.parquet"
if not text_features_path.exists():
raise FileNotFoundError(
f"Missing input: {text_features_path}\n\n"
"Run notebook 07 first to generate this dataset:\n"
" python 08_text_feature_engineering/code/07_news_return_signals.py"
)
text_features = pl.read_parquet(text_features_path)
if len(text_features) == 0:
raise ValueError(
f"Empty dataset: {text_features_path}\nNotebook 07 ran but produced no usable rows."
)
print(f"Loaded {len(text_features):,} observations")
print(f"Columns: {text_features.columns}")
# %%
# Normalize schema from notebook 07
def normalize_schema(df: pl.DataFrame) -> pl.DataFrame:
"""
Normalize the schema produced by notebook 07 to a canonical evaluation schema.
Canonical requirements:
- symbol: str (entity identifier)
- timestamp: pl.Date (tradable date)
- forward returns: fwd_ret_1d, fwd_ret_5d, fwd_ret_20d
"""
# Normalize entity column to 'symbol'
if "symbol" not in df.columns:
for alt in ("ticker", "asset"):
if alt in df.columns:
df = df.rename({alt: "symbol"})
break
else:
raise ValueError("Missing entity column. Expected 'symbol', 'ticker', or 'asset'.")
# Normalize time column to 'timestamp'
if "timestamp" not in df.columns:
for alt in ("date", "trade_date"):
if alt in df.columns:
df = df.rename({alt: "timestamp"})
break
else:
raise ValueError("Missing time column. Expected 'timestamp', 'date', or 'trade_date'.")
# Parse date string if needed
if df["timestamp"].dtype == pl.Utf8:
df = df.with_columns(pl.col("timestamp").str.to_date().alias("timestamp"))
# Verify forward return columns exist
required_return_cols = {"fwd_ret_1d", "fwd_ret_5d", "fwd_ret_20d"}
missing = required_return_cols - set(df.columns)
if missing:
raise ValueError(f"Missing forward return columns: {sorted(missing)}")
return df
text_features = normalize_schema(text_features)
print(f"Date range: {text_features['timestamp'].min()} to {text_features['timestamp'].max()}")
print(f"Unique assets: {text_features['symbol'].n_unique()}")
text_features.head(10)
# %% [markdown]
# ## 2. Define Signal Evaluation Functions
#
# We compute:
# - **Daily IC**: Cross-sectional Spearman correlation between signal and forward return
# - **ICIR**: IC mean / IC std (measures signal consistency)
# - **t-stat**: Statistical significance of mean IC
# - **Quintile returns**: Average return by signal quintile
# - **Long-short spread**: Q5 - Q1 return
# %%
# Signal evaluation utilities
TEXT_SIGNALS = [
"weighted_surprise",
"sentiment_mean",
"sentiment_momentum",
"coverage_count",
]
AVAILABLE_SIGNALS = [c for c in TEXT_SIGNALS if c in text_features.columns]
if not AVAILABLE_SIGNALS:
raise ValueError(
f"No expected signals found. Expected one of: {TEXT_SIGNALS}. Have: {text_features.columns}"
)
print(f"Signals to evaluate: {AVAILABLE_SIGNALS}")
RET_COL_BY_HORIZON = {1: "fwd_ret_1d", 5: "fwd_ret_5d", 20: "fwd_ret_20d"}
# %% [markdown]
# ### Date Grouping Utility
# Helper to iterate over per-date cross-sections for IC computation.
# %%
def iter_date_groups(df: pl.DataFrame) -> Iterator[pl.DataFrame]:
"""Yield per-date groups for cross-sectional calculations."""
for _, g in df.partition_by("timestamp", as_dict=True).items():
yield g
# %% [markdown]
# ### Daily Information Coefficient
# Compute daily cross-sectional Spearman IC for signal vs forward return.
# %%
def daily_ic(
df: pl.DataFrame,
signal_col: str,
ret_col: str,
min_assets: int,
) -> pl.DataFrame:
"""Compute daily cross-sectional Spearman IC for signal vs forward return."""
records: list[dict[str, object]] = []
for g in iter_date_groups(df.select(["timestamp", signal_col, ret_col]).drop_nulls()):
if len(g) < min_assets:
continue
ic, _ = spearmanr(g[signal_col].to_numpy(), g[ret_col].to_numpy())
if np.isnan(ic):
continue
records.append({"timestamp": g["timestamp"][0], "ic": float(ic)})
return pl.DataFrame(records).sort("timestamp")
# %% [markdown]
# ### IC Summary Statistics
# Compute mean, std, ICIR, and t-stat from a daily IC series.
# %%
def summarize_ic(ic_df: pl.DataFrame) -> dict[str, float]:
"""Summarize IC series with mean, std, ICIR, and t-stat."""
if len(ic_df) == 0:
return {"ic_mean": np.nan, "ic_std": np.nan, "icir": np.nan, "t_stat": np.nan, "n": 0}
values = ic_df["ic"].to_numpy()
ic_mean = float(np.mean(values))
ic_std = float(np.std(values, ddof=1)) if len(values) > 1 else np.nan
icir = float(ic_mean / ic_std) if ic_std and ic_std > 0 else np.nan
t_stat = float(ic_mean / (ic_std / np.sqrt(len(values)))) if ic_std and ic_std > 0 else np.nan
return {"ic_mean": ic_mean, "ic_std": ic_std, "icir": icir, "t_stat": t_stat, "n": len(values)}
# %% [markdown]
# ### Quintile Long-Short Returns
# Compute daily long-short returns (top minus bottom quantile) for a signal.
# %%
def quintile_long_short(
df: pl.DataFrame,
signal_col: str,
ret_col: str,
quantiles: int,
min_assets: int,
) -> pl.DataFrame:
"""Compute daily long-short returns (top minus bottom quantile) for a signal."""
d = df.select(["timestamp", "symbol", signal_col, ret_col]).drop_nulls()
# Rank within date, then bin into quantiles using rank percentiles.
d = d.with_columns(
(
(pl.col(signal_col).rank(method="average").over("timestamp") - 1)
/ (pl.len().over("timestamp") - 1).clip(lower_bound=1)
).alias("rank_pct")
)
# Quantile index 1..Q
d = d.with_columns(
((pl.col("rank_pct") * quantiles).floor().clip(0, quantiles - 1) + 1)
.cast(pl.Int64)
.alias("q")
)
# Filter dates with insufficient cross-section
d = d.join(
d.group_by("timestamp")
.agg(pl.len().alias("n"))
.filter(pl.col("n") >= min_assets)
.select(["timestamp"]),
on="timestamp",
how="inner",
)
qrets = d.group_by(["timestamp", "q"]).agg(pl.col(ret_col).mean().alias("q_ret"))
wide = qrets.pivot(index="timestamp", on="q", values="q_ret").sort("timestamp")
top = str(quantiles)
bottom = "1"
if top not in wide.columns or bottom not in wide.columns:
return pl.DataFrame({"timestamp": [], "ls_ret": []})
return wide.with_columns((pl.col(top) - pl.col(bottom)).alias("ls_ret")).select(
["timestamp", "ls_ret"]
)
# %% [markdown]
# ## 3. Evaluate Signals Against Forward Returns
# %%
# Run evaluation for all signals and horizons
summary_rows: list[dict[str, object]] = []
daily_ic_rows: list[pl.DataFrame] = []
daily_ls_rows: list[pl.DataFrame] = []
for signal in AVAILABLE_SIGNALS:
for h in CONFIG.horizons:
ret_col = RET_COL_BY_HORIZON[h]
ic_df = daily_ic(text_features, signal, ret_col, CONFIG.min_assets_per_day)
ic_summary = summarize_ic(ic_df)
summary_rows.append(
{
"signal": signal,
"horizon_days": h,
**ic_summary,
}
)
daily_ic_rows.append(
ic_df.with_columns(pl.lit(signal).alias("signal"), pl.lit(h).alias("horizon_days"))
)
ls_df = quintile_long_short(
text_features, signal, ret_col, CONFIG.quantiles, CONFIG.min_assets_per_day
)
daily_ls_rows.append(
ls_df.with_columns(pl.lit(signal).alias("signal"), pl.lit(h).alias("horizon_days"))
)
summary_df = pl.DataFrame(summary_rows).sort(["horizon_days", "icir"], descending=[False, True])
print("\n" + "=" * 80)
print("TEXT SIGNAL EVALUATION SUMMARY")
print("=" * 80)
print(summary_df)
# %% [markdown]
# ## 4. Visualization: IC and Long-Short Analysis
# %%
# IC time series for the key signal (weighted_surprise, 1-day horizon)
key_signal = "weighted_surprise"
key_horizon = 1
if key_signal in AVAILABLE_SIGNALS:
ic_df = daily_ic(
text_features, key_signal, RET_COL_BY_HORIZON[key_horizon], CONFIG.min_assets_per_day
)
if len(ic_df) > 10:
ic_vals = ic_df["ic"].to_numpy()
ic_mean = np.mean(ic_vals)
ic_std = np.std(ic_vals, ddof=1)
fig, axes = plt.subplots(1, 2, figsize=(14, 4))
# IC time series
axes[0].plot(range(len(ic_df)), ic_vals, alpha=0.7, linewidth=0.5)
axes[0].axhline(0, color="black", linestyle="-", linewidth=0.5)
axes[0].axhline(ic_mean, color="red", linestyle="--", label=f"Mean IC: {ic_mean:.4f}")
axes[0].fill_between(
range(len(ic_df)),
ic_mean - ic_std,
ic_mean + ic_std,
alpha=0.2,
color="red",
label="±1 std",
)
axes[0].set_xlabel("Trading Day")
axes[0].set_ylabel("Information Coefficient")
axes[0].set_title(f"Daily Cross-Sectional IC ({key_signal}, {key_horizon}d)")
axes[0].legend()
# IC histogram
axes[1].hist(ic_vals, bins=50, edgecolor="black", alpha=0.7)
axes[1].axvline(0, color="black", linestyle="-", linewidth=0.5)
axes[1].axvline(ic_mean, color="red", linestyle="--", label=f"Mean: {ic_mean:.4f}")
axes[1].set_xlabel("Information Coefficient")
axes[1].set_ylabel("Frequency")
axes[1].set_title("IC Distribution")
axes[1].legend()
plt.suptitle(f"Text Signal IC Analysis: {key_signal}")
plt.tight_layout()
plt.show()
# %%
# Quintile returns for key signal
if key_signal in AVAILABLE_SIGNALS:
# Compute quintile returns
d = text_features.select(
["timestamp", "symbol", key_signal, RET_COL_BY_HORIZON[key_horizon]]
).drop_nulls()
# Rank and assign quintiles
d = d.with_columns(
(
(pl.col(key_signal).rank(method="average").over("timestamp") - 1)
/ (pl.len().over("timestamp") - 1).clip(lower_bound=1)
).alias("rank_pct")
).with_columns(
pl.when(pl.col("rank_pct") < 0.2)
.then(pl.lit("Q1"))
.when(pl.col("rank_pct") < 0.4)
.then(pl.lit("Q2"))
.when(pl.col("rank_pct") < 0.6)
.then(pl.lit("Q3"))
.when(pl.col("rank_pct") < 0.8)
.then(pl.lit("Q4"))
.otherwise(pl.lit("Q5"))
.alias("quintile")
)
# Average returns by quintile
quintile_returns = (
d.group_by("quintile")
.agg(
[
pl.col(RET_COL_BY_HORIZON[key_horizon]).mean().alias("mean_ret"),
pl.len().alias("n_obs"),
]
)
.sort("quintile")
)
print(f"\nQuintile Returns ({key_signal}, {key_horizon}d forward return):")
print(quintile_returns)
# %%
# Plot quintile returns
if key_signal in AVAILABLE_SIGNALS:
fig, ax = plt.subplots(figsize=(8, 5))
quintiles = quintile_returns["quintile"].to_list()
returns_bps = [r * 10000 for r in quintile_returns["mean_ret"].to_list()]
# Sequential blue gradient for ordinal quintile encoding (greyscale-safe)
quintile_colors = ["#cfe2f3", "#9fc5e8", "#6fa8dc", "#3d85c6", "#0b5394"]
bars = ax.bar(quintiles, returns_bps, color=quintile_colors)
ax.axhline(0, color="black", linestyle="-", linewidth=0.5)
ax.set_xlabel(f"{key_signal} Quintile (Q1=Low, Q5=High)")
ax.set_ylabel(f"Average {key_horizon}-Day Forward Return (bps)")
ax.set_title(f"Quintile Returns: {key_signal}")
# Add value labels
for bar, val in zip(bars, returns_bps, strict=False):
ax.annotate(
f"{val:.1f}",
xy=(bar.get_x() + bar.get_width() / 2, bar.get_height()),
xytext=(0, 3 if val >= 0 else -10),
textcoords="offset points",
ha="center",
fontsize=10,
)
plt.tight_layout()
plt.show()
# %% [markdown]
# ## 5. Save Results
# %%
# Create output directory
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# Save summary
summary_path = OUTPUT_DIR / "text_signal_summary.parquet"
summary_df.write_parquet(summary_path)
print(f"Saved summary to: {summary_path}")
# Save daily IC series
if daily_ic_rows:
daily_ic_df = pl.concat(daily_ic_rows, how="vertical_relaxed")
daily_ic_path = OUTPUT_DIR / "daily_ic.parquet"
daily_ic_df.write_parquet(daily_ic_path)
print(f"Saved daily IC to: {daily_ic_path}")
# Save daily long-short series
if daily_ls_rows:
daily_ls_df = pl.concat(daily_ls_rows, how="vertical_relaxed")
daily_ls_path = OUTPUT_DIR / "daily_long_short.parquet"
daily_ls_df.write_parquet(daily_ls_path)
print(f"Saved daily long-short to: {daily_ls_path}")
# %% [markdown]
# ## Key Takeaways
#
# ### Measured signal performance on this evaluation set
# 1. All four text signals produce 1-day cross-sectional ICIRs within ±0.005
# of zero (weighted_surprise 0.004, sentiment_mean 0.004,
# sentiment_momentum 0.0005, coverage_count 0.004; n≈1,260 dates).
# Per-signal t-stats are all in [0.2, +0.2] at the 1-day horizon. The
# construction methodology is demonstrated here; the magnitudes do not
# support a ranking claim — none of the four signals is statistically
# distinguishable from zero on this sample.
# 2. The weighted_surprise quintile sort on 1-day forward returns is
# non-monotonic and Q5 < Q1 (Q1 0.000998, Q2 0.000417, Q3 0.000604,
# Q4 0.000786, Q5 0.000221). The predicted bullish quintile (Q5) earns
# less than the predicted bearish quintile (Q1) — the bullish/bearish
# framing in NB07 is **not** confirmed.
# 3. ICIR magnitudes here are an order of magnitude or more below the
# ICIR > 0.5 robustness threshold used in NB07's takeaway 4. The
# notebook does not establish tradeability for any of the four signals
# on the 2009-2017 FNSPID 50-ticker subset.
#
# ### Evaluation Methodology
# - We use the exact dataset from notebook 07 (no label recomputation)
# - Forward returns are already aligned to tradable dates with proper lag
# - Daily cross-sectional IC avoids temporal autocorrelation issues
#
# ### Limitations
# 1. **Coverage bias**: Notebook 07 drops low-coverage tickers (see its takeaways)
# 2. **Data sparsity**: News coverage is uneven across stocks and time
# 3. **Decay**: Text signals may have shorter predictive horizons than momentum
#
# ### Next Steps
# - Chapter 12: Train ML models on combined feature set (text + price features)
# - Chapter 17: Backtest text-enhanced strategies
# %%
# Save run metadata for reproducibility
run_metadata = {
"horizons": list(CONFIG.horizons),
"min_assets_per_day": CONFIG.min_assets_per_day,
"quantiles": CONFIG.quantiles,
"signals_evaluated": AVAILABLE_SIGNALS,
"n_observations": len(text_features),
"n_assets": text_features["symbol"].n_unique(),
"date_range": {
"min": str(text_features["timestamp"].min()),
"max": str(text_features["timestamp"].max()),
},
"input_file": str(text_features_path),
}
metadata_path = OUTPUT_DIR / "run_metadata.json"
with open(metadata_path, "w") as f:
json.dump(run_metadata, f, indent=2)
print(f"Run metadata saved to: {metadata_path}")
print("\nText feature evaluation complete")