跳至正文
返回文库全部文档

校准风险控制并配置回测扫描

代码 《交易机器学习》

总结

该配置模块介绍一种框架,用于为各案例研究声明回测扫描设置。它从配置文件读取入场方案、配置方法、交易成本网格和风险控制;如果缺少必需配置,则明确报错。模块还集中管理执行默认值和配置器回看窗口,使投资组合配置方法使用一致的估计期。可选的分阶段筛选可以先测试一组基线策略,再对较小的排名子集进行更广泛的比较。

一种风险控制方法估计不同资产和持有期限下的不利价格变动,再利用选定百分位数上的中位数变动设定追踪止损阈值。该流程会跳过价格历史过短的资产,并删除重复阈值。模块还区分横截面多空筛选与账户做空权限,避免错误解读期权策略。这些工具用于构建比较性回测;本文提供的是实现逻辑,并非任何特定配置器、止损或策略能够改善表现的证据。

核心观点

  • 每个案例研究的回测扫描维度和执行默认值都在配置中声明。
  • 可根据不同资产的不利变动中位数估算追踪止损水平。
  • 使用统一回看期有助于在一致基础上比较基于矩的配置器。
  • 筛选模式与卖空权限是不同概念,不应混为一谈。
  • 分阶段扫描可先对基线子集排序,再对选定预测结果进行额外测试。

标签

全文
# sweep_config.py


```py
"""Central sweep configuration for Ch16-20 parametric backtesting.

The Ch16-19 sweep grid (entry schemes, allocators, cost grid, risk controls)
is declared per-case-study under ``backtest.sweep`` in each case study's
``config/setup.yaml``. ``load_sweep(case_study)`` and the ``*_for`` / ``get_*``
helpers read that block and synthesize dispatcher-shaped config dicts. No
implicit fallback — the block is required, and ``KeyError`` is raised when
missing.

Usage:
    from case_studies.utils.sweep_config import (
        get_entry_schemes_for, get_top_k_values_for,
        get_allocators, get_cost_grid_bps,
        get_position_risk_controls, get_portfolio_risk_controls,
    )

    schemes = get_entry_schemes_for(
        "us_firm_characteristics", label="fwd_ret_1m",
        n_assets=2500, long_short=True,
    )
"""

from __future__ import annotations

import polars as pl
import yaml

from case_studies.utils.warning_policy import warn_the_reader

# ---------------------------------------------------------------------------
# MAE/MFE-calibrated risk controls (Ch19)
# ---------------------------------------------------------------------------


def calibrate_trailing_stops(
    prices: pl.DataFrame,
    horizons: list[int] | None = None,
    percentiles: tuple[float, ...] = (10, 25),
    asset_col: str = "symbol",
    time_col: str = "timestamp",
    price_col: str = "close",
) -> list[dict]:
    """Compute case-study-specific trailing stop thresholds from MAE percentiles.

    Uses the ml4t-diagnostic excursion module to analyze how far prices
    typically draw down over various holding horizons, then converts
    MAE percentiles into trailing stop thresholds.

    Args:
        prices: Long-format DataFrame with [timestamp, symbol, close] (at minimum).
        horizons: Bar horizons to analyze. Default uses [10, 20, 40] for daily data.
        percentiles: MAE percentiles to convert to stops. Default (10, 25) gives
            tight and moderate thresholds.
        asset_col: Name of the asset identifier column.
        time_col: Name of the timestamp column.
        price_col: Name of the close price column.

    Returns:
        List of risk control dicts in the same shape as the declared
        ``backtest.sweep.risk_controls.position`` entries in setup.yaml,
        with names like ``trailing_mae_p10_h20`` (10th percentile MAE at
        20-bar horizon).
    """
    import numpy as np
    from ml4t.diagnostic.evaluation.excursion import analyze_excursions

    if horizons is None:
        horizons = [10, 20, 40]

    # Aggregate across all assets for a universe-level excursion profile
    # Key: (horizon, percentile) → list of per-asset MAE values
    symbols = prices[asset_col].unique().to_list()
    mae_by_hp: dict[tuple[int, float], list[float]] = {
        (h, p): [] for h in horizons for p in percentiles
    }

    for sym in symbols:
        sym_prices = (
            prices.filter(pl.col(asset_col) == sym)
            .sort(time_col)
            .get_column(price_col)
            .drop_nulls()
        )
        if len(sym_prices) < max(horizons) + 10:
            continue

        result = analyze_excursions(
            sym_prices,
            horizons=horizons,
            percentiles=list(percentiles),
        )

        for h in horizons:
            if h not in result.statistics:
                continue
            stats = result.statistics[h]
            for p in percentiles:
                val = stats.mae_percentiles.get(p)
                if val is not None and not np.isnan(val):
                    mae_by_hp[(h, p)].append(abs(val))

    # Build calibrated trailing stop configs from aggregated MAE percentiles
    controls: list[dict] = []
    seen_thresholds: set[float] = set()

    for h in horizons:
        for p in percentiles:
            vals = mae_by_hp[(h, p)]
            if not vals:
                continue
            # Median across all assets gives a robust universe-level threshold
            threshold = float(np.median(vals))
            threshold = round(threshold, 3)
            if threshold <= 0 or threshold in seen_thresholds:
                continue
            seen_thresholds.add(threshold)
            pct_str = f"{threshold * 100:.1f}".replace(".", "p")
            controls.append(
                {
                    "name": f"trailing_mae_p{int(p)}_h{h}_{pct_str}pct",
                    "type": "trailing_stop",
                    "threshold": threshold,
                    "calibration": {"horizon": h, "percentile": p, "source": "mae"},
                }
            )

    # Sort by threshold for readable sweep ordering
    controls.sort(key=lambda c: c["threshold"])
    return controls


# ---------------------------------------------------------------------------
# setup.yaml-driven sweep loader (Ch16-19)
# ---------------------------------------------------------------------------


def load_sweep(case_study: str) -> dict:
    """Return the ``backtest.sweep`` block from a case study's setup.yaml.

    The block declares the Ch16-19 sweep grid (signal selection, allocators,
    cost grid, risk controls) per-case-study. Raises ``KeyError`` if missing —
    there is no fallback to module-level constants. See
    ``case_studies/us_firm_characteristics/config/setup.yaml`` for the schema.
    """
    setup_path = _setup_path(case_study)
    setup = yaml.safe_load(setup_path.read_text())
    sweep = (setup.get("backtest") or {}).get("sweep")
    if sweep is None:
        raise KeyError(
            f"backtest.sweep missing from case_studies/{case_study}/config/"
            "setup.yaml — the Ch16-19 sweep grid must be declared explicitly. "
            "See case_studies/us_firm_characteristics/config/setup.yaml for "
            "the schema."
        )
    return sweep


def _setup_path(case_study: str):
    # Resolve through get_case_study_dir so setup.yaml is read from the same place
    # the model stages read it: the source tree in production, or ML4T_OUTPUT_DIR
    # when set (tests, and the create_experiment workflow where the reader edits
    # backtest.sweep/costs/execution in their isolated experiment copy).
    from utils.paths import get_case_study_dir

    # create=False: reading config must never materialize the case-study dir. A
    # side-effect mkdir under ML4T_OUTPUT_DIR would make create_experiment think
    # the experiment already exists.
    return get_case_study_dir(case_study, create=False) / "config" / "setup.yaml"


def _load_setup(case_study: str) -> dict:
    return yaml.safe_load(_setup_path(case_study).read_text())


def get_declared_long_short(case_study: str) -> bool:
    """Whether a case study's declared selection mode is cross-sectional long-short.

    Delegates to ``get_backtest_config``, which reads it off
    ``mapping.position_state_space``. That is the selection mode, not
    ``account.allow_short_selling``: the account flag is an execution permission, and
    the two come apart. ``sp500_options`` sets the permission because it sells
    straddles, but its state space is ``short_straddle_hedged`` - a one-sided short
    that builds no cross-sectional short sleeve, and its backtest passes
    ``long_short: False`` accordingly. Sizing its concentration grid off the account
    flag would halve a ceiling that nothing halves at run time.
    """
    from case_studies.utils.backtest_loaders import get_backtest_config

    return bool(get_backtest_config(case_study).long_short)


def get_execution_defaults(case_study: str) -> dict:
    """Return the ``execution:`` block from setup.yaml.

    Single source of truth for engine-level defaults — ``initial_cash``,
    ``share_type``, ``allocator_lookback``. Raises ``KeyError`` if missing;
    the Ch16-19 notebooks must not declare local INITIAL_CASH constants.
    """
    setup = _load_setup(case_study)
    block = setup.get("execution")
    if block is None:
        raise KeyError(
            f"execution: block missing from case_studies/{case_study}/config/"
            "setup.yaml — must declare initial_cash, share_type, allocator_lookback."
        )
    return block


def get_allocator_lookback(case_study: str) -> int:
    """Return the CS-level lookback (bars-of-underlying) for moment-based allocators.

    Read from ``setup.yaml::execution.allocator_lookback``. Applied uniformly
    to inverse_vol, risk_parity, hrp, mvo_ledoit_wolf so allocators compete
    on method, not on window choice. Bars count on the price DataFrame the
    allocator consumes (typically daily even when rebalance is monthly).
    """
    exec_block = get_execution_defaults(case_study)
    lb = exec_block.get("allocator_lookback")
    if lb is None:
        raise KeyError(
            f"execution.allocator_lookback missing from case_studies/{case_study}/"
            "config/setup.yaml — required for moment-based allocators."
        )
    return int(lb)


_STAGE_DEFAULTS = {
    "signal": 0,  # 0 = all predictions (equal-weight baseline)
    "allocation": 10,  # top-10 model configs by baseline Sharpe
    "cost_sensitivity": 1,  # top-1 of {signal+allocation} per label
    "risk_overlay": 1,  # top-1 of {signal+allocation} per label
}

_DEFAULT_CHECKPOINTS_PER_CONFIG = 1


def get_top_n_predictions(case_study: str, stage: str) -> int:
    """Return the top-N predictions to feed into ``stage`` from the upstream stage.

    Reads ``backtest.sweep.top_n_predictions[stage]`` with safe fallbacks.
    ``stage`` is one of ``signal | allocation | cost_sensitivity | risk_overlay``.
    Unknown stage names raise ``ValueError`` rather than ``KeyError`` so the stack
    trace clearly distinguishes a typo'd lookup from a missing key in the YAML.

    For ``allocation`` the unit counted is a *model config* — one ``(family,
    config_name)`` pair — not a prediction. See ``get_checkpoints_per_config``
    for how many checkpoints each advancing config contributes.
    """
    if stage not in _STAGE_DEFAULTS:
        raise ValueError(f"unknown stage {stage!r}; expected one of {sorted(_STAGE_DEFAULTS)}")
    block = load_sweep(case_study).get("top_n_predictions") or {}
    return int(block.get(stage, _STAGE_DEFAULTS[stage]))


def top_n_cap(top_n: int) -> int | None:
    """Return the cap ``top_n`` asks for, or ``None`` when it asks for every candidate.

    ``0`` is how ``top_n_predictions`` spells "all of them". ``_STAGE_DEFAULTS["signal"]``
    is 0, every shipped ``setup.yaml`` declares ``signal: 0`` under the comment "all signal
    predictions", and the four signal-stage notebooks read it that way, each guarding its
    truncation with ``if TOP_N_PREDICTIONS > 0``.

    The allocation stage had no such reading, so the same number meant the opposite one stage
    later: ``0`` reached a SQL ``LIMIT 0`` and a ``.head(0)`` and selected nothing. Taking the
    cap from here keeps one number meaning one thing at every stage. A negative width is a
    caller error rather than a second spelling of "all", because SQLite's ``LIMIT -1`` means
    no limit and a width arithmetic produced would otherwise pass through as one.
    """
    width = int(top_n)
    if width < 0:
        raise ValueError(f"top_n must be 0 (every candidate) or positive, got {top_n!r}")
    return width or None


def get_checkpoints_per_config(case_study: str) -> int:
    """Return how many checkpoints each advancing model config contributes.

    Reads ``backtest.sweep.checkpoints_per_config``. The default of 1 means a
    config enters the allocation sweep through its single best checkpoint, so
    a long checkpoint sweep of one model cannot crowd out other model families.
    """
    value = load_sweep(case_study).get("checkpoints_per_config")
    if value is None:
        return _DEFAULT_CHECKPOINTS_PER_CONFIG
    return int(value)


def get_expensive_allocators_skip(case_study: str) -> bool:
    """Return whether MVO/HRP should be skipped at Ch17 (intraday escape hatch)."""
    return bool(load_sweep(case_study).get("expensive_allocators_skip", False))


def get_cost_grid_half_spread_usd(case_study: str) -> list[float]:
    """Return the half-spread (USD per share) grid for per-share cost-regime sweep.

    Used by Ch18 cost sensitivity for CSes whose declared cost model is
    ``per_share_plus_spread`` (etfs, nasdaq100_microstructure). Returns an
    empty list when the key is absent — the bps grid is the only cost
    dimension for that CS.
    """
    block = load_sweep(case_study).get("cost_grid_half_spread_usd")
    if block is None:
        return []
    return [float(v) for v in block]


def get_per_share_commission(case_study: str, default: float = 0.0035) -> float:
    """Return the per-share commission (USD/share) from ``costs.per_share``.

    Single source of truth for the per-share companion cost regime in Ch18.
    For CSes whose headline cost model is bps (e.g. sp500_equity_option_analytics)
    the ``costs.per_share`` key may be absent; ``default`` (IBKR Pro Tiered top
    tier, $0.0035/share) is returned in that case. Replaces ad-hoc
    ``open(setup.yaml)["costs"]["per_share"]`` reads in cost notebooks.
    """
    costs = _load_setup(case_study).get("costs") or {}
    return float(costs.get("per_share", default))


def get_cadence_sweep(case_study: str) -> list[str]:
    """Return the alternative-cadence list for the Ch18 cadence × cost heatmap.

    Read from ``backtest.sweep.cadence_sweep`` in setup.yaml. Used by CSes
    that explore rebalance-cadence sensitivity (nasdaq100_microstructure).
    Returns an empty list when the key is absent — the CS does not run a
    cadence sweep. Tokens are the engine's cadence vocabulary (e.g.
    ``15_minute``, ``1_hour``).
    """
    block = load_sweep(case_study).get("cadence_sweep")
    if block is None:
        return []
    return [str(c) for c in block]


# --- Calendar-aware allocator lookback resolution ---------------------------

# Calendar tokens we recognize in setup.yaml allocator entries. The numeric
# multiplier is read from the token prefix (e.g., ``3M`` → 3 × month).
_CALENDAR_UNITS_PER_YEAR = {
    "Y": 1,
    "M": 12,
    "W": 52,
    "D": None,  # special-cased to periods_per_year (handles intraday cadences)
}


def _resolve_calendar_lookback(value, periods_per_year: float) -> int:
    """Translate a ``"3M"``/``"6M"``/``"1Y"`` token into bars-of-underlying.

    ``periods_per_year`` is interpreted as the **price-data bars per year**
    (not the Sharpe-annualization factor — for most CSes the two coincide
    because both equal 252 when underlying prices are daily). The allocator
    consumes the raw ``prices`` DataFrame whose row cadence is the price
    cadence, not the rebalance cadence; rolling-vol windows are measured in
    those rows.

    Cases where the two definitions diverge:
      - crypto_perps_funding: ``evaluation.periods_per_year=365`` is the
        daily-equivalent annualization factor, but the underlying data is
        8-hourly (1095 bars/yr). Override per-CS via a future
        ``evaluation.bars_per_year`` field; until then prefer literal bars
        in setup.yaml for non-daily-underlying CSes.
      - nasdaq100_microstructure: same situation (intraday underlying).

    Integer values pass through unchanged (legacy ``vol_window: 63``).
    """
    if isinstance(value, (int, float)):
        return int(value)
    if not isinstance(value, str):
        raise TypeError(
            f"lookback must be int or calendar string, got {type(value).__name__}: {value!r}"
        )
    token = value.strip().upper()
    if not token:
        raise ValueError(f"empty lookback string: {value!r}")
    unit = token[-1]
    if unit not in _CALENDAR_UNITS_PER_YEAR:
        raise ValueError(
            f"unsupported lookback unit {unit!r} in {value!r}; expected one of Y/M/W/D"
        )
    try:
        n = int(token[:-1])
    except ValueError as exc:
        raise ValueError(f"cannot parse lookback prefix in {value!r}") from exc
    units_per_year = _CALENDAR_UNITS_PER_YEAR[unit]
    if units_per_year is None:
        # D → use periods_per_year directly (assumes ppy counts daily bars;
        # intraday CSes that nonetheless evaluate at daily MTM keep ppy=252)
        bars_per_unit = periods_per_year / 252.0
        return max(1, int(round(n * bars_per_unit)))
    bars_per_unit = periods_per_year / units_per_year
    return max(1, int(round(n * bars_per_unit)))


def _periods_per_year_for(case_study: str) -> float:
    setup = _load_setup(case_study)
    ppy = (setup.get("evaluation") or {}).get("periods_per_year")
    if ppy is None:
        raise KeyError(
            f"evaluation.periods_per_year missing from case_studies/{case_study}/"
            "config/setup.yaml — required for calendar-aware allocator lookbacks."
        )
    return float(ppy)


# How many registered prediction sets the width resolver reads before it decides.
# One is enough where they agree and cannot detect it where they do not.
_RANKED_WIDTH_SAMPLE = 8


def ranked_cross_section_width(
    case_study: str, label: str, *, split: str = "validation"
) -> int | None:
    """Distinct symbols in the predictions an entry-scheme sweep will rank.

    ``None`` when nothing is registered yet for ``(label, split)``, when the
    artifacts behind the registered sets are missing, or when the sampled sets do
    not agree on a width. A case study whose model stages have not run has no
    ranked cross-section, and inventing one would refuse a sweep that has not
    been set up wrong.

    The registry is sampled rather than asked about the population a particular
    caller is sweeping, and those are different questions: `cme_futures/13_backtest`
    selects official or preview candidates, and a width taken from an unrelated
    set could remove a concentration that is feasible for the ones actually being
    swept. So the resolver only speaks when the sample is unanimous - up to eight
    registered sets, all reporting the same width - and otherwise returns ``None``
    and leaves the caller's own number standing. A caller that knows its
    population passes ``ranked_width`` and skips this entirely.

    Unanimity is the common case, not a hope: measured 2026-09-07 over six
    registered sets per label in etfs, us_firm_characteristics,
    sp500_equity_option_analytics and us_equities_panel, every label had exactly
    one width - 99, 3,708, 549 and 3,147 respectively.

    Distinct symbols over the window, which is the same count the callers take
    off the price panel, so the comparison in ``get_entry_schemes_for`` is between
    two measurements of the same kind. The number that literally bounds a ranking
    is the per-date cross-section, which is smaller - 88 against 99 for etfs,
    2,032 against 3,708 for us_firm_characteristics - and moving the rule onto it
    would change which concentrations are feasible today. That is a separate
    decision about the rule; this function is about which set the rule reads.
    """
    import sqlite3

    from case_studies.utils.registry.store import _case_dir, _registry_db_path, _run_log_dir

    case_dir = _case_dir(case_study)
    db_path = _registry_db_path(case_dir)
    if not db_path.is_file():
        return None
    try:
        with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) as db:
            rows = db.execute(
                """
                SELECT p.prediction_hash
                FROM prediction_sets p
                JOIN training_runs t ON t.training_hash = p.training_hash
                WHERE t.label = ? AND p.split = ?
                LIMIT ?
                """,
                (label, split, _RANKED_WIDTH_SAMPLE),
            ).fetchall()
    except sqlite3.Error:
        return None

    widths: set[int] = set()
    for (prediction_hash,) in rows:
        artifact = _run_log_dir(case_dir) / "predictions" / prediction_hash / "predictions.parquet"
        if not artifact.is_file():
            continue
        try:
            widths.add(
                int(pl.scan_parquet(artifact).select(pl.col("symbol").n_unique()).collect().item())
            )
        except (pl.exceptions.PolarsError, OSError, ValueError):
            return None
    if not widths:
        return None
    if len(widths) > 1:
        warn_the_reader(
            f"{case_study}/{label}: registered prediction sets disagree on the ranked "
            f"cross-section ({sorted(widths)}), so the entry-scheme feasibility rule falls "
            "back to the caller's price-panel width. Pass ranked_width= with the width of "
            "the population actually being swept.",
            source="ranked_width_from_registry",
            key=(case_study, label, tuple(sorted(widths))),
        )
        return None
    return widths.pop()


def get_entry_schemes_for(
    case_study: str,
    label: str,
    n_assets: int,
    long_short: bool,
    *,
    ranked_width: int | None = None,
) -> list[dict]:
    """Synthesize Ch16 entry schemes for ``(case_study, label)`` from setup.yaml.

     Reads ``backtest.sweep.{top_k_grid, percentile_grid, quantile_grid}``
     keyed by label, produces one scheme dict per (axis × value), filtered for
     feasibility against ``n_assets``. Output dicts match the shape consumed
     by ``Ch16 backtest notebooks`` (one scheme per (axis, value)).

     Quantile schemes (``quintile_long_short`` / ``decile_long_short``) carry
     ``long_short=True`` regardless of the ``long_short`` argument — they are
     inherently long-short by construction. ``long_short`` controls only the
     sign of top-k / percentile schemes.

     When the case study declares a ``backtest.sweep.signal_nasdaq100`` block
     (the nasdaq100 v4 slot-mechanism sweep), schemes from that block are
     appended — see ``get_signal_nasdaq100_schemes_for`` for the cross-product.

     ``n_assets`` is the width of the price panel. What bounds a ranked selection
     is the prediction cross-section, and the two are not the same number: every
     caller measures ``n_assets`` off the panel it loaded, while the ranking runs
     over whatever the fitting stages scored. ``ranked_width`` is that second
     number, resolved from the registry when the caller does not supply it, and
     the feasibility rule below applies to the narrower of the two.

     They agree on `main` today, which is why nothing is currently wrong and why
     the check could not fire on the condition it exists to catch
    . Any change that narrows the fitting stages
     without narrowing the panel - a new preview tier, a per-notebook
     ``max_symbols``, a family that scores a subset - reproduces the same
     failure: twelve backtests with a Sharpe, zero trades, and
     four notebooks failing several stages downstream of the cause.
    """
    sweep = load_sweep(case_study)
    schemes: list[dict] = []
    if ranked_width is None:
        ranked_width = ranked_cross_section_width(case_study, label)
    tradeable = n_assets if ranked_width is None else min(n_assets, ranked_width)

    top_k_by_label = sweep.get("top_k_grid") or {}
    pct_by_label = sweep.get("percentile_grid") or {}
    qnt_by_label = sweep.get("quantile_grid") or {}

    # Strict label gate. If any of the three grids is declared in the YAML
    # but the label appears in none of them, raise — silently returning an
    # empty scheme list lets a typo'd LABEL papermill parameter register
    # zero backtests with no warning (Ch16/13 loops over schemes have no
    # else-clause). The legacy "no backtest.sweep block at all" path is
    # preserved: when all three grids are empty/absent, we fall through and
    # return [] (callers that explicitly opt into the legacy
    # ``get_entry_schemes(...)`` helper keep working).
    any_grid_declared = (
        "top_k_grid" in sweep or "percentile_grid" in sweep or "quantile_grid" in sweep
    )
    label_known = label in top_k_by_label or label in pct_by_label or label in qnt_by_label
    if any_grid_declared and not label_known:
        raise KeyError(
            f"label {label!r} not declared in any of backtest.sweep.{{top_k_grid, "
            f"percentile_grid, quantile_grid}} for case_studies/{case_study}/"
            f"config/setup.yaml; known labels: top_k={sorted(top_k_by_label)}, "
            f"pct={sorted(pct_by_label)}, qnt={sorted(qnt_by_label)}"
        )

    declared_top_k = [int(k) for k in top_k_by_label.get(label, [])]
    top_k_schemes: list[dict] = []
    for k in declared_top_k:
        # k == tradeable holds the whole cross-section = equal-weight benchmark, not
        # a prediction-based portfolio; exclude it to match get_top_k_values_for.
        if k >= tradeable:
            continue
        # Long and short selections must be disjoint. Keep the declared grid and
        # exclude only members that the available cross-section cannot realize.
        if long_short and 2 * k > tradeable:
            continue
        top_k_schemes.append(
            {
                "name": f"ew_top{k}",
                "method": "equal_weight_top_k",
                "top_k": k,
                "long_short": long_short,
            }
        )
    # Raised here rather than left to the caller's empty-list guard, because only
    # this scope holds both numbers. A caller that reports `n_assets` back is
    # reporting the number the check did not use, which is how the original
    # failure presented. The panel-too-narrow case (`tradeable == n_assets`) is
    # left to that guard, which names the right number for it.
    if declared_top_k and not top_k_schemes and tradeable < n_assets:
        raise ValueError(
            f"no concentration declared for {case_study}/{label} can be realized: "
            f"backtest.sweep.top_k_grid declares {sorted(declared_top_k)}, and the "
            f"predictions this sweep ranks carry {tradeable} symbols against a price "
            f"panel of {n_assets}. Every k is at or above the ranked cross-section, so "
            "each would hold all of it rather than select from it, and every backtest "
            "in the sweep would record num_trades = 0 while still reporting a Sharpe. "
            "Narrow the price panel and the fitting stages together, or declare a "
            "smaller concentration."
        )
    schemes.extend(top_k_schemes)

    for p in pct_by_label.get(label, []):
        p = float(p)
        schemes.append(
            {
                "name": f"cs_pct{int(p)}",
                "method": "cross_sectional_percentile",
                "percentile": p,
                "long_short": long_short,
            }
        )

    for n_q in qnt_by_label.get(label, []):
        n_q = int(n_q)
        if n_q == 5:
            name, method = "quintile_ls", "quintile_long_short"
        elif n_q == 10:
            name, method = "decile_ls", "decile_long_short"
        else:
            raise ValueError(
                f"quantile_grid only supports n_quantiles ∈ {{5, 10}}; "
                f"got {n_q} for label {label!r} in case_studies/{case_study}/"
                f"config/setup.yaml. The backtest dispatcher has no method for "
                f"q{n_q}_long_short."
            )
        if n_assets < 2 * n_q:
            continue
        schemes.append(
            {
                "name": name,
                "method": method,
                "n_quantiles": n_q,
                "long_short": True,
            }
        )

    # nasdaq100 v4 slot mechanism — appended when the block is present.
    if "signal_nasdaq100" in sweep:
        schemes.extend(get_signal_nasdaq100_schemes_for(case_study, label, n_assets))

    return schemes


def get_signal_nasdaq100_schemes_for(
    case_study: str,
    label: str,
    n_assets: int,
) -> list[dict]:
    """Expand the ``backtest.sweep.signal_nasdaq100`` block into entry schemes.

    Block shape (all keys required unless noted)::

        signal_nasdaq100:
          selection_method: [slot_persistent_signal_exit, eq_w_topk]
          long_q: [0.90, 0.95, 0.99]      # slot only — entry quantile
          direction: [long_only, long_short]
          max_slots: [5, 10, 20]           # slot only — concurrent holdings
          hold_bars: [8, 16, 32]           # slot only — max-hold backstop
          exit_signal_q: [null, 0.30, ...] # slot only — stay threshold, null disables
          pred_freshness_max_min: 14       # slot only — backward-asof tolerance
          bars_per_day_grid: [14]          # slot only — execution cadence
          top_k_grid: [5, 10, 20]          # eq_w_topk only — top-k holdings
          lookback_days: 21                # slot only — rolling window depth

    Slot mechanism is single-direction (the slot book cannot be both long
    and short simultaneously); slot × long_short combinations are dropped.
    eq_w_topk supports both directions via the canonical long_short axis.

    Schemes carry a ``name`` derived from the cross-product coordinates so
    the registry rows are distinguishable. ``selection_method_config`` keys
    are flattened into the scheme dict so the existing 14_backtest.py loop
    passes them through to ``run_backtest`` unchanged.
    """
    sweep = load_sweep(case_study)
    block = sweep.get("signal_nasdaq100")
    if block is None:
        msg = (
            f"backtest.sweep.signal_nasdaq100 missing from "
            f"case_studies/{case_study}/config/setup.yaml"
        )
        raise KeyError(msg)

    methods = list(block.get("selection_method", []))
    if not methods:
        msg = (
            f"signal_nasdaq100.selection_method must list at least one method "
            f"for case_studies/{case_study}"
        )
        raise ValueError(msg)
    long_qs = [float(v) for v in block.get("long_q", [])]
    directions = list(block.get("direction", []))
    max_slots_grid = [int(v) for v in block.get("max_slots", [])]
    hold_bars_grid = [int(v) for v in block.get("hold_bars", [])]
    # Tolerate a scalar/`null` exit_signal_q (e.g. ``exit_signal_q: null``)
    # instead of raising an opaque ``list(None)`` TypeError far from the config.
    raw_exit = block.get("exit_signal_q", [None])
    exit_qs = list(raw_exit) if isinstance(raw_exit, list) else [raw_exit]
    pred_freshness = block.get("pred_freshness_max_min")
    bpd_grid = [int(v) for v in block.get("bars_per_day_grid", [])]
    top_k_grid = [int(v) for v in block.get("top_k_grid", [])]
    lookback_days = int(block.get("lookback_days", 21))

    # Fail loudly on unknown directions and on missing/typo'd required keys: a
    # YAML typo (e.g. ``max_slot:`` or ``bars_per_day:``) would otherwise leave
    # the corresponding grid empty, silently collapse the cross-product to zero
    # schemes, and register zero backtests — the failure mode the strict-label
    # gate exists to prevent.
    _allowed_dirs = {"long_only", "short_only", "long_short"}
    bad_dirs = [d for d in directions if d not in _allowed_dirs]
    if bad_dirs:
        msg = (
            f"signal_nasdaq100.direction has unknown value(s) {bad_dirs} for "
            f"case_studies/{case_study}; allowed: {sorted(_allowed_dirs)}"
        )
        raise ValueError(msg)
    _required: dict[str, list] = {"direction": directions}
    if "slot_persistent_signal_exit" in methods:
        _required.update(
            long_q=long_qs,
            max_slots=max_slots_grid,
            hold_bars=hold_bars_grid,
            bars_per_day_grid=bpd_grid,
        )
    if "eq_w_topk" in methods:
        _required["top_k_grid"] = top_k_grid
    missing = sorted(k for k, v in _required.items() if not v)
    if missing:
        msg = (
            f"signal_nasdaq100 is missing/empty required key(s) {missing} for "
            f"case_studies/{case_study} given selection_method={methods}; "
            f"check for a YAML typo in the sweep block."
        )
        raise ValueError(msg)

    schemes: list[dict] = []
    for method in methods:
        n_before = len(schemes)
        if method == "slot_persistent_signal_exit":
            for long_q in long_qs:
                for direction in directions:
                    # slot books are single-direction by construction:
                    # long_only/short_only are supported, long_short is dropped.
                    if direction not in ("long_only", "short_only"):
                        continue
                    for max_slots in max_slots_grid:
                        if max_slots >= n_assets:
                            continue
                        for hold_bars in hold_bars_grid:
                            for exit_q in exit_qs:
                                exit_q_norm = None if exit_q is None else float(exit_q)
                                if exit_q_norm is not None and exit_q_norm >= long_q:
                                    continue
                                for bpd in bpd_grid:
                                    eq_tag = (
                                        "noexit"
                                        if exit_q_norm is None
                                        else f"q{int(exit_q_norm * 100):02d}"
                                    )
                                    name = (
                                        f"slot_{direction[0]}_lq{int(long_q * 100):02d}"
                                        f"_s{max_slots}_h{hold_bars}_{eq_tag}_b{bpd}"
                                    )
                                    schemes.append(
                                        {
                                            "name": name,
                                            "method": "slot_persistent_signal_exit",
                                            "long_q": long_q,
                                            "lookback_days": lookback_days,
                                            "bars_per_day": bpd,
                                            "max_slots": max_slots,
                                            "hold_bars": hold_bars,
                                            "exit_signal_q": exit_q_norm,
                                            "pred_freshness_max_min": pred_freshness,
                                            "direction": direction,
                                            "long_short": False,
                                        }
                                    )
        elif method == "eq_w_topk":
            for direction in directions:
                ls = direction == "long_short"
                for top_k in top_k_grid:
                    # Same ceiling as `get_top_k_values_for`, and it has to be
                    # applied here too: this branch sets `long_short` off its own
                    # `direction` axis, so a `k` in `(n_assets // 2, n_assets)`
                    # would register as `ewtopk_ls_k<k>` and run at the clamped
                    # half. `direction` is per-scheme, so use it rather than the
                    # case study's declared mode.
                    if top_k >= n_assets or (ls and 2 * top_k > n_assets):
                        continue
                    dtag = "ls" if ls else direction[0]
                    schemes.append(
                        {
                            "name": f"ewtopk_{dtag}_k{top_k}",
                            "method": "equal_weight_top_k",
                            "top_k": top_k,
                            "long_short": ls,
                            "direction": "long_only" if ls else direction,
                        }
                    )
        else:
            msg = (
                f"signal_nasdaq100.selection_method has unknown method "
                f"{method!r} for case_studies/{case_study}; supported: "
                f"slot_persistent_signal_exit, eq_w_topk"
            )
            raise ValueError(msg)
        # A requested method that expands to zero schemes is the silent-zero
        # failure the validation exists to catch (e.g. a slot-only block with
        # direction=[long_short], which slot drops, or every grid value filtered
        # out by max_slots/top_k >= n_assets). Fail loudly instead.
        if len(schemes) == n_before:
            msg = (
                f"signal_nasdaq100 method {method!r} produced zero schemes for "
                f"case_studies/{case_study} (n_assets={n_assets}); every grid "
                f"combination was filtered out — check direction/max_slots/top_k."
            )
            raise ValueError(msg)
    return schemes


def get_top_k_values_for(
    case_study: str,
    label: str,
    n_assets: int,
    *,
    long_short: bool | None = None,
    ranked_width: int | None = None,
) -> list[int]:
    """Return the top-K grid for ``(case_study, label)`` used by Ch17.

     Filters out k >= n_assets (holding everything is the equal-weight
     benchmark, not a prediction-based portfolio). Raises ``KeyError`` if
     ``backtest.sweep.top_k_grid[label]`` is not declared, and ``ValueError``
     when the filter empties the grid.

     A long-short strategy takes ``k`` names on each side, so its ceiling is half
     the universe, not all of it. ``signals.py`` clamps ``eff_k`` to
     ``n_assets // 2`` when ``long_short`` is set, and ``top_k`` is identity-bearing
     through ``plan_backtests(signal=...)`` - so a ``k`` in the half-open interval
     ``(n_assets // 2, n_assets)`` passes this filter, registers under the declared
     ``k``, and runs at the clamped one. Two such values register distinct identities
     over a byte-identical weight series. ``get_entry_schemes_for`` has excluded that
     interval since it took a ``long_short`` argument; this function is the other half
     of the same grid and has to agree with it.

     ``long_short`` defaults to the case study's declared selection mode - the same
     ``mapping.position_state_space`` that ``get_backtest_config`` reads, which is what
     every caller already passes to ``get_entry_schemes_for`` as ``bt_config.long_short``.
     Not ``account.allow_short_selling``: that is an execution permission, and
     ``sp500_options`` holds it while selecting long-only. Pass it explicitly to override.

     An empty grid is never a legitimate result: the caller multiplies it into
     a sweep size, so zero concentrations means zero backtests, and the sweep
     loop then completes without registering anything while still reporting
     itself done. The downstream risk-overlay notebook is the only thing that
     notices, and it blames the operator for not having run this stage. Raise
     here instead, where the cause - a universe cap smaller than the smallest
     declared k - is still visible.

     ``ranked_width`` is the prediction cross-section, resolved from the registry
     when not supplied, and the filter runs against the narrower of it and
     ``n_assets`` for the reason given under ``get_entry_schemes_for``
    . The two functions are halves of one grid and
     have to filter it the same way or a sweep and its plumbing test disagree
     about which concentrations exist.
    """
    sweep = load_sweep(case_study)
    grid = (sweep.get("top_k_grid") or {}).get(label)
    if grid is None:
        raise KeyError(
            f"backtest.sweep.top_k_grid[{label!r}] not declared in "
            f"case_studies/{case_study}/config/setup.yaml"
        )
    if long_short is None:
        long_short = get_declared_long_short(case_study)
    if ranked_width is None:
        ranked_width = ranked_cross_section_width(case_study, label)
    tradeable = n_assets if ranked_width is None else min(n_assets, ranked_width)
    ceiling = tradeable // 2 if long_short else tradeable
    values = [
        int(k) for k in grid if int(k) < tradeable and not (long_short and 2 * int(k) > tradeable)
    ]
    if not values:
        smallest = min(int(k) for k in grid)
        # What the universe has to hold, which is not the same number in the two
        # cases. Long-short takes `k` names on each side, so it needs `2k`, and
        # saying "raise MAX_SYMBOLS above k" there sends the reader to a cap that
        # cannot fix it - or worse, to a cap the fixture cannot reach at all.
        needed = (
            f"at least {2 * smallest} names for the smallest declared k of {smallest} "
            f"({smallest} long and {smallest} short)"
            if long_short
            else f"more than {smallest} names for the smallest declared k"
        )
        side = f"long-short ceiling ({ceiling})" if long_short else "universe"
        raise ValueError(
            f"top_k_grid[{label!r}] = {list(grid)} is empty after filtering against "
            f"{tradeable} tradeable names for case_studies/{case_study} (price panel "
            f"{n_assets}, ranked cross-section {ranked_width}): every declared k holds "
            f"the whole {side}. A cross-sectional sweep here needs {needed}. Raise the "
            f"universe cap (MAX_SYMBOLS) if the panel has them, widen the panel if it "
            f"does not, or declare a smaller k."
        )
    return values


_MOMENT_ALLOCATORS = {"inverse_vol", "risk_parity", "hrp", "mvo_ledoit_wolf", "mvo"}
_LOOKBACK_KEYS = ("vol_window", "lookback")


def get_allocators(case_study: str) -> list[dict]:
    """Return the Ch17 allocator configs (lookback-injected from setup.yaml).

    Each dict matches the shape consumed by
    ``case_studies.utils.backtest_runner._apply_allocation``:
    ``{"method": str, ...kwargs}``. Common kwargs: ``vol_window``,
    ``lookback``, ``max_weight``.

    Moment-based allocators (inverse_vol, risk_parity, hrp, mvo_ledoit_wolf)
    receive ``vol_window``/``lookback`` from the CS-level
    ``execution.allocator_lookback`` — a single window keeps allocators
    comparable. Calendar-string fallback (``"3M"``/``"6M"``) is still
    supported on individual entries when an override is needed, but the
    standard path is the CS-level lookback.

    The ``name`` key in setup.yaml is human-readable metadata only; it is
    stripped here so the dispatcher sees a stable spec shape and the
    allocation-stage registry hash is reproducible.
    """
    raw = load_sweep(case_study).get("allocators") or []
    cs_lookback = None
    if any(a.get("method") in _MOMENT_ALLOCATORS for a in raw):
        cs_lookback = get_allocator_lookback(case_study)
    ppy = _periods_per_year_for(case_study) if _needs_calendar_resolve(raw) else None
    resolved = []
    for entry in raw:
        out = {k: v for k, v in entry.items() if k != "name"}
        # CS-level lookback injection for moment-based allocators that don't
        # carry an explicit per-entry override.
        if out.get("method") in _MOMENT_ALLOCATORS and cs_lookback is not None:
            if out["method"] in {"mvo", "mvo_ledoit_wolf"}:
                out.setdefault("lookback", cs_lookback)
            else:
                out.setdefault("vol_window", cs_lookback)
        # Calendar-string overrides (``"3M"`` etc.) resolved against ppy.
        for key in _LOOKBACK_KEYS:
            if key in out and isinstance(out[key], str):
                if ppy is None:
                    raise RuntimeError("calendar lookback resolution requires periods_per_year")
                out[key] = _resolve_calendar_lookback(out[key], ppy)
        resolved.append(out)
    return resolved


def _needs_calendar_resolve(allocators: list[dict]) -> bool:
    return any(isinstance(a.get(k), str) for a in allocators for k in _LOOKBACK_KEYS)


def get_allocator_label(alloc: dict) -> str:
    """Return a human-readable label for an allocator dict (``alloc['method']``)."""
    return str(alloc.get("method", "unknown"))


def get_cost_grid_bps(case_study: str) -> list[float]:
    """Return the Ch18 cost-sweep grid (bps; commission + slippage combined)."""
    return [float(c) for c in (load_sweep(case_study).get("cost_grid_bps") or [])]


def get_htm_cost_cascade(case_study: str) -> dict:
    """Return the Ch18 HTM cost-cascade block (sp500_options only).

    The cascade dispatches the O'Donovan & Yu (2025) hold-to-expiry cost
    analysis: entry-only half-spread fractions, optionally restricted to a
    liquid-universe subset (rung-3). Raises ``KeyError`` if the block is
    missing — case studies that use the standard bps regime should call
    :func:`get_cost_grid_bps` instead.
    """
    block = load_sweep(case_study).get("htm_cost_cascade")
    if block is None:
        raise KeyError(
            f"backtest.sweep.htm_cost_cascade missing from "
            f"case_studies/{case_study}/config/setup.yaml — only the "
            f"HTM-cascade case studies (sp500_options) declare this block."
        )
    return block


def get_universe_filters_for(case_study: str) -> list[str | None]:
    """Return the list of ``strategy.signal.universe_filter`` axis values to sweep.

    ``None`` represents the full universe (no filter applied, equivalent to
    ``apply_universe_filter`` returning predictions unchanged). Other values
    are passed through to ``apply_universe_filter`` in ``backtest_runner.py``
    where they drive a spec-declared universe restriction at the
    rebalance-date grain. Two names are supported there: ``"liquid"`` (the
    sp500_options bottom-quantile half-spread subset) and ``"cost_feasible"``
    (the nasdaq100_microstructure frozen per-split symbol list under
    ``setup.yaml::universe.cost_feasible``); anything else raises.

    Sourced from ``backtest.sweep.universe_filter`` in ``setup.yaml``: a
    single scalar value pinning the canonical sweep to one universe. For
    sp500_options this is ``"liquid"`` (the only economic universe for the
    HTM straddle strategy after costs); absent everywhere else, which
    yields ``[None]``. ``"full"`` and ``"none"`` are normalized to ``None``
    so pre-universe-axis registry rows (which carry no
    ``signal.universe_filter`` in their spec) remain hash-stable.

    Note: ``backtest.sweep.htm_cost_cascade.universes`` is a separate
    Ch18-only block consumed directly by ``15_costs.py`` via
    ``get_htm_cost_cascade``; it is the cost-comparison axis (full vs
    liquid) and does NOT participate in the canonical rank-1 sweep.
    """
    sweep = load_sweep(case_study)
    uf = sweep.get("universe_filter")
    if uf is not None:
        return [(None if str(uf).lower() in ("full", "none") else str(uf))]
    return [None]


def get_position_risk_controls(case_study: str) -> list[dict]:
    """Return the Ch19 position-level risk controls (engine case studies only)."""
    risk = load_sweep(case_study).get("risk_controls") or {}
    return list(risk.get("position") or [])


def get_portfolio_risk_controls(case_study: str) -> list[dict]:
    """Return the Ch19 portfolio-level risk controls (all case studies)."""
    risk = load_sweep(case_study).get("risk_controls") or {}
    return list(risk.get("portfolio") or [])


def get_signal_passes_for(case_study: str) -> dict | None:
    """Return the declared two-pass plan for the signal stage, or ``None``.

    Shape, from ``backtest.sweep.signal_passes`` in ``setup.yaml``::

        signal_passes:
          baseline_schemes: [ew_top5, ew_top10, ew_top20]
          baseline_universe: cost_feasible
          mechanism_top_n: 8
          reference_schemes: [ew_top5, ew_top10, ew_top20]
          reference_universe: full

    Pass 1 runs ``baseline_schemes`` on ``baseline_universe`` over every
    admissible prediction. Pass 2 runs every remaining entry scheme on
    ``baseline_universe``, plus ``reference_schemes`` on ``reference_universe``,
    over the ``mechanism_top_n`` predictions with the highest pass-1 Sharpe.

    A case study that declares no block gets ``None`` and its sweep is the
    single pass over the full cross-product, which is what every case study
    other than ``nasdaq100_microstructure`` runs.

    Universe names are normalized the way ``get_universe_filters_for``
    normalizes them: ``full`` and ``none`` become ``None``, because a ``None``
    written into a spec is not the same thing as an absent key and every row
    registered before the universe axis existed carries the absent one.

    Raises on a block that is present but unusable, rather than returning a
    plan that quietly sweeps nothing: an empty ``baseline_schemes`` would run
    pass 1 over zero arms, rank nothing, and hand pass 2 an empty selection,
    which reports a completed sweep and registers no rows.
    """
    sweep = load_sweep(case_study)
    block = sweep.get("signal_passes")
    if block is None:
        return None

    def _universe(key: str) -> str | None:
        raw = block.get(key)
        if raw is None:
            return None
        return None if str(raw).lower() in ("full", "none") else str(raw)

    baseline = [str(x) for x in (block.get("baseline_schemes") or [])]
    reference = [str(x) for x in (block.get("reference_schemes") or [])]
    top_n = int(block.get("mechanism_top_n") or 0)
    if not baseline or top_n <= 0:
        msg = (
            f"backtest.sweep.signal_passes for {case_study} declares "
            f"baseline_schemes={baseline} and mechanism_top_n={top_n}; both must be "
            "non-empty or the sweep ranks nothing and pass 2 selects nothing. Remove "
            "the block to sweep the full cross-product instead."
        )
        raise ValueError(msg)
    return {
        "baseline_schemes": baseline,
        "baseline_universe": _universe("baseline_universe"),
        "mechanism_top_n": top_n,
        "reference_schemes": reference,
        "reference_universe": _universe("reference_universe"),
    }

```

在遵守原作品许可的前提下,附作者信息全文展示。 许可协议: MIT

此摘要由 Stratmill 研究智能体根据原文撰写,并非原文副本。