Перейти к содержимому
Все документы библиотеки

TabM для FX: планирование контрольных точек и каталог предсказаний

Код Machine Learning for Trading

Сводка

В ноутбуке описан рабочий процесс TabM для моделей валютных пар. TabM применяет небольшую нейронную сеть к каждой строке решения, не рассматривая наблюдения как последовательность. Ноутбук получает архитектуру и расписания контрольных точек из конфигурации, планирует ожидаемые идентификаторы обучения и предсказаний до подгонки модели и запускает каждый заявленный запрос модели. Предобработка подгоняется внутри обучающих фолдов, а контрольная точка каждой эпохи сохраняется вместе с полным набором валидационных предсказаний.

Рабочий процесс ориентирован на воспроизводимость и целостность каталога: проверяется соответствие запланированных конфигураций меню, получение результатов для каждого запрошенного запуска, а также полнота записей каталога и их однозначная связь с контрольными точками. При повторном запуске предполагается загрузить сохранённые контрольные точки и сохранить идентификаторы предсказаний. Ранговая корреляция записывается как диагностический показатель, а не используется для выбора контрольной точки. Ноутбук поддерживает сокращённые предварительные запуски в изолированной рабочей среде; они не входят в официальную совокупность. В нём объясняется, как обрабатываются происхождение совокупности и идентификаторы замен, но самостоятельных доказательств прибыльного прогнозирования доходности FX с помощью TabM не приводится.

Ключевые идеи

  • TabM обрабатывает каждую строку решения, не кодируя историю как последовательность.
  • Подгоняйте предобработку внутри каждого обучающего фолда, чтобы в обучающие преобразования не попадала валидационная информация.
  • До обучения планируйте идентификаторы моделей и контрольных точек, чтобы пропущенные результаты оставались видимыми.
  • Сохраняйте полные валидационные предсказания для каждой заявленной контрольной точки вместо выбора по ранговой корреляции.
  • Рассматривайте сокращённые предварительные запуски отдельно от официальной совокупности предсказаний.

Теги

Полный текст
# 03_financial_features.py


```py
# ---
# jupyter:
#   jupytext:
#     cell_metadata_filter: tags,-all
#     text_representation:
#       extension: .py
#       format_name: percent
#       format_version: '1.3'
#       jupytext_version: 1.19.3
#   kernelspec:
#     display_name: Python 3 (ipykernel)
#     language: python
#     name: python3
# ---

# %% [markdown]
# # ETFs: Feature Engineering
#
# Every model here sees the world through this matrix, so what is computable now bounds what
# can be learned later. Each feature answers one question: at the moment a position is decided,
# which observations are already on the tape, and what does the feature make of them?
#
# ## Learning objectives
#
# - State a feature's timing contract - its lookback and its information lag - before writing
#   the code that computes it
# - Compute trailing and cross-sectional statistics that read no observation dated after the
#   decision timestamp, and declare a lag where an input is not yet available at it
# - Show that withholding later dates leaves every feature value unchanged, which is what
#   separates a trailing statistic from one fitted over the whole sample
# - Read a feature set for scale, dispersion, redundancy and decay before any model sees it
#
# ## Book reference, prerequisites and artifacts
#
# Chapter 8, Sections 8.1-8.6. Reads split- and dividend-adjusted daily bars via `load_etfs()`,
# the Treasury constant-maturity series via `load_macro()`, the tradability gate
# `eligibility.csv` from [`01_feasibility_analysis`](01_feasibility_analysis.ipynb), and
# `config/setup.yaml`. Writes `features/financial.parquet` with a `.digest.json` sidecar, read by
# [`05_evaluation`](05_evaluation.ipynb), which tests fold by fold whether any of it predicts.
# [`04_model_based_features`](04_model_based_features.ipynb) builds a second matrix from the same
# prices, of features that are themselves model outputs, and `05_evaluation` evaluates the two
# together.

# %%
"""ETFs: Feature Engineering."""

import logging
from datetime import date

import polars as pl
import yaml
from ml4t.engineer.features.momentum import adx, aroon, cci, macd, rsi, stochastic
from ml4t.engineer.features.regime import choppiness_index, hurst_exponent
from ml4t.engineer.features.trend import ema, sma
from ml4t.engineer.features.volatility import bollinger_bands, natr
from ml4t.engineer.features.volume import obv

from case_studies.utils.artifact_digest import value_digest, write_artifact
from case_studies.utils.artifact_quality import (
    label_universe,
    quality_report,
    render_quality_report,
)
from case_studies.utils.feature_engineering import (
    EPS,
    assert_values_agree,
    assign_families,
    clip_within_date,
    cross_sectional_percentile,
    drawdown_block,
    families_from_config,
    family_coverage,
    momentum_volatility_block,
    plot_coverage_through_time,
    plot_cross_sectional_dispersion,
    plot_feature_distributions,
    plot_persistence,
    plot_redundancy_clusters,
    plot_timing_contract,
    register_frame,
    rolling_zscore,
    trailing_volume_ratio,
    warmup_audit,
)
from case_studies.utils.warning_policy import apply_notebook_warning_policy
from data import load_etfs, load_macro
from utils.artifact_specs import resolve_label_horizon
from utils.paths import display_path, get_case_study_dir

apply_notebook_warning_policy()
logging.disable(logging.INFO)

CASE_DIR = get_case_study_dir("etfs")
FEATURES_DIR = CASE_DIR / "features"

# %% [markdown]
# The start of the sample is a parameter so the matrix can be rebuilt over a shorter window
# without editing the notebook; left unset it reads the whole history.

# %% tags=["parameters"]
START_DATE = None

# %% [markdown]
# ## Configuration
#
# Every window, the ranked feature list, the regime threshold, the decision horizon and the
# holdout boundary are declared in `config/setup.yaml` and bound here. A value the notebook
# invents is a second source of truth for a decision the rest of the pipeline reads from one
# place. The horizon fixes how far the persistence figure has to look, because a feature has to
# hold its ordering for at least one decision cycle to be usable at that cadence.

# %%
setup = yaml.safe_load((CASE_DIR / "config" / "setup.yaml").read_text())
FAMILIES = families_from_config(setup)
WINDOWS = setup["features"]["windows"]
OSCILLATORS = setup["features"]["oscillators"]
STATE = setup["features"]["state"]
RANKED = setup["features"]["ranked"]
REGIME_THRESHOLD = setup["features"]["regime_threshold"]
MACRO_LAG_DAYS = setup["modeling"]["latent_factors"]["macro_context"]["availability_lag_days"]
DECISION_CYCLE = int(resolve_label_horizon("etfs", setup["labels"]["primary"], setup).rstrip("Dd"))
HOLDOUT_START = date.fromisoformat(setup["evaluation"]["holdout_start"])

print(f"{len(FAMILIES)} feature families are declared, each with its own lookback and lag")
print(
    f"Positions are re-decided every {DECISION_CYCLE} sessions, so a feature has to hold its "
    f"ordering that long to be usable at this cadence"
)
print(
    f"Dates from {HOLDOUT_START} onward are the holdout; D.3 rebuilds the matrix without them "
    f"and compares the two builds value by value"
)

# %% [markdown]
# ## A. What the thesis says should carry information
#
# The hypothesis is cross-sectional and it is about persistence: among liquid ETFs spanning
# equities, bonds, commodities and currencies, the ones that have gained relative to the rest
# continue to over the following month. Three things follow.
#
# What the ranking is formed on is trailing relative performance, so the matrix holds returns
# and risk-adjusted returns at eight horizons rather than one - which horizon the effect lives
# at is an empirical question, not a modelling assumption. The skip-recent construction drops the
# most recent month from the six- and twelve-month windows, because that month reverses where
# the ones before it continue.
#
# The **conditioning** is regime: a cross-asset rotation only works while the assets disagree,
# so the matrix also carries volatility, drawdown, trend strength, the equity-bond correlation
# and the shape of the yield curve. These rank nothing against each other; they say which
# environment a ranking is being formed in, which is what the register's `role` column records
# and what no assertion can recover from the values.
#
# The **representation** matters as much as the quantity. A raw return has a distribution that
# drifts with the volatility of the period, so the three features `config/setup.yaml` lists as
# ranked - the six-month return, its risk-adjusted version and the three-month volatility - are
# carried as cross-sectional percentiles as well as levels, comparable across dates by
# construction.
#
# The register is declared in `config/setup.yaml`, one row per family: what it reads, how far
# back, and with what delay. A `lag` of zero means the input is on the tape at the decision
# itself, which every price-derived family here is: the decision is taken at the close and
# executes at the next open. The yield curve is the one exception, at one session: a Treasury
# series dated t is treated as available from the close of t+1, which is what
# `config/setup.yaml` declares as the case study's macro policy. That lag is why it is a family
# of its own rather than sharing one with the cross-asset correlation, which reads prices.

# %%
register_frame(FAMILIES).select(
    ["family", "role", "inputs", "lookback (bars)", "lag (bars)", "frame"]
)

# %% [markdown]
# ## B. Inputs and their observability
#
# Three inputs. Daily bars are split- and dividend-adjusted, so a trailing return spans a
# corporate action without a jump. The tradability gate is a table of fund-years written by
# [`01_feasibility_analysis`](01_feasibility_analysis.ipynb), which admits a fund to a year on
# the turnover it averaged in the year before, so membership is decided by what was already
# known and a fund listed in 2019 cannot appear in a 2011 ranking. The 10-year minus 2-year
# Treasury spread is the third, and the only input not on the tape at the decision; C.4 says
# what is done about that.

# %%
prices = (
    load_etfs()
    .select(["symbol", "timestamp", "open", "high", "low", "close", "volume"])
    .sort(["symbol", "timestamp"])
)
if START_DATE is not None:
    prices = prices.filter(pl.col("timestamp") >= pl.lit(START_DATE).str.to_date())

eligibility = pl.read_csv(CASE_DIR / "eligibility.csv")
yield_curve = (
    load_macro()
    .select("timestamp", ((pl.col("dgs10") - pl.col("dgs2")) / 100).alias("slope"))
    .drop_nulls()
    .sort("timestamp")
)
print(
    f"{len(prices):,} daily bars covering {prices['symbol'].n_unique()} of the "
    f"{len(setup['universe']['assets'])} declared funds, {prices['timestamp'].min()} to "
    f"{prices['timestamp'].max()}"
)
print(f"The tradability gate admits {len(eligibility):,} fund-years")
print(
    f"The Treasury spread carries {len(yield_curve):,} rows, one per calendar day from "
    f"{yield_curve['timestamp'].min()} to {yield_curve['timestamp'].max()}, repeating the last "
    f"published value on weekends and holidays"
)

# %% [markdown]
# The longest window below runs over 252 sessions, roughly one trading year, so how much history
# a fund brings decides when it can carry a feature at all. Grouping the funds by the year of
# their first bar says where that constraint bites. What to read off the table is the split
# between the block present from the beginning, which fills every window inside the first year
# and never falls out again, and the funds that arrive later, a handful at a time, each of which
# spends its own first year producing nulls in the long-window families. That thin tail is what
# F1's coverage dips are, what Section E's null policy removes, and why the matrix starts a year
# after the price history does.

# %%
prices.group_by("symbol").agg(
    pl.col("timestamp").min().alias("first"), pl.len().alias("sessions")
).with_columns(pl.col("first").dt.year().alias("first traded")).group_by("first traded").agg(
    pl.len().alias("funds"),
    pl.col("sessions").min().alias("fewest sessions"),
    pl.col("sessions").median().cast(pl.Int32).alias("median sessions"),
).sort("first traded")

# %% [markdown]
# ## C. Feature construction, one subsection per family
#
# ### C.1 Momentum, volatility and their differences
#
# The trailing return, volatility and risk-adjusted return block comes from
# `case_studies/utils`, shared with the other panel case studies, so `ret_126d` means one
# construction and one denominator guard wherever it appears. What stays here is what is
# specific to this universe: the skip-recent windows, the differences between horizons, and the
# ratio of a short volatility window to a long one. The skip-recent pair counts both its start and
# the stretch it drops in the month of sessions `config/setup.yaml` declares, so the month dropped
# and the window it is dropped from cannot drift apart.


# %%
def momentum_features(df: pl.DataFrame) -> pl.DataFrame:
    """The shared trailing block, plus the differences this case study builds on it."""
    df = momentum_volatility_block(
        df,
        entity="symbol",
        return_windows=WINDOWS["momentum"],
        volatility_windows=WINDOWS["volatility"],
    )
    month = WINDOWS["skip_recent"]
    held = pl.col("close").shift(month).over("symbol")
    return df.with_columns(
        (held / pl.col("close").shift(12 * month).over("symbol").clip(lower_bound=EPS) - 1).alias(
            "skip_recent_12_1"
        ),
        (held / pl.col("close").shift(6 * month).over("symbol").clip(lower_bound=EPS) - 1).alias(
            "skip_recent_6_1"
        ),
        (pl.col("ret_21d") - pl.col("ret_63d")).alias("mom_accel_short"),
        (pl.col("ret_63d") - pl.col("ret_126d")).alias("mom_accel_medium"),
        (pl.col("ret_126d") - pl.col("ret_252d")).alias("mom_accel_long"),
        (pl.col("vol_21d") / pl.col("vol_63d").clip(lower_bound=EPS)).alias("vol_ratio_short"),
        (pl.col("vol_63d") / pl.col("vol_126d").clip(lower_bound=EPS)).alias("vol_ratio_medium"),
    )


# %% [markdown]
# ### C.2 Oscillators, trend ratios and range
#
# Everything in this block reads one fund's price against its own recent path. The
# constructions fall into four groups, and a reader who has not met them needs to know what
# each measures before the matrix can be read at all.
#
# **Where the price sits inside its recent range.** The relative strength index weighs the
# sessions that closed up against those that closed down over its window and reports the result
# between 0 and 100, so 50 is a window with as much up movement as down. The stochastic reports
# on the same scale where the close sits between the window's lowest low and its highest high.
# The commodity channel index measures the same idea without bounds: how far the session's
# average of high, low and close sits from that average over the window, in units of the
# window's own mean deviation. Bollinger %B is the fourth, and the notebook assembles it from
# the bands rather than taking it ready-made: 0 is two standard deviations below the window's
# mean close, 1 is two above, and the value is where the close falls between them.
#
# **How strongly the price is trending.** The average directional index summarizes how much of
# the session-to-session movement went one way rather than back and forth, without saying which
# way, so it separates a trend from a range without taking a side. Aroon counts how recently
# the window's high and its low occurred; subtracting the two counts gives one number that is
# positive when the high is the more recent. The moving-average convergence-divergence line is
# the gap between a fast and a slow exponential average of the close, which widens as a move
# accelerates and crosses zero when it turns.
#
# **How far the price moved and how much ground it covered doing so.** The normalized average
# true range is the typical session's high-to-low span, counting any overnight gap, as a
# percentage of the price - normalizing by price is what makes it comparable across funds
# quoted at different levels. The choppiness index divides those spans summed session by session
# by the span of the whole window: a market that ends near where it started after covering a lot
# of ground reads high, one that travelled in a straight line reads low. The Hurst exponent asks
# whether the size of a move grows with the length of the interval faster or slower than
# independent increments would give. Above one half, a move tends to be followed by another in
# the same direction; below one half, by a reversal; one half is what a random walk gives. It is
# estimated by comparing the range of the cumulative move against its dispersion at many
# interval lengths at once, which is why it needs a window several times longer than the others
# here.
#
# **Where the price sits against its own averages.** The close divided by its 50- and
# 200-session simple averages and by its 26-session exponential average. Above one, the fund is
# trading above that average. Dividing rather than subtracting is what makes the three
# comparable across funds, for the same reason the range is normalized by price.
#
# All of them come from `ml4t.engineer.features` rather than being written here. The smoothing
# convention inside an oscillator is where two implementations of one name diverge - Wilder's
# recursive average and a simple moving average of the same gains give different numbers - and
# a shared implementation is what keeps `rsi_14` meaning one thing across the nine case studies.


# %%
def oscillator_features(df: pl.DataFrame) -> pl.DataFrame:
    """Bounded oscillators, moving-average ratios, normalized range and regime exponents."""
    adx_p, stoch_p, aroon_p = OSCILLATORS["adx"], OSCILLATORS["stochastic"], OSCILLATORS["aroon"]
    natr_p, chop_p, hurst_p = OSCILLATORS["natr"], OSCILLATORS["choppiness"], OSCILLATORS["hurst"]
    ema_p, bb_p = OSCILLATORS["ema"], OSCILLATORS["bollinger"]
    df = df.with_columns(
        *[rsi("close", period=p).over("symbol").alias(f"rsi_{p}") for p in OSCILLATORS["rsi"]],
        macd("close", fast_period=OSCILLATORS["macd_fast"], slow_period=OSCILLATORS["macd_slow"])
        .over("symbol")
        .alias("macd_line"),
        adx("high", "low", "close", period=adx_p).over("symbol").alias(f"adx_{adx_p}"),
        *[
            cci("high", "low", "close", period=p).over("symbol").alias(f"cci_{p}")
            for p in OSCILLATORS["cci"]
        ],
        stochastic("high", "low", "close", fastk_period=stoch_p).over("symbol").alias("stoch_k"),
        aroon("high", "low", timeperiod=aroon_p).over("symbol").alias("_aroon"),
        natr("high", "low", "close", period=natr_p).over("symbol").alias(f"natr_{natr_p}"),
        choppiness_index("high", "low", "close", period=chop_p)
        .over("symbol")
        .alias(f"chop_{chop_p}"),
        hurst_exponent("close", period=hurst_p).over("symbol").alias(f"hurst_{hurst_p}"),
        *[
            (pl.col("close") / sma("close", period=p).over("symbol")).alias(f"sma_ratio_{p}")
            for p in OSCILLATORS["sma"]
        ],
        (pl.col("close") / ema("close", period=ema_p).over("symbol")).alias(f"ema_ratio_{ema_p}"),
        bollinger_bands("close", period=bb_p).over("symbol").alias("_bb"),
    )
    band = pl.col("_bb").struct.field("upper") - pl.col("_bb").struct.field("lower")
    return df.with_columns(
        (pl.col("_aroon").struct.field("up") - pl.col("_aroon").struct.field("down")).alias(
            "aroon_diff"
        ),
        pl.when(band > 0)
        .then((pl.col("close") - pl.col("_bb").struct.field("lower")) / band)
        .alias(f"bb_pctb_{bb_p}"),
    ).drop(["_aroon", "_bb"])


# %% [markdown]
# ### C.3 Drawdown, volume and distance from extremes
#
# `max_dd_63d` is the share by which the price currently sits below its highest close of the
# trailing quarter, so it is zero at a new high and negative otherwise - the *current*
# drawdown, not the worst decline inside the window, which is a different statistic.
#
# On-balance volume is a running total that adds the session's share volume when the fund
# closed up and subtracts it when it closed down, so it rises while buying pressure leads and
# falls while selling pressure does. The level of that total says nothing on its own, because it
# depends on how long the fund has traded and how much it trades; what is carried is its
# distance from its own trailing mean in trailing standard deviations, which is comparable
# across funds and across time.
#
# The last two read the 52-week extremes directly. The close over its highest close of the past
# year is one at a new high and falls away below it; the close over its lowest is one at a new
# low and rises above it. The share of the past quarter's sessions that closed up sits beside
# them as the same question asked without reference to any extreme.
#
# Relative volume - the session's volume over its own trailing mean - is built with the same
# per-fund block, and C.7 says why the extreme values it produces are cut back within the date
# rather than here.


# %%
def drawdown_and_extremes(df: pl.DataFrame) -> pl.DataFrame:
    """Drawdown, on-balance volume and position in the 52-week range."""
    obv_w, share_w, range_w = STATE["obv_zscore"], STATE["positive_share"], STATE["extremes"]
    df = drawdown_block(df, entity="symbol", windows=WINDOWS["drawdown"])
    df = df.with_columns(obv("close", "volume").over("symbol").alias("_obv"))
    return df.with_columns(
        rolling_zscore("_obv", obv_w, "symbol").alias(f"obv_zscore_{obv_w}d"),
        (pl.col("log_return") > 0)
        .cast(pl.Float64)
        .rolling_mean(share_w)
        .over("symbol")
        .alias(f"pct_positive_{share_w}d"),
        (
            pl.col("close")
            / pl.col("close").rolling_max(range_w).over("symbol").clip(lower_bound=EPS)
        ).alias("dist_52w_high"),
        (
            pl.col("close")
            / pl.col("close").rolling_min(range_w).over("symbol").clip(lower_bound=EPS)
        ).alias("dist_52w_low"),
    ).drop("_obv")


# %% [markdown]
# ### C.4 Cross-asset state and macro state
#
# The SPY-TLT correlation and the curve slope are one number per date, shared by every ETF. A
# rolling window reads row order rather than the timestamp column, so the pair is re-sorted after
# the join that builds it, and the curve is joined backward in time so a market holiday carries
# the previous session's spread forward and never a later one's.


# %%
def equity_bond_correlation(df: pl.DataFrame) -> pl.DataFrame:
    """The trailing SPY-TLT return correlation, one row per date."""
    corr_w = STATE["correlation"]
    return (
        df.filter(pl.col("symbol") == "SPY")
        .select("timestamp", pl.col("log_return").alias("_spy"))
        .join(
            df.filter(pl.col("symbol") == "TLT").select(
                "timestamp", pl.col("log_return").alias("_tlt")
            ),
            on="timestamp",
            how="inner",
        )
        .sort("timestamp")
        .select(
            "timestamp",
            pl.rolling_corr(pl.col("_spy"), pl.col("_tlt"), window_size=corr_w).alias(
                f"corr_spy_tlt_{corr_w}d"
            ),
        )
    )


# %% [markdown]
# The Treasury spread is the one input that is not on the tape at the decision: the value
# describing a day is published after that day has ended, so a decision taken at its close could
# not have read it. `config/setup.yaml` declares the macro policy - `alfred_initial_release_close_lagged`
# - and with it how long a published value waits before a decision may use it, and each value is
# re-stamped with the date it becomes readable rather than the date it describes. F4 draws the
# resulting gap; it is the only family in the register that has one.
#
# The stamped series is then reduced to the trading calendar before the z-score's window runs.
# The register counts every family's lookback in trading sessions, and the Treasury frame carries
# a row for every calendar day, so a window run over it before that reduction would be counting
# weekends.


# %%
def yield_curve_state(df: pl.DataFrame) -> pl.DataFrame:
    """Curve level, regime flag and trailing z-score, on the session calendar."""
    zscore_w = STATE["curve_zscore"]
    available = yield_curve.select(
        pl.col("timestamp").dt.offset_by(f"{MACRO_LAG_DAYS}d"), "slope"
    ).sort("timestamp")
    return (
        df.select("timestamp")
        .unique()
        .sort("timestamp")
        .join_asof(available, on="timestamp", strategy="backward")
        .select(
            "timestamp",
            pl.when(pl.col("slope") > REGIME_THRESHOLD).then(1).otherwise(0).alias("regime"),
            pl.col("slope").alias("yield_curve_slope"),
            (
                (pl.col("slope") - pl.col("slope").rolling_mean(zscore_w))
                / pl.col("slope").rolling_std(zscore_w).clip(lower_bound=EPS)
            ).alias("yield_curve_zscore"),
        )
    )


# %% [markdown]
# Both state frames carry one row per date, so a left join broadcasts each to every ETF trading
# that day without changing the panel's row count.


# %%
def regime_and_state(df: pl.DataFrame) -> pl.DataFrame:
    """Equity-bond correlation and yield-curve state, broadcast to every row."""
    return (
        df.join(equity_bond_correlation(df), on="timestamp", how="left")
        .join(yield_curve_state(df), on="timestamp", how="left")
        .sort(["symbol", "timestamp"])
    )


# %% [markdown]
# ### C.5 The tradability gate
#
# `01_feasibility_analysis` decided, one fund-year at a time, which ETFs traded at enough volume
# to be worth ranking. Applying that here is a semi-join on the row's own calendar year, which
# filters and cannot duplicate a row.


# %%
def gate_to_eligible(df: pl.DataFrame) -> pl.DataFrame:
    """Drop the rows the annual tradability gate excludes, before anything ranks them."""
    return (
        df.with_columns(pl.col("timestamp").dt.year().alias("_year"))
        .join(
            eligibility.select("symbol", pl.col("eligible_year").alias("_year")),
            on=["symbol", "_year"],
            how="semi",
        )
        .drop("_year")
    )


# %% [markdown]
# ### C.6 What is computable before the gate
#
# None of the four blocks above needs the eligible cross-section: three read one fund's own
# history, and the fourth reads two named funds and the Treasury series and broadcasts one value
# per date. So they all run on every bar the ETF traded, before the gate removes any. The
# relative-volume ratio runs in this group for the same reason:
# its trailing mean has to see every bar, or a fund admitted to the universe this year divides by
# the mean of its first few eligible days, and one that re-enters after a gap averages across the
# gap.


# %%
def per_entity_features(df: pl.DataFrame) -> pl.DataFrame:
    """Everything computable before the gate: per-fund history plus the broadcast state."""
    return (
        df.pipe(momentum_features)
        .pipe(oscillator_features)
        .pipe(drawdown_and_extremes)
        .pipe(regime_and_state)
        .pipe(trailing_volume_ratio, entity="symbol", windows=WINDOWS["volume"])
    )


# %% [markdown]
# ### C.7 The two steps taken within a date
#
# A percentile and a clip are properties of the cross-section they are taken over, so both run
# after the gate: ranking an ETF against one the strategy cannot trade moves the number written
# for every ETF that it can. Which features are carried as their percentile within the date as
# well as their level is `config/setup.yaml`'s `ranked` list, so the same ones are ranked in
# every stage that reads the register.
#
# The clip cuts relative volume back to the 1st and 99th percentile of its own date. An index
# rebalance puts one fund's volume orders of magnitude above its own trailing mean for a single
# session, and one such row otherwise sets the scale every model sees for the whole column.
# Cutting within the date rather than over the column is what keeps the bound from being fitted
# on the sample, which is the property D.3 checks.


# %%
def clip_and_rank(df: pl.DataFrame) -> pl.DataFrame:
    """The two within-date steps, over the eligible cross-section only."""
    clipped = clip_within_date(
        df, columns=[f"vol_ratio_{w}d" for w in WINDOWS["volume"]], time="timestamp"
    )
    return clipped.with_columns(
        cross_sectional_percentile(col, "timestamp").alias(f"{col}_rank") for col in RANKED
    )


# %% [markdown]
# ### C.8 The construction as one function
#
# The three groups run in that order - per entity, then the gate, then within the date - and
# wrapping them in one function is what lets D.3 re-run the identical construction on a shorter
# panel and compare the results value by value.


# %%
def build_features(df: pl.DataFrame) -> pl.DataFrame:
    """The whole construction, as one function Section D can re-run on a shorter panel."""
    return df.pipe(per_entity_features).pipe(gate_to_eligible).pipe(clip_and_rank)


# %% [markdown]
# The panel is built once here in two stages, because D.2 audits the warmup on the ungated frame:
# the gate drops the early rows the warmup stretch is made of.


# %%
EXCLUDED = {"symbol", "timestamp", "open", "high", "low", "close", "volume", "log_return"}
per_entity = per_entity_features(prices)
built = per_entity.pipe(gate_to_eligible).pipe(clip_and_rank)
feature_cols = [c for c in built.columns if c not in EXCLUDED]
print(f"{len(built):,} eligible bars carrying {len(feature_cols)} features")

# %% [markdown]
# ## D. The timing contract
#
# ### D.1 What each construction reads
#
# Three kinds of operation appear above. A **rolling** window ends at its own row and reads a
# fixed number of bars backward. A **cross-sectional** statistic - the three percentiles, and
# the volume clip - is taken with `.over("timestamp")`, so it reads every ETF on that date and
# no other. An **as-of join** carries the most recent macro value at or before the row's
# timestamp. D.2 and D.3 establish that none of the three reaches forward.
#
# ### D.2 Warmup
#
# A trailing window cannot produce a value until it has enough bars to fill, so every family has
# a leading stretch of nulls as long as its lookback. The audit checks that length rather than
# describing it: a column carrying a value before its window could have filled is reading bars
# that do not exist, and that is the failure it raises on. It runs on the panel before the
# eligibility gate, because the gate drops the early rows the warmup stretch is made of. The
# lengths it checks are the declared windows themselves, so each column is audited against the
# number its own construction ran on.

# %%
longest_return = max(WINDOWS["momentum"])
longest_vol = max(WINDOWS["volatility"])
longest_drawdown = max(WINDOWS["drawdown"])
trend_slow, hurst_p, obv_w = max(OSCILLATORS["sma"]), OSCILLATORS["hurst"], STATE["obv_zscore"]
warmup_audit(
    per_entity,
    {
        f"ret_{longest_return}d": longest_return,
        "skip_recent_12_1": 12 * WINDOWS["skip_recent"],
        f"sharpe_{longest_return}d": longest_return,
        f"vol_{longest_vol}d": longest_vol,
        "dist_52w_high": STATE["extremes"],
        f"sma_ratio_{trend_slow}": trend_slow,
        f"max_dd_{longest_drawdown}d": longest_drawdown,
        f"hurst_{hurst_p}": hurst_p,
        f"obv_zscore_{obv_w}d": obv_w,
    },
    entity="symbol",
)

# %% [markdown]
# ### D.3 Withholding the holdout changes nothing
#
# Trailing and within-date statistics share a property worth checking directly: recomputed on a
# panel that stops before the holdout, they reproduce the same values on the rows the two panels
# share. A parameter fitted over a whole column - a winsorization bound, a scaler, an encoder -
# does not, because truncating the column moves the parameter and with it every row it was
# applied to. Building the panel twice and comparing tests the whole construction at once -
# every emitted column, not a sample - and does not depend on anyone having flagged the
# transform that fits. A value on one side against a null on the other counts as a difference,
# because that is the form of the failure a null-skipping comparison hides.

# %%
seal = assert_values_agree(
    built.filter(pl.col("timestamp") < HOLDOUT_START),
    build_features(prices.filter(pl.col("timestamp") < HOLDOUT_START)),
    columns=feature_cols,
    keys=["timestamp", "symbol"],
)
seal.filter(pl.col("column").is_in(["ret_126d", "ret_126d_rank", "yield_curve_zscore"]))

# %% [markdown]
# ## E. Matrix assembly and coverage
#
# The panel key is `symbol` + `timestamp`. Raw OHLC, volume and the intermediate log return are
# excluded: they are the inputs the features are made of, and a model handed the contemporaneous
# log return beside a label derived from the same prices would be reading its own answer. One
# null policy is applied once - a row is kept when the six-month risk-adjusted return has warmed
# up. Most of the warmup stretch is already gone by then, because the eligibility gate admits a
# fund only from its second year, so what stays thin is the 252-session families, and F1 shows
# where.

# %%
features = (
    built.select(["timestamp", "symbol", *feature_cols])
    .drop_nulls(subset=["sharpe_126d"])
    .sort(["timestamp", "symbol"])
)
assert features.select(["timestamp", "symbol"]).is_duplicated().sum() == 0, "duplicate panel key"
assignment = assign_families(feature_cols, FAMILIES)
register_frame(FAMILIES, feature_cols).select(["family", "columns", "role", "representation"])

# %% [markdown]
# ### F1. Coverage through time
#
# The axis is scaled to the data, not pinned to zero: this matrix is dense everywhere, and on a
# zero-based axis every family would draw as one flat line at the top. What is left to see is
# where the residual percent sits, and it sits in the families with the longest windows - an ETF
# admitted by the eligibility gate partway through a year has not yet filled a 252-session
# lookback.

# %%
plot_coverage_through_time(
    family_coverage(features, assignment, every="1mo"),
    warmup_boundary=features["timestamp"].min(),
    title="Only the longest-window families are ever thin, and only early",
    subtitle="Monthly non-null share per feature family, on an axis scaled to the data",
    alt=(
        "Line chart of non-null share by feature family on a y-axis that starts at about 0.985 "
        "and ends just above one. Six of the ten families sit exactly at one throughout. The "
        "four that do not - "
        "extremes and consistency, momentum, volatility, risk-adjusted momentum, all built on "
        "252-session windows - dip in three or four months before 2012 and nowhere else, the "
        "deepest to about 0.987, and no family is ever materially incomplete."
    ),
)

# %% [markdown]
# ### F4. The timing contract

# %%
plot_timing_contract(
    FAMILIES,
    bar_unit="trading sessions",
    title="Only the yield curve waits for its input to publish",
    subtitle="Register lookback per family; a gap at the right edge is an information lag",
    alt=(
        "Horizontal bars, one per feature family, each extending leftward from the decision "
        "line by that family's lookback, from 63 sessions for volume and the cross-asset "
        "correlation to 252 for momentum and the yield curve. Every bar reaches the decision "
        "line except the yield curve, which stops one session short of it."
    ),
)

# %% [markdown]
# ## F. What the features look like
#
# Four properties decide whether this matrix can be used at all: the scale each feature arrives
# on, whether the cross-section disagrees enough to rank on, how much of the set is one ordering
# under several names, and how long a value lasts. `05_evaluation` is where the matrix is tested
# fold by fold for whether any of it predicts.
#
# ### F2. Feature distributions

# %%
plot_feature_distributions(
    features,
    ["ret_21d", "ret_126d", "ret_252d", "sharpe_21d", "sharpe_126d", "sharpe_252d"],
    title="A longer window widens the return and narrows the ratio",
    subtitle="Trailing return and risk-adjusted return, display tails clipped at 0.5%",
    alt=(
        "Six histograms in two rows. Along the top the trailing returns broaden from a narrow "
        "spike spanning about minus 0.22 to 0.21 at 21 sessions to a wide body running from "
        "about minus 0.55 to 0.88 at 252, every one of them sharply peaked with long tails "
        "either side. Below, the annualized risk-adjusted returns move the other way, from "
        "roughly minus eight to eleven at 21 sessions in to about minus two to four at 252, "
        "and all three are broad single-peaked bells rather than spikes."
    ),
)

# %% [markdown]
# ### F3. Cross-sectional dispersion through time
#
# A cross-sectional strategy needs the cross-section to disagree. On a date where the band
# narrows to nothing there is nothing to rank, whatever the average level of the feature.

# %%
plot_cross_sectional_dispersion(
    features,
    "ret_126d",
    every="1mo",
    title="The gap between leading and lagging ETFs widens in stress",
    subtitle="10th-90th percentile of the six-month trailing return across the eligible universe",
    alt=(
        "Shaded band of the 10th to 90th percentile of six-month trailing return by month, with "
        "the median drawn through it. In most months the band is between about 0.15 and 0.30 "
        "wide. It is widest through 2008 and 2009, reaching about 0.62, when the median also "
        "falls to about minus 0.4, and it widens again to about 0.44 in 2020. The 2022 widening "
        "is smaller, about 0.34, and more than a tenth of all months are at least that wide."
    ),
)

# %% [markdown]
# ### F5. Redundancy structure
#
# Clustering on the distance $1 - |\rho|$ groups features that carry the same ordering, whatever
# the sign. Above the cut two features are close enough that a linear model cannot separate
# their contributions. This states the clusters and stops there: `05_evaluation` measures each
# feature's predictive content fold by fold and reports which pairs are one piece of evidence
# counted twice, and the modelling notebooks decide what to keep.

# %%
clusters = plot_redundancy_clusters(
    features,
    feature_cols,
    cut=0.7,
    title="Adjacent horizons pair off; short and long momentum stay apart",
    subtitle=r"Average linkage on rank-correlation distance, cut drawn at $|\rho| = 0.7$",
    alt=(
        "Dendrogram of every feature in the matrix. Neighbouring horizons join at very small "
        "distances - the five- and ten-day returns with their risk-adjusted twins, the six-month "
        "return with its risk-adjusted twin, its skip-recent version and the 200-day trend ratio "
        "- and the bounded oscillators join that shortest-horizon block rather than standing "
        "apart from it. The nine- and twelve-month returns form a block of their own that stays "
        "separate from the six-month one at the cut and joins it only further out; the "
        "short-horizon and long-horizon blocks join later still. Volatility and the macro "
        "features attach as separate branches."
    ),
)

# %% [markdown]
# ### F6. Persistence and rank stability
#
# The right-hand panel compares the ordering across consecutive **rebalances**, which
# `config/setup.yaml` declares as `monthly_month_end` - a varying number of sessions apart, so
# a fixed lag would correlate dates the strategy never puts side by side.
#
# The autocorrelation on the left is of the feature, not of the return, and it runs past one
# full decision cycle. A feature whose value has decayed inside a cycle cannot support that
# rebalance cadence, however well it predicts on the day it is computed. It is estimated per ETF
# on pairs of dates exactly one lag apart and summarized by the median over ETFs, with a
# bootstrap interval over ETFs: a correlation pooled over every ETF-date pair would read high
# whenever ETFs sit at different levels, whether or not any one of them persists.

# %%
DECISION_DATES = (
    features.group_by(pl.col("timestamp").dt.truncate("1mo"))
    .agg(pl.col("timestamp").max().alias("decision"))["decision"]
    .sort()
    .to_list()
)

plot_persistence(
    features,
    ["ret_21d", "ret_126d", "sharpe_126d", "vol_63d", "rsi_14"],
    entity="symbol",
    max_lag=2 * DECISION_CYCLE,
    decision_dates=DECISION_DATES,
    title="Six-month features hold their ordering a month out; one-month ones do not",
    subtitle=(
        f"Median over ETFs to {2 * DECISION_CYCLE} sessions; rank correlation across rebalances"
    ),
    alt=(
        "Two panels. On the left, autocorrelation against lag: the six-month return, the "
        "three-month volatility and the six-month risk-adjusted return decay slowly and are all "
        "still above 0.6 at 42 sessions, while the one-month return reaches zero at about 21 "
        "sessions - the length of its own window - and the 14-day oscillator falls below 0.1. The "
        "bootstrap ribbon around each curve is only a few hundredths wide. On the right, the "
        "cross-sectional rank correlation between consecutive rebalances separates the features "
        "sharply: about 0.98 for the three-month volatility and 0.83 for the two six-month "
        "features, against 0.2 for the oscillator and almost nothing for the one-month return, "
        "whose window is about one rebalance long."
    ),
)

# %% [markdown]
# ## G. Emit
#
# The matrix goes to a parquet file, and beside it a small JSON file records what was written.
# Its purpose is to let a later stage tell whether the matrix it is reading is the one it was
# tested against, without re-reading the whole file: it holds a digest of the values, the row
# count, the key columns, and the digest of each input this notebook read.
#
# A digest is a short string computed from the contents, so that two files holding the same
# values get the same string and any change in a value gets a different one. Computing it over
# the values rather than over the bytes of the file is what makes it usable here: rewriting the
# parquet with the rows in another order, or with different compression, leaves the digest
# alone, while a single corrected feature value moves it. Recording the inputs' digests
# alongside it is what makes the record a chain - a downstream stage can see not only that the
# matrix changed but that it changed because its input did.

# %%
FEATURES_DIR.mkdir(parents=True, exist_ok=True)
record = write_artifact(
    features,
    FEATURES_DIR / "financial.parquet",
    keys=["symbol", "timestamp"],
    written_by="case_studies/etfs/03_financial_features.py",
    inputs={
        "eligibility.csv": value_digest(eligibility),
        "load_etfs": value_digest(prices),
        "load_macro:dgs10-dgs2": value_digest(yield_curve),
    },
)
print(f"Wrote {display_path(FEATURES_DIR / 'financial.parquet')}")

# %% [markdown] tags=["results"]
# The matrix carries **57 features** on **404,500 rows** across **99 ETFs**, from **2007-01-03**
# to **2025-12-31**, under content digest **9c02a41ef4364257**. Cutting the redundancy tree
# leaves **22 clusters**, so well over half the columns repeat an ordering another column
# already carries.

# %%
print(f"{len(feature_cols)} features, {len(features):,} rows, {features['symbol'].n_unique()} ETFs")
print(f"{features['timestamp'].min()} to {features['timestamp'].max()}, digest {record['digest']}")
print(f"{len(set(clusters.values()))} redundancy clusters")

# %% [markdown]
# ## What the matrix holds, and what it owes
#
# Two questions about the file this stage just wrote. The first is what is in each column - nulls,
# how much sits at exactly zero, how far the extreme values are from the body, whether anything is
# constant. A threshold crossed there asks for a sentence of explanation and settles nothing on
# its own: a 45% null share is a defect in a price and expected in a 252-session family on a fund
# with three years of history.
#
# The second is the question a null count cannot reach. **Coverage is measured against the keys
# the labels declare, not against the rows this matrix happens to hold.** A matrix emitting a
# thousand rows where a million were owed carries no nulls at all and is wrong; nothing inside it
# can say so, because the missing rows are not there to be counted. The labels are the right
# reference because a `(symbol, timestamp)` carrying a label and no feature row is one no model can
# be asked to score - it is lost to every family at once, before any of them is fitted.
#
# And a shortfall against that reference is not by itself a defect, so the matrix declares where
# it is entitled to be short before the number is printed. Two mechanisms decide it and only one
# of them is a window. The null policy keeps a row when the six-month risk-adjusted return has
# warmed up, which costs `sharpe_126d`'s window. **The eligibility gate costs far more, and it
# costs a different amount per fund**: it is annual and admits a fund only for the years it
# cleared, so a fund that listed early and grew slowly loses years at the front, one that lost
# liquidity mid-life loses a stretch out of the middle, and one that never cleared loses
# everything. None of that is a session count, so the declaration below names the mechanism
# without pretending to bound it, and the check after it is what makes the declaration worth
# having: every missing key is tested against the fund-years the gate actually admitted.

# %%
LEADING_BUDGET = WINDOWS["volatility"][2]
print(f"warmup budget {LEADING_BUDGET} sessions = the sharpe_126d window the null policy keeps on")

report = quality_report(
    features,
    name="financial features",
    key_columns=["symbol", "timestamp"],
    expected=label_universe(CASE_DIR, keys=["symbol", "timestamp"]),
    keys=["symbol", "timestamp"],
    entity="symbol",
    session="timestamp",
    expected_missing={
        "leading": (
            None,
            "the eligibility gate's first admitted year, then the 126-session warmup",
        ),
        "interior": (None, "a year the eligibility gate did not admit"),
        "trailing": (None, "a year the eligibility gate did not admit"),
        "absent": (None, "a fund the eligibility gate never admitted"),
    },
)
render_quality_report(report)

# %% [markdown]
# Four declarations, one mechanism, and one check for all four. A key is owed a feature row only
# when the gate admitted that fund for that key's year *and* the warmup has passed inside it; every
# other missing key is the gate, wherever in a fund's life it happens to fall. Testing it against
# the gate's own table is the whole point - the shape of the loss looks like four different
# problems and is one.

# %%
admitted = eligibility.select("symbol", pl.col("eligible_year").alias("_year"))
missing = report["missing_classified"].with_columns(pl.col("timestamp").dt.year().alias("_year"))
outside = missing.join(admitted, on=["symbol", "_year"], how="anti")
print(
    f"of {missing.height:,} missing keys, {outside.height:,} "
    f"({outside.height / missing.height:.2%}) fall in a fund-year the gate did not admit"
)
inside = missing.join(admitted, on=["symbol", "_year"], how="semi")
if inside.height:
    per_fund = inside.group_by("symbol").len().sort("len", descending=True)
    print(
        f"  {inside.height:,} key(s) across {per_fund.height} fund(s) sit in an admitted year; "
        f"worst fund short {per_fund['len'].max()} session(s), median {int(per_fund['len'].median())}"
    )

# %% [markdown]
# ### Sign-off
#
# **Coverage is 85.93% - 404,500 of the 470,162 keys the labels declare - and every one of the
# 66,132 missing keys falls in a fund-year the eligibility gate did not admit.** Not most of them,
# and not most of them after a warmup is subtracted: all of them. The gate is annual, so a fund
# enters the matrix in the first year it cleared and leaves in any year it did not, and the shape
# that produces looks like four separate problems until it is tested against the gate's own table -
# 46,795 keys before a fund's first feature row, 10,319 inside eleven funds' spans, 3,992 after
# five funds' last row, and one fund with no row at all. One mechanism, four positions, nothing
# else.
#
# The 126-session warmup the null policy applies is real but almost invisible here, because the
# gate has already removed the stretch it would have taken: a fund is admitted from a year in which
# it was already liquid, by which point its six-month window has long since filled. That is why the
# budget is printed and then not needed, and it is worth seeing rather than assuming - a warmup
# that *was* biting would show as keys inside an admitted year, and there are none.
#
# **470 keys carry a feature row and no label**, the mirror case, at 0.1% of the matrix. A feature
# row with no label is never joined, so it is carried rather than dropped.
#
# %% [markdown]
# ## Key takeaways
#
# - **State the timing contract before writing the feature.** The configuration fixes each family's
#   lookback and lag in the register and every window the construction runs, so the timing figure
#   and the warmup assertion read those declarations rather than numbers retyped in the code.
# - **Check that later dates were not read, by rebuilding rather than by inspecting.**
#   Constructing the panel a second time with the holdout withheld and comparing the two value
#   by value catches any transform that fits across the sample, including the ones nobody
#   thought to flag.
# - **Rank inside the date.** A percentile taken within one timestamp is comparable across dates
#   in a way a raw level, whose distribution drifts with the period's volatility, is not.
# - **Read the matrix before modelling it.** Distribution, dispersion, redundancy and decay each
#   rule out a use: a feature with no cross-sectional spread cannot rank, and one whose ordering
#   decays inside the rebalance cycle cannot be traded at that cadence.
#
# ### Known limitations
#
# - The cross-asset regime feature is one pair, SPY against TLT. It describes the equity-bond
#   relationship and says nothing about the commodity and currency funds the universe also
#   holds.
# - The eligibility gate is annual, so an ETF that lost liquidity in June stays in the
#   cross-section until December.
# - The yield-curve features carry the configured one-session availability lag, but they read
#   the revised Treasury history rather than the initial release. A value revised later is not
#   the value the decision could have seen, whatever its timestamp says.
# - Every feature here is a rule written in advance. `04_model_based_features` adds the features
#   that are themselves model outputs, where the rule is estimated from the data.

```

Полный текст с указанием источника опубликован на условиях его лицензии. Лицензия: MIT

Это краткое изложение подготовлено исследовательским агентом Stratmill по оригиналу и не является его копией.