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

Чувствительность ETF к издержкам: за акцию и в базисных пунктах

Код Machine Learning for Trading

Сводка

В этом ноутбуке ведущие конфигурации распределения капитала в ETF пересчитываются для двух сеток издержек при неизменных прогнозах, концентрации и правилах распределения. Сравниваются модель комиссии за акцию и половины спреда из примера, отражающая связь издержек торговли ETF с числом акций, и сетка в базисных пунктах, где комиссия пропорциональна торговому обороту в денежном выражении. Зарегистрированные бэктесты при нескольких уровнях издержек формируют кривые снижения результатов; по ним можно оценить, где чистый коэффициент Шарпа достигает нуля или насколько границы проверенной сетки удалены от этой точки. Сравнение призвано показать чувствительность к допущениям об издержках, а не заново ранжировать стратегии. В ноутбуке подчёркивается, что два режима издержек отвечают на разные вопросы; их следует сравнивать по наклону кривых и точкам безубыточности, а не сопоставлять точка к точке. В каждом переборе издержки применяются единообразно, тогда как производственное ценообразование использует карту спредов по активам. Анализ использует фолды валидации, не учитывает рыночное воздействие и ограничения ёмкости и выбирает лидеров из уже успешной группы; поэтому допустимый уровень издержек не гарантирован для более крупных портфелей или более широкой совокупности стратегий.

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

  • Фиксация зарегистрированной конфигурации стратегии позволяет изолировать влияние изменения допущений о транзакционных издержках.
  • Издержки за акцию растут с числом торгуемых акций, а издержки в базисных пунктах — с торговым оборотом в денежном выражении.
  • Сетка издержек помогает определить точку безубыточности или установить её нижнюю границу, если проверенный диапазон её не достигает.
  • Частота ребалансировки влияет на то, как часто стратегия несёт издержки, и определяет её устойчивость к ним.
  • Единые переборы не учитывают спреды отдельных активов, рыночное воздействие и ограничения ёмкости.

Теги

Полный текст
# 17_costs.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]
# # ETF costs: where the edge runs out
#
# Every Sharpe ratio so far was earned at one cost level - the one `config/setup.yaml` declares.
# That single number hides the question a reader actually needs answered, which is not "what did it
# make" but **"how much friction would it take to make nothing"**. A strategy whose Sharpe is
# unchanged at four times the declared cost is a different proposition from one that breaks even
# just above it, even when the two report the same number today.
#
# This notebook walks a cost grid over the leading allocation-stage combinations and registers a
# backtest at each level, so the decay curve is a set of registered results rather than an
# extrapolation from one point.
#
# **Two cost regimes are swept, and they are not two views of one thing.** The case study's
# declared model is per-share commission plus a half-spread in cents, which is how an ETF actually
# trades: the cost of a share does not scale with its price. The basis-point grid is the convention
# most of the literature uses and most other case studies here declare, and it is run alongside as
# a comparator. The two disagree by construction - a one-cent half-spread is about one basis point
# on a $500 fund and about five on a $20 one - so the panels are read for their slopes and their
# breakevens, not point against point.
#
# **Learning objectives**
#
# - Read a cost decay curve and locate the level at which a strategy stops paying.
# - Say why a per-share cost model and a basis-point cost model give different answers on the same
#   universe, and which one this universe is priced under.
# - Say what a uniform cost sweep does and does not tell you about a strategy priced under a
#   per-asset spread map.
#
# **Book reference**: Chapter 18, Sections 18.2 to 18.5.
#
# **Prerequisites**: [`16_risk_management`](16_risk_management.ipynb), and through it
# [`15_portfolio_management`](15_portfolio_management.ipynb) and [`14_backtest`](14_backtest.ipynb).
# This is the last stage that selects, so it runs after all three and draws from all of them.
#
# **What it writes**: one row in `backtest_runs` per combination and cost level, at
# `stage='cost_sensitivity'`, under both regimes.

# %%
"""Re-price the leading ETF allocation combinations across two cost-model grids."""

import json
import sqlite3
import time
import warnings

import plotly.graph_objects as go
import polars as pl
import yaml
from plotly.subplots import make_subplots

from case_studies.research import open_study, reuse_disclosure, split_unpublished_members
from case_studies.utils.backtest_explorer import BacktestExplorer
from case_studies.utils.backtest_loaders import get_backtest_config, load_backtest_prices_for
from case_studies.utils.backtest_presets import (
    clone_backtest_spec,
    ensure_backtest_spec,
    set_backtest_costs_bps,
    set_backtest_costs_per_share,
    strategy_view,
)
from case_studies.utils.backtest_runner import run_backtest
from case_studies.utils.registry import (
    load_existing_backtest_hashes,
    load_prediction_index,
    read_predictions,
    resolve_best_backtest_runs,
)
from case_studies.utils.sweep_config import (
    get_cost_grid_bps,
    get_cost_grid_half_spread_usd,
    get_top_n_predictions,
)
from case_studies.utils.uncertainty import STAGE_SEQUENCE
from utils.paths import get_case_study_dir
from utils.style import COLORS, ml4t_palette, show_plotly_with_alt

warnings.filterwarnings("ignore")

# %% tags=["parameters"]
CASE_STUDY_ID = "etfs"
LABEL = ""
MAX_SYMBOLS = 0
# None defers to the case study's configured count; an int caps it.
TOP_N_COMBOS = None
# Both names stay bound here although nothing below reads them: that is what makes the harness
# force preview and supply a workspace - `_declares_tier_and_workspace` in `tests/pm_helpers.py`
# looks for exactly this pair. Without them the canonical
# branch regenerates in place, which needs symlinks a CI checkout does not have.
EXECUTION_TIER = "canonical"
WORKSPACE: str = ""

# %% [markdown]
# ## 1. What is being re-priced, and under what
#
# The combinations are the highest-Sharpe validation runs across every stage a selected
# configuration can come from - the equal-weight baseline, the allocation sweep and the risk
# overlay - not the allocation stage alone. Each later stage is an alternative to the one before it
# rather than an improvement on it by construction: where every allocator lands below the
# equal-weight parent it was built from, an allocation-only rule would carry forward a strategy the
# earlier notebook measured as worse than doing nothing, and where every risk control hurts, an
# overlay-only rule would charge costs against an overlay the sweep just found unhelpful. Which
# stage wins is decided by measurement here and printed below, not by which stages this query
# happens to name.
#
# They are re-priced rather than re-selected: the prediction, the concentration and the allocator
# are held exactly as registered, and the only thing that moves is the cost model. That is what
# makes the curve below a statement about cost sensitivity and not about which strategy happens to
# do best under friction.
#
# The per-share commission is read from `setup.yaml` with no default. Elsewhere in the fleet that
# key is exploratory and a missing value can fall back; here it is the headline regime, so a
# missing key has to fail loudly rather than quietly re-price the whole sweep at somebody's
# default.

# %%
# Configuration only. Anything that reads the registry resolves the directory at call time,
# because `Study.activate()` moves the output root for a preview run and this binding predates it.
CASE_DIR = get_case_study_dir(CASE_STUDY_ID)
bt_config = get_backtest_config(CASE_STUDY_ID)
if TOP_N_COMBOS is None:
    TOP_N_COMBOS = get_top_n_predictions(CASE_STUDY_ID, "cost_sensitivity")
if not LABEL:
    LABEL = bt_config.primary_label

COST_GRID_BPS = get_cost_grid_bps(CASE_STUDY_ID)
COST_GRID_HALF_SPREAD_USD = get_cost_grid_half_spread_usd(CASE_STUDY_ID)
PER_SHARE_COMMISSION = float(
    yaml.safe_load((CASE_DIR / "config" / "setup.yaml").read_text())["costs"]["per_share"]
)

print(f"Case study: {CASE_STUDY_ID}, label: {LABEL}")
print(f"Per-share commission: ${PER_SHARE_COMMISSION}/share")
print(f"Basis-point grid:     {COST_GRID_BPS}")
print(f"Half-spread grid (¢): {[round(v * 100, 2) for v in COST_GRID_HALF_SPREAD_USD]}")

# %% [markdown]
# **The population the leaders are drawn from.** A refit publishes a second generation under the
# same population name and leaves the one it replaced in the registry, backtests and all. Reading
# the leaders without asking the population lineage lets a retired identity - or one no population
# ever listed - be re-priced here and carried onward as though it were what the model notebook
# publishes.

# %%
LIVE_PREDICTIONS = (
    split_unpublished_members(
        open_study(CASE_STUDY_ID, execution_tier=EXECUTION_TIER, workspace=WORKSPACE or None),
        load_prediction_index(CASE_STUDY_ID, label=LABEL, split="validation"),
    )
    .live["prediction_hash"]
    .to_list()
)
if not LIVE_PREDICTIONS:
    raise RuntimeError(
        f"no live prediction sets for {CASE_STUDY_ID}/{LABEL}/validation; run 14_backtest first"
    )
print(f"Live prediction sets: {len(LIVE_PREDICTIONS):,}")

# %%
# The stages the sweep may draw the configuration it overlays from. This is not a free choice: it is
# exactly the set `resolve_canonical_rank1_lineage` selects over, and the two have to agree. Pool
# anything narrower and they can name different configurations - the curve below would then describe
# a strategy `20_strategy_analysis` does not report, and that notebook would find no cost rows for
# the configuration it did select.
#
# Breadth is also what keeps the risk question empirical. The risk stage files one row per named
# control and none for the un-overlaid strategy, so a pool of `risk_overlay` alone would force an
# overlay onto the selected configuration even where every control hurt it - letting the shape of a
# query decide what the sweep is supposed to measure. `signal` and `allocation` are how an
# un-overlaid configuration wins when it deserves to.
#
# `cost_sensitivity` stays out: pooling it would let a cost-charged run re-enter the selection it
# is a consequence of. It is also the terminal stage, which is why taking everything before it
# is the whole pool rather than a subset of one - `STAGE_SEQUENCE` is the order the backtests
# run and `STAGE_CARRIER_BLOCK` records that nothing is ever built on top of a cost sweep.
# Derived from that sequence rather than written out again, so a stage added to the pipeline
# joins this pool without anyone remembering to come here.
#
# `holdout` is the one member of the resolver's list absent from both, and not by choice. That
# resolver reads `backtest_runs` with raw SQL; this path goes through
# `registry.store._stage_filter_clause`, whose `VALID_STAGES` raises on anything outside
# {signal, allocation, risk_overlay, cost_sensitivity}. Naming it here would not widen the pool,
# it would raise before the first stage was read. The two selections still agree, because the
# resolver asks for validation-split rows and a holdout-stage backtest is not one.
PRE_COST_STAGES = tuple(stage for stage in STAGE_SEQUENCE if stage != "cost_sensitivity")


def resolve_pre_cost_runs(top_n: int) -> pl.DataFrame:
    """The highest-Sharpe validation runs across every stage the selected configuration may come from.

    Each stage is asked for its whole ranked list and the pool is sorted afterwards, rather than
    taking `top_n` from each and merging them: truncating first lets one stage's leader hold a
    slot that a better run in another stage should have had, and at `top_n=1` that drops a whole
    stage from consideration instead of falling through to the next candidate.

    No solvency filter, unlike `us_firm_characteristics/14_costs`, which drops runs whose equity
    reached zero before sweeping them - its long-short book has no margin call, so a run can
    compound through zero and carry a Sharpe computed on a balance that no longer exists. That
    boundary belongs with the stages that apply it, and this case study's backtest stages apply
    none. Measured on this registry 2026-08-30: 1,289 backtests, none with `max_drawdown` at or
    past -100% and none missing it, so the filter would exclude nothing here while introducing a
    criterion the stages it draws from never used.
    """
    ranked = [
        frame.with_columns(pl.lit(stage).alias("pool_stage"))
        for stage, frame in (
            (
                stage,
                resolve_best_backtest_runs(
                    CASE_STUDY_ID,
                    LABEL,
                    split="validation",
                    stage=stage,
                    top_n=1_000_000,
                    prediction_hashes=set(LIVE_PREDICTIONS),
                ),
            )
            for stage in PRE_COST_STAGES
        )
        if not frame.is_empty()
    ]
    if not ranked:
        return pl.DataFrame()
    return (
        pl.concat(ranked)
        .sort("sharpe", descending=True)
        .unique("backtest_hash", maintain_order=True)
        .head(top_n)
    )


# %% tags=["results"]
top_combos = resolve_pre_cost_runs(TOP_N_COMBOS)
if top_combos.is_empty():
    raise RuntimeError(
        "no backtests are registered at any of "
        f"{', '.join(PRE_COST_STAGES)}, so there is nothing to re-price; run 14_backtest, "
        "15_portfolio_management and 16_risk_management first"
    )
# `resolve_best_backtest_runs` returns the stored specification and the Sharpe, and nothing
# about the model behind it - the family and configuration are projected away. The model is
# read from the explorer and joined on `backtest_hash`, which both carry.
sources: dict[str, str] = {}
for _stage in PRE_COST_STAGES:
    sources.update(
        BacktestExplorer(CASE_STUDY_ID)
        .best(stage=_stage, top_n=100000, label=LABEL, prediction_hashes=LIVE_PREDICTIONS)
        .select("backtest_hash", "source")
        .iter_rows()
    )
# The stage each selected run came from is printed rather than assumed, because which one wins is
# the question the pool exists to leave open.
for row in top_combos.iter_rows(named=True):
    allocator = strategy_view(json.loads(row["spec_json"])).get("allocation", {}).get("method")
    print(
        f"  Sharpe={row['sharpe']:+.3f}  stage={row['pool_stage']}  "
        f"{sources.get(row['backtest_hash'], 'unknown source')}  "
        f"alloc={allocator}  backtest={row['backtest_hash'][:8]}"
    )

# Whether the overlay earned its place, reported rather than assumed. The risk stage files a row
# per named control and none for the un-overlaid strategy, so the two sides have to be read
# separately and differenced. A negative difference is the stage saying its controls did not help,
# which is a result and not a failure.
#
# Both sides are restricted to the same live population the selection ran over. Without
# that, a retired or unpublished generation can supply either Sharpe, and the difference would
# then compare a number the sweep would never carry against one it might.
_best: dict[str, float | None] = {}
for _stage in ("risk_overlay", "allocation"):
    _frame = resolve_best_backtest_runs(
        CASE_STUDY_ID,
        LABEL,
        split="validation",
        stage=_stage,
        top_n=1,
        prediction_hashes=set(LIVE_PREDICTIONS),
    )
    _best[_stage] = None if _frame.is_empty() else _frame["sharpe"][0]
if _best["risk_overlay"] is None:
    print("  Risk overlay: no run registered, so the selected configuration above is un-overlaid.")
elif _best["allocation"] is None:
    print(f"  Risk overlay: {_best['risk_overlay']:+.3f}, with no allocation run to compare it to.")
else:
    _delta = _best["risk_overlay"] - _best["allocation"]
    print(
        f"  Best overlaid {_best['risk_overlay']:+.3f} vs best un-overlaid "
        f"{_best['allocation']:+.3f}, difference {_delta:+.3f}"
    )

# %%
prices = load_backtest_prices_for(CASE_STUDY_ID, LABEL, split="validation", max_symbols=MAX_SYMBOLS)
print(f"Prices: {len(prices):,} rows, {prices['symbol'].n_unique()} tradeable funds")

# %% [markdown]
# ## 2. Sweeping both grids
#
# **The sweep applies one flat rate to every fund, which the production backtests do not.** The
# signal, allocation and risk stages price each fund from the tiered per-asset half-spread map in
# `setup.yaml` - a half cent on the largest funds, a cent on the sector funds, two cents by
# default. What follows is therefore one-axis sensitivity to a universe-wide cost level, not a
# re-pricing of the live cost structure. Read the slope and the crossing point; do not read a
# single point as what the strategy would have earned.


# %%
# Accumulated across both regime sweeps and read by the curve loader below.
SWEPT_COST_HASHES: set[str] = set()


def _stage_of(backtest_hash: str) -> str | None:
    """The stage one backtest was actually registered under, read back from the registry.

    The directory is resolved here rather than reused from `CASE_DIR`, which is bound above
    the first `open_study` call. `Study.activate()` re-points the output root at
    `<workspace>/.preview` for a preview run, so a preview sweep registers into a registry
    that `CASE_DIR` does not name - and this check would then read the canonical database,
    find none of the hashes it just wrote, and report every one of them misfiled. Resolving
    at call time is what `resolve_best_backtest_runs` and `load_existing_backtest_hashes`
    already do, so this reads the same database the rest of the notebook does.
    """
    registry = get_case_study_dir(CASE_STUDY_ID) / "run_log" / "registry.db"
    with sqlite3.connect(str(registry)) as conn:
        row = conn.execute(
            "SELECT stage FROM backtest_runs WHERE backtest_hash = ?", (backtest_hash,)
        ).fetchone()
    return None if row is None else row[0]


def sweep_costs(regime: str, grid, apply_costs) -> tuple[int, list[dict]]:
    """Re-price every leading combination at every level of one cost grid.

    Prints two facts a reader needs both of: what the stage held before this run, and what this
    execution did. `run_backtest` returns a cached result and a fresh fit through the same call,
    so a warm re-run would otherwise report a completed sweep in no time at all - a wrong number
    that looks exactly like a right one - while reporting only what this run computed would make
    that same re-run look like an empty stage.

    Failures carry the exception that caused them. A count on its own cannot distinguish a sweep
    that lost one level from one that lost every level of a regime, and the two mean very
    different things about the curve.
    """
    registered, served, failures = 0, 0, []
    started = time.time()
    total = len(top_combos) * len(grid)
    # Every row this sweep stands behind, cached ones included. The registry also holds
    # cost-sensitivity rows from earlier sweeps whose leaders differed; averaging those into the
    # curves below would price a combination this run never selected.
    swept: set[str] = SWEPT_COST_HASHES
    registered_before = load_existing_backtest_hashes(CASE_STUDY_ID, stage="cost_sensitivity")
    print(f"{regime}: {len(registered_before):,} cost-sensitivity backtests already registered")

    for combo_row in top_combos.iter_rows(named=True):
        pred_hash = combo_row["prediction_hash"]
        base_spec = ensure_backtest_spec(
            CASE_STUDY_ID,
            bt_config,
            json.loads(combo_row["spec_json"]),
            prices=prices,
            prediction_hash=pred_hash,
            initial_cash=bt_config.initial_cash,
        )
        allocator = strategy_view(base_spec).get("allocation", {}).get("method", "equal_weight")
        predictions = read_predictions(CASE_STUDY_ID, pred_hash)

        for level in grid:
            spec = apply_costs(clone_backtest_spec(base_spec), level)
            spec["chapter"] = "ch18"
            try:
                result = run_backtest(
                    CASE_STUDY_ID,
                    pred_hash,
                    spec,
                    prices=prices,
                    predictions=predictions,
                    label=LABEL,
                    register=True,
                    initial_cash=bt_config.initial_cash,
                    calendar=bt_config.calendar,
                )
            except Exception as error:
                failures.append(
                    {
                        "regime": regime,
                        "allocator": allocator,
                        "level": level,
                        "error": f"{type(error).__name__}: {error}",
                    }
                )
                continue
            swept.add(result.backtest_hash)
            if result.backtest_hash in registered_before:
                served += 1
            registered += 1
            print(
                f"  [{registered}/{total}] {regime} {allocator} @ {level}: "
                f"Sharpe={result.metrics.get('sharpe', 0):+.3f}"
            )

    print(
        f"{regime} sweep in {time.time() - started:.0f}s: "
        f"{reuse_disclosure(registered - served, served, len(failures))}"
    )
    return registered, failures


bps_done, bps_failures = sweep_costs(
    "bps",
    COST_GRID_BPS,
    lambda spec, level: set_backtest_costs_bps(
        spec, commission_bps=level / 2, slippage_bps=level / 2
    ),
)
ps_done, ps_failures = sweep_costs(
    "per-share",
    COST_GRID_HALF_SPREAD_USD,
    lambda spec, level: set_backtest_costs_per_share(
        spec, per_share=PER_SHARE_COMMISSION, default_half_spread_usd=level
    ),
)

# %%
# Every hash this sweep registered has to have landed at `cost_sensitivity`. It is checked rather
# than assumed because the stage is inferred from the spec, and the spec being priced is a clone of
# the selected configuration's - so a selection from the risk stage brings its risk block along, and
# an inference that read that block before the chapter tag would file the whole curve as new risk
# overlays. The readback below would then report an empty stage, which points at the sweep rather
# than at the classification. This names it.
_misfiled = {
    _hash: _stage
    for _hash, _stage in ((_hash, _stage_of(_hash)) for _hash in sorted(SWEPT_COST_HASHES))
    if _stage != "cost_sensitivity"
}
if _misfiled:
    raise RuntimeError(
        f"{len(_misfiled)} of {len(SWEPT_COST_HASHES)} cost-sweep backtests were registered "
        f"under the wrong stage, e.g. {sorted(_misfiled.items())[:3]}; they carry chapter='ch18' "
        "so `registry.store._infer_stage` should classify them as cost_sensitivity"
    )

# %%
failures = bps_failures + ps_failures
if failures:
    failure_frame = pl.DataFrame(failures)
    print(f"{failure_frame.height} backtests raised. Distinct causes:")
    print(failure_frame.group_by("regime", "error").len().sort("len", descending=True))
else:
    print("no backtest raised in either regime")

# %% [markdown]
# ## 3. The decay curves
#
# Read back from the registry rather than from the sweep, so a resumed run and a fresh one show the
# same thing. Each row's cost level and allocator come out of its own registered specification,
# which is what the backtest hash was taken over.

# %%
COST_REGIMES = {"percentage": "bps per leg", "per_share": "cents of half-spread per share"}


def load_cost_curve(commission_model: str) -> pl.DataFrame:
    """Read the registered cost-sensitivity rows for one commission model."""
    rows = resolve_best_backtest_runs(
        CASE_STUDY_ID,
        LABEL,
        split="validation",
        stage="cost_sensitivity",
        top_n=100000,
        prediction_hashes=set(LIVE_PREDICTIONS),
    )
    if rows.is_empty():
        return pl.DataFrame()
    # Not every cost-sensitivity row for a live prediction belongs to this sweep: a previous
    # leader that is still live left its own curve behind. Keep the rows this run produced.
    rows = rows.filter(pl.col("backtest_hash").is_in(list(SWEPT_COST_HASHES)))
    if rows.is_empty():
        return pl.DataFrame()
    parsed = []
    for row in rows.iter_rows(named=True):
        spec = json.loads(row["spec_json"])
        config = spec.get("backtest_config", {})
        commission, slippage = config.get("commission", {}), config.get("slippage", {})
        if commission.get("model") != commission_model:
            continue
        level = (
            round((commission.get("rate", 0.0) + slippage.get("rate", 0.0)) * 10_000.0, 4)
            if commission_model == "percentage"
            else round(slippage.get("spread", 0.0) * 100.0, 4)
        )
        parsed.append(
            {
                "level": level,
                "sharpe": row["sharpe"],
                "allocator": strategy_view(spec)
                .get("allocation", {})
                .get("method", "equal_weight"),
            }
        )
    if not parsed:
        return pl.DataFrame()
    return (
        pl.DataFrame(parsed)
        .group_by("allocator", "level")
        .agg(pl.col("sharpe").mean())
        .sort("allocator", "level")
    )


bps_curve = load_cost_curve("percentage")
ps_curve = load_cost_curve("per_share")
if bps_curve.is_empty() and ps_curve.is_empty():
    raise RuntimeError("the cost-sensitivity stage registered no readable rows")
print(f"basis-point regime: {bps_curve.height} allocator-level points")
print(f"per-share regime:   {ps_curve.height} allocator-level points")

# %% [markdown]
# ### Where each regime crosses zero
#
# The breakeven is the cost level at which the mean Sharpe crosses zero, interpolated between the
# two grid points that bracket it. A curve that never crosses within the grid has no breakeven to
# report, and saying so is the answer: the grid did not reach far enough to find one.


# %% tags=["results"]
def breakeven(curve: pl.DataFrame) -> tuple[str, float | None]:
    """Where the mean Sharpe first crosses zero, and which of three cases the curve is.

    Returns one of "crosses" with the interpolated level, "never_positive" when the curve is
    already at or below zero at the cheapest level on the grid, or "stays_positive" when it
    has not crossed by the most expensive. The three are different findings and collapsing
    them into "no breakeven" would report a strategy that never paid and one that always paid
    with the same sentence.
    """
    if curve.is_empty():
        return "empty", None
    mean_curve = curve.group_by("level").agg(pl.col("sharpe").mean()).sort("level")
    levels = mean_curve["level"].to_list()
    sharpes = mean_curve["sharpe"].to_list()
    if sharpes[0] <= 0:
        return "never_positive", levels[0]
    for (low, low_sharpe), (high, high_sharpe) in zip(
        zip(levels, sharpes, strict=True), zip(levels[1:], sharpes[1:], strict=True), strict=False
    ):
        if low_sharpe >= 0 > high_sharpe:
            return "crosses", low + (high - low) * low_sharpe / (low_sharpe - high_sharpe)
    return "stays_positive", levels[-1]


for name, curve, unit in [
    ("basis points per leg", bps_curve, "bps"),
    ("cents of half-spread", ps_curve, "¢"),
]:
    case, level = breakeven(curve)
    if case == "empty":
        print(f"{name}: no registered rows")
    elif case == "never_positive":
        print(
            f"{name}: mean Sharpe is already at or below zero at {level}{unit}, the cheapest "
            "level on the grid, so there is no edge for cost to consume"
        )
    elif case == "stays_positive":
        print(
            f"{name}: mean Sharpe is still above zero at {level}{unit}, the most expensive "
            "level on the grid, so the grid does not reach the breakeven"
        )
    else:
        print(f"{name}: mean Sharpe crosses zero at {level:.2f}{unit}")

# %%
fig = make_subplots(
    rows=1,
    cols=2,
    shared_yaxes=True,
    subplot_titles=("Basis points per leg (comparator)", "Cents of half-spread (declared regime)"),
)
allocators = sorted(
    set(bps_curve["allocator"].to_list() if not bps_curve.is_empty() else [])
    | set(ps_curve["allocator"].to_list() if not ps_curve.is_empty() else [])
)
palette = ml4t_palette(max(len(allocators), 1), categorical=True)
for column, curve in ((1, bps_curve), (2, ps_curve)):
    if curve.is_empty():
        continue
    for index, allocator in enumerate(allocators):
        subset = curve.filter(pl.col("allocator") == allocator).sort("level")
        if subset.is_empty():
            continue
        fig.add_trace(
            go.Scatter(
                x=subset["level"].to_list(),
                y=subset["sharpe"].to_list(),
                mode="lines+markers",
                name=allocator,
                legendgroup=allocator,
                showlegend=column == 1,
                line=dict(color=palette[index % len(palette)]),
            ),
            row=1,
            col=column,
        )
    fig.add_hline(
        y=0, line_width=1, line_dash="dash", line_color=COLORS["neutral"], row=1, col=column
    )
fig.update_xaxes(title_text="Total cost, bps per leg", row=1, col=1)
fig.update_xaxes(title_text="Half-spread, cents per share", row=1, col=2)
fig.update_yaxes(title_text="Net Sharpe ratio", row=1, col=1)
fig.update_layout(
    title="Two cost models disagree about the same strategy",
    height=460,
    width=980,
    margin=dict(t=110),
)
_all_sharpes = pl.concat(
    [curve["sharpe"] for curve in (bps_curve, ps_curve) if not curve.is_empty()]
)
show_plotly_with_alt(
    fig,
    "Two side-by-side line charts of net Sharpe ratio against transaction cost, one line per "
    "allocator, sharing a y-axis, each with a dashed line at zero. The left panel's x-axis is "
    "basis points per leg and the right panel's is cents of half-spread per share. Counted from "
    f"the frames: {bps_curve.height} points on the left and {ps_curve.height} on the right, across "
    f"{len(allocators)} allocators, net Sharpe from {_all_sharpes.min():+.2f} to "
    f"{_all_sharpes.max():+.2f}.",
)

# %% [markdown]
# ## 4. What to notice
#
# **The rebalancing cadence is what buys cost tolerance, and it is a design choice rather than a
# result.** A position held for a month pays its round trip once and earns a month of return
# against it; the same position held for a day pays it about twenty times over the same month. So
# where the crossing falls - or whether the grid reaches one at all - was largely decided by the
# label horizon back in [`02_labels`](02_labels.ipynb), long before any cost was charged.
#
# **A grid that does not reach the crossing has still answered something.** It bounds the friction
# the strategy tolerates from below rather than locating it, and the honest report of that is the
# bound, not an extrapolation of the curve past its last point. Widening the grid is what would
# locate it, and that is a decision about what friction is worth modelling rather than a defect in
# the sweep.
#
# **The two regimes are two different questions, and the declared one is the per-share panel.** A
# basis-point cost says friction scales with the value traded. A per-share cost says it scales with
# the number of shares, which is what an ETF spread actually does - so the same half-spread is
# cheap on a high-priced fund and expensive on a low-priced one, and a universe holding both is
# mis-priced by either model applied uniformly. The gap between the panels is the size of that
# convention's effect on this universe.
#
# **A breakeven is not a safety margin.** It is where the mean Sharpe reaches zero on a curve
# fitted to validation folds, under a uniform cost, with no market impact and no capacity limit.
# The distance between the declared cost and that crossing is worth knowing and is not a promise.
#
# **Known limitations.** The sweep applies one flat rate to every fund while the production stages
# price each from the tiered map, so this is sensitivity to a level rather than a re-pricing.
# Nothing here models impact, so a larger book than the declared initial cash would face costs this
# curve does not contain. The combinations re-priced are the leaders of their pool, so the curve
# describes how the strategies that already did well degrade, not how the whole population does.
# And every point is measured on validation folds; the holdout is not consulted.

# %% [markdown]
# **Next**: [`18_holdout_predictions`](18_holdout_predictions.ipynb) refits the selected
# configuration this sweep priced on the history before the holdout window,
# [`19_holdout_backtest`](19_holdout_backtest.ipynb) trades it there, and
# [`20_strategy_analysis`](20_strategy_analysis.ipynb) reports the whole progression. This is the
# last stage that selects; nothing after it chooses anything.

```

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

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