Pular para o conteúdo
Todos os documentos da biblioteca

Avaliação de uma estratégia selecionada de ações e opções com dados de holdout

Código Machine Learning for Trading

Resumo

Este notebook aplica uma estratégia de ações e opções do S&P 500, selecionada anteriormente, a previsões para um período de holdout separado de 2021. Ele mantém inalterada a configuração selecionada, incluindo o alocador, as regras de concentração, a sobreposição de risco e os custos de trading, e exige que as previsões de holdout correspondam à especificação de treinamento pretendida. Assim, a avaliação se concentra no desempenho da mesma estratégia com novas previsões, em vez de uma estratégia ajustada ao holdout.

O notebook compara o resultado do holdout com o desempenho de validação e explica por que qualquer queda pode refletir tanto viés de seleção quanto um período de mercado diferente. Um único ano de holdout é ruidoso, e o intervalo de incerteza relatado descreve esse resultado, em vez de testar a diferença em relação à validação. A avaliação também usa os constituintes atuais do S&P 500, criando viés de sobrevivência porque as empresas que saíram do índice estão ausentes do universo histórico; o notebook observa que isso torna o resultado otimista, embora não quantifique o efeito.

Ideias principais

  • Mantenha fixa a especificação da estratégia selecionada ao avaliar novas previsões.
  • Associe as previsões de holdout à configuração selecionada usando a especificação completa de treinamento.
  • Uma única janela não permite atribuir a diferença entre resultados de validação e holdout à seleção ou às condições de mercado.
  • Um único ano de holdout é ruidoso, e seu intervalo de incerteza não testa a diferença em relação à validação.
  • Usar os constituintes atuais do índice para avaliar um período histórico introduz viés de sobrevivência.

Tags

Texto completo
# 19_holdout_backtest.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]
# # S&P 500 Equity+Options: Holdout Backtest
#
# [`18_holdout_predictions`](18_holdout_predictions.ipynb) refit the selected
# configuration and predicted 2021. This notebook runs the selected *strategy*
# on those predictions - the same allocator, the same concentration, the same
# risk overlay, the same costs - and reports what the portfolio would have
# earned. It is the first number in this case study that was not produced on
# data the selection could see.
#
# **Learning objectives**
#
# 1. Hold a strategy specification fixed across two windows and change only the
#    predictions it consumes.
# 2. Read a holdout result against the validation figure that selected it, and
#    know what the gap between them does and does not establish.
# 3. Recognise which parts of a degradation are attributable to selection and
#    which to the period.
#
# **Book reference:** Chapter 20, Section 20.3.
#
# **Prerequisites:** [`18_holdout_predictions`](18_holdout_predictions.ipynb).
# Signals form after Friday's close and execute at the next available open. The
# current-constituent universe retains survivorship bias, and it does so more
# consequentially here than anywhere else in the case study: a 2021 window
# evaluated on today's membership excludes every company that left the index,
# so this number is optimistic by an amount this case study does not measure.

# %%
"""S&P 500 Equity+Options: run the selected strategy on the holdout predictions."""

import json
import sqlite3

import matplotlib.pyplot as plt
import polars as pl

from case_studies.research import Study, open_selection_field
from case_studies.research.holdout import build_holdout_training_spec
from case_studies.utils.backtest_loaders import (
    get_backtest_config,
    load_backtest_prices_for,
    warmup_periods_for,
)
from case_studies.utils.backtest_presets import (
    clone_backtest_spec,
    ensure_backtest_spec,
    strategy_view,
)
from case_studies.utils.backtest_runner import run_backtest
from case_studies.utils.notebook_contracts import prediction_members_in_force
from case_studies.utils.registry import (
    backtest_hash_from_parts,
    model_source,
    read_predictions,
    resolve_best_backtest_runs,
)
from case_studies.utils.registry.specs import training_hash_from_spec
from case_studies.utils.strategy_analysis import resolve_solvent_carrier
from case_studies.utils.uncertainty import load_daily_returns_with_timestamp
from utils.paths import get_case_study_dir
from utils.style import COLORS, FIGSIZE, add_message_title, show_with_alt

# %% tags=["parameters"]
CASE_STUDY_ID = "sp500_equity_option_analytics"
LABEL = ""
MAX_SYMBOLS = 0

# %% [markdown]
# ### What is asked for, and what it resolves to
#
# The parameters above are the request; the values this notebook runs on are resolved here under
# different names, so a resolved value cannot overwrite the request that produced it. An injected
# parameter wins; otherwise the case study's own declaration does.

# %%
CASE_DIR = get_case_study_dir(CASE_STUDY_ID)
REGISTRY_DB = CASE_DIR / "run_log" / "registry.db"
bt_config = get_backtest_config(CASE_STUDY_ID)
# The label this stage runs under is a property of what the selection chose, so it is resolved
# below rather than here. `labels.primary` was the winner's label only by coincidence: the field
# spans every declared label, and the stages after the selection - price windows, schedule
# thinning, the return contract - have to be keyed to the label that won.
REQUESTED_LABEL = LABEL
print(f"Case study: {CASE_STUDY_ID}")

# %%
_study = Study.at(CASE_DIR, case_study=CASE_STUDY_ID, entry_point="19_holdout_backtest")
CURRENT_MEMBERS, _population_notes = prediction_members_in_force(_study)
for _note in _population_notes:
    print(_note)

# %% [markdown]
# ## 1. The strategy, read out of the frozen candidate set
#
# [`16_risk_management`](16_risk_management.ipynb) freezes the field it ranked
# over, and both this notebook and
# [`18_holdout_predictions`](18_holdout_predictions.ipynb) read the selection out
# of that set rather than re-deriving it. The set is immutable, so the two
# resolve the same configuration without either being told what it is; handing
# it over in a parameter or a file would add a way for them to disagree.
#
# A holdout run that could be pointed at a strategy of the reader's choosing
# would be a way to search the holdout. This one cannot be.

# %%
CANDIDATE_SET_NAME = f"{CASE_STUDY_ID}:holdout-candidates"
# The frozen set where it exists, and the same construction applied live where it does not.
# 16_risk_management writes it by opening the study, which canonical regeneration refuses
# wherever the generated directories are not symlinks - a reader's clean clone and the test
# fixtures both - so the set is in the published run log and absent everywhere else. Reading it
# is the stronger path: it is immutable, so it cannot follow an upstream change. Re-deriving is
# the same rule applied live, and cannot notice that something moved. Which one ran is printed.
#
# Both paths go through `open_selection_field`, which is also what 16 freezes with. They used to
# be separate copies and they disagreed: the freeze spanned every declared label and this
# fallback spanned one, so which configuration a reader selected depended on whether their
# registry held a `candidate_sets` table.
FIELD = open_selection_field(
    _study,
    case_study=CASE_STUDY_ID,
    name=CANDIDATE_SET_NAME,
    prediction_hashes=CURRENT_MEMBERS,
    resolve_best_backtest_runs=resolve_best_backtest_runs,
)
CANDIDATES = FIELD.candidate_set
FIELD_HASHES = list(FIELD.members)
FIELD_NAME = f"frozen candidate set {CANDIDATES.hash}" if CANDIDATES is not None else "live ranking"
SELECTION_SOURCE = FIELD.source
# The field says which backtests may be chosen from; the resolver says which one is chosen,
# and it is handed the field rather than the whole registry. `SelectionField.selected` is
# `CandidateSet.best_validation_sharpe`, which ranks the stored Sharpe column with a hash
# tie-break and applies nothing else - no re-ranking onto the timestamps every candidate
# prices, no label or universe restriction, no refusal of a run whose equity reached zero.
# This case study's field holds a conformal allocator that sits out its warm-up and books it
# as returns of exactly zero, so the two rankings read two different samples: they name the
# same backtest on this registry and value it at 2.6087 stored against 2.6329 over the shared
# sessions. `20_strategy_analysis` resolves the same way, so all three notebooks describe one
# configuration.
CARRIER = resolve_solvent_carrier(CASE_STUDY_ID, admitted=frozenset(FIELD_HASHES))
SELECTED = _study.results.open(CARRIER["val_backtest_hash"])
print(
    f"{FIELD_NAME}: {len(FIELD_HASHES)} members, resolver picks {SELECTED.hash}, "
    f"stored-Sharpe pick {FIELD.selected.hash}"
)

# The label the stages after the selection run under is the winner's, not the case study's
# primary. An injected LABEL is a request to run a different one, and it has to agree with what
# was selected or the holdout backtest would be keyed to a contract the selection does not name.
HOLDOUT_LABEL = CARRIER["label"]
if REQUESTED_LABEL and REQUESTED_LABEL != HOLDOUT_LABEL:
    raise RuntimeError(
        f"LABEL={REQUESTED_LABEL!r} was requested but the selection carried forward is "
        f"{SELECTED.hash} on {HOLDOUT_LABEL!r}. Running the holdout backtest under another "
        "label's contract would report a different strategy from the one selected."
    )
print(f"Label carried by the selection: {HOLDOUT_LABEL}")
print(f"Selection read from the {SELECTION_SOURCE}")

_why = SELECTED.completeness()
if _why is not None:
    raise RuntimeError(
        f"the selected validation backtest {SELECTED.hash} is incomplete: {_why}. "
        f"It was chosen from the {SELECTION_SOURCE}."
    )
CARRIER_SPEC = SELECTED.spec()
CARRIER_STRATEGY = strategy_view(CARRIER_SPEC)
carrier_record = SELECTED.registry_record()
CARRIER_PREDICTION_HASH = carrier_record["prediction_hash"]

with sqlite3.connect(REGISTRY_DB) as db:
    _carrier_source = db.execute(
        "SELECT t.family, t.config_name FROM prediction_sets p "
        "JOIN training_runs t USING(training_hash) WHERE p.prediction_hash = ?",
        (CARRIER_PREDICTION_HASH,),
    ).fetchone()
print(
    f"{FIELD_NAME} with {len(FIELD_HASHES)} members selects "
    f"{model_source(*_carrier_source)} with "
    f"{(CARRIER_STRATEGY.get('allocation') or {}).get('method', 'equal_weight')} allocation, "
    f"top-{(CARRIER_STRATEGY.get('signal') or {}).get('top_k')}, "
    + (
        f"risk overlay {(CARRIER_STRATEGY.get('risk') or {}).get('name')}"
        if (CARRIER_STRATEGY.get("risk") or {}).get("name")
        else "no risk overlay"
    )
)

# %% [markdown]
# ## 2. Which holdout prediction set belongs to this configuration
#
# The holdout fit is a different training run from the validation fit by
# construction - a different interval is a different computation - so the two
# cannot be matched on a training hash, and matching on a family and
# configuration name would accept a fit of the same model over the wrong window.
#
# Rather than compare fields and hope the list is complete, this derives the
# identity the selected configuration *should* have on the holdout, by the same
# `build_holdout_training_spec` [`18_holdout_predictions`](18_holdout_predictions.ipynb)
# fits, and requires a registered prediction under exactly that hash. The
# derivation is deterministic and costs a dataset read rather than a fit, so the
# two notebooks agree by construction: every boundary, the label buffer, the
# feature floor, the fold identifier and the request metadata are all inside the
# hash, and any of them differing makes it a different identity that this will
# not accept.

# %%
with sqlite3.connect(REGISTRY_DB) as db:
    carrier_training_hash = db.execute(
        "SELECT training_hash FROM prediction_sets WHERE prediction_hash = ?",
        (CARRIER_PREDICTION_HASH,),
    ).fetchone()[0]
    VALIDATION_SPEC = json.loads(
        db.execute(
            "SELECT spec_json FROM training_runs WHERE training_hash = ?",
            (carrier_training_hash,),
        ).fetchone()[0]
    )
    carrier_checkpoint = db.execute(
        "SELECT checkpoint_kind, checkpoint_value FROM prediction_sets WHERE prediction_hash = ?",
        (CARRIER_PREDICTION_HASH,),
    ).fetchone()
    holdout_rows = db.execute(
        "SELECT p.prediction_hash, p.training_hash, p.checkpoint_kind, p.checkpoint_value "
        "FROM prediction_sets p WHERE p.split = 'holdout'"
    ).fetchall()

# %%
OBSERVATIONS = (
    pl.read_parquet(_study.root / "labels" / f"{VALIDATION_SPEC['label']}.parquet")
    .get_column("timestamp")
    .unique()
    .sort()
    .to_list()
)
EXPECTED_HOLDOUT_SPEC = build_holdout_training_spec(
    _study, VALIDATION_SPEC, timeline=OBSERVATIONS, case_study=CASE_STUDY_ID
)
EXPECTED_HOLDOUT_TRAINING = training_hash_from_spec(EXPECTED_HOLDOUT_SPEC)
_expected_fold = EXPECTED_HOLDOUT_SPEC["computation"]["cv"]["folds"][0]
print(
    f"The selected configuration's holdout fit is {EXPECTED_HOLDOUT_TRAINING}: "
    f"train {str(_expected_fold['train_start'])[:10]} to {str(_expected_fold['train_end'])[:10]}, "
    f"evaluate {str(_expected_fold['val_start'])[:10]} to {str(_expected_fold['val_end'])[:10]}"
)

matches = [row for row in holdout_rows if row[1] == EXPECTED_HOLDOUT_TRAINING]
if not matches:
    raise RuntimeError(
        f"no holdout prediction is registered under {EXPECTED_HOLDOUT_TRAINING}, the identity "
        f"this configuration's configuration derives. Run 18_holdout_predictions. The registry holds "
        + (
            ", ".join(f"{row[0]} (training {row[1]})" for row in holdout_rows)
            if holdout_rows
            else "no holdout predictions at all"
        )
    )
if len(matches) > 1:
    raise RuntimeError(
        f"{len(matches)} holdout predictions share training {EXPECTED_HOLDOUT_TRAINING}: "
        + ", ".join(row[0] for row in matches)
        + ". A holdout fit publishes exactly the selected checkpoint, so this registry carries "
        "rows from an older rule."
    )

HOLDOUT_PREDICTION_HASH, HOLDOUT_TRAINING_HASH, holdout_kind, holdout_value = matches[0]
if (holdout_kind, holdout_value) != carrier_checkpoint:
    raise RuntimeError(
        f"the holdout prediction is at {holdout_kind}={holdout_value} but the selection was made "
        f"on {carrier_checkpoint[0]}={carrier_checkpoint[1]}"
    )
print(
    f"Holdout prediction {HOLDOUT_PREDICTION_HASH} from training {HOLDOUT_TRAINING_HASH}, "
    f"the validation configuration {carrier_training_hash} refitted over the holdout interval "
    f"at {holdout_kind}={holdout_value}"
)

# %% [markdown]
# The two windows the two fits saw, side by side. The gap between the training
# end and the holdout open is one label buffer, which is what stops the last
# training label's five-day outcome from resolving inside the window being
# judged.

# %%
with sqlite3.connect(REGISTRY_DB) as db:
    holdout_spec = json.loads(
        db.execute(
            "SELECT spec_json FROM training_runs WHERE training_hash = ?",
            (HOLDOUT_TRAINING_HASH,),
        ).fetchone()[0]
    )
holdout_fold = holdout_spec["computation"]["cv"]["folds"][0]
validation_folds = VALIDATION_SPEC["computation"]["cv"]["folds"]

pl.DataFrame(
    [
        {
            "fit": "validation",
            "folds": len(validation_folds),
            "train from": min(str(f["train_start"])[:10] for f in validation_folds),
            "train to": max(str(f["train_end"])[:10] for f in validation_folds),
            "evaluated": f"{min(str(f['val_start'])[:10] for f in validation_folds)}"
            f" to {max(str(f['val_end'])[:10] for f in validation_folds)}",
        },
        {
            "fit": "holdout",
            "folds": 1,
            "train from": str(holdout_fold["train_start"])[:10],
            "train to": str(holdout_fold["train_end"])[:10],
            "evaluated": f"{str(holdout_fold['val_start'])[:10]}"
            f" to {str(holdout_fold['val_end'])[:10]}",
        },
    ]
)

# %% [markdown]
# ## 3. Run the selected strategy on the holdout window
#
# The specification is the selected configuration's own, cloned and re-pointed at the holdout
# predictions. Nothing about the strategy is re-derived here: re-deriving it
# would let a change anywhere upstream alter what the holdout evaluates without
# the change being visible as a different selection.
#
# Prices are loaded for the holdout window with the allocator's warmup prefix.
# The covariance estimator this configuration uses needs history before its first
# rebalance, and without the prefix it would fall back to an imputed warmup on
# exactly the dates the result is read from.

# %%
prices = load_backtest_prices_for(
    CASE_STUDY_ID,
    HOLDOUT_LABEL,
    split="holdout",
    warmup_periods=warmup_periods_for(CASE_STUDY_ID),
    max_symbols=MAX_SYMBOLS,
)
print(
    f"Price support: {len(prices):,} rows across {prices['symbol'].n_unique()} symbols, "
    f"{prices['timestamp'].min()} to {prices['timestamp'].max()}"
)
predictions = read_predictions(CASE_STUDY_ID, HOLDOUT_PREDICTION_HASH)
print(
    f"Predictions: {predictions.height:,} rows over {predictions['timestamp'].n_unique()} sessions"
)

# %%
holdout_strategy_spec = clone_backtest_spec(
    ensure_backtest_spec(
        CASE_STUDY_ID,
        bt_config,
        CARRIER_SPEC,
        prices=prices,
        prediction_hash=HOLDOUT_PREDICTION_HASH,
        initial_cash=bt_config.initial_cash,
    )
)
HOLDOUT_BACKTEST_HASH = backtest_hash_from_parts(HOLDOUT_PREDICTION_HASH, holdout_strategy_spec)

# The whole backtest specification, not the strategy block. `strategy_view` returns signal,
# allocation and risk and stops there, so a check built on it would pass a holdout run at
# different commissions, slippage, fill timing or stop behaviour - and holding those fixed is
# most of what makes the holdout number comparable to the validation one. What legitimately
# differs between the two runs is the predictions consumed and the price panel sliced to, so
# only those are projected out.
BACKTEST_VARYING_PATHS = (
    ("_runtime_backtest_config",),
    ("input_identity",),
    ("backtest_config", "metadata", "prediction_hash"),
    ("backtest_config", "metadata", "preset_path"),
)


def _without(value, path: tuple[str, ...]):
    """``value`` with ``path`` removed, leaving every sibling in place."""
    if not isinstance(value, dict) or path[0] not in value:
        return value
    pruned = dict(value)
    if len(path) == 1:
        pruned.pop(path[0])
    else:
        pruned[path[0]] = _without(pruned[path[0]], path[1:])
    return pruned


def _comparable_backtest(spec: dict) -> dict:
    projected = spec
    for path in BACKTEST_VARYING_PATHS:
        projected = _without(projected, path)
    return projected


carried = _comparable_backtest(holdout_strategy_spec)
validation_carried = _comparable_backtest(CARRIER_SPEC)
if carried != validation_carried:
    moved = sorted(
        key
        for key in set(carried) | set(validation_carried)
        if carried.get(key) != validation_carried.get(key)
    )
    raise RuntimeError(f"the holdout strategy differs from the selected one in {moved}")
print(f"Holdout backtest identity {HOLDOUT_BACKTEST_HASH}")

# %%
with sqlite3.connect(REGISTRY_DB) as db:
    already = db.execute(
        "SELECT stage FROM backtest_runs WHERE backtest_hash = ?", (HOLDOUT_BACKTEST_HASH,)
    ).fetchone()
if already is None:
    result = run_backtest(
        CASE_STUDY_ID,
        HOLDOUT_PREDICTION_HASH,
        holdout_strategy_spec,
        prices=prices,
        predictions=predictions,
        label=HOLDOUT_LABEL,
        register=True,
        initial_cash=bt_config.initial_cash,
        calendar=bt_config.calendar,
    )
    print(
        f"Ran: Sharpe={result.metrics['sharpe']:.3f}, "
        f"max drawdown={result.metrics['max_drawdown']:.1%}"
    )
else:
    print(f"Already registered under stage {already[0]!r}; read, not re-run")

with sqlite3.connect(REGISTRY_DB) as db:
    registered_stage = db.execute(
        "SELECT stage FROM backtest_runs WHERE backtest_hash = ?", (HOLDOUT_BACKTEST_HASH,)
    ).fetchone()
if registered_stage is None:
    raise RuntimeError("the holdout backtest did not register")
if registered_stage[0] != "holdout":
    raise RuntimeError(
        f"the holdout backtest registered under stage {registered_stage[0]!r}; the stage is "
        "inferred from the prediction's split, so this row is not reachable as holdout evidence"
    )

# %% [markdown]
# ## 4. What the holdout says
#
# The two rows below are the same strategy on two windows. Both are read from
# `backtest_metrics`, so each is measured over the span its own run priced. The
# validation row is therefore the registered run's own figure and not the number the
# selection was decided on: the field here holds a conformal allocator that sits out
# its warm-up and books it as returns of exactly zero, so the ranking re-measures every
# candidate over the sessions they all price. That figure is printed above the table,
# and on this registry the two differ - 2.6329 over the shared sessions against 2.6087
# stored.
#
# Either way the validation figure is optimistic by construction: it is the maximum of
# a search, and the maximum of a search is a biased estimate of the thing searched over.
# The holdout figure has no such bias from *this* case study's selection.
#
# It carries a different one. The window is a single year, and 2021 was a
# particular year - a broad advance in US equities - so a result this good in a
# year like that is weaker evidence than the same result across a decade. One
# window cannot separate a strategy that works from a strategy that suits the
# window it was tested on, and nothing downstream of here can either.

# %%
# The metric the configuration was actually selected on, stated before the table so the
# validation row below is read as what it is - the registered run over its own span.
print(
    f"selected on Sharpe={CARRIER['val_sharpe']:.4f} over the "
    f"{CARRIER['comparison_n_periods'] or 'full'} sessions every candidate prices"
)

with sqlite3.connect(REGISTRY_DB) as db:
    comparison = pl.read_database(
        """
        SELECT b.backtest_hash, b.stage, m.sharpe, m.total_return, m.max_drawdown,
               m.volatility, m.num_trades, m.n_periods,
               m.sharpe_ci95_lo, m.sharpe_ci95_hi
        FROM backtest_runs b JOIN backtest_metrics m USING(backtest_hash)
        WHERE b.backtest_hash IN (?, ?)
        """,
        connection=db,
        execute_options={"parameters": [HOLDOUT_BACKTEST_HASH, SELECTED.hash]},
    )
if comparison.height != 2:
    raise RuntimeError("one of the two backtests has no registered metrics")

comparison = comparison.with_columns(
    pl.when(pl.col("backtest_hash") == HOLDOUT_BACKTEST_HASH)
    .then(pl.lit("holdout (2021)"))
    .otherwise(pl.lit("validation (registered span)"))
    .alias("window")
).sort("window")
comparison.select(
    "window",
    "sharpe",
    "sharpe_ci95_lo",
    "sharpe_ci95_hi",
    "total_return",
    "max_drawdown",
    "volatility",
    "num_trades",
    "n_periods",
)

# %% [markdown]
# The Sharpe intervals are the part to read first, and what they support is
# narrower than it looks.
#
# Each interval describes uncertainty in *its own* estimate. The holdout's says
# how much a single year of this strategy's weekly rebalances pins down its
# Sharpe; where it spans zero, that year does not establish that the strategy
# earns anything at all. The validation interval says the same for the
# development window, and carries the additional problem that its point estimate
# is the maximum of a search and is optimistic by an amount neither interval
# measures.
#
# What neither interval gives is a test of the *difference* between the two
# windows. That would need an interval for the gap itself, and the two are
# measured over disjoint periods on different numbers of observations, so it
# cannot be read off by asking whether one point estimate falls inside the
# other's interval. The cells below therefore report both intervals and the
# arithmetic difference, and stop there. Attributing the difference - to
# selection bias, to a different market, or to both - is not something one
# holdout year can do, and no notebook downstream of this one can either.

# %%
_holdout = comparison.filter(pl.col("window") == "holdout (2021)").row(0, named=True)
_validation = comparison.filter(pl.col("window") == "validation (registered span)").row(
    0, named=True
)
_spans_zero = _holdout["sharpe_ci95_lo"] <= 0.0 <= _holdout["sharpe_ci95_hi"]
print(
    f"Validation Sharpe {_validation['sharpe']:.3f} over the registered span "
    f"[{_validation['sharpe_ci95_lo']:.3f}, {_validation['sharpe_ci95_hi']:.3f}] "
    f"over {int(_validation['n_periods'])} sessions"
)
print(
    f"Holdout Sharpe {_holdout['sharpe']:.3f} "
    f"[{_holdout['sharpe_ci95_lo']:.3f}, {_holdout['sharpe_ci95_hi']:.3f}] "
    f"over {int(_holdout['n_periods'])} sessions"
)
print(f"Arithmetic difference {_holdout['sharpe'] - _validation['sharpe']:+.3f}, untested")
print(
    "The holdout interval "
    + (
        "spans zero, so this year does not establish that the strategy earns anything"
        if _spans_zero
        else "excludes zero, so this year places its Sharpe away from zero"
    )
)

# %% [markdown]
# The holdout equity path, drawn on its own axis. It is one year at weekly
# rebalances, so the shape carries far less information than the same picture
# over the validation window and is shown to make the drawdown legible rather
# than to support a claim.

# %%
holdout_returns = load_daily_returns_with_timestamp(CASE_STUDY_ID, HOLDOUT_BACKTEST_HASH)
if holdout_returns is None or holdout_returns.is_empty():
    raise RuntimeError("the holdout backtest registered no daily return series")

curve = holdout_returns.sort("timestamp").with_columns(
    ((1.0 + pl.col("ret")).cum_prod()).alias("equity")
)
curve = curve.with_columns((pl.col("equity") / pl.col("equity").cum_max() - 1.0).alias("drawdown"))

fig, (ax_equity, ax_dd) = plt.subplots(
    2,
    1,
    figsize=FIGSIZE["dual_v"],
    sharex=True,
    height_ratios=[2, 1],
    constrained_layout=True,
)
ax_equity.plot(curve["timestamp"], curve["equity"], color=COLORS["blue"], linewidth=1.6)
ax_equity.set_ylabel("Growth of 1")
ax_dd.fill_between(curve["timestamp"], curve["drawdown"], 0.0, color=COLORS["negative"], alpha=0.5)
ax_dd.set_ylabel("Drawdown")
add_message_title(
    ax_equity,
    "Selected strategy on the 2021 holdout: equity curve and drawdown",
    "Growth of 1 above, peak-to-trough drawdown below, daily",
)
show_with_alt(
    fig,
    "Two panels sharing a date axis over the holdout year: growth of one unit of capital above, "
    "and peak-to-trough drawdown filled below.",
)

# %% [markdown]
# ## Key takeaways
#
# 1. The strategy run here is the selected configuration's own specification, cloned and
#    re-pointed at the holdout predictions. Nothing about it was re-derived, so
#    a change upstream would show up as a different selection rather than as a
#    quietly different holdout.
# 2. The holdout prediction set is matched to the selected configuration by comparing the two
#    training specifications field by field, because a holdout refit is a
#    different training identity and cannot be matched on a hash.
# 3. A validation figure is the maximum of a search and is optimistic. A single
#    holdout year is unbiased with respect to that search and is noisy, and the
#    registered Sharpe interval is what says how noisy. Neither interval is a
#    test of the difference between the two, which is why none is reported.
# 4. A drop from validation to holdout has at least two sufficient explanations
#    - selection bias and a different market - and one window cannot separate
#    them. Neither can any notebook downstream of this one.
# 5. Survivorship bias bites hardest here. The universe is today's S&P 500
#    membership, so the 2021 window is evaluated on companies known to have
#    stayed in the index.
#
# **Next:** [`20_strategy_analysis`](20_strategy_analysis.ipynb) assembles what
# the case study has established across all of its stages, and states which of
# its claims this window supports.

```

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.