Pular para o conteúdo
Todos os documentos da biblioteca

Avaliação de features ETF com ranking IC e controles de testes múltiplos

Código Machine Learning for Trading

Resumo

Este notebook avalia features de ETF uma a uma, calculando um coeficiente de informação de ranking entre os valores das features e os retornos mensais subsequentes, data a data. Ele faz a triagem de features financeiras e derivadas de modelos em datas de validação walk-forward e, em seguida, resume a série de IC de cada feature. A análise considera a dependência causada por retornos futuros sobrepostos com um erro-padrão HAC e ajusta testes simultâneos por meio de um procedimento de controle da taxa de falsas descobertas.

Também verifica a cobertura e a atualidade das features, compara sinais entre folds walk-forward e identifica pares de features altamente correlacionadas para que sinais relacionados não sejam confundidos com evidências independentes. Um registro de triagem atribui decisões de avançar, revisar ou interromper, enquanto features cross-seccionais constantes permanecem sem decisão, em vez de serem classificadas como falhas. O documento ressalta que essas são triagens exploratórias, não uma confirmação de negociabilidade: os resultados dependem dos limites escolhidos e do horizonte do rótulo principal, e testes univariados não capturam como as features funcionam em conjunto. São necessários modelos multivariados e backtests posteriores para avaliar o desempenho combinado.

Ideias principais

  • O ranking IC mede se uma feature ordena ETFs da mesma forma que seus retornos subsequentes.
  • Rótulos de retornos futuros sobrepostos exigem ajustes para dependência serial ao estimar a significância.
  • Controles de falsas descobertas ajudam a considerar o teste simultâneo de muitas features.
  • A concordância dos sinais entre folds ajuda a distinguir relações persistentes de resultados de um único período.
  • Features altamente correlacionadas devem ser tratadas como evidências sobrepostas, não como descobertas independentes.
  • A triagem univariada de features não demonstra que um sinal é negociável ou útil em combinação.

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
#     language: python
#     name: python3
# ---

# %% [markdown]
# # ETFs: which features carry information about the forward return
#
# Chapters 8 and 9 built two feature matrices for this case study. `financial.parquet`
# holds arithmetic on past bars - trailing returns, volatility, oscillators. Neither
# notebook asked whether any of it predicts anything, and `04_model_based_features` wrote
# `model_based.parquet`, whose columns are the output of models fitted inside each
# walk-forward window. This notebook asks the question, one feature at a time.
#
# The measurement is the **information coefficient**, IC for short: on each date, rank the
# ETFs by the feature, rank them by the return they went on to earn over the following
# month, and correlate the two rankings. That gives one number per date and a series over
# the development sample. Everything after it is inference on that series - how much of
# its average is left once the overlap between consecutive monthly returns is allowed for,
# how much is left after allowing for having asked the same question of every feature at
# once, and whether the association holds across the walk-forward windows or came out of a
# single favorable stretch.
#
# **Learning objectives.** By the end of this notebook you will be able to:
#
# - Measure how well a feature ranks assets against what they went on to earn, date by
#   date, and read the resulting series rather than only its average.
# - Correct a t-statistic for the serial dependence that overlapping forward returns
#   create, and say how much of the apparent significance that correction removes.
# - Adjust a set of simultaneous tests for the number of tests in it, and report the size
#   of the search beside the result, so a reader can judge what a p-value here is worth.
# - Separate a feature that works in every walk-forward window from one that worked in a
#   single period, using the sign the feature takes in each window.
# - Find the pairs of features that are close to the same measurement, so a family's
#   breadth is not read as independent corroboration.
#
# **Book reference**: Chapter 7, Section 7.3 (Univariate feature-label evaluation) and
# Section 7.4 (Search accounting and multiple testing). Section 8.6 is the secondary
# reference for search control.
#
# **Prerequisites**: `02_labels` has written the forward-return labels, and
# `03_financial_features` and `04_model_based_features` have written
# `features/financial.parquet` and `features/model_based.parquet`.
#
# **What it writes.** `evaluation/triage_ledger.parquet`, one row per candidate feature
# carrying its statistics and a PROCEED, REVISE or STOP decision, and
# `evaluation/ic_timeseries.parquet`, the per-date series those statistics summarize. The
# ledger is read by `20_strategy_synthesis/02_feature_evaluation.py`, which sets the nine
# case studies' decisions side by side. The model notebooks from `06_linear` on train on
# the whole feature matrix: this screen is evidence about the features, not a filter
# applied to them.

# %%
"""Feature evaluation - ETFs case study."""

import re
from datetime import date

import numpy as np
import pandas as pd
import plotly.graph_objects as go
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
from plotly.subplots import make_subplots
from scipy.stats import spearmanr

import utils.style as style
from case_studies.utils.feature_engineering import (
    assign_families,
    families_from_config,
    quantile_profile,
)
from utils.artifact_specs import load_setup_config, resolve_label_buffer
from utils.cv_splits import generate_cv_splits
from utils.data_quality import top_entities, validate_modeling_inputs
from utils.paths import get_case_study_dir

COLORS = style.COLORS
GRAY_FILLS = style.GRAY_FILLS

# %% tags=["parameters"]
# Production defaults
MAX_SYMBOLS = 0

# %% [markdown]
# ## Settings, and what each one decides
#
# Everything the screens, the triage rule and the figures depend on is bound here: from
# `config/setup.yaml` where the case study declares it, and as a named constant where this
# notebook is the one making the choice. A threshold retyped further down would be a
# second source of truth for a decision already made once.
#
# Four of them come from the configuration. The **primary label** is the forward return
# every statistic below is measured against, and its **horizon** sets two other things:
# the width of the seal that keeps the holdout out of this notebook, and the bandwidth of
# the standard-error correction, because a return measured over a month and sampled every
# day overlaps its neighbours for all but one of those days. The **holdout boundary** is
# the date from which no row may inform anything here. The **walk-forward folds** come
# from the same configuration and are re-derived rather than stored.
#
# The rest are this notebook's own judgements, and each one is a place a reader working on
# their own data would choose differently:
#
# - A date needs a minimum number of ETFs quoted before a rank correlation across them
#   means anything; below that the date contributes nothing to the series.
# - A feature has to be present on most rows to be screened at all, and it has to actually
#   change from date to date - a column that repeats yesterday's value for half the panel
#   is describing the feed rather than the market.
# - The false-discovery level is the share of the promotions that are allowed to be false.
# - The exploration arm's threshold is the smallest average rank correlation worth
#   carrying into a monthly rebalance, and the sign-agreement bar is how much of the
#   walk-forward history has to point the same way for that to count as stable.
# - Two features whose ranks agree above the redundancy cut are treated as one piece of
#   evidence rather than two.

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

SETUP = load_setup_config(CASE_STUDY_ID)
eval_config = SETUP["evaluation"]

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}"
HAC_MAXLAGS = int(re.match(r"^(\d+)", LABEL_BUFFER).group(1))
LABEL_HORIZON = HAC_MAXLAGS
HOLDOUT_START = date.fromisoformat(eval_config["holdout_start"])

MIN_CROSS_SECTION_TARGET = 10  # ETFs a date needs before its rank correlation is read
IC_THRESHOLD = 0.01  # smallest average IC the exploration arm will promote on
N_QUANTILES = 5  # buckets the shape diagnostic sorts each date into
MIN_COVERAGE = 0.70  # non-null share the correctness gate requires
MAX_STALENESS = 0.50  # unchanged-from-prior-date share the correctness gate allows
FDR_ALPHA = 0.05  # share of promotions allowed to be false discoveries
NAIVE_T = 1.96  # two-sided normal critical value, for the naive-versus-HAC comparison
MIN_SIGN_CONSISTENCY = 0.60  # fold-sign agreement the exploration arm requires
REDUNDANCY_CUT = 0.7  # |rho| above which two features are one piece of evidence

print(
    f"Label: {PRIMARY_LABEL}, a forward return over {LABEL_HORIZON} trading sessions.\n"
    f"Consecutive labels therefore share {LABEL_HORIZON - 1} of those sessions, so the\n"
    f"  standard-error correction is given {HAC_MAXLAGS} sessions of dependence to allow for,\n"
    f"  and the holdout seal is {LABEL_HORIZON} sessions wide.\n"
    f"Holdout: dates from {HOLDOUT_START} onward inform nothing in this notebook.\n"
    f"Walk-forward design: {eval_config['n_splits']} folds, "
    f"{eval_config['train_size']} of training and {eval_config['val_size']} of validation each."
)

# %% [markdown]
# ## A. The panel this is measured on
#
# Three files come in: the two feature matrices and the forward-return label. They are
# joined on the date and the ETF, which is the pair that identifies a row everywhere in
# this case study.
#
# The model-based matrix needs restricting before it can be screened. Each of its columns
# is the output of a model - a two-state description of whether the broad market is calm or
# stressed, a filtered version of ten reference price series, and a volatility model per
# ETF - and `04_model_based_features` estimates every one of them on a **refit schedule**:
# it spends a burn-in, fits on everything up to that point, lets those parameters speak for
# the sessions until the next refit, and carries on to the end of the history. No
# observation is ever used to estimate the parameters that describe it, so the value on a
# date is one number, the same one whichever window a model reads it under, and the table
# carries one row per date and ETF.
#
# A **walk-forward window**, or fold, is a pair of adjacent date ranges: a stretch of
# history the model is fitted on, and the stretch that follows it, which the model has not
# seen. What is screened here is the second of those. A rank correlation measured on
# training dates would be measuring how well a description fits what it was fitted on, and
# the model notebooks are scored on validation dates, so the screen reads the same rows
# they do. **The evaluation panel is therefore the union of the validation ranges**, and
# the two matrices are screened on it alike.
#
# The cell below also reads the older shape of the artifact, in which `04` fitted inside
# each fold and wrote the same date once per fold. That shape needs each row kept only
# inside the validation range of the fold that produced it, rather than a date filter; the
# comment on the branch says when it goes.

# %%
JOIN_COLS = ["timestamp", "symbol"]
DATE_COL = "timestamp"

features = pl.read_parquet(CASE_DIR / "features" / "financial.parquet").with_columns(
    pl.col("timestamp").cast(pl.Date)
)
model_based_artifact = pl.read_parquet(CASE_DIR / "features" / "model_based.parquet").with_columns(
    pl.col("timestamp").cast(pl.Date)
)
label_df = pl.read_parquet(CASE_DIR / "labels" / f"{PRIMARY_LABEL}.parquet").with_columns(
    pl.col("timestamp").cast(pl.Date)
)
label_col = [c for c in label_df.columns if c not in ("timestamp", "symbol")][0]

financial_cols = [c for c in features.columns if c not in JOIN_COLS]
model_based_cols = [c for c in model_based_artifact.columns if c not in (*JOIN_COLS, "fold")]

print(
    f"financial.parquet:   {features.height:,} rows, {len(financial_cols)} features, "
    f"{features[DATE_COL].min()} to {features[DATE_COL].max()}\n"
    f"model_based.parquet: {model_based_artifact.height:,} rows, {len(model_based_cols)} features, "
    f"{'one row per date, ETF and fold' if 'fold' in model_based_artifact.columns else 'one row per date and ETF'}\n"
    f"{PRIMARY_LABEL}.parquet:  {label_df.height:,} rows, label column {label_col!r}"
)

# %% [markdown]
# ### What is in the feature set
#
# `config/setup.yaml` declares the case study's feature register: one row per family,
# saying what the family reads, how far back, and whether it is meant to rank ETFs against
# each other or to describe the environment a ranking is formed in.
# `03_financial_features` builds the matrix from that register, and reading the same
# register here is what keeps a feature in the same family on both sides. The model-based
# columns are not in it - the register covers the price-derived matrix - so they are named
# by the model that produced them.

# %%
FAMILY_REGISTER = families_from_config(SETUP)
families = assign_families(financial_cols, FAMILY_REGISTER)


def model_based_family(column: str) -> str:
    """Name a model-based column by the model that produced it."""
    if column.startswith("regime_"):
        return "market regime (HMM)"
    if column.startswith("ffd_"):
        return "fractional differencing"
    return "conditional volatility (GARCH)"


families |= {c: model_based_family(c) for c in model_based_cols}

all_feature_cols = financial_cols + model_based_cols
source_of = {c: ("financial" if c in financial_cols else "model-based") for c in all_feature_cols}

inventory = (
    pl.DataFrame(
        {
            "family": [families[c] for c in all_feature_cols],
            "source": [source_of[c] for c in all_feature_cols],
            "feature": all_feature_cols,
        }
    )
    .group_by(["source", "family"])
    .agg(pl.len().alias("features"), pl.col("feature").sort().str.join(", ").alias("columns"))
    .sort(["source", "features"], descending=[False, True])
)
display(inventory)

# %% [markdown]
# ### The walk-forward folds
#
# `generate_cv_splits` derives the folds from the dates the label frame carries and the
# design declared in `config/setup.yaml`. It is the call `04_model_based_features` makes
# to decide which fold each of its rows belongs to, and the one `load_modeling_dataset`
# makes for the model notebooks, so a fold number denotes the same pair of date ranges
# wherever it appears.
#
# The folds are numbered chronologically, so fold 0 is the earliest and the highest number is
# the one that ends against the holdout boundary.
# Each validation range stops far enough before the boundary that the forward return of
# its last date has finished before the holdout opens.

# %%
splits = generate_cv_splits(
    label_df.select(DATE_COL).unique().sort(DATE_COL),
    case_study_id=CASE_STUDY_ID,
    label_buffer=LABEL_BUFFER,
)


def _as_date(value) -> date:
    return pd.Timestamp(value).date()


for split in splits:
    print(
        f"  Fold {split['fold']}: train {_as_date(split['train_start'])} → "
        f"{_as_date(split['train_end'])}, validation {_as_date(split['val_start'])} → "
        f"{_as_date(split['val_end'])}"
    )

# %% [markdown]
# ### Restricting to validation, and sealing the holdout
#
# Two filters build the panel. The first keeps a model-based row only where some fold
# validates on its date, which leaves one value per date and ETF; the assertion below is
# what proves it, since two overlapping ranges would silently give a feature two values on
# one date.
#
# The second is the seal. The holdout must not inform which features look predictive, and
# the date a decision is taken is the wrong place to cut: a date a week before the
# boundary carries a forward return that finishes inside the holdout, so keeping it would
# put holdout prices into the rank correlation, the multiple-testing adjustment and the
# triage. The cut is therefore on the date the label **finishes**, found by stepping
# forward one horizon along the label's own calendar of trading dates.

# %%
val_windows = {int(s["fold"]): (_as_date(s["val_start"]), _as_date(s["val_end"])) for s in splits}
IN_VALIDATION = pl.any_horizontal(
    [(pl.col(DATE_COL) >= start) & (pl.col(DATE_COL) <= end) for start, end in val_windows.values()]
)
if "fold" in model_based_artifact.columns:
    # A fold-keyed artifact carries the same date once per fold, each copy produced by that
    # fold's own parameters, so the date filter alone would leave several values on a date.
    # Each row is kept only inside the validation range of the fold that produced it, which
    # also drops the appended holdout fold. `04` now writes one row per date and ETF under a
    # refit schedule, and this branch goes when the intermediates in the test-data repo have
    # been regenerated from it.
    model_based = (
        model_based_artifact.filter(pl.col("fold").is_in(list(val_windows)))
        .filter(
            pl.col("fold").replace_strict({f: s for f, (s, _) in val_windows.items()}, default=None)
            <= pl.col(DATE_COL)
        )
        .filter(
            pl.col(DATE_COL)
            <= pl.col("fold").replace_strict(
                {f: e for f, (_, e) in val_windows.items()}, default=None
            )
        )
        .drop("fold")
    )
else:
    model_based = model_based_artifact.filter(IN_VALIDATION)
assert model_based.select(JOIN_COLS).is_duplicated().sum() == 0, (
    "validation windows overlap; a fitted feature would take two values on one date"
)

# %%
eval_panel = features.join(model_based, on=JOIN_COLS, how="left")
assert eval_panel.height == features.height, "the model-based join changed the panel's row count"
eval_panel = eval_panel.join(label_df, on=JOIN_COLS, how="inner")

last_signal_date = (
    label_df.select(DATE_COL)
    .unique()
    .sort(DATE_COL)
    .with_columns(pl.col(DATE_COL).shift(-LABEL_HORIZON).alias("_label_end"))
    .filter(pl.col("_label_end") < HOLDOUT_START)[DATE_COL]
    .max()
)
n_before_seal = eval_panel.height
eval_panel = eval_panel.filter(pl.col(DATE_COL) <= last_signal_date).filter(IN_VALIDATION)
assert eval_panel[DATE_COL].max() <= last_signal_date

# `top_entities` breaks a tie on the symbol, which a local sort did not: ETFs quoted over
# the same span carry the same row count, and the winner then came from frame order. Two
# callers reducing the same panel to the same size have to choose the same universe, or a
# symbol one of them kept joins to null features in the other.
if MAX_SYMBOLS > 0:
    kept = top_entities(eval_panel, MAX_SYMBOLS)
    eval_panel = eval_panel.filter(pl.col("symbol").is_in(kept))

n_rows = eval_panel.height
n_symbols = eval_panel["symbol"].n_unique()
n_dates = eval_panel[DATE_COL].n_unique()

# A date needs enough ETFs quoted before a rank correlation across them says anything.
# The floor tracks the universe actually loaded, so a run on a reduced universe narrows
# the gate with it instead of screening every feature out.
MIN_CROSS_SECTION = min(MIN_CROSS_SECTION_TARGET, n_symbols)

print(
    f"Cut on the date the label finishes, then narrowed to the validation ranges: "
    f"{n_before_seal:,} -> {n_rows:,} rows.\n"
    f"The last date whose forward return finishes before {HOLDOUT_START} is "
    f"{last_signal_date}.\n"
    f"Evaluation panel: {n_symbols} ETFs over {n_dates:,} dates, "
    f"{eval_panel[DATE_COL].min()} to {eval_panel[DATE_COL].max()}, "
    f"{len(all_feature_cols)} candidate features.\n"
    f"A date enters the series once {MIN_CROSS_SECTION} ETFs carry both the feature and the label."
)

# %% [markdown]
# ## B. Is the artifact sound, and is each column usable?
#
# Two different questions, and they read different rows.
#
# The first is about the artifacts as `03` and `04` wrote them: does either contain a
# value no amount of screening can make usable - an infinity, a negative price, a monthly
# return so large it can only be a price-adjustment failure? The model notebooks train on
# every fold's training range, back to the start of the panel, so a broken value outside
# the validation ranges reaches them whether or not it is screened here. This check
# therefore reads the whole span up to the seal, and the model-based matrix as it was
# written rather than as the validation filter above leaves it. It stops at the seal like
# everything else, and
# it raises rather than warns, because a broken input makes the rest of the notebook
# meaningless.

# %%
sealed_features = features.filter(pl.col(DATE_COL) <= last_signal_date)
sealed_model_based = model_based_artifact.filter(pl.col(DATE_COL) <= last_signal_date)
sealed_labels = label_df.filter(pl.col(DATE_COL) <= last_signal_date)

validate_modeling_inputs(
    features_df=sealed_features,
    label_df=sealed_labels,
    feature_cols=financial_cols,
    label_col=label_col,
    join_cols=JOIN_COLS,
    asset_col="symbol",
    max_abs_return=1.0,  # a 21-day ETF return above this is a price-adjustment failure
    fail_on_critical=True,
)
validate_modeling_inputs(
    features_df=sealed_model_based,
    label_df=sealed_labels,
    feature_cols=model_based_cols,
    label_col=label_col,
    join_cols=JOIN_COLS,
    asset_col="symbol",
    max_abs_return=1.0,
    fail_on_critical=True,
)

# %% [markdown]
# The second question is per column. Before it can be asked, one group has to be separated
# out: some columns describe the market as a whole rather than one ETF against another -
# the yield-curve level, the filtered reference series, the regime probabilities. They take
# the same value for every ETF on a date, so ranking the cross-section by them produces no
# ordering at all and their cross-sectional rank correlation is undefined rather than zero.
# Nothing in the screen below can decide anything about them, and they are separated here
# rather than judged against a statistic they cannot have.

# %%
cs_std_df = eval_panel.group_by(DATE_COL).agg([pl.col(f).std().alias(f) for f in all_feature_cols])
date_level_features = {
    feat
    for feat in all_feature_cols
    if (mean_std := cs_std_df[feat].drop_nulls().mean()) is not None and mean_std < 1e-10
}
cross_sectional_features = [f for f in all_feature_cols if f not in date_level_features]
print(
    f"{len(date_level_features)} columns take one value across the whole cross-section on "
    f"each date:\n  {', '.join(sorted(date_level_features))}\n"
    f"{len(cross_sectional_features)} columns vary across ETFs and go on to the screen."
)

# %% [markdown]
# For the rest, the question is whether the column can be screened at all on the panel
# above:
#
# - **Coverage** is the share of rows where the feature has a value. A column present on
#   less than `MIN_COVERAGE` of the panel is being ranked on a different, smaller
#   cross-section than the one the strategy would trade.
# - **Staleness** is the share of rows that repeat the same ETF's previous value. Above
#   `MAX_STALENESS` the column changes so rarely that a daily rank correlation is mostly
#   reading the same ordering over and over.
#
# A column failing either gate is recorded STOP in the ledger and takes no further part.
# Both quantities are computed for the market-wide columns too, so the ledger carries them,
# but they decide nothing there: a column that cannot be ranked across ETFs is not made
# usable by changing more often.

# %%
coverage = {}
staleness = {}

for feat in all_feature_cols:
    col = eval_panel[feat]
    coverage[feat] = col.drop_nulls().len() / n_rows

    unchanged = (
        eval_panel.sort(JOIN_COLS)
        .select((pl.col(feat) == pl.col(feat).shift(1).over("symbol")).alias("same"))["same"]
        .sum()
    )
    staleness[feat] = float(unchanged) / max(n_rows - n_symbols, 1)

correctness = {
    feat: coverage[feat] >= MIN_COVERAGE and staleness[feat] <= MAX_STALENESS
    for feat in all_feature_cols
}
failed = [f for f in cross_sectional_features if not correctness[f]]
n_gate_pass = len(cross_sectional_features) - len(failed)
print(
    f"Coverage and staleness: {n_gate_pass} of {len(cross_sectional_features)} "
    f"cross-sectional columns pass, {len(failed)} recorded STOP"
)

if failed:
    display(
        pl.DataFrame(
            {
                "feature": failed,
                "family": [families[f] for f in failed],
                "coverage": [round(coverage[f], 3) for f in failed],
                "staleness": [round(staleness[f], 3) for f in failed],
            }
        )
    )

# %% [markdown]
# ### How much independent evidence the panel really holds
#
# The panel has one row per ETF per date, and neither of its two dimensions contributes a
# row's worth of evidence.
#
# Across ETFs, the observation the test is built on is not a row. Section C scores the
# whole cross-section at one date into a single number, the information coefficient for
# that date, and it is that series the average and its standard error are computed on. The
# ETFs also move together, so forty rows on one date are nothing like forty independent
# readings even before the statistic pools them.
#
# Across dates, the readings overlap: a return measured over $h$ sessions and sampled every
# session shares $h-1$ of them with the next one, so consecutive dates score almost the
# same stretch of prices. Dividing the dates by the horizon counts the blocks that do not
# overlap:
#
# $$N_{\text{eff}} \approx \frac{N_{\text{dates}}}{h}$$
#
# So the evidence is measured in tens of independent blocks, not in tens of thousands of
# rows, and that is worth knowing before reading a p-value computed from it. This is not
# the correction the notebook applies - that is the standard-error adjustment in the next
# section, which handles the overlap properly. It is here so the row count printed above is
# read for what it is, which is not a sample size.

# %%
n_eff_dates = n_dates // LABEL_HORIZON
print(
    f"Panel rows: {n_rows:,} ({n_dates:,} dates x {n_symbols} ETFs)\n"
    f"Information coefficients, one per date: {n_dates:,}\n"
    f"Roughly independent blocks among them: ~{n_eff_dates:,} "
    f"(the {n_dates:,} dates over a {LABEL_HORIZON}-session label), "
    f"a factor of {n_dates / n_eff_dates:.0f} fewer."
)

# %% [markdown]
# ## C. Does the feature rank the cross-section the way the return does?
#
# On each date, the ETFs quoted that day are ranked by the feature and by the return they
# went on to earn, and the two rankings are correlated. That is one information
# coefficient per date. Ranks rather than levels, because what a ranking strategy acts on
# is the order, and because a single outlying return would otherwise set the number.
#
# Averaging that series gives the feature's association with the label. Testing whether
# the average is distinguishable from zero needs more care than a textbook t-test, because
# the series is serially dependent by construction: neighbouring dates score overlapping
# return windows. The Newey-West estimator widens the standard error to allow for that,
# given a bandwidth - how many neighbouring dates to treat as dependent. Overlap of a
# window $h$ sessions long reaches $h-1$ sessions, so the bandwidth is set to the label
# horizon itself, which covers it. Deriving that number from the label rather than typing
# it is what stops the correction and the label from drifting apart.

# %%
evaluable_features = [f for f in all_feature_cols if correctness[f]]

# %% [markdown]
# The series is built in one pass over the dates, computing every feature's rank
# correlation on each date's cross-section. A feature enters a date's series only when
# enough ETFs carry both it and the label on that date, which is what keeps a sparsely
# covered column from contributing a correlation over a handful of pairs at the same
# weight as one measured over the whole universe.

# %%
cs_features = [f for f in evaluable_features if f not in date_level_features]
eval_sub = eval_panel.select([DATE_COL, *cs_features, label_col]).drop_nulls(subset=[label_col])

dates_list = eval_sub[DATE_COL].unique().sort().to_list()
n_total = len(dates_list)
ic_series_data = {feat: [] for feat in cs_features}

for i, dt in enumerate(dates_list):
    cross_section = eval_sub.filter(pl.col(DATE_COL) == dt)
    label_arr = cross_section[label_col].to_numpy()
    label_valid = ~np.isnan(label_arr)

    for feat in cs_features:
        feat_arr = cross_section[feat].to_numpy()
        valid_mask = label_valid & ~np.isnan(feat_arr)
        if int(valid_mask.sum()) >= MIN_CROSS_SECTION:
            ic_val, _ = spearmanr(feat_arr[valid_mask], label_arr[valid_mask])
            if not np.isnan(ic_val):
                ic_series_data[feat].append((dt, float(ic_val), int(valid_mask.sum())))

    if (i + 1) % 1000 == 0:
        print(f"  {i + 1}/{n_total} dates")

print(f"  {n_total}/{n_total} dates")

# %% [markdown]
# The series is sorted by date before the standard error is computed. The estimator reads
# the values in the order it is given them, so a series in any other order would be
# measuring dependence between dates that are not neighbours.

# %%
MIN_IC_DATES = 20  # dates a feature needs before its series is summarized at all

ic_results = {}
ic_timeseries = {}
for feat in cs_features:
    data = ic_series_data[feat]
    if len(data) < MIN_IC_DATES:
        continue
    dates_f, ics_f, nobs_f = zip(*data, strict=False)
    ic_df = pl.DataFrame({DATE_COL: list(dates_f), "ic": list(ics_f), "n_obs": list(nobs_f)}).sort(
        DATE_COL
    )
    ic_results[feat] = compute_ic_hac_stats(ic_df, ic_col="ic", maxlags=HAC_MAXLAGS)
    ic_timeseries[feat] = ic_df

print(
    f"An IC series was computed for {len(ic_results)} of the {len(all_feature_cols)} candidates. "
    f"Of the rest, {len(date_level_features)} carry no cross-sectional variation, "
    f"{len(failed)} failed the coverage or staleness gate, and "
    f"{len(cs_features) - len(ic_results)} had fewer than {MIN_IC_DATES} usable dates."
)

# %% [markdown]
# ## D. Does it hold across the walk-forward windows, or in one of them?
#
# An average taken over eight years can be produced by eight ordinary years or by one
# extraordinary one, and the two say different things about what a reader should expect
# next. So the series is cut into the folds' own validation ranges - the same ranges
# everything else in this pipeline numbers the same way - and summarized within each: the
# mean IC per fold, its median and spread across folds, the worst fold, and how much of
# the history agrees on a direction.
#
# Agreement is measured against the feature's **own** overall sign, not against a positive
# sign. A feature that is negative in every window is exactly as stable as one that is
# positive in every window, and just as usable: a ranking strategy reads it upside-down
# and is otherwise unaffected. A rule that counted positive folds would score the most
# dependable inverse predictor in the panel at zero.

# %%
MIN_FOLD_DATES = 5  # dates a fold must contribute before its mean IC is read


def per_fold_mean_ics(feat: str) -> list[float]:
    """Mean IC inside each fold's validation window, under the screen's own rule."""
    ts = ic_timeseries[feat]
    out = []
    for start, end in val_windows.values():
        window = ts.filter((pl.col(DATE_COL) >= start) & (pl.col(DATE_COL) <= end))
        if len(window) >= MIN_FOLD_DATES:
            out.append(float(window["ic"].mean()))
    return out


fold_stats = {}
for feat in ic_results:
    fold_ics = per_fold_mean_ics(feat)
    if fold_ics:
        pooled_sign = np.sign(ic_results[feat]["mean_ic"])
        agreeing = sum(1 for ic in fold_ics if np.sign(ic) == pooled_sign and pooled_sign != 0)
        fold_stats[feat] = {
            "n_folds": len(fold_ics),
            "sign_consistency": agreeing / len(fold_ics),
            "worst_fold_ic": min(fold_ics),
            "best_fold_ic": max(fold_ics),
            "median_fold_ic": float(np.median(fold_ics)),
        }

n_consistent = sum(1 for s in fold_stats.values() if s["sign_consistency"] >= MIN_SIGN_CONSISTENCY)
print(
    f"Fold statistics for {len(fold_stats)} features. {n_consistent} of them keep their own "
    f"overall sign in at least {MIN_SIGN_CONSISTENCY:.0%} of the folds."
)

# %% [markdown]
# ## E. What the search costs
#
# Every feature above was tested against the same label at the same level, so some of them
# clear the bar by chance alone: at a five percent level, one test in twenty does. How
# many depends on how many tests were run, which is why a p-value here cannot be read
# without the size of the search that produced it. **The searched set is every feature
# that cleared the correctness gate and produced an IC series**, and its size is printed
# below beside the results.
#
# The Benjamini-Hochberg procedure adjusts for that. Rather than holding down the chance of
# any false promotion at all, it holds down the false share *on average*: run this
# procedure over many such searches and the share of promotions that are false averages no
# more than the level set below. That is the right question when the point is to choose a
# set of features rather than to defend a single claim, and it is a weaker guarantee than
# it first sounds - it says nothing about the particular set promoted here, which can carry
# a higher false share than the level or none at all.
#
# Three counts follow, and the gaps between them are the two prices being paid. Naive
# significance ignores the overlap in the labels. The lag-aware count pays for the
# overlap. The false-discovery count pays for the number of tests on top of that.

# %%
feature_names = list(ic_results.keys())
p_values = [ic_results[f]["p_value"] for f in feature_names]

fdr_result = benjamini_hochberg_fdr(p_values, alpha=FDR_ALPHA, return_details=True)

eval_summary = pl.DataFrame(
    {
        "feature": feature_names,
        "source": [source_of[f] for f in feature_names],
        "ic_mean": [ic_results[f]["mean_ic"] for f in feature_names],
        "hac_se": [ic_results[f]["hac_se"] for f in feature_names],
        "hac_t": [ic_results[f]["t_stat"] for f in feature_names],
        "hac_p": p_values,
        "fdr_p": [float(p) for p in fdr_result["adjusted_p_values"]],
        "fdr_sig": [bool(r) for r in fdr_result["rejected"]],
        "naive_t": [ic_results[f]["naive_t_stat"] for f in feature_names],
    }
).sort(pl.col("ic_mean").cast(pl.Float64, strict=False).abs(), descending=True)

n_significant_naive = sum(1 for f in feature_names if abs(ic_results[f]["naive_t_stat"]) > NAIVE_T)
n_significant_hac = sum(1 for f in feature_names if abs(ic_results[f]["t_stat"]) > NAIVE_T)
n_significant_fdr = int(fdr_result["n_rejected"])


def survivor_share(remaining: int, started: int) -> str:
    """How much of the naive count a correction leaves, or that it leaves none."""
    if remaining == 0:
        return "nothing left"
    return f"{remaining / started:.0%} of the naive count"


print(
    f"Searched set: {len(feature_names)} features, each tested once against {label_col}.\n"
    f"  |t| > {NAIVE_T} treating dates as independent: {n_significant_naive}\n"
    f"  |t| > {NAIVE_T} with the overlap-aware standard error: {n_significant_hac} "
    f"({survivor_share(n_significant_hac, n_significant_naive)})\n"
    f"  Benjamini-Hochberg at q < {FDR_ALPHA}: {n_significant_fdr} "
    f"({survivor_share(n_significant_fdr, n_significant_naive)})"
)

# %% [markdown]
# ### The series behind the average
#
# Every statistic so far is one number standing for a series, and two things that decide
# whether a feature is worth anything are invisible once it has been averaged: an
# association that comes from a single episode and is absent around it, and one that
# changes direction partway through. So the series is drawn for the three features with
# the largest average association, before it is reduced any further. The heavy line is a
# six-month moving average, and the dotted pair marks the overlap-aware interval around the
# full-sample mean. That interval is narrow enough on this axis to be worth stating: the
# swing of the daily series is an order of magnitude wider than any uncertainty about where
# its average sits.

# %%
LEADING_FOR_SERIES = 3
ROLLING_DAYS = 126
series_features = eval_summary.head(LEADING_FOR_SERIES)["feature"].to_list()

fig = make_subplots(
    rows=len(series_features), cols=1, shared_xaxes=True, subplot_titles=series_features
)
for row, feat in enumerate(series_features, start=1):
    series = ic_timeseries[feat]
    bands = compute_ic_uncertainty(series, horizon=LABEL_HORIZON, ic_col="ic")
    dates = series[DATE_COL].to_list()
    fig.add_trace(
        go.Scatter(
            x=dates,
            y=series["ic"].to_list(),
            mode="lines",
            line=dict(color=GRAY_FILLS["muted"], width=0.6),
            showlegend=False,
        ),
        row=row,
        col=1,
    )
    fig.add_trace(
        go.Scatter(
            x=dates,
            y=series["ic"].rolling_mean(ROLLING_DAYS, min_samples=ROLLING_DAYS).to_list(),
            mode="lines",
            line=dict(color=COLORS["blue"], width=1.4),
            showlegend=False,
        ),
        row=row,
        col=1,
    )
    for value, dash in (
        (bands["mean_ic"], "solid"),
        (bands["ci_hac_lower"], "dot"),
        (bands["ci_hac_upper"], "dot"),
    ):
        fig.add_hline(y=value, line=dict(color=COLORS["amber"], width=1, dash=dash), row=row, col=1)
    fig.add_hline(y=0, line=dict(color=GRAY_FILLS["border"], width=0.8), row=row, col=1)
fig.update_layout(
    template="ml4t",
    height=200 * len(series_features) + 80,
    width=900,
    title_text="The daily IC swings far wider than the mean it averages to",
)
fig.update_yaxes(title_text="Rank IC")
style.show_plotly_with_alt(
    fig,
    "One stacked panel per leading feature, each showing the daily rank IC as a pale noisy "
    "series swinging between about -0.75 and +0.75 and a dark rolling mean that stays within "
    "roughly -0.1 to +0.35. Dashed reference lines mark the full-sample mean, which every "
    "panel's rolling line crosses repeatedly.",
)

# %% [markdown]
# ### Every feature, ranked by how strongly it ranks
#
# The features with the largest average association, with the overlap-aware t-statistic
# printed against each bar so the size of the association and the confidence in it are
# read together. Blue marks a feature that clears false-discovery control; grey is
# everything else, and that is the one colour convention every figure in this section
# uses.

# %%
top_n = min(25, len(eval_summary))
top = eval_summary.head(top_n).sort("ic_mean")

SURVIVES, DOES_NOT = COLORS["blue"], GRAY_FILLS["muted"]

fig = go.Figure(
    go.Bar(
        x=top["ic_mean"].to_list(),
        y=top["feature"].to_list(),
        orientation="h",
        marker_color=[SURVIVES if s else DOES_NOT for s in top["fdr_sig"].to_list()],
        text=[f"t={value:.1f}" for value in top["hac_t"].to_list()],
        textposition="outside",
        showlegend=False,
    )
)
fig.add_vline(x=0, line=dict(color=GRAY_FILLS["border"], width=1))
# Room for the t-statistic label past the end of the longest bar.
ic_span = max(abs(value) for value in top["ic_mean"].to_list()) * 1.35
fig.update_layout(
    template="ml4t",
    height=620,
    width=900,
    title_text="Volatility ranks the cross-section upward and recent strength downward",
    xaxis_title="Mean cross-sectional rank IC, with the overlap-aware t-statistic",
    xaxis_range=[-ic_span, ic_span],
    yaxis_title="Feature",
    margin=dict(l=170),
)
style.show_plotly_with_alt(
    fig,
    "Horizontal bars of mean cross-sectional rank IC per feature, sorted, with each bar "
    "annotated by its overlap-aware t-statistic. The volatility and distance-from-low features "
    "run positive to about +0.08 with t near 2.4 to 3.3, and the momentum, oscillator and "
    "drawdown features run negative to about -0.09 with t between -1.1 and -2.8.",
)

# %% [markdown]
# ### The same association, fold by fold
#
# Each row is one feature and each dot is its average IC inside one fold's validation
# range. The amber diamond is the median of those folds. A feature whose dots sit on one
# side of zero behaved the same way in every period; one whose dots straddle zero did not,
# whatever its overall average says.

# %%
FOLDS_SHOWN = 12
fold_features = [f for f in eval_summary["feature"].to_list() if f in fold_stats][:FOLDS_SHOWN]
fig = go.Figure()
for feat in fold_features:
    per_fold = per_fold_mean_ics(feat)
    first = feat == fold_features[0]
    fig.add_trace(
        go.Scatter(
            x=per_fold,
            y=[feat] * len(per_fold),
            mode="markers",
            marker=dict(color=GRAY_FILLS["muted"], size=7),
            name="one fold",
            legendgroup="fold",
            showlegend=first,
        )
    )
    fig.add_trace(
        go.Scatter(
            x=[fold_stats[feat]["median_fold_ic"]],
            y=[feat],
            mode="markers",
            marker=dict(color=COLORS["amber"], size=11, symbol="diamond"),
            name="median fold",
            legendgroup="median",
            showlegend=first,
        )
    )
fig.add_vline(x=0, line=dict(color=GRAY_FILLS["border"], width=1))
fig.update_layout(
    template="ml4t",
    height=520,
    width=900,
    title_text="Every leading feature changes sign in at least one fold",
    xaxis_title="Mean rank IC within the fold's validation range",
    yaxis_title="Feature",
    legend=dict(orientation="h", y=-0.12),
    margin=dict(l=170),
)
style.show_plotly_with_alt(
    fig,
    "One row per leading feature, with a grey dot for each fold's mean rank IC and an amber "
    "diamond at the median fold. Every row has dots on both sides of the zero line, so each "
    "feature changes sign in at least one fold, and the spread within a feature is wider than "
    "the gap between features.",
)

# %% [markdown]
# ### What the overlap correction costs
#
# The same t-statistic computed twice: once treating each date as independent evidence,
# once allowing for the overlap between neighbouring labels. A point on the dashed
# diagonal would mean the overlap cost nothing.

# %%
fig = go.Figure(
    go.Scatter(
        x=eval_summary["naive_t"].to_list(),
        y=eval_summary["hac_t"].to_list(),
        mode="markers",
        marker=dict(
            color=[SURVIVES if s else DOES_NOT for s in eval_summary["fdr_sig"].to_list()],
            size=7,
        ),
        text=eval_summary["feature"].to_list(),
        showlegend=False,
    )
)
max_t = (
    max(
        eval_summary["naive_t"].cast(pl.Float64, strict=False).abs().max() or 1.0,
        eval_summary["hac_t"].cast(pl.Float64, strict=False).abs().max() or 1.0,
    )
    * 1.1
)
fig.add_trace(
    go.Scatter(
        x=[-max_t, max_t],
        y=[-max_t, max_t],
        mode="lines",
        line=dict(dash="dash", color=GRAY_FILLS["border"]),
        showlegend=False,
    )
)
fig.update_layout(
    template="ml4t",
    height=480,
    width=760,
    title_text="Overlapping labels pull every t-statistic toward zero",
    xaxis_title="Naive t",
    yaxis_title="HAC t",
)
style.show_plotly_with_alt(
    fig,
    "A scatter of each feature's HAC t-statistic against its naive one, with a dashed diagonal "
    "marking equality. Every point sits between the diagonal and the horizontal zero line - "
    "naive values spread from about -10 to +11 while the HAC values stay inside roughly -3 to "
    "+3.5 - so correcting for overlap shrinks every t-statistic toward zero.",
)

# %% [markdown]
# Every point sits inside the diagonal, and the ones furthest along it lose the most: a
# t-statistic of ten computed as if each date were fresh evidence is worth about three
# once the overlap is allowed for. The number of simultaneous tests is charged on top of
# that, which is what leaves the false-discovery count where it is.

# %% [markdown]
# ## F. Is the relationship shaped like something a model can use?
#
# A rank correlation says the ordering carries information. It does not say the
# information is spread evenly across the ordering, and that matters for what a model can
# do with it. So on each date the ETFs are sorted by the feature and split into five equal
# buckets, and the return of each bucket is averaged - first within the date, then across
# dates, so that every date counts the same and a busy year does not outvote a quiet one.
#
# The buckets are cut **within each date**. Cutting once over the pooled panel would sort a
# 2016 observation against a 2023 one, so the feature's drift through time would decide
# which bucket a row lands in, and the profile would be answering a different question
# from the rank correlation printed beside it.
#
# Both the mean and the median of each bucket are drawn. The mean is what a book holding
# that bucket would earn; the median describes the typical ETF in it. Where the two are far
# apart, a few large moves are carrying the bucket.

# %% [markdown]
# The panels show the features that clear false-discovery control first, then the largest
# remaining associations, so the diagnostic still shows something when few features clear
# it.

# %%
fdr_shape = eval_summary.filter(pl.col("fdr_sig").fill_null(False))["feature"].to_list()
ranked_shape = eval_summary["feature"].to_list()
top_features_for_shape = (fdr_shape + [f for f in ranked_shape if f not in fdr_shape])[:6]

QUANTILE_LABELS = [f"Q{i + 1}" for i in range(N_QUANTILES)]
MIN_SHAPE_DATES = 20

monotonicity_scores = {}
quantile_spreads = {}

for feat in top_features_for_shape:
    profile = quantile_profile(
        eval_panel,
        feat,
        label_col,
        date_col=DATE_COL,
        n_quantiles=N_QUANTILES,
        min_cross_section=MIN_CROSS_SECTION,
    )
    if profile is None or profile.periods_used < MIN_SHAPE_DATES:
        continue
    quantile_spreads[feat] = {
        "q_means": profile.means,
        "q_medians": profile.medians,
        "spread": profile.spread,
    }
    # The ledger's monotonicity column is the rank correlation between the bucket index
    # and the bucket's mean return: +1 for a profile that rises all the way across, -1
    # for one that falls all the way, and near zero for one that turns in the middle.
    monotonicity_scores[feat] = profile.monotonicity

print(f"Bucket profiles built for {len(quantile_spreads)} features.")

# %%
if quantile_spreads:
    n_show = min(6, len(quantile_spreads))
    feats_to_show = list(quantile_spreads.keys())[:n_show]
    n_rows_fig = (n_show + 2) // 3
    fig = make_subplots(rows=n_rows_fig, cols=3, subplot_titles=feats_to_show, shared_yaxes=True)
    for idx, feat in enumerate(feats_to_show):
        r, c = divmod(idx, 3)
        fig.add_trace(
            go.Bar(
                x=QUANTILE_LABELS,
                y=quantile_spreads[feat]["q_means"],
                marker_color=COLORS["blue"],
                name="mean",
                legendgroup="mean",
                showlegend=idx == 0,
            ),
            row=r + 1,
            col=c + 1,
        )
        fig.add_trace(
            go.Scatter(
                x=QUANTILE_LABELS,
                y=quantile_spreads[feat]["q_medians"],
                mode="markers",
                marker=dict(color=COLORS["amber"], size=9, symbol="diamond"),
                name="median",
                legendgroup="median",
                showlegend=idx == 0,
            ),
            row=r + 1,
            col=c + 1,
        )
    # One y range across the panels, so their heights are comparable. It includes zero
    # and is otherwise taken from the values.
    profile_values = [
        value
        for feat in feats_to_show
        for key in ("q_means", "q_medians")
        for value in quantile_spreads[feat][key]
    ]
    lo, hi = min(0.0, min(profile_values)), max(0.0, max(profile_values))
    pad = 0.15 * (hi - lo)
    fig.update_yaxes(range=[lo - pad, hi + pad])
    fig.update_layout(
        template="ml4t",
        height=260 * n_rows_fig + 60,
        width=900,
        title_text="The lowest-returning bucket is the bottom-ranked one in every panel",
        legend=dict(orientation="h", y=-0.08),
    )
    fig.update_yaxes(title_text="Mean forward return", col=1)
    style.show_plotly_with_alt(
        fig,
        "A grid of panels, one per leading feature, each with five bars for quintiles Q1 to Q5 of "
        "mean forward return and an amber diamond for the median. In every panel Q1 is the lowest "
        "bar at roughly a quarter of the others, while Q2 through Q5 sit close together, so the "
        "signal separates the bottom bucket rather than ordering the whole cross-section.",
    )

# %% [markdown]
# A profile that rises or falls all the way across is the shape a linear coefficient can
# carry on its own, which is what the ridge and elastic-net fits in `06_linear` are
# limited to. Where a panel turns at one end instead, the information is there but a
# single coefficient has to average the turn away, and the tree ensembles in `07_gbm` can
# split on it rather than fit through it. Where a median marker sits far from the top of
# its bar, that bucket's average is carried by a few large moves rather than by the ETF in
# the middle of it.

# %% [markdown]
# ## G. Which of these are the same evidence twice?
#
# Nothing above stops two features from measuring the same thing. Volatility at 63 and at
# 126 sessions is one measurement at two speeds; an oscillator and the ratio of price to
# its own moving average are two arrangements of the same recent path. Counting them
# separately makes a family's breadth look like independent corroboration, and it is not.
#
# So the ranks of every screened pair are correlated across a sample of the panel's dates,
# and the pairs above `REDUNDANCY_CUT` are counted and ranked. The sample keeps the
# comparison cheap; correlations this strong do not depend on the last decimal place.

# %%
sample_step = max(1, n_dates // 200)
sample_dates = eval_panel[DATE_COL].unique().sort().to_list()[::sample_step]
corr_matrix = (
    eval_panel.filter(pl.col(DATE_COL).is_in(sample_dates))
    .select(evaluable_features)
    .to_pandas()
    .corr(method="spearman")
)

high_corr_pairs = []
cols = corr_matrix.columns
for i in range(len(cols)):
    for j in range(i + 1, len(cols)):
        if abs(corr_matrix.iloc[i, j]) > REDUNDANCY_CUT:
            high_corr_pairs.append((cols[i], cols[j], float(corr_matrix.iloc[i, j])))

print(
    f"{len(high_corr_pairs)} of the "
    f"{len(evaluable_features) * (len(evaluable_features) - 1) // 2:,} feature pairs agree "
    f"above |rho| = {REDUNDANCY_CUT}, sampled every {sample_step} dates."
)

# %% [markdown]
# ### Where the association sits, by family
#
# The register's families are the groups a reader would think in, so the association is
# summarized over them: how large it is on average within the family, and whether the
# family points one way or cancels out inside itself. A family whose features disagree in
# direction is not a family carrying a signal, it is a label over several different ones.

# %%
fdr_sig_set = set(eval_summary.filter(pl.col("fdr_sig").fill_null(False))["feature"].to_list())

family_summary = (
    pl.DataFrame(
        {
            "family": [families[f] for f in ic_results],
            "ic": [ic_results[f]["mean_ic"] for f in ic_results],
            "fdr_sig": [f in fdr_sig_set for f in ic_results],
        }
    )
    .group_by("family")
    .agg(
        pl.len().alias("n_features"),
        pl.col("ic").abs().mean().alias("avg_abs_ic"),
        pl.col("ic").mean().alias("avg_ic"),
        pl.col("fdr_sig").sum().alias("n_fdr_sig"),
    )
    .sort("avg_abs_ic", descending=True)
)

fig = go.Figure()
fig.add_trace(
    go.Bar(
        x=family_summary["avg_abs_ic"].to_list()[::-1],
        y=family_summary["family"].to_list()[::-1],
        orientation="h",
        marker_color=GRAY_FILLS["muted"],
        name="average |IC| in the family",
    )
)
fig.add_trace(
    go.Scatter(
        x=family_summary["avg_ic"].to_list()[::-1],
        y=family_summary["family"].to_list()[::-1],
        mode="markers",
        marker=dict(color=COLORS["amber"], size=11, symbol="diamond"),
        name="average IC, signs kept",
    )
)
fig.add_vline(x=0, line=dict(color=GRAY_FILLS["border"], width=1))
fig.update_layout(
    template="ml4t",
    height=460,
    width=900,
    title_text="Only some families point one way; the rest cancel out inside themselves",
    xaxis_title="Mean cross-sectional rank IC across the family's features",
    yaxis_title="Feature family",
    legend=dict(orientation="h", y=-0.18),
    margin=dict(l=200),
)
style.show_plotly_with_alt(
    fig,
    "Horizontal bars of the average absolute rank IC within each feature family, with an amber "
    "diamond for the average signed IC. Conditional volatility and volatility keep their "
    "sign, their diamonds sitting near the end of their bars, and the oscillator and "
    "risk-adjusted-momentum families point as consistently the other way, their diamonds "
    "as far to the left as their bars are long. Only range-and-drawdown and cross-sectional "
    "position have a diamond near zero against a much longer bar, which is what cancelling "
    "inside a family looks like.",
)

# %% [markdown]
# ### The pairs that are one measurement
#
# A full correlation matrix over this many features has unreadable tick labels and is
# mostly empty space, and the reader's question is narrower than a matrix answers: which
# specific pairs are one measurement entered twice. So the strongest pairs are ranked
# instead.

# %%
TOP_PAIRS = 15
ranked_pairs = sorted(high_corr_pairs, key=lambda item: -abs(item[2]))[:TOP_PAIRS]
if ranked_pairs:
    fig = go.Figure(
        go.Bar(
            x=[rho for _, _, rho in ranked_pairs][::-1],
            y=[f"{a} / {b}" for a, b, _ in ranked_pairs][::-1],
            orientation="h",
            marker_color=[
                COLORS["blue"] if rho > 0 else COLORS["copper"] for _, _, rho in ranked_pairs
            ][::-1],
            showlegend=False,
        )
    )
    fig.add_vline(x=0, line=dict(color=GRAY_FILLS["border"], width=1))
    fig.update_layout(
        template="ml4t",
        height=520,
        width=900,
        title_text="The strongest pairs are near-duplicates, not merely related",
        xaxis_title="Pairwise Spearman correlation",
        xaxis_range=[-1, 1],
        yaxis_title="Feature pair",
        margin=dict(l=300),
    )
    style.show_plotly_with_alt(
        fig,
        "Horizontal bars of pairwise Spearman correlation for the most correlated feature pairs, on "
        "an axis spanning -1 to 1. Every bar is positive and close to the right edge, above about "
        "0.9, so the strongest pairs are near-duplicates rather than merely related.",
    )

# %% [markdown]
# The leading pairs are the same measurement at two window lengths, two arrangements of
# the same recent price path, or two filtered series of ETFs that track overlapping
# markets. Nothing downstream has to drop one of each pair: the ridge and elastic-net
# penalties in `06_linear` shrink correlated coefficients together rather than letting one
# of them absorb the pair, and the tree ensembles in `07_gbm` split on whichever member
# happens to be available. What the pair count does change is how this section's own
# results are read, because a family with eight members above the cut is not eight
# independent pieces of evidence.

# %% [markdown]
# ## H. The decision each feature gets
#
# | Decision | When | Arm |
# |----------|------|-----|
# | **PROCEED** | clears Benjamini-Hochberg at `FDR_ALPHA` | confirmation |
# | **PROCEED** | keeps its own sign in at least `MIN_SIGN_CONSISTENCY` of the folds, and its average IC is at least `IC_THRESHOLD` in size | exploration |
# | **STOP** | failed the coverage or staleness gate | - |
# | **REVISE** | everything else | - |
#
# The two promotion rules are an **or**, not an **and**, so the promoted set can be larger
# than the set that clears false-discovery control, and the ledger's `note` column records
# which of the two promoted each feature. Reading the count without that column would
# credit the exploration arm's promotions to the confirmation arm.
#
# The second arm exists because false-discovery control over a search this wide can leave
# nothing at all, and a screen that returns an empty menu has told the reader nothing about
# which features to look at first. It is an exploration filter in the sense of Section 7.4
# rather than a test: `IC_THRESHOLD` is a stated judgement about the smallest association
# worth carrying into a monthly rebalance, not a quantity derived from the data, and a
# feature promoted through it has not been confirmed by anything.
#
# The columns that take one value across the whole cross-section are recorded REVISE. They
# cannot have a cross-sectional IC, so there is nothing here to decide them on, and they
# stay in the feature matrix the model notebooks train on.

# %%
triage = {}
for feat in all_feature_cols:
    if feat in date_level_features:
        triage[feat] = ("REVISE", "date_level_feature")
        continue

    if not correctness[feat]:
        triage[feat] = ("STOP", "correctness_fail")
        continue

    if feat not in ic_results:
        triage[feat] = ("REVISE", "insufficient_data")
        continue

    is_fdr_sig = feat in fdr_sig_set
    sign_con = fold_stats.get(feat, {}).get("sign_consistency", 0)
    abs_ic = abs(ic_results[feat]["mean_ic"])

    if is_fdr_sig:
        triage[feat] = ("PROCEED", "fdr_significant")
    elif sign_con >= MIN_SIGN_CONSISTENCY and abs_ic >= IC_THRESHOLD:
        triage[feat] = ("PROCEED", "stable_and_above_threshold")
    else:
        triage[feat] = ("REVISE", "not_significant_standalone")

# %%
ledger_rows = []
for feat in all_feature_cols:
    decision, note = triage[feat]
    row = {
        "feature": feat,
        "family": families[feat],
        "source": source_of[feat],
        "ic_mean": ic_results.get(feat, {}).get("mean_ic"),
        "hac_t": ic_results.get(feat, {}).get("t_stat"),
        "hac_p": ic_results.get(feat, {}).get("p_value"),
        "fdr_p": None,
        "fdr_sig": False,
        "sign_consistency": fold_stats.get(feat, {}).get("sign_consistency"),
        "worst_fold_ic": fold_stats.get(feat, {}).get("worst_fold_ic"),
        "monotonicity": monotonicity_scores.get(feat),
        "coverage": coverage[feat],
        "staleness": staleness[feat],
        "decision": decision,
        "note": note,
    }
    match = eval_summary.filter(pl.col("feature") == feat)
    if len(match) > 0:
        row["fdr_p"] = float(match["fdr_p"][0])
        row["fdr_sig"] = bool(match["fdr_sig"][0])
    ledger_rows.append(row)

triage_ledger = pl.DataFrame(ledger_rows)
triage_ledger.write_parquet(EVAL_DIR / "triage_ledger.parquet")

ic_ts_all = pl.concat(
    [ts.with_columns(pl.lit(feat).alias("feature")) for feat, ts in ic_timeseries.items()]
)
ic_ts_all.write_parquet(EVAL_DIR / "ic_timeseries.parquet")

print(
    f"evaluation/triage_ledger.parquet: {triage_ledger.height} features, one row each.\n"
    f"evaluation/ic_timeseries.parquet: {ic_ts_all.height:,} rows, the per-date series behind them."
)

# %% [markdown]
# ### The funnel, end to end
#
# Every candidate feature ends in one of the three decisions, and the figure below is the
# whole path: how many were offered, how many can be ranked across ETFs at all, how many
# of those cleared the coverage and staleness gates, how many cleared false-discovery
# control, and how many were promoted. The distance between the last two bars is the
# exploration arm.

# %%
proceed_features = sorted(f for f, (d, _) in triage.items() if d == "PROCEED")
revise_features = [f for f, (d, _) in triage.items() if d == "REVISE"]
stop_features = [f for f, (d, _) in triage.items() if d == "STOP"]

funnel_stages = [
    ("Candidate features", len(all_feature_cols)),
    ("Vary across the cross-section", len(cross_sectional_features)),
    ("Cleared coverage and staleness", n_gate_pass),
    ("Cleared false-discovery control", n_significant_fdr),
    ("Promoted (PROCEED)", len(proceed_features)),
]
fig = go.Figure(
    go.Bar(
        x=[n for _, n in funnel_stages][::-1],
        y=[stage for stage, _ in funnel_stages][::-1],
        orientation="h",
        marker_color=[COLORS["blue"] if i == 4 else GRAY_FILLS["muted"] for i in range(5)][::-1],
        text=[str(n) for _, n in funnel_stages][::-1],
        textposition="outside",
        showlegend=False,
    )
)
fig.update_layout(
    template="ml4t",
    height=380,
    width=900,
    title_text="The promoted set is wider than what clears false-discovery control",
    xaxis_title="Features",
    xaxis_range=[0, len(all_feature_cols) * 1.15],
    margin=dict(l=250),
)
style.show_plotly_with_alt(
    fig,
    "A funnel of five rows counting the features surviving each screen, each labelled with its "
    "c

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.