用于横截面动量的防泄漏 ETF 特征
代码 《交易机器学习》
总结
本笔记为跨资产 ETF 动量假设构建金融特征矩阵:相对表现较强的资产可能在接下来的一个月继续跑赢。矩阵结合多个期限的滚动收益、风险调整收益、波动率、回撤、趋势指标、跨资产相关性和收益率曲线背景。部分特征还表示为逐日百分位数,使其相对排名能在不同市场环境下进行比较。
主要方法是定义每项特征的回看窗口和可用性滞后,再使用决策时点可获得的信息进行计算。价格特征在收盘时可用,可用于下一开盘执行;国债数据滞后一个交易日。笔记通过排除留出日期后重建矩阵并比较所得数值来检查泄漏,同时考察特征覆盖率、离散度、冗余性和持续性。ETF 资格门槛按年设置,修订后的国债历史数据可能不同于最初发布的数据。股债关系仅由 SPY 和 TLT 表示,因此该市场状态指标无法涵盖投资范围内的所有资产类别。
核心观点
- 计算特征前,先定义其回看窗口和信息滞后。
- 仅使用决策时点前已可用的观测,并通过排除后续日期重建特征来检验泄漏。
- 逐日百分位数可使选定信号在波动率状态变化时更具可比性。
- 在模型使用特征前,检查其离散度、冗余性、覆盖率和持续性。
- 年度流动性筛选和修订后的宏观数据会限制输入与实时可用情况的吻合程度。
标签
全文
# 08_tabular_dl.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]
# # Tabular Deep Learning - FX Pairs
#
# TabM applies a small neural network to each decision row without turning the history into a
# sequence. This notebook submits the published capacity choices to the shared TabM runner. The
# runner fits preprocessing inside each training fold, saves every declared weight checkpoint, and
# publishes a separate complete validation prediction set for every checkpoint.
#
# **Learning objectives**
#
# - Express neural-network capacity and checkpoint schedules as visible requests.
# - Verify that every fold and epoch checkpoint has reloadable fitted state.
# - Continue from complete prediction rows without selecting a checkpoint by rank correlation.
#
# **Book reference**: Chapter 12, Section 12.3
#
# **Prerequisites**: `02_labels`, `03_financial_features`, and `04_model_based_features`.
# %%
"""Fit and catalog the published TabM FX configurations."""
import polars as pl
import torch
from case_studies.research import (
ExecutionTier,
declared_labels,
narrows_declared_catalog,
open_study,
plan_models,
population_supersedes,
sweep_labels,
)
from utils.modeling import load_configs
from utils.reproducibility import set_global_seeds
# %% tags=["parameters"]
CASE_STUDY_ID = "fx_pairs"
PRIMARY_LABEL = ""
MAX_SYMBOLS = 0
MAX_FOLDS = 0
FORCE_RETRAIN = False
PREDICTION_SPLIT = "validation"
N_EPOCHS = 0
BATCH_SIZE = 0
DEVICE = ""
SEED = 42
POPULATION_NAME = ""
SUPERSEDES_POPULATION: str = "7896f6bcaf7e"
# The tier is a parameter, not something inferred from whether a reduction happens to be set.
# Inferring it meant a run could be reduced and still open the case study's own artifacts in
# place, which is the production path; a reader under test then wrote where the published run
# writes. WORKSPACE is the other half: a preview has nowhere else to put its results.
EXECUTION_TIER = "canonical"
WORKSPACE: str | None = None
# %% [markdown]
# ## Select the task and execution tier
#
# Canonical execution uses every configured fold, symbol, epoch, and batch setting. A preview
# declares its reductions, takes an isolated workspace, and creates a preview identity there. A
# preview proves the path but cannot join the official model population.
#
# The reductions are read before the study is opened, because which study to open is decided by
# the tier and the two have to agree: a preview that reduces nothing is a canonical run wearing
# the wrong tier, and a canonical run carrying reductions would publish a narrowed population
# under the canonical name.
# %%
set_global_seeds(SEED)
REDUCTION_PARAMETERS = {
"folds": list(range(MAX_FOLDS)) if MAX_FOLDS else None,
"max_symbols": MAX_SYMBOLS or None,
"n_epochs": N_EPOCHS or None,
}
reductions = {key: value for key, value in REDUCTION_PARAMETERS.items() if value is not None}
tier = ExecutionTier(EXECUTION_TIER)
if tier is ExecutionTier.PREVIEW and not reductions:
raise ValueError("preview execution must declare at least one reduction")
if tier is ExecutionTier.CANONICAL and reductions:
raise ValueError(f"canonical execution cannot carry reductions: {sorted(reductions)}")
study = open_study(CASE_STUDY_ID, execution_tier=tier, workspace=WORKSPACE or None)
# Which labels this notebook fits is a question for the training menus, not for the sweep list:
# `setup.yaml` says which labels the case study carries, a menu says what to fit for one of them,
# and a label in the sweep whose menu declares no `tabular_dl:` section owes nothing here. The two
# agree in this case study today, so restating the sweep list produced the right answer by
# coincidence and would have kept producing it silently after a menu changed. The order stays
# `setup.yaml`'s rather than `declared_labels`' menu-file order because the population is named
# after its labels and hashed over its members as an ordered list, so re-ordering would give the
# published population a new identity and demand a supersedes for a run that fits the same models.
fits_tabm = set(declared_labels(study, "tabular_dl"))
labels = (
[PRIMARY_LABEL]
if PRIMARY_LABEL
else [label for label in sweep_labels(study) if label in fits_tabm]
)
if PREDICTION_SPLIT != "validation":
raise ValueError("model selection uses validation predictions; holdout runs start from a lock")
if FORCE_RETRAIN:
raise ValueError("valid checkpoints are reloaded by identity; change the request to refit")
print(f"Labels: {', '.join(labels)}")
print(f"Execution tier: {tier.value}")
# An empty DEVICE resolves to what the machine has. The runners refuse "cuda" on a host without
# it rather than falling back silently - which is the right contract for a run whose results get
# registered - so a hardcoded "cuda" default made the notebook unrunnable for any reader without
# an NVIDIA card, and unrunnable on a CPU CI runner. Resolving here keeps the refusal for anyone
# who asks for "cuda" explicitly, and prints what was chosen so a run never leaves it implicit.
device = DEVICE or ("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {device}")
# %% [markdown]
# ## Build the published requests
#
# The YAML menu supplies the architecture settings and production checkpoint schedules. The
# parameter cell can reduce epochs or change batch size for a preview without changing the menu.
# %%
overrides = {
"device": device,
**({"batch_size": BATCH_SIZE} if BATCH_SIZE else {}),
}
menu = [
(label, config)
for label in labels
for config in load_configs(CASE_STUDY_ID, label, family="tabular_dl")
]
# `PRIMARY_LABEL` narrows what is fitted, and a narrowed run declares a different set of members
# than the canonical population does. A population is immutable once written, so such a run must
# publish under its own name. The comparison is over `(label, config_name)` pairs rather than a
# row count, and it says so here rather than several cells later in a message about hashes.
if (
narrows_declared_catalog(
study,
"tabular_dl",
pl.DataFrame(
{
"label": [label for label, _ in menu],
"config_name": [config["config_name"] for _, config in menu],
}
),
)
and not POPULATION_NAME
):
raise ValueError(
f"this run declares {len(menu)} label-configuration pairs, which is not the complete "
"declared catalog, so it cannot publish the canonical population; pass POPULATION_NAME "
"to give it its own"
)
requests = [
study.model(
family="tabular_dl",
label=label,
config_name=config["config_name"],
execution_tier=tier,
preview_reductions=reductions,
overrides=overrides,
)
for label, config in menu
]
pl.DataFrame(
{
"config_name": [request.config_name for request in requests],
"label": [request.label for request in requests],
"device": [device] * len(requests),
"execution_tier": [request.execution_tier.value for request in requests],
}
)
# %% [markdown]
# ## Declare every epoch checkpoint before training
#
# The declared epoch schedule, not the run that follows, decides how many downstream configurations
# this notebook owes. Planning resolves each one without training, so a failed member is visible as
# a gap in the population rather than a shorter catalog.
# %% tags=["results"]
plan = plan_models(study, requests=requests)
if len(plan.expected_training_hashes) != len(requests):
raise RuntimeError("each TabM configuration must plan exactly one training identity")
configured = {(label, config["config_name"]) for label, config in menu}
planned = {(member.label, member.config_name) for member in plan.members}
if planned != configured:
raise RuntimeError(
"the plan does not match the configured TabM menu; "
f"missing {sorted(configured - planned)}, unexpected {sorted(planned - configured)}"
)
pl.DataFrame(
{
"label": [member.label for member in plan.members],
"config_name": [member.config_name for member in plan.members],
"checkpoint_kind": [member.checkpoint_kind for member in plan.members],
"checkpoint_value": [member.checkpoint_value for member in plan.members],
"prediction_hash": [member.prediction_hash for member in plan.members],
}
)
# %% [markdown]
# ## Record the official population, then fit or reload every capacity choice
#
# Compatible TabM requests share base-fold materialization. Candidate-specific scaling, random
# state, weights, and prediction identities remain separate. Any failed member stops the cell.
#
# `SUPERSEDES_POPULATION` names the population hash this run replaces. A population is the set of
# prediction identities it publishes, so anything that moves a training identity produces a
# different population under the same name, and the registry refuses to write it without being
# told which snapshot it supersedes. That lineage is the only record of which generation is which,
# and what moved the identities here was a change to the family's own source file rather than to
# anything the notebook declares.
#
# `population_supersedes` decides whether the declared hash may be offered. It is offered when the
# name already carries the generation this declaration produced, so a re-run resolves to the
# population it published, and when the declaration names the generation in force, so a refit
# publishes the next one. It is withheld everywhere else - on a reader's clean clone, where
# `run_log/` is gitignored and the registry has no generation at all; under a caller's own
# `POPULATION_NAME`; and in a preview, whose isolated registry holds nothing under this name.
# %% tags=["results"]
population_name = POPULATION_NAME or f"{CASE_STUDY_ID}:{'+'.join(labels)}:tabular_dl"
population = (
plan.create_population(
name=population_name,
supersedes=population_supersedes(
study, name=population_name, declared=SUPERSEDES_POPULATION
),
)
if tier is ExecutionTier.CANONICAL
else None
)
execution = plan.run()
if len(execution.runs) != len(requests):
raise RuntimeError("the TabM runner did not return every requested configuration")
catalog = execution.catalog_rows.sort("label", "config_name", "checkpoint_value")
if set(catalog.get_column("prediction_hash")) != set(plan.expected_prediction_hashes):
raise RuntimeError("the published catalog differs from the population planned before fitting")
if catalog.filter(~pl.col("complete")).height:
raise RuntimeError("partial TabM checkpoints cannot pass to backtesting")
if catalog.select("label", "config_name", "checkpoint_value").n_unique() != catalog.height:
raise RuntimeError("each configuration and epoch checkpoint must identify one prediction set")
if catalog.get_column("checkpoint_value").null_count():
raise RuntimeError("every TabM prediction must name its epoch checkpoint")
catalog.select(
"label",
"config_name",
"checkpoint_kind",
"checkpoint_value",
"complete",
"ic_mean",
"ic_t",
"training_hash",
"prediction_hash",
)
# %% [markdown]
# ## Reload the checkpoint population
#
# Repeating the same request validates the saved checkpoint manifests and returns the same catalog
# identities. No empty cached summary or single IC-chosen checkpoint is substituted.
# %% tags=["results"]
replayed = plan.run()
if set(replayed.catalog_rows.get_column("prediction_hash")) != set(
catalog.get_column("prediction_hash")
):
raise RuntimeError("TabM checkpoint reload changed the prediction population")
if population is not None:
population.require_complete()
print(f"Official prediction population: {population.hash}")
else:
print("Preview checkpoints remain outside official comparison and holdout selection.")
# %% [markdown]
# ## Key takeaways
#
# - Train-only preprocessing and checkpoint persistence belong to the shared TabM computation.
# - Every declared epoch remains available to the backtest stage.
# - Rank correlation is a diagnostic field in the catalog, not a checkpoint-selection rule.
```在遵守原作品许可的前提下,附作者信息全文展示。 许可协议: MIT
此摘要由 Stratmill 研究智能体根据原文撰写,并非原文副本。