565 lines
19 KiB
Python
565 lines
19 KiB
Python
# ---
|
||
# 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")
|