Pular para o conteúdo
Todos os documentos da biblioteca

Triagem cross-seccional de features com controles de estabilidade e testes múltiplos

Código Machine Learning for Trading

Resumo

Este notebook avalia features candidatas para classificar ações US por retornos subsequentes. Calcula coeficientes de informação cross-seccionais diários, resume a média com estimativas de incerteza que consideram a dependência serial e ajusta a significância para testar muitos candidatos. Também verifica se as associações persistem entre folds walk-forward, compara retornos entre quantis de features dentro de cada sessão e agrupa features correlacionadas para identificar construções redundantes. O registro resultante documenta evidências e decisões de triagem para a construção posterior de modelos.

A avaliação alinha seus períodos aos folds de treinamento do modelo e termina cedo o suficiente para que os resultados dos rótulos futuros não entrem no conjunto de holdout reservado. Exige uma seção transversal diária ampla o suficiente para o universo da estratégia e distingue a magnitude da associação da precisão estatística. O notebook ressalta que o ajuste para testes múltiplos não corrige a dependência entre observações diárias e que a quantidade nominal de candidatos pode exagerar o número de testes distintos quando há características duplicadas. Entre as limitações estão a baixa resolução dos folds para mudanças de regime, a redundância baseada em correlações de níveis amostrados e a avaliação de apenas um horizonte de rótulo.

Ideias principais

  • Um coeficiente de informação univariado é uma medida de triagem, não uma evidência de que a feature agrega valor dentro de um modelo.
  • A dependência serial e os testes de múltiplas features exigem correções estatísticas separadas.
  • Verificações por fold ajudam a distinguir associações recorrentes de resultados concentrados em um período.
  • Calcule os quantis dentro de cada sessão e compare os retornos com a média da sessão.
  • Features correlacionadas podem representar evidências repetidas; por isso, a quantidade nominal de testes requer interpretação cuidadosa.

Tags

Texto completo
# 05_evaluation.py


```py
# ---
# 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]
# # US Equities Panel: Feature Evaluation
#
# Every feature built in [`03_financial_features`](03_financial_features.ipynb) and
# [`04_model_based_features`](04_model_based_features.ipynb) is a candidate: something that
# might help rank stocks by their next-session return. This notebook takes them one at a
# time, measures how well each one ranks, asks how much of that is chance given how many
# were tried, and records a decision for each.
#
# It reads the two feature files, the primary label file, and the walk-forward design in
# `config/setup.yaml`. It writes two files into `evaluation/`: a per-feature ledger of
# decisions and evidence, which [`20_strategy_synthesis/02_feature_evaluation`](../../20_strategy_synthesis/02_feature_evaluation.ipynb)
# reads to build the cross-case-study comparison, and the daily correlation series the
# ledger is computed from.
#
# One feature at a time is a screen, not a model. Two features that each rank weakly can be
# strong together, and a feature that ranks well alone can add nothing once another is
# already in the model. What this stage produces is therefore an auditable record of the
# evidence on each candidate, which [`06_linear`](06_linear.ipynb) and the model notebooks
# after it weigh against each other.
#
# **Learning objectives**
#
# - Measure how well a feature orders stocks by their next-session return, by correlating
#   the two across the stocks quoted on each session and averaging that daily correlation.
# - Put an interval around that average that allows for the fact that consecutive daily
#   correlations are not independent draws.
# - Work out how many of the associations found would be expected from chance alone, given
#   how many features were tested at once, and say what "how many were tested" means here.
# - Score each feature separately in each walk-forward period, so an association carried by
#   one episode can be told apart from one that repeats.
# - Read average return by feature quintile to see whether the relationship is one a
#   ranking-based strategy can act on.
# - Identify features carrying the same evidence twice and keep one of each group.
#
# **Book reference**: Chapter 7, Section 7.3 (Univariate feature-label evaluation) and
# Section 7.4 (Search accounting and multiple testing).
#
# **Prerequisites**: [`02_labels`](02_labels.ipynb),
# [`03_financial_features`](03_financial_features.ipynb) and
# [`04_model_based_features`](04_model_based_features.ipynb) have been run.

# %%
"""Feature Evaluation - US Equities Panel.

Univariate screening of the Chapter 8 financial features and the Chapter 9 model-based
features against the primary forward-return label, with multiplicity control, fold
stability and redundancy, ending in a per-feature triage decision.
"""

import gc
from datetime import date

import matplotlib.pyplot as plt
import numpy as np
import polars as pl
from IPython.display import display
from ml4t.diagnostic.evaluation.stats import benjamini_hochberg_fdr
from ml4t.diagnostic.metrics import (
    compute_ic_hac_stats,
    compute_ic_uncertainty,
    cross_sectional_ic_series,
)
from scipy.cluster import hierarchy
from scipy.spatial.distance import squareform

from case_studies.utils.cv_window import modeling_fold_boundaries
from case_studies.utils.feature_engineering import quantile_profile
from utils.artifact_specs import load_setup_config, resolve_label_buffer, resolve_label_horizon
from utils.data_quality import top_entities, validate_modeling_inputs
from utils.paths import get_case_study_dir
from utils.reproducibility import set_global_seeds
from utils.style import (
    COLORS,
    FIGSIZE,
    add_message_title,
    show_with_alt,
    zero_line,
)

# %% tags=["parameters"]
MAX_SYMBOLS = 0
SEED = 42

# %% [markdown]
# ## Configuration
#
# Six settings decide what this notebook does, and all six come from
# `config/setup.yaml` rather than being typed here, so that the evaluation and the
# training that follows it cannot drift apart.
#
# The **primary label** is the forward return every feature is scored against, and its
# **horizon** is how many sessions ahead that return is measured over. The horizon sets two
# other things. It sets how far the evaluation has to stop short of the **holdout**, the
# later block of history reserved for a single final measurement: a decision made on the
# last session of the evaluation window must have its outcome known before the holdout
# opens, or the screen has read a session it is not allowed to see. And it sets the
# bandwidth of the standard error in Section C, because a return measured over several
# sessions overlaps the next one and makes consecutive daily correlations dependent.
#
# The **walk-forward design** - how many folds, how long each trains for, how long each is
# then scored over - decides which sessions a feature is scored over here and which sessions
# a model is validated on later. Section A resolves it from the label file, which is where
# [`04_model_based_features`](04_model_based_features.ipynb) and the model stages resolve it
# from, so all of them mean the same window by fold *k*.
#
# The **smallest cross-section** worth correlating is derived from what the strategy this
# case study builds would have to fill. `setup.yaml` sweeps a long-short book of up to
# fifty names a side, so a session quoting fewer than a hundred names is one where the
# ranking could not be acted on and its correlation is not evidence about this strategy.

# %%
CASE_STUDY_ID = "us_equities_panel"
CASE_DIR = get_case_study_dir(CASE_STUDY_ID)
EVAL_DIR = CASE_DIR / "evaluation"
EVAL_DIR.mkdir(exist_ok=True)

DATE_COL = "timestamp"
ENTITY_COL = "symbol"
JOIN_COLS = [DATE_COL, ENTITY_COL]

SETUP = load_setup_config(CASE_STUDY_ID)
PRIMARY_LABEL = SETUP["labels"]["primary"]
LABEL_BUFFER = resolve_label_buffer(CASE_STUDY_ID, PRIMARY_LABEL, SETUP)
assert LABEL_BUFFER, f"no label buffer configured for {PRIMARY_LABEL}"
LABEL_HORIZON = int(resolve_label_horizon(CASE_STUDY_ID, PRIMARY_LABEL, SETUP).rstrip("Dd"))
HOLDOUT_START = date.fromisoformat(str(SETUP["evaluation"]["holdout_start"]))
N_SPLITS = SETUP["evaluation"]["n_splits"]
TOP_K_PER_SIDE = max(SETUP["backtest"]["sweep"]["top_k_grid"][PRIMARY_LABEL])
MIN_CROSS_SECTION = 2 * TOP_K_PER_SIDE

set_global_seeds(SEED)

print(f"Label            {PRIMARY_LABEL}, the return over the next {LABEL_HORIZON} session")
print(f"Holdout          opens {HOLDOUT_START}; everything below stops before it")
print(f"Walk-forward     {N_SPLITS} folds of {SETUP['evaluation']['train_size']} training")
print(f"                 scored over {SETUP['evaluation']['val_size']} each")
print(f"Cross-section    at least {MIN_CROSS_SECTION} names quoted, which is both sides of a")
print(f"                 book holding {TOP_K_PER_SIDE} long and {TOP_K_PER_SIDE} short")

# %% [markdown]
# ## A. Panel and the holdout seal
#
# ### The sessions the folds are cut from
#
# A walk-forward fold is a training window followed by a later window the trained thing is
# scored over, and the folds step through the sample so that the last one ends as close to
# the holdout as the label allows. The boundaries are cut by position on a list of trading
# sessions, so which list they are cut on decides where every boundary falls.
#
# They are taken from the label file, through `modeling_fold_boundaries`, which is the call
# [`04_model_based_features`](04_model_based_features.ipynb) resolves its folds with and the
# call `load_modeling_dataset` resolves them with on the other side of the join. All three
# therefore mean the same window by fold *k* by construction rather than by agreement.

# %%
temporal_scan = pl.scan_parquet(CASE_DIR / "features" / "model_based.parquet")
temporal_names = temporal_scan.collect_schema().names()
temporal_cols = [c for c in temporal_names if c not in JOIN_COLS]
assert "fold" not in temporal_names, (
    "model_based.parquet carries a fold column, which this stage has no key to read it by: "
    "a stock-session is expected to carry one value"
)
sessions = temporal_scan.select(DATE_COL).unique().sort(DATE_COL).collect()[DATE_COL].to_list()
print(f"{len(sessions):,} trading sessions, {sessions[0]} to {sessions[-1]}")

splits = modeling_fold_boundaries(CASE_STUDY_ID, PRIMARY_LABEL)
for split in splits:
    print(f"  Fold {split['fold']:>2}: scored over {split['val_start']} to {split['val_end']}")

# %% [markdown]
# ### One value per session and symbol
#
# A model-based feature has one value per session and symbol, because what bounds every
# estimate behind it is a refit schedule rather than a fold: the parameters that produced a
# value were fitted on sessions strictly earlier than it, whichever fold later selects the
# row. So there is no fold to filter on and no substitution to make - the file is read as it
# stands and the uniqueness of the two-column key is asserted, because a repeat would
# quietly double the rows underneath every statistic below.
#
# What restricting to the union of the scoring windows still does is fix the span of
# everything that follows. The Chapter 8 features exist on every session, and putting both
# sets on the same dates is what makes their correlations comparable. A session no fold is
# scored over drops out here; the count is printed rather than left to the join.

# %%
val_windows = {int(s["fold"]): (s["val_start"], s["val_end"]) for s in splits}
in_a_scoring_window = pl.any_horizontal(
    [pl.col(DATE_COL).is_between(start, end) for start, end in val_windows.values()]
)
temporal = temporal_scan.filter(in_a_scoring_window).collect()
assert temporal.select(JOIN_COLS).is_duplicated().sum() == 0, (
    "the model-based artifact repeats a session and symbol; a downstream join would multiply rows"
)

covered = temporal[DATE_COL].unique().sort().to_list()
EVAL_START, EVAL_END = covered[0], covered[-1]
uncovered = sum(1 for d in sessions if EVAL_START <= d <= EVAL_END and d not in set(covered))
print(f"Evaluation window {EVAL_START} to {EVAL_END}, {len(temporal):,} rows")
print(f"{len(covered):,} sessions scored, {uncovered} inside the window scored by no fold")

# %% [markdown]
# ### Where each model-based column starts
#
# A fitted column starts after the estimation window that produced it, so it is normal for
# one to carry no value over the first years of the panel and a defect for it to carry none
# over the window being scored. The two are indistinguishable downstream - the sequence
# loaders turn a null feature into zero, which after normalization is the feature's mean -
# so the coverage over the scored window is printed here rather than discovered later.

# %%
display(
    pl.DataFrame(
        [
            {
                "feature": c,
                "first value": temporal.filter(pl.col(c).is_not_null())[DATE_COL].min(),
                "coverage over the scored window": temporal[c].drop_nulls().len() / temporal.height,
            }
            for c in temporal_cols
        ]
    )
)

# %% [markdown]
# ### The panel
#
# The Chapter 8 features and the label join onto that frame on session and symbol. Both
# joins are inner: a row is evaluated only where the feature, the model-based feature and
# the realized label all exist.

# %%
financial_scan = pl.scan_parquet(CASE_DIR / "features" / "financial.parquet")
financial_cols = [c for c in financial_scan.collect_schema().names() if c not in JOIN_COLS]
label_scan = pl.scan_parquet(CASE_DIR / "labels" / f"{PRIMARY_LABEL}.parquet")
label_col = next(c for c in label_scan.collect_schema().names() if c not in JOIN_COLS)

in_window = pl.col(DATE_COL).is_between(EVAL_START, EVAL_END)
eval_panel = (
    financial_scan.filter(in_window)
    .join(temporal.lazy(), on=JOIN_COLS, how="inner")
    .join(label_scan.filter(in_window), on=JOIN_COLS, how="inner")
    .sort(JOIN_COLS)
    .collect()
)
del temporal
gc.collect()

if MAX_SYMBOLS > 0:
    # `top_entities` rather than a local sort by row count: ties in the count were being
    # broken arbitrarily here, so a reduced run could score a different five symbols from the
    # ones every loader's `apply_max_symbols` reduces to, and a symbol only one side chose
    # carries null features on the other.
    keep = top_entities(eval_panel, MAX_SYMBOLS, ENTITY_COL)
    eval_panel = eval_panel.filter(pl.col(ENTITY_COL).is_in(pl.Series(ENTITY_COL, keep).implode()))
    MIN_CROSS_SECTION = min(MIN_CROSS_SECTION, MAX_SYMBOLS)
    print(f"Reduced run: {MAX_SYMBOLS} symbols, cross-section floor {MIN_CROSS_SECTION}")

all_feature_cols = financial_cols + temporal_cols
n_sessions = eval_panel[DATE_COL].n_unique()
print(f"Panel: {len(eval_panel):,} rows, {eval_panel[ENTITY_COL].n_unique():,} symbols")
print(f"       {n_sessions:,} sessions, {len(all_feature_cols)} candidate features")

# %% [markdown]
# ### The seal
#
# The holdout is a block of later history that nothing in the research pipeline may read
# until a single configuration has been chosen. A screen that ranks features on data
# reaching into it has spent the holdout before anyone meant to.
#
# The condition is about the label's endpoint, not the date a decision would be taken on.
# A decision taken on the last session of the panel is scored by a return that resolves the
# label's horizon later, so it is that later session which has to fall before the boundary.
# Counting those sessions on the panel's own list, rather than in calendar days, is what
# makes the check right across weekends and market holidays.

# %%
last_decision = eval_panel[DATE_COL].max()
label_endpoint = sessions[sessions.index(last_decision) + LABEL_HORIZON]
assert label_endpoint < HOLDOUT_START, (
    f"the last decision session {last_decision} is scored by a return resolving "
    f"{label_endpoint}, on or after the holdout opening {HOLDOUT_START}"
)
print(f"Last decision {last_decision}, its return resolves {label_endpoint}")
print(f"Holdout opens {HOLDOUT_START}; nothing below reads a session on or after it")

# %% [markdown]
# ### What the panel holds
#
# Everything below is a statistic computed across the stocks quoted on one session, so how
# many are quoted is what those statistics rest on. The count is not flat: this universe is
# built from a price and turnover screen, and both the number of listed names and the
# number clearing that screen move with the market. A correlation across a thousand names
# and one across two and a half thousand are not equally precise, and the chart is where
# that shows.

# %%
per_session = eval_panel.group_by(DATE_COL).len().sort(DATE_COL)
fig, ax = plt.subplots(figsize=FIGSIZE["single"])
ax.fill_between(
    per_session[DATE_COL].to_list(),
    per_session["len"].to_list(),
    color=COLORS["blue"],
    alpha=0.85,
    linewidth=0,
)
ax.axhline(MIN_CROSS_SECTION, color=COLORS["copper"], linewidth=1.0, linestyle="--")
ax.set_ylabel("Names quoted")
ax.set_ylim(0, None)
add_message_title(
    ax,
    "The cross-section more than doubles over the evaluation window",
    subtitle=(
        "Eligible names per session; dashed line is the floor below which a session's "
        "correlation is not used"
    ),
)
show_with_alt(
    fig,
    "Filled area chart of the number of eligible stocks per trading session across the "
    "evaluation window. The count starts near one thousand, dips and recovers over the "
    "period, and ends more than twice as high. A dashed horizontal line near the bottom "
    "marks the minimum cross-section, which no session falls below.",
)

# %% [markdown]
# ### The candidates, by construction
#
# The other thing worth seeing before any of it is measured is what the candidates are.
# Grouping them by what they are computed from is the classification
# `03_financial_features` assigns, extended here to cover the model-based features, which
# are named after their transform rather than their input. The groups matter twice over:
# they are how Section G reads the redundancy, and they are why a count of candidates
# overstates how many separate ideas are being tested. Several horizons of one return are
# several columns and one idea.


# %%
def assign_feature_family(feature_name: str) -> str:
    """Return the construction a feature comes from.

    The first three rules are for the model-based features, which would otherwise be filed
    by a substring of their input: `ffd_log_volume` is a fractionally differenced series,
    not a liquidity measure. The rest is the classification `03_financial_features` uses, so
    a feature carries the same family in both notebooks. Blends and interactions are matched
    before their ingredients, so a composite of momentum ranks is a composite.
    """
    family_map = [
        (["wass_"], "regime"),
        (["ffd_"], "fractional_difference"),
        (["garch"], "conditional_volatility"),
        (["composite", "quality_", "spread", "_x_liq", "_x_size"], "composite"),
        (["mom_", "ret_", "skip_recent", "cumret"], "momentum"),
        (["rev_", "reversal", "str_"], "reversal"),
        (["vol_", "rv_", "realized", "natr", "range_", "mdd_"], "volatility"),
        (["sharpe_", "risk_adj"], "sharpe"),
        (["rsi", "macd", "adx", "cci", "stoch", "bb_", "aroon"], "technical"),
        (["sma_", "ema_", "kama_", "dist_from_52w", "trend"], "trend"),
        (["liq", "turnover", "volume", "amihud"], "liquidity"),
        (["size", "mktcap"], "size"),
    ]
    for prefixes, family in family_map:
        if any(p in feature_name.lower() for p in prefixes):
            return family
    return "other"


families = {f: assign_feature_family(f) for f in all_feature_cols}
unfamilied = sorted(f for f, fam in families.items() if fam == "other")
assert not unfamilied, f"features matching no family rule: {unfamilied}"

inventory = (
    pl.DataFrame({"feature": all_feature_cols})
    .with_columns(
        pl.col("feature").replace_strict(families).alias("family"),
        pl.col("feature")
        .replace_strict(
            {f: ("model_based" if f in temporal_cols else "financial") for f in families}
        )
        .alias("built_by"),
    )
    .group_by("family", "built_by")
    .len()
    # Family name breaks ties, or two families of equal size swap places between runs
    # and the committed table stops reproducing.
    .sort(["len", "family"], descending=[True, False])
)
display(inventory)

# %% [markdown]
# ## B. Correctness screens
#
# Two different questions are asked before any association is measured, and it is worth
# keeping them apart.
#
# The first is whether the artifacts are intact: infinite values, returns too large to be
# a real price move, feature columns that are empty. This is a property of what the
# upstream notebooks wrote, and a failure here means going back to them.
#
# The second is whether each individual feature can be trusted as of the moment a decision
# would be made. Two things break that. **Coverage** is the share of panel rows where the
# feature has a value at all; a feature present for a third of the panel is being scored on
# a different, self-selected sample than its neighbours. **Staleness** is the share of rows
# where the value is unchanged from the same symbol's previous session; a feature that
# rarely moves cannot re-rank the cross-section, and when it comes from a periodic source it
# may be repeating the last release rather than reporting new information.
#
# Two further checks in the same family - that a feature's timestamp is the moment the
# information was available, and that its mask lines up with the label's - are settled where
# the feature is built, in `03_financial_features` and `04_model_based_features`.

# %%
MAX_ABS_RETURN = 1.0
COVERAGE_FLOOR = 0.70
STALENESS_CEILING = 0.50

gate = validate_modeling_inputs(
    features_df=eval_panel,
    label_df=eval_panel,
    feature_cols=all_feature_cols,
    label_col=label_col,
    join_cols=JOIN_COLS,
    asset_col=ENTITY_COL,
    max_abs_return=MAX_ABS_RETURN,
    fail_on_critical=True,
)

# %% [markdown]
# The gate runs on the panel Section A built, so the sessions it counts over all end before
# the holdout opens. A one-session total return above one hundred percent is rare and real -
# a small name on a takeover approach or a trial result - so the check reports how many
# rows clear that bar rather than treating them as corrupt.

# %%
n_rows = len(eval_panel)
n_symbols = eval_panel[ENTITY_COL].n_unique()
coverage = {f: eval_panel[f].drop_nulls().len() / n_rows for f in all_feature_cols}

repeats = eval_panel.select(
    [(pl.col(f) == pl.col(f).shift(1).over(ENTITY_COL)).sum().alias(f) for f in all_feature_cols]
)
staleness = {f: float(repeats[f][0]) / max(n_rows - n_symbols, 1) for f in all_feature_cols}

correctness = {
    f: coverage[f] >= COVERAGE_FLOOR and staleness[f] <= STALENESS_CEILING for f in all_feature_cols
}
evaluable_features = [f for f in all_feature_cols if correctness[f]]
print(f"{len(evaluable_features)} of {len(all_feature_cols)} features clear both screens")

# %%
stopped = [f for f in all_feature_cols if not correctness[f]]
display(
    pl.DataFrame(
        {
            "feature": stopped,
            "coverage": [coverage[f] for f in stopped],
            "staleness": [staleness[f] for f in stopped],
        },
        schema={"feature": pl.String, "coverage": pl.Float64, "staleness": pl.Float64},
    )
)

# %% [markdown]
# ### Features with no cross-sectional variation
#
# A rank correlation across the stocks quoted on a session needs the feature to differ
# between them. Some of the model-based features describe the market rather than a stock -
# a regime distance computed from the whole cross-section, a market-level conditional
# volatility - so every symbol carries the same value on a given session and the
# correlation is undefined.
#
# These are not broken features. They are conditioning variables: things a model can use to
# say *when* another feature works. What they cannot have is a cross-sectional correlation,
# so they are separated here and carry that reason into the ledger.

# %%
cs_std = eval_panel.group_by(DATE_COL).agg([pl.col(f).std().alias(f) for f in evaluable_features])
date_level_features = {
    f for f in evaluable_features if (cs_std[f].drop_nulls().mean() or 0.0) < 1e-10
}
cs_features = [f for f in evaluable_features if f not in date_level_features]
print(f"Market-level (no cross-sectional variation): {sorted(date_level_features)}")
print(f"{len(cs_features)} features go forward to the correlation")

# %% [markdown]
# ## C. Univariate association
#
# The **information coefficient** is the rank correlation, across the stocks quoted on one
# session, between a feature's value and the return that follows. It is computed once per
# session, giving a series, and that series is then averaged. Holding the session fixed is
# what makes it answer "which stock is better today", which is the only question a strategy
# that ranks the cross-section every day ever asks.
#
# A rank correlation is used rather than a linear one because the strategy only ever sorts:
# it takes the top names and shorts the bottom, so what matters is the order the feature
# puts stocks in, not the shape of the numbers.
#
# The average is small by construction. It is an average over stocks and sessions of a
# relationship that is mostly noise on any single one, and the reason it can still be worth
# trading is breadth: independent bets on many names at once. That is what the average has
# to be read against, and what the interval around it in the next section quantifies.

# %%
label_frame = eval_panel.select([*JOIN_COLS, label_col])
ic_series = {}
for i, feat in enumerate(cs_features, start=1):
    series = (
        cross_sectional_ic_series(
            eval_panel.select([*JOIN_COLS, feat]),
            label_frame,
            pred_col=feat,
            ret_col=label_col,
            date_col=DATE_COL,
            entity_col=ENTITY_COL,
            min_obs=MIN_CROSS_SECTION,
        )
        .drop_nulls("ic")
        .sort(DATE_COL)
    )
    if len(series) >= N_SPLITS:
        ic_series[feat] = series
    if i % 20 == 0:
        print(f"  {i}/{len(cs_features)} features")
print(f"IC series for {len(ic_series)} features over up to {n_sessions:,} sessions")

# %% [markdown]
# ### The interval around the average
#
# Consecutive daily correlations are not independent draws. Features built from overlapping
# windows drift slowly, so a session where a feature ranks well is more likely to be
# followed by another, and a standard error that assumes independence is too narrow. The
# Newey-West estimator widens it by however much serial correlation the series actually
# shows, over a bandwidth set from the label horizon: a return measured over `h` sessions
# overlaps the next `h - 1`, so the bandwidth is at least that.
#
# The series is sorted before the estimator sees it. It reads values in the order it is
# given, and a bandwidth applied to rows in the wrong order measures nothing.

# %%
ic_stats = {
    feat: compute_ic_hac_stats(series, ic_col="ic", label_horizon=LABEL_HORIZON)
    for feat, series in ic_series.items()
}
by_abs_ic = sorted(ic_stats, key=lambda f: abs(ic_stats[f]["mean_ic"]), reverse=True)
print(f"Newey-West bandwidth: {ic_stats[by_abs_ic[0]]['effective_lags']} sessions")

# %% [markdown]
# ### The daily series, kept
#
# The averages above are what the ledger carries, but the series they came from is the
# object that shows whether an average is a steady small edge or one good year. It is
# written out and read back for the chart below, which is also the cheapest check that the
# file holds what the notebook thinks it does.

# %%
pl.concat(
    [series.with_columns(pl.lit(feat).alias("feature")) for feat, series in ic_series.items()]
).write_parquet(EVAL_DIR / "ic_timeseries.parquet")
ic_ts = pl.read_parquet(EVAL_DIR / "ic_timeseries.parquet")
print(f"Wrote evaluation/ic_timeseries.parquet: {len(ic_ts):,} rows read back")

# %% [markdown]
# The chart follows the strongest feature from each of the four leading constructions
# rather than the four largest averages, which on this panel are four ways of writing the
# same volatility ranking and would draw one line four times.
#
# The upper panel smooths each series over a year of trading, the same length as a fold,
# because the raw daily correlation swings across most of its range within a week and a
# line of it is unreadable. What to look for is whether a curve holds one side of zero or
# crosses it: a feature whose average comes from one episode shows a spike and then a flat
# stretch, and one that repeats holds its level across the window.
#
# The lower panel puts three intervals on the same average. The naive one assumes
# independent sessions, the Newey-West one allows for serial correlation, and the block
# bootstrap resamples runs of consecutive sessions rather than assuming any particular form
# for it. Where the three disagree, the naive one is the one to distrust.

# %%
ROLLING_SESSIONS = 252
leaders: list[str] = []
for feat in by_abs_ic:
    if families[feat] not in {families[f] for f in leaders}:
        leaders.append(feat)
    if len(leaders) == 4:
        break
leader_series = {f: ic_ts.filter(pl.col("feature") == f).sort(DATE_COL) for f in leaders}
uncertainty = {
    f: compute_ic_uncertainty(s, horizon=LABEL_HORIZON, ic_col="ic", seed=SEED)
    for f, s in leader_series.items()
}
print("Leading feature of each construction: " + ", ".join(leaders))

# %%
fig, (ax_series, ax_ci) = plt.subplots(2, 1, figsize=FIGSIZE["dual_v"], layout="constrained")
for feat, color in zip(leaders, ("blue", "copper", "amber", "neutral"), strict=False):
    s = leader_series[feat]
    ax_series.plot(
        s[DATE_COL].to_list(),
        s["ic"].rolling_mean(ROLLING_SESSIONS).to_list(),
        color=COLORS[color],
        linewidth=1.3,
        label=feat,
    )
zero_line(ax_series)
ax_series.set_ylabel("Rank correlation")
ax_series.margins(y=0.30)
ax_series.legend(fontsize=8, frameon=False, ncol=len(leaders), loc="lower center")

for row, feat in enumerate(leaders):
    u = uncertainty[feat]
    for offset, lo, hi, color in (
        (0.24, u["ci_naive_lower"], u["ci_naive_upper"], "recede"),
        (0.0, u["ci_hac_lower"], u["ci_hac_upper"], "blue"),
        (-0.24, u["ci_boot_lower"], u["ci_boot_upper"], "copper"),
    ):
        ax_ci.plot([lo, hi], [row + offset] * 2, color=COLORS[color], linewidth=3)
    ax_ci.plot(u["mean_ic"], row, marker="o", markersize=5, color=COLORS["blue"], zorder=3)
zero_line(ax_ci, axis="x")
ax_ci.set_yticks(range(len(leaders)), leaders, fontsize=8)
ax_ci.invert_yaxis()
ax_ci.set_xlabel("Mean rank correlation, with naive, Newey-West and bootstrap intervals")

add_message_title(
    ax_series,
    "A one-session edge is a slow drift, not an episode",
    subtitle=f"Rank correlation with the next session's return, {ROLLING_SESSIONS}-session mean",
)
add_message_title(
    ax_ci,
    "All three intervals stay clear of zero, and agree on where it is",
    subtitle="Naive, Newey-West and block-bootstrap intervals on the same average",
)
show_with_alt(
    fig,
    "Two stacked panels. The upper panel plots year-smoothed daily rank correlations for "
    "the leading feature of each of four constructions, with a zero reference line; the "
    "curves drift within a narrow band and cross zero only occasionally. The lower panel "
    "shows, for the same four features, the average correlation as a point with three "
    "horizontal intervals around it - naive, Newey-West and block bootstrap - all of "
    "similar width and all on the same side of zero.",
)

# %% [markdown]
# ## D. Fold stability
#
# One average over sixteen years can be produced by a relationship that held throughout or
# by one that held in three years and not the rest, and those call for different decisions.
# Scoring each walk-forward fold separately separates them.
#
# The summary is the median across folds, the spread between the quartiles, the highest and
# lowest fold, and how often a fold agrees with the feature's own direction. That last one
# has to be measured against the feature's own sign: a feature that ranks stocks the wrong
# way round is as useful as one that ranks them the right way, because the strategy can
# sort in either direction, and counting positive folds would score a perfectly steady
# inverse predictor at zero. For the same reason the fold worth looking at is the one
# furthest against that direction, which for a negative predictor is its highest.
#
# A fold counts towards that only where the feature was actually present across it, held to
# the same coverage bar Section B applies to the panel. A feature that exists for a tenth of
# a fold has a mean for that fold, and letting it vote alongside a fold it covers fully
# would make the stability score partly a coverage score.
#
# The bar that Section H later applies to that score is three folds in five agreeing, which
# is the level Chapter 7's triage table sets: high enough that a feature flipping direction
# in half its periods cannot pass, low enough that one bad regime does not disqualify one
# that repeats elsewhere.

# %%
SIGN_CONSISTENCY_FLOOR = 0.60
sessions_per_fold = {
    fold: eval_panel.filter(pl.col(DATE_COL).is_between(start, end))[DATE_COL].n_unique()
    for fold, (start, end) in val_windows.items()
}

fold_stats = {}
for feat, series in ic_series.items():
    fold_ics = []
    for fold, (start, end) in val_windows.items():
        window = series.filter(pl.col(DATE_COL).is_between(start, end))
        if window.height >= max(1, COVERAGE_FLOOR * sessions_per_fold[fold]):
            fold_ics.append(float(window["ic"].mean()))
    if not fold_ics:
        continue
    median_ic = float(np.median(fold_ics))
    direction = 1.0 if median_ic >= 0 else -1.0
    # Worst, best and sign consistency all read against the feature's own direction.
    fold_stats[feat] = {
        "n_folds": len(fold_ics),
        "fold_ics": fold_ics,
        "median_fold_ic": median_ic,
        "fold_iqr": float(np.subtract(*np.percentile(fold_ics, [75, 25]))),
        "worst_fold_ic": min(fold_ics, key=lambda ic: ic * direction),
        "best_fold_ic": max(fold_ics, key=lambda ic: ic * direction),
        "sign_consistency": sum(1 for ic in fold_ics if ic * direction > 0) / len(fold_ics),
    }
n_stable = sum(1 for s in fold_stats.values() if s["sign_consistency"] >= SIGN_CONSISTENCY_FLOOR)
print(f"{len(fold_stats)} features scored fold by fold; {n_stable} hold their own direction")
print(f"in at least {SIGN_CONSISTENCY_FLOOR:.0%} of the folds they were scored on")

# %% [markdown]
# The chart is one row per feature and one dot per fold, for the features with the largest
# median. Read the horizontal spread: a tight row is a feature that behaved the same way in
# every period, and a row with one dot far from the rest is an average carried by a single
# fold. The bar marks the median and the open circle the fold that ran furthest against
# the feature's own direction, which for a negative predictor is its highest fold, not
# its lowest.

# %%
ranked = sorted(fold_stats, key=lambda f: abs(fold_stats[f]["median_fold_ic"]), reverse=True)[:18]
fig, ax = plt.subplots(figsize=FIGSIZE["single_tall"])
for row, feat in enumerate(ranked):
    s = fold_stats[feat]
    ax.scatter(
        s["fold_ics"], [row] * s["n_folds"], s=14, color=COLORS["recede"], zorder=2, linewidths=0
    )
    ax.plot([s["median_fold_ic"]] * 2, [row - 0.3, row + 0.3], color=COLORS["blue"], linewidth=2)
    ax.scatter(
        s["worst_fold_ic"],
        row,
        s=34,
        facecolors="none",
        edgecolors=COLORS["copper"],
        linewidths=1.1,
        zorder=3,
    )
zero_line(ax, axis="x")
ax.set_yticks(range(len(ranked)), ranked, fontsize=8)
ax.invert_yaxis()
ax.set_xlabel("Fold mean rank correlation")
add_message_title(
    ax,
    "Most features change size across folds, and several change direction",
    subtitle="One dot per walk-forward fold; bar is the median, circle the fold most against it",
)
show_with_alt(
    fig,
    "Strip plot with one row per feature and one dot per walk-forward fold, showing each "
    "fold's mean rank correlation. Rows are ordered by the absolute median. A vertical bar "
    "marks each feature's median and an open circle marks the fold running furthest against "
    "its direction. Most rows spread "
    "across a range several times the width of their median, and many straddle the zero "
    "reference line.",
)

# %% [markdown]
# ## E. Shape
#
# A rank correlation says the ordering carries information but not how the return is
# distributed along it. A strategy that goes long the top fifth and short the bottom fifth
# needs the extremes to be the extremes: a feature whose middle quintile earns the most is
# carrying real information that this particular strategy cannot collect.
#
# Quintiles are assigned **within each session**, across the stocks quoted that day, for the
# same reason the correlation is cross-sectional: the boundaries that decide which quintile
# a stock falls in are then set by the stocks it is competing with that day, so the top
# quintile means "ranked high today" and nothing else.
#
# What is averaged inside each quintile is the return relative to the session's own average
# return, not the raw return. Every quintile of every feature earns the market's drift, so
# raw levels put five bars of almost equal height on the chart and hide the only quantity
# the long-short book actually collects: the difference between the quintiles.
#
# The average is taken twice, first across the stocks in a quintile on one session and then
# across sessions, so every session counts once however many names it quoted. That is both
# what the strategy earns - it rebalances every session and holds each quintile equally
# weighted - and what keeps the profile comparable with the correlation above, which is
# also a per-session statistic averaged over sessions.
#
# The monotonicity score is the rank correlation between quintile number and that average:
# one where it rises across every quintile, minus one where it falls across every quintile,
# and near zero where the profile turns over in the middle.
#
# The score can disagree in sign with the information coefficient of the same feature, and
# that disagreement is the reason this section exists. The correlation is computed on the
# ranks of the returns, so it counts how often a stock finishes above its neighbours; the
# profile is computed on the returns themselves, so it is driven by how much. A feature that
# picks stocks which lose slightly more often but win far larger when they win has a
# negative correlation and a rising profile, and a long-short book sorted on it earns the
# profile, not the correlation.

# %%
N_QUANTILES = 5
profiles = {}
for feat in by_abs_ic:
    profile = quantile_profile(
        eval_panel,
        feat,
        label_col,
        date_col=DATE_COL,
        n_quantiles=N_QUANTILES,
        min_cross_section=MIN_CROSS_SECTION,
        demean_within_date=True,
    )
    if profile is None:
        continue
    profiles[feat] = {
        "means": profile.means,
        "spread": profile.spread,
        "monotonicity": profile.monotonicity,
    }
monotonicity_scores = {f: p["monotonicity"] for f, p in profiles.items()}
disagree = [
    f
    for f in profiles
    if np.sign(profiles[f]["monotonicity"]) != np.sign(ic_stats[f]["mean_ic"])
    and profiles[f]["monotonicity"] != 0
]
print(f"Quintile profile for {len(profiles)} features, assigned within each session")
print(f"{len(disagree)} of them slope against the sign of their own correlation, led by")
print(", ".join(disagree[:4]))

# %%
fig, axes = plt.subplots(2, 2, figsize=FIGSIZE["grid_2x2"], sharex=True, sharey=True)
for ax, feat in zip(axes.ravel(), leaders, strict=False):
    means = profiles[feat]["means"]
    ax.bar(
        range(1, len(means) + 1),
        [m * 1e4 for m in means],
        color=[COLORS["copper"] if m < 0 else COLORS["blue"] for m in means],
        width=0.7,
    )
    zero_line(ax)
    ax.annotate(
        feat,
        xy=(0.03, 0.88),
        xycoords="axes fraction",
        fontsize=8,
        color=COLORS["neutral"],
    )
    ax.set_xticks(range(1, len(means) + 1))
for ax in axes[-1]:
    ax.set_xlabel("Feature quintile, lowest to highest")
for ax in axes[:, 0]:
    ax.set_ylabel("Excess return (bp)")
add_message_title(
    axes[0, 0],
    "The strongest ranking feature moves the mean return the other way",
    subtitle="Next-session return against the session average, by within-session quintile",
)
show_with_alt(
    fig,
    "Four small bar charts, one per feature, each showing the average next-session return "
    "relative to the session's own average, in basis points, for stocks in each of five "
    "quintiles of that feature. Quintiles are assigned within each session and bars are "
    "coloured by sign against a zero reference line. Three panels slope steadily across a "
    "few basis points; the panel for the feature with the largest rank correlation is much "
    "flatter and rises where that correlation is negative.",
)

# %% [markdown]
# ## F. Search accounting and multiple testing
#
# Every association above was found by looking. With enough features, some will correlate
# with the label by chance, and a p-value read one feature at a time does not account for
# the others that were tried. So the number tried has to be declared before any of them is
# called significant, and it is the count of features that reached a correlation - not the
# count that survived it.
#
# The search here is narrow and worth stating as such: one label, one horizon, one way of
# computing the correlation, and no threshold or window swept. The only multiplicity is
# across features.
#
# The Benjamini-Hochberg procedure controls the **false discovery rate**: the expected share
# of the features it calls significant that are not. That is a different guarantee from
# controlling the chance of any false positive at all, and a deliberately weaker one -
# at this stage the cost of missing a real feature is higher than the cost of carrying one
# extra into a model that will screen it again.

# %%
FDR_ALPHA = 0.05
searched = list(ic_stats)
p_values = [ic_stats[f]["p_value"] for f in searched]
fdr = benjamini_hochberg_fdr(p_values, alpha=FDR_ALPHA, return_details=True)
fdr_significant = {f for f, r in zip(searched, fdr["rejected"], strict=True) if r}
fdr_by_feature = dict(zip(searched, (float(p) for p in fdr["adjusted_p_values"]), strict=True))

eval_summary = pl.DataFrame(
    {
        "feature": searched,
        "source": ["model_based" if f in temporal_cols else "financial" for f in searched],
        "ic_mean": [ic_stats[f]["mean_ic"] for f in searched],
        "hac_se": [ic_stats[f]["hac_se"] for f in searched],
        "hac_t": [ic_stats[f]["t_stat"] for f in searched],
        "hac_p": p_values,
        "fdr_p": list(fdr["adjusted_p_values"]),
        "fdr_sig": list(fdr["rejected"]),
        "naive_t": [ic_stats[f]["naive_t_stat"] for f in searched],
    }
).sort(pl.col("ic_mean").abs(), descending=True)

n_naive = sum(1 for p in p_values if p < FDR_ALPHA)
print(f"Searched set: {len(searched)} features, one label, one horizon")
print(f"  significant one at a time: {n_naive}")
print(f"  significant after the false-discovery correction: {int(fdr['n_rejected'])}")

# %% [markdown]
# The upper panel ranks features by the size of the association and shades the ones the
# correction still calls a discovery. Horizontal bars are used because the names are long,
# and the ordering is by absolute value because a negative association is as usable as a
# positive one: the strategy can sort either way round.
#
# The lower panel is the diagnostic for Section C: each feature's t-statistic before and
# after allowing for serial correlation. Points on the diagonal are features whose daily
# correlations were near independent, so the wider bandwidth cost them nothing. Points
# pulled towards the horizontal axis would be features whose apparent significance came
# from persistence rather than from more evidence.

# %%
SHOWN_FEATURES = 20
top = eval_summary.head(SHOWN_FEATURES)
fig, (ax_bar, ax_t) = plt.subplots(
    2,
    1,
    figsize=FIGSIZE["dual_v"],
    gridspec_kw={"height_ratios": [2, 1]},
    layout="constrained",
)
ax_bar.barh(
    range(len(top)),
    top["ic_mean"].to_list(),
    color=[COLORS["blue"] if s else COLORS["recede"] for s in top["fdr_sig"].to_list()],
    height=0.75,
)
ax_bar.set_yticks(range(len(top)), top["feature"].to_list(), fontsize=7)
ax_bar.invert_yaxis()
zero_line(ax_bar, axis="x")
ax_bar.set_xlabel("Mean rank correlation")

ax_t.scatter(
    eval_summary["naive_t"].to_list(),
    eval_summary["hac_t"].to_list(),
    s=18,
    color=[COLORS["blue"] if s else COLORS["recede"] for s in eval_summary["fdr_sig"].to_list()],
    linewidths=0,
)
limit = 1.1 * max(
    eval_summary["naive_t"].abs().max() or 1.0, eval_summary["hac_t"].abs().max() or 1.0
)
ax_t.plot([-limit, limit], [-limit, limit], color=COLORS["neutral"], linewidth=0.8, linestyle="--")
ax_t.set_xlabel("t-statistic assuming independent sessions")
ax_t.set_ylabel("Allowing for\nserial correlation")

add_message_title(
    ax_bar,
    "The largest associations all survive the correction",
    subtitle="Mean rank correlation with the next session's return, largest first",
)
add_message_title(
    ax_t,
    "A one-session label leaves almost nothing for the wider bandwidth to remove",
    subtitle="Each point one feature; the dashed line is where the two agree",
)
show_with_alt(
    fig,
    "Two stacked panels. The upper panel is a horizontal bar chart of the twenty "
    "features with the largest average rank correlation, positive and negative, shaded "
    "where the feature survives the false-discovery correction; every bar shown is shaded. "
    "The lower panel is a scatter of each feature's t-statistic computed assuming "
    "independent sessions against the same statistic allowing for serial correlation, with "
    "a dashed diagonal; the points sit on the diagonal.",
)

# %%
print(f"Candidates                        {len(all_feature_cols)}")
print(f"Clear coverage and staleness      {len(evaluable_features)}")
print(f"  of those, market-level only     {len(date_level_features)}")
print(f"Have a cross-sectional IC         {len(ic_stats)}")
print(f"Significant one at a time         {n_naive}")
print(f"Significant after the correction  {int(fdr['n_rejected'])}")
print(
    f"Largest association               {eval_summary['feature'][0]} "
    f"at {eval_summary['ic_mean'][0]:+.4f}"
)
print(
    f"Cross-section                     {per_session['len'].min():,} to "
    f"{per_session['len'].max():,} names"
)

# %% [markdown] tags=["results"]
# Of 71 candidate features, 70 clear the coverage and staleness screens. Five of those vary
# only through time and so cannot have a cross-sectional correlation, leaving 65 tested.
# Testing one at a time makes 48 of them significant; controlling the false discovery rate
# over the same 65 tests leaves 46. The largest association in the panel is vol_zscore at
# -0.0173, measured across a cross-section running from 1,024 to 2,692 names.

# %% [markdown]
# ## G. Redundancy and families
#
# A long candidate list is not a long list of separate pieces of evidence. A twenty-one
# session return and a forty-two session return over the same prices move together, and a
# feature and its own cross-sectional rank move together exactly. Counting each as a
# separate discovery overstates how much the panel knows, and it also inflated the
# correction in Section F, which was told it had that many independent tests to control.
#
# The sharpest case is worth measuring first, because a rank correlation is blind to it. If
# two features order the stocks in a session identically - a return and its rank, a
# volatility and its z-score - then their information coefficients are not merely close,
# they are the same number to the last digit.

# %%
identical = (
    pl.DataFrame({"feature": list(ic_stats), "ic_mean": [s["mean_ic"] for s in ic_stats.values()]})
    .group_by("ic_mean")
    .agg(pl.col("feature").sort())
    .filter(pl.col("feature").list.len() > 1)
    .sort("ic_mean")
)
print(f"{len(identical)} groups of features share a mean correlation exactly:")
for row in identical.iter_rows(named=True):
    print(f"  {row['ic_mean']:+.6f}  {' = '.join(row['feature'])}")

# %% [markdown]
# The general case needs measuring. The **correlation clusters** below group features by how
# strongly they move together, and one member of each group is kept to stand for the rest:
# the member with the largest median fold correlation, breaking ties towards the one whose
# folds disagree least. Both numbers come from Section D, and the choice is written into the
# ledger so a reader can see which member survived and which was dropped for it. Being the
# representative is not itself a promotion; it still has to earn its own decision in
# Section H.
#
# The correlation is measured on a sample of sessions rather than the whole panel. A rank
# correlation between two features over seven million rows and over a few hundred thousand
# differs in the fourth decimal, and the pairs worth acting on are the ones near one.

# %%
REDUNDANCY_CUT = 0.70
SAMPLE_TARGET = 200
step = max(1, n_sessions // SAMPLE_TARGET)
sampled = eval_panel[DATE_COL].unique().sort()[::step]
corr = (
    eval_panel.filter(pl.col(DATE_COL).is_in(sampled.implode()))
    .select(evaluable_features)
    .to_pandas()
    .corr(method="spearman")
)
pairs = [
    (a, b, float(corr.loc[a, b]))
    for i, a in enumerate(corr.columns)
    for b in corr.columns[i + 1 :]
    if abs(corr.loc[a, b]) > REDUNDANCY_CUT
]
pairs.sort(key=lambda p: -abs(p[2]))
print(f"{len(pairs)} feature pairs correlate above {REDUNDANCY_CUT:.2f}")
print(f"measured on {len(sampled)} sampled sessions")

# %% [markdown]
# Clustering turns those pairs into groups. Two features are close when the absolute value
# of their correlation is near one, so `1 - |correlation|` is the distance, and cutting the
# tree at the same threshold used for the pair count makes the groups and the pairs answer
# the same question.

# %%
distance = 1.0 - corr.abs().to_numpy()
np.fill_diagonal(distance, 0.0)
labels = hierarchy.fcluster(
    hierarchy.linkage(squareform(distance, checks=False), method="average"),
    t=1.0 - REDUNDANCY_CUT,
    criterion="distance",
)
clusters: dict[int, list[str]] = {}
for feat, cluster_id in zip(corr.columns, labels, strict=True):
    clusters.setdefault(int(cluster_id), []).append(feat)


def fold_standing(feature: str) -> tuple[float, float]:
    """Rank a cluster's members: strongest median fold correlation, then steadiest."""
    stats = fold_stats.get(feature)
    if stats is None:
        return (0.0, 0.0)
    return (abs(stats["median_fold_ic"]), -stats["fold_iqr"])


representative_of = {
    member: max(members, key=fold_standing) for members in clusters.values() for member in members
}
redundant = {f for members in clusters.values() for f in members if len(members) > 1}
n_multi = sum(1 for members in clusters.values() if len(members) > 1)
print(f"{len(clusters)} clusters, {n_multi} of them holding more than one feature")
print(f"{len(redundant)} features share a cluster; one of each is kept to stand for it")

# %% [markdown]
# The chart is the strongest pairs rather than the whole matrix. A grid of sixty-odd rows
# and the same number of columns is mostly empty space with tick labels too small to read,
# and the question the section asks is which specific pairs are duplicates. Both members of
# a pair are named, and the feature its cluster keeps is named after them - which is often
# neither of the two, because a cluster is usually larger than any one pair inside it.

# %%
SHOWN_PAIRS = 20
shown_pairs = pairs[:SHOWN_PAIRS]
fig, ax = plt.subplots(figsize=FIGSIZE["single_tall"])
ax.barh(
    range(len(shown_pairs)),
    [r for _, _, r in shown_pairs],
    color=[COLORS["blue"] if r > 0 else COLORS["copper"] for _, _, r in shown_pairs],
    height=0.75,
)
ax.set_yticks(
    range(len(shown_pairs)),
    [f"{a} + {b}" for a, b, _ in shown_pairs],
    fontsize=7,
)
# The kept feature goes in the empty half of each row rather than into the tick label.
# Twenty labels carrying it as well ran the y-axis out of width, and constrained layout
# then reported the axes collapsing to zero on the rendered page.
for i, (a, _, r) in enumerate(shown_pairs):
    ax.annotate(
        f"keeps {representative_of[a]}",
        xy=(-0.03 if r > 0 else 0.03, i),
        ha="right" if r > 0 else "left",
        va="center",
        fontsize=6.5,
        color=COLORS["neutral"],
    )
ax.invert_yaxis()
zero_line(ax, axis="x")
ax.set_xlabel("Rank correlation between the two features")
add_message_title(
    ax,
    "Redundancy is one signal rewritten, not two signals agreeing",
    subtitle="Strongest correlated feature pairs, with the feature their cluster keeps",
)
show_with_alt(
    fig,
    "Horizontal bar chart of the twenty most strongly correlated feature pairs, coloured "
    "by the sign of the correlation. Each row names both members of the pair and the "
    "feature kept to represent their cluster. Almost every bar reaches beyond plus or "
    "minus nine tenths, and the pairs are returns against their own ranks or risk-adjusted "
    "forms, and moving averages against each other.",
)

# %% [markdown]
# ## H. Triage and handoff
#
# Each feature gets one of three decisions, and the rule is stated before it is applied.
#
# There are two ways to be carried forward. The first is the multiplicity-controlled test
# from Section F: the association is large enough that the correction still calls it a
# discovery. The second is an **exploration** route, and naming it that way matters. It
# promotes a feature whose association is ordinary in size but steady in direction across
# folds, on the grounds that fold stability is evidence a single t-statistic does not carry,
# and that a screen at this stage should hand the modelling notebooks a menu. A feature
# promoted that way has not been confirmed, and the ledger records which route fired so a
# reader can tell the two apart.
#
# The exploration route exists mainly for the case this case study is not in: a narrow
# cross-section, where the correction is strict enough to empty the menu. Its bar is read
# off the panel rather than chosen - the median association among the candidates that got
# one - so a feature promoted through it is at least as strongly associated as the typical
# candidate, and holds its direction in most folds. Whether it fires at all is a fact about
# the data, and the funnel below is where to read it.

# %%
evaluated_ics = [abs(s["mean_ic"]) for s in ic_stats.values()]
IC_EXPLORATION_FLOOR = float(np.median(evaluated_ics)) if evaluated_ics else 0.0
print(f"Exploration route: |correlation| at least {IC_EXPLORATION_FLOOR:.4f}")
print(f"and direction held in {SIGN_CONSISTENCY_FLOOR:.0%} of folds")

# %%
triage = {}
for feat in all_feature_cols:
    if not correctness[feat]:
        triage[feat] = ("STOP", "correctness_fail")
    elif feat in date_level_features:
        triage[feat] = ("REVISE", "no_cross_sectional_variation")
    elif feat not in ic_stats:
        triage[feat] = ("REVISE", "insufficient_sessions")
    elif feat in fdr_significant:
        triage[feat] = ("PROCEED", "fdr_significant")
    elif (
        fold_stats.get(feat, {}).get("sign_consistency", 0.0) >= SIGN_CONSISTENCY_FLOOR
        and abs(ic_stats[feat]["mean_ic"]) >= IC_EXPLORATION_FLOOR
    ):
        triage[feat] = ("PROCEED", "stable_and_above_threshold")
    else:
        triage[feat] = ("REVISE", "not_significant_standalone")

# %% [markdown]
# ### The ledger
#
# One row per candidate, carrying the decision and the evidence behind it: the association
# and its corrected p-value, the fold summary, the shape score where one was computed, the
# two screen values, the family and the cluster representative. The strategy-synthesis
# notebook reads this file for all nine case studies and builds the comparison across them,
# so a column dropped here is a column missing there.

# %%
ledger = pl.DataFrame(
    [
        {
            "feature": feat,
            "family": families[feat],
            "source": "model_based" if feat in temporal_cols else "financial",
            "ic_mean": ic_stats.get(feat, {}).get("mean_ic"),
            "hac_t": ic_stats.get(feat, {}).get("t_stat"),
            "hac_p": ic_stats.get(feat, {}).get("p_value"),
            "fdr_p": fdr_by_feature.get(feat),
            "fdr_sig": feat in fdr_significant,
            "sign_consistency": fold_stats.get(feat, {}).get("sign_consistency"),
            "median_fold_ic": fold_stats.get(feat, {}).get("median_fold_ic"),
            "worst_fold_ic": fold_stats.get(feat, {}).get("worst_fold_ic"),
            "best_fold_ic": fold_stats.get(feat, {}).get("best_fold_ic"),
            "monotonicity": monotonicity_scores.get(feat),
            "coverage": coverage[feat],
            "staleness": staleness[feat],
            "cluster_representative": representative_of.get(feat),
            "decision": triage[feat][0],
            "note": triage[feat][1],
        }
        for feat in all_feature_cols
    ]
)
ledger.write_parquet(EVAL_DIR / "triage_ledger.parquet")
print(f"Wrote evaluation/triage_ledger.parquet: {len(ledger)} rows")
display(ledger.head(6))

# %% [markdown]
# The funnel is where the counts belong: how many candidates were built, how many could be
# scored at all, how many the correction confirmed, and how many are carried forward once
# the exploration route is included. The gap between the fourth bar and the fifth is exactly
# what the exploration route adds on top of the correction.

# %%
stage_counts = [
    ("Candidates built", len(all_feature_cols)),
    ("Clear the screens", len(evaluable_features)),
    ("Have a correlation", len(ic_stats)),
    ("Confirmed by the correction", len(fdr_significant)),
    ("Carried forward", sum(1 for d, _ in triage.values() if d == "PROCEED")),
]
fig, ax = plt.subplots(figsize=FIGSIZE["single_wide"])
ax.barh(
    range(len(stage_counts)),
    [n for _, n in stage_counts],
    color=[COLORS["recede"]] * (len(stage_counts) - 1) + [COLORS["blue"]],
    height=0.7,
)
for row, (_, n) in enumerate(stage_counts):
    ax.annotate(
        f"{n}", (n, row), xytext=(4, 0), textcoords="offset points", va="center", fontsize=9
    )
ax.set_yticks(range(len(stage_counts)), [name for name, _ in stage_counts], fontsize=9)
ax.invert_yaxis()
ax.set_xlabel("Features")
add_message_title(
    ax,
    "The multiplicity correction is where candidates fall, not the screens",
    subtitle="Candidates surviving each step of the screen",
)
show_with_alt(
    fig,
    "Horizontal bar chart of five steps of the screen, from candidates built down to "
    "features carried forward, with the count annotated at the end of each bar. The first "
    "three bars are nearly the same length; the drop happens at the multiplicity "
    "correction, and the last bar matches it exactly because the exploration route "
    "promotes nothing here.",
)

# %%
promoted = ledger.filter(pl.col("decision") == "PROCEED")
print(ledger.group_by("decision").len().sort("decision"))
print(promoted.group_by("note").len().sort("note"))
print(f"Promoted features span {promoted['family'].n_unique()} of {len(set(families.values()))}")
print(f"families; {len(pairs)} screened pairs correlate above {REDUNDANCY_CUT:.2f}")

# %% [markdown] tags=["results"]
# The screen carries 46 features forward, marks 24 for revision and stops 1 on coverage and
# staleness. All 46 came through the false-discovery correction and none through the
# exploration route, whose bar - the median association among the candidates that got one -
# stands at 0.0066 on this panel. The features carried forward span 9 of the 11 families,
# and 248 pairs among the screened candidates correlate above 0.70, so the count of
# promotions is comfortably larger than the count of distinct ideas behind them.

# %% [markdown]
# ## Key takeaways
#
# 1. **Screen one feature at a time, then stop.** This stage says which candidates carry
#    information about the label and how reliably; it does not say which set to train on.
#    Two features that duplicate each other both pass here, and it is the model that has to
#    choose between them.
#
# 2. **The seal is on the label's endpoint, not on the decision date.** Stopping the panel
#    at the holdout boundary would still let the last few decisions be scored by returns
#    that resolve inside it. Counting the horizon on the trading calendar the panel actually
#    uses is what makes the boundary hold across holidays.
#
# 3. **Breadth buys precision, not size.** Averaging a cross-sectional correlation over a
#    wide panel and a long history gives a tight interval around a small number. It does not
#    make the number large, and a screen reporting significance without reporting the size
#    of the association invites the reader to confuse the two.
#
# 4. **Correct the interval for serial correlation, and the p-values for the search.** These
#    are different corrections for different problems: the first because consecutive daily
#    correlations are not independent draws, the second because many features were tried at
#    once. Applying one is not applyin

Exibido na íntegra, com atribuição conforme a licença da fonte. Licença: MIT

Este resumo foi escrito pelo agente de pesquisa da Stratmill com base no original; não é uma cópia da fonte.