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

交易成本单位、换手率、市场冲击与盈亏平衡门槛

代码 《交易机器学习》

总结

本笔记构建了一个考虑单位的框架,用于估算股票、加密货币永续合约、期货、ETF 和外汇的交易成本。它区分股数成交量、合约成交量、基础资产成交量和价格更新次数;只有当价格和成交量描述同一交易数量时,才将交易活动换算为美元成交额。要将已公布的费用换算为相对名义价值的成本,还需要股价或期货乘数等具体交易信息。

分析比较不同订单规模下的佣金、价差和市场冲击,再将单笔交易成本换算为覆盖交易频率所需的总收益。股票借券被作为单独成本处理,因为它会在空头持仓期间持续累积。证据来自日度市场数据、已公布的费率表和示意性情景假设。不同市场面板的覆盖期各不相同,费率表反映单一时点,冲击参数是情景设定而非拟合估计;成交额比较仅作描述,不是容量估计。

核心观点

  • 只有当成交量单位与相应价格序列匹配时,才能将其换算为美元成交额。
  • 将每股或每合约收费与名义价值比较前,需要具体交易输入。
  • 佣金、价差和市场冲击中哪项成本占主导,取决于订单规模。
  • 交易频率会将单笔成本折算为年度收益门槛。
  • 空头头寸的借券成本随时间累积,应与交易成本分开建模。

标签

全文
# 01_cost_taxonomy.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]
# # Cost Taxonomy and Fee Schedules
#
# **Docker image**: `ml4t`
#
# A trading cost is charged in whatever unit the venue happens to use: cents per
# share, dollars per contract, a spread quoted in the currency pair itself, or a
# percentage of notional. This notebook reads the daily bars and the published fee
# schedules for six markets, keeps each quantity in the unit it was measured in,
# and converts only where the conversion has a defensible basis. It then asks how
# much gross return a strategy has to earn before those costs are paid.
#
# A basis point (bp) is one hundredth of a percent, and it is the unit almost every
# cost in this chapter is quoted in: 10 bps of a $1,000,000 order is $1,000.
#
# **Learning Objectives:**
# - Read the daily volume of a market and say whether it can be turned into dollars
#   traded per day, given what the price series in hand actually measures
# - Read a published fee schedule and name the trade details you would still need
#   before that fee becomes a number you can subtract from a return
# - Work out which of commission, spread and price impact is the largest part of the
#   cost of an order, and how that answer changes as the order gets bigger
# - Compute the gross annual return a strategy needs just to pay for its trading,
#   from how often it trades and what one round of trading costs
# - Add the cost of borrowing stock to sell short to that same hurdle
#
# **Book Reference:** Chapter 18, Sections 18.1 and 18.2
#
# **Prerequisites:** Access to all six OHLCV datasets covered here and
# case study `config/setup.yaml` files.

# %%
"""Cost taxonomy and fee schedules for unit-aware scenario analysis."""

import matplotlib.pyplot as plt
import numpy as np
import polars as pl
import seaborn as sns
import yaml
from _cost_analysis import breakeven_alpha
from IPython.display import Markdown, display
from matplotlib.colors import LinearSegmentedColormap, ListedColormap

from data import (
    load_cme_futures,
    load_crypto_perps,
    load_etfs,
    load_fx_pairs,
    load_sp500_daily_bars,
    load_us_equities,
)
from utils.paths import REPO_ROOT, get_case_study_source_dir
from utils.style import COLORS, add_message_title, ml4t_palette, show_with_alt

# %% tags=["parameters"]
# 0 = all symbols
MAX_SYMBOLS = 0
SOURCE_CHECK_DATE = "2026-07-21"

# %% [markdown]
# ## 1. Liquidity Has Units
#
# Average daily volume (ADV) is the average number of units that change hands in a
# day, and the unit differs by market: shares for a stock, contracts for a future,
# base-asset quantity for a crypto perpetual, and - for the FX feed used here -
# the number of price updates rather than any traded amount. Multiplying volume by
# price gives dollars traded only when the two describe the same transaction. That
# holds for the equity and crypto panels, holds for futures once the contract's
# price multiplier is applied, and fails for the ETF and FX panels for reasons
# given in each section below. Those two are therefore reported in their own units.
#
# `MAX_SYMBOLS` caps how many instruments are read from each panel, taking them in
# alphabetical order so the selection never depends on the data. Leave it at 0 to
# use every instrument; set it lower to shorten a run while working.


# %%
def limit_symbols(df: pl.DataFrame, symbol_col: str) -> pl.DataFrame:
    """Keep the alphabetically first MAX_SYMBOLS instruments, or all of them when it is 0."""
    if MAX_SYMBOLS <= 0:
        return df
    symbols = df.select(symbol_col).unique().sort(symbol_col).head(MAX_SYMBOLS)
    return df.join(symbols, on=symbol_col, how="semi")


# %% [markdown]
# ### Daily Dollar-Turnover Summary
#
# Each input to this helper is already daily and carries a defensible dollar
# turnover column. The output retains the observation window so that a reader
# can see when markets are not contemporaneous.


# %%
def summarize_dollar_turnover(
    df: pl.DataFrame,
    symbol_col: str,
    date_col: str,
    asset_class: str,
) -> pl.DataFrame:
    """Summarize a precomputed daily dollar-turnover column by instrument."""
    return (
        df.group_by(symbol_col)
        .agg(
            adv_usd=pl.col("daily_turnover_usd").mean(),
            coverage_start=pl.col(date_col).min().cast(pl.Date),
            coverage_end=pl.col(date_col).max().cast(pl.Date),
        )
        .rename({symbol_col: "symbol"})
        .with_columns(asset_class=pl.lit(asset_class))
        .select("symbol", "asset_class", "adv_usd", "coverage_start", "coverage_end")
    )


# %% [markdown]
# ### Equity Dollar Turnover
#
# Daily share volume multiplied by the traded close gives approximate daily
# dollar turnover. These two panels retain their own source windows.

# %%
adv_records = []
for asset_class, frame in {
    "US Equities": limit_symbols(load_us_equities(), "symbol"),
    "S&P 500 Equities": limit_symbols(load_sp500_daily_bars(), "symbol"),
}.items():
    daily = frame.with_columns(daily_turnover_usd=pl.col("close") * pl.col("volume"))
    adv_records.append(summarize_dollar_turnover(daily, "symbol", "timestamp", asset_class))

# %% [markdown]
# ### Crypto Daily Quote Turnover
#
# Binance volume is base-asset quantity. The loader returns three funding-aligned
# bars per day, so we sum quote turnover by UTC date before computing ADV.

# %%
crypto = limit_symbols(load_crypto_perps(frequency="8h"), "symbol")
crypto_daily = (
    crypto.with_columns(
        activity_date=pl.col("timestamp").dt.date(),
        bar_turnover_usd=pl.col("close") * pl.col("volume"),
    )
    .group_by("symbol", "activity_date")
    .agg(daily_turnover_usd=pl.col("bar_turnover_usd").sum())
)
adv_records.append(
    summarize_dollar_turnover(
        crypto_daily,
        "symbol",
        "activity_date",
        "Crypto Perpetuals",
    )
)

# %% [markdown]
# ### CME Contract-Notional Turnover
#
# Futures volume counts contracts. The price multiplier is derived from tick
# value divided by tick size, matching the backtest contract specification.

# %%
spec_path = REPO_ROOT / "data" / "futures" / "market" / "futures_specs.yaml"
product_specs = yaml.safe_load(spec_path.read_text())["products"]
multiplier_df = pl.DataFrame(
    [
        {
            "product": product,
            "price_multiplier": float(spec["tick_value"]) / float(spec["tick_size"]),
        }
        for product, spec in product_specs.items()
    ]
)

cme = limit_symbols(load_cme_futures(tenors=[0]), "product").join(
    multiplier_df,
    on="product",
    how="left",
)
missing_multipliers = cme.filter(pl.col("price_multiplier").is_null())["product"].unique().to_list()
if missing_multipliers:
    raise ValueError(f"Missing CME multipliers for: {sorted(missing_multipliers)}")

cme = cme.with_columns(
    daily_turnover_usd=pl.col("volume") * pl.col("raw_close") * pl.col("price_multiplier")
)
adv_records.append(summarize_dollar_turnover(cme, "product", "session_date", "CME Futures"))

all_adv = pl.concat(adv_records)

# %% [markdown]
# ### ETF Share Activity
#
# Yahoo supplies adjusted OHLC together with observed share volume. Without raw
# historical closes or adjustment factors, multiplying these fields would not
# recover contemporaneous traded notional. ETF activity therefore remains in
# its native shares-per-day unit.

# %%
etfs = limit_symbols(load_etfs(), "symbol")
etf_activity = (
    etfs.group_by("symbol")
    .agg(
        mean_daily_shares=pl.col("volume").mean(),
        coverage_start=pl.col("timestamp").min().cast(pl.Date),
        coverage_end=pl.col("timestamp").max().cast(pl.Date),
    )
    .sort("mean_daily_shares", descending=True)
)
etf_activity

# %% [markdown]
# ### FX Tick Activity
#
# OANDA daily volume counts price updates rather than traded currency. It can
# describe activity within this dataset, but it cannot share a USD ADV axis.

# %%
fx = limit_symbols(load_fx_pairs(frequency="daily"), "symbol")
fx_activity = (
    fx.group_by("symbol")
    .agg(
        mean_daily_ticks=pl.col("volume").mean(),
        coverage_start=pl.col("timestamp").min().cast(pl.Date),
        coverage_end=pl.col("timestamp").max().cast(pl.Date),
    )
    .sort("mean_daily_ticks", descending=True)
)
fx_activity

# %% [markdown]
# ### Comparable Dollar-Turnover Summary

# %%
adv_summary = (
    all_adv.group_by("asset_class")
    .agg(
        n_symbols=pl.col("symbol").n_unique(),
        median_adv_usd=pl.col("adv_usd").median(),
        p25_adv_usd=pl.col("adv_usd").quantile(0.25),
        p75_adv_usd=pl.col("adv_usd").quantile(0.75),
        coverage_start=pl.col("coverage_start").min(),
        coverage_end=pl.col("coverage_end").max(),
    )
    .sort("median_adv_usd", descending=True)
)
adv_summary

# %% [markdown]
# ### Dollar-Turnover Distribution

# %%
adv_pd = all_adv.filter(pl.col("adv_usd") > 0).to_pandas()
adv_pd["log_adv_usd"] = np.log10(adv_pd["adv_usd"])

order = (
    all_adv.group_by("asset_class")
    .agg(pl.col("adv_usd").median())
    .sort("adv_usd", descending=True)["asset_class"]
    .to_list()
)

fig, ax = plt.subplots(figsize=(9, 5.5))
sns.violinplot(
    data=adv_pd,
    x="asset_class",
    y="log_adv_usd",
    order=order,
    ax=ax,
    inner="quartile",
    cut=0,
    color=COLORS["blue"],
)
ax.set_ylabel("Average daily dollar turnover (log₁₀ USD)")
ax.set_xlabel("")
ax.tick_params(axis="x", rotation=15)
for label in ax.get_xticklabels():
    label.set_horizontalalignment("right")

for val, label in [(6, "$1M"), (7, "$10M"), (8, "$100M"), (9, "$1B")]:
    ax.axhline(val, color=COLORS["neutral"], linestyle=":", alpha=0.3)
    ax.text(len(order) - 0.5, val + 0.05, label, fontsize=8, alpha=0.5)

add_message_title(
    ax,
    "Average daily dollar turnover by market",
    subtitle="Instrument-level mean daily turnover; distributions shown on a log scale",
    source="ML4T datasets; each market retains its source coverage window",
)
show_with_alt(
    fig,
    "Violin plots of average daily dollar turnover per instrument, one violin per market, on "
    "a log scale. CME futures sit highest and tightest, US equities lowest and by far the "
    "widest; the markets in between overlap heavily.",
)

# %% tags=["results"]
highest_turnover_market = adv_summary.row(0, named=True)
display(
    Markdown(
        f"**Reading the chart:** {highest_turnover_market['asset_class']} has the highest "
        f"median daily dollar turnover in this sample. The comparison is descriptive, not a "
        f"capacity estimate, because coverage ranges from "
        f"{adv_summary['coverage_start'].min()} to {adv_summary['coverage_end'].max()}. "
        "ETFs remain separate because adjusted prices cannot be paired with observed share "
        "volume for historical notional; FX remains separate because tick volume has no "
        "dollar-notional conversion."
    )
)

# %% [markdown]
# ## 2. Exchange Fee Schedules
#
# Fees use different denominators. Stocks charge per share, futures and options
# per contract, FX through a variable spread, and crypto per unit of notional.
# The table preserves those charging units and separates sourced rates from
# case-study assumptions.

# %% [markdown]
# ### One Row per Published Rate
#
# Each row records the venue, the instrument the rate applies to, the rate as the
# venue publishes it, how firm that figure is, and where it came from. The rate is
# stored as text so that a per-share fee and a per-contract fee cannot be added
# together by accident.


# %%
def fee_evidence_row(
    venue: str,
    instrument: str,
    rate: str,
    status: str,
    source: str,
) -> dict:
    """Create one source-dated fee row without coercing its charging unit."""
    return {
        "venue": venue,
        "instrument": instrument,
        "rate": rate,
        "status": status,
        "source": source,
    }


# %%
fee_source_urls = {
    "IBKR stocks": "https://www.interactivebrokers.com/en/pricing/commissions-stocks.php",
    "IBKR commissions": "https://www.interactivebrokers.com/en/pricing/commissions-home.php?menu=A",
    "NFA assessment FAQ": "https://www.nfa.futures.org/faqs/members/nfa-assessment-fees.html",
    "CME fee finder": "https://www.cmegroup.com/company/clearing-fees/fee-finder.html",
    "OANDA pricing": "https://www.oanda.com/us-en/trading/our-pricing/",
    "crypto setup.yaml": "case_studies/crypto_perps_funding/config/setup.yaml",
}

# %% [markdown]
# The first rows have explicit, dated values in official schedules.

# %%
fee_rows = [
    fee_evidence_row(
        "IBKR", "US stocks, Pro Tiered first tier", "USD 0.0035/share", "official", "IBKR stocks"
    ),
    fee_evidence_row("IBKR", "US stocks, Pro Fixed", "USD 0.005/share", "official", "IBKR stocks"),
    fee_evidence_row(
        "IBKR", "US options, Pro Fixed", "USD 0.65/contract", "official", "IBKR commissions"
    ),
    fee_evidence_row(
        "NFA",
        "Futures assessment",
        "USD 0.01/contract/side",
        "effective 2026-07-01",
        "NFA assessment FAQ",
    ),
]

# %% [markdown]
# Variable schedules retain their native qualification. Binance remains a
# case-study assumption because the public fee page is account-dependent.

# %%
fee_rows.extend(
    [
        fee_evidence_row(
            "CME",
            "Futures exchange fee",
            "Product and account specific",
            "official fee finder",
            "CME fee finder",
        ),
        fee_evidence_row(
            "OANDA",
            "Spot FX spread-only plan",
            "Pair- and market-dependent",
            "official description",
            "OANDA pricing",
        ),
        fee_evidence_row(
            "Binance USDT-M",
            "Perpetual taker example",
            "Account-tier dependent; see case-study configuration",
            "case-study assumption",
            "crypto setup.yaml",
        ),
    ]
)
fee_schedule_df = pl.DataFrame(fee_rows).with_columns(
    source_url=pl.col("source").replace_strict(fee_source_urls),
    source_checked=pl.lit(SOURCE_CHECK_DATE),
)
fee_schedule_df

# %% [markdown]
# **Interpretation:** Knowing the dollar size of an order is not enough to put
# these rates on one axis. A per-share fee needs the share price before it becomes
# a fraction of notional. A per-contract fee needs the futures price or option
# premium and the contract multiplier. The FX plan charges through the spread, so
# it needs the spread that was actually quoted, and the crypto rate depends on the
# account's fee tier. Each of those is a trade detail, and the next section supplies
# them by writing out named scenarios.

# %% [markdown]
# ## 3. Which Cost Component Is Largest
#
# The cost of an order splits into three parts. **Commission** is what the broker
# and the venue charge. **Spread** is the gap between the highest price anyone is
# currently bidding and the lowest anyone is offering, half of which an order that
# executes immediately gives up. **Impact** is
# the price move the order itself causes by consuming the resting liquidity, and
# unlike the first two it grows with the size of the order.
#
# Each scenario below fixes five numbers per market: commission and half-spread in
# basis points, the market's daily volatility `sigma`, its average daily dollar
# turnover `adv_usd`, and `impact_eta`, the coefficient that scales the impact
# model. They are round figures chosen to span the range these markets plausibly
# occupy, not estimates fitted to the data loaded above, and the answer the section
# gives holds only for the numbers written here.

# %%
cost_params = {
    "ETFs": {
        "commission_bps": 1.0,
        "spread_bps": 3.0,
        "impact_eta": 0.05,
        "sigma": 0.015,
        "adv_usd": 5e8,
    },
    "Crypto Perps": {
        "commission_bps": 4.0,
        "spread_bps": 2.0,
        "impact_eta": 0.03,
        "sigma": 0.04,
        "adv_usd": 1e9,
    },
    "CME Futures": {
        "commission_bps": 1.5,
        "spread_bps": 1.5,
        "impact_eta": 0.04,
        "sigma": 0.01,
        "adv_usd": 2e9,
    },
    "FX Pairs": {
        "commission_bps": 0.0,
        "spread_bps": 2.0,
        "impact_eta": 0.02,
        "sigma": 0.005,
        "adv_usd": 5e9,
    },
}

# %% [markdown]
# The equity scenarios extend the same illustrative parameter grid.

# %%
cost_params |= {
    "S&P 500": {
        "commission_bps": 1.0,
        "spread_bps": 2.0,
        "impact_eta": 0.05,
        "sigma": 0.015,
        "adv_usd": 1e8,
    },
    "US Equities": {
        "commission_bps": 1.0,
        "spread_bps": 8.0,
        "impact_eta": 0.10,
        "sigma": 0.025,
        "adv_usd": 5e6,
    },
}

scenario_df = pl.DataFrame(
    [{"asset_class": asset, **params} for asset, params in cost_params.items()]
).sort("asset_class")
scenario_df

# %% [markdown]
# ### Component Calculator
#
# Commission and spread do not depend on order size in this model, so they enter at
# their stated rate. Impact does. The square-root law, which Section 18.4 derives
# and `03_market_impact_calibration` fits to data, says the price move an order
# causes grows with the square root of the fraction of a day's volume it represents:
#
# $$\text{impact} = \eta \, \sigma \sqrt{\frac{Q}{\text{ADV}}}$$
#
# where $Q$ is the order's dollar size, ADV is average daily dollar turnover, and
# $\sigma$ is daily volatility. Multiplying by 10,000 states the result in basis
# points. The square root is what makes the answer to "which component is largest"
# depend on order size: doubling the order less than doubles the impact, but a
# hundredfold larger order still pays ten times the impact per dollar traded.


# %%
def cost_components(size: float, params: dict) -> dict:
    """Compute commission, spread, and square-root impact in basis points."""
    participation = size / params["adv_usd"]
    impact = params["impact_eta"] * params["sigma"] * np.sqrt(participation) * 10_000
    return {
        "Commission": params["commission_bps"],
        "Spread": params["spread_bps"],
        "Impact": impact,
    }


# %%
def dominant_component_label(costs: dict[str, float]) -> str:
    """Name the largest component or all components tied for largest."""
    largest = max(costs.values())
    leaders = [name for name, value in costs.items() if np.isclose(value, largest)]
    return " + ".join(leaders)


# %% [markdown]
# ### Map the Dominant Cost Component by Trade Size

# %%
dominance_rows = []
size_labels = ["$10K", "$100K", "$1M", "$10M", "$100M"]
size_values = [1e4, 1e5, 1e6, 1e7, 1e8]

for asset, params in cost_params.items():
    for size, label in zip(size_values, size_labels, strict=False):
        costs = cost_components(size, params)
        dominant = dominant_component_label(costs)
        total = sum(costs.values())

        dominance_rows.append(
            {
                "asset_class": asset,
                "trade_size": label,
                "trade_size_usd": size,
                "dominant": dominant,
                "total_bps": total,
            }
        )

dominance = pl.DataFrame(dominance_rows)

# %% [markdown]
# ### Dominance Map and Crossover Sizes
#
# Cell color identifies the largest component or an exact tie. The annotation
# reports total one-way cost for context. This design keeps dominance distinct
# from magnitude.

# %%
component_codes = {"Commission": 0, "Spread": 1, "Impact": 2, "Commission + Spread": 3}
dominance = dominance.with_columns(
    component_code=pl.col("dominant").replace_strict(component_codes, return_dtype=pl.Int8)
)
dom_pivot = dominance.pivot(on="trade_size", index="asset_class", values="component_code")
col_order = ["asset_class"] + size_labels
dom_pivot = dom_pivot.select([c for c in col_order if c in dom_pivot.columns])

data = dom_pivot.drop("asset_class").to_numpy()
asset_names = dom_pivot["asset_class"].to_list()
total_lookup = {
    (row["asset_class"], row["trade_size"]): row["total_bps"]
    for row in dominance.iter_rows(named=True)
}

# %% [markdown]
# The chart uses the shared ML4T palette for the three components and a tie class.

# %%
fig, ax = plt.subplots()
dominance_cmap = ListedColormap(
    [COLORS["blue"], COLORS["amber"], COLORS["copper"], COLORS["neutral"]]
)
im = ax.imshow(data, cmap=dominance_cmap, aspect="auto", vmin=-0.5, vmax=3.5)
ax.set_xticks(range(len(size_labels)))
ax.set_xticklabels(size_labels)
ax.set_yticks(range(len(asset_names)))
ax.set_yticklabels(asset_names)
ax.set_xlabel("Trade Size")

for i in range(len(asset_names)):
    for j in range(len(size_labels)):
        total_bps = total_lookup[(asset_names[i], size_labels[j])]
        text_color = "white" if data[i, j] in (0, 2, 3) else COLORS["blue"]
        ax.text(j, i, f"{total_bps:.1f}", ha="center", va="center", color=text_color)

colorbar = fig.colorbar(im, ax=ax, ticks=[0, 1, 2, 3])
colorbar.ax.set_yticklabels(["Commission", "Spread", "Impact", "Commission + Spread"])
add_message_title(
    ax,
    "Largest cost component and total one-way cost by market and trade size",
    subtitle="Color shows the largest component or tie; labels show total one-way cost in bps",
    source="Illustrative parameters displayed above; square-root impact model",
)
show_with_alt(
    fig,
    "A grid of markets by trade size, each cell colored by whichever cost component is "
    "largest and labelled with the total one-way cost in basis points. Commission or spread "
    "dominates the small sizes everywhere; impact takes the largest sizes in three of the "
    "markets, and the labelled totals climb fastest along the least liquid row.",
)

# %% [markdown]
# The impact crossover solves for the order size at which modeled impact equals
# the larger fixed component. A crossover outside the displayed range explains
# why some rows retain the same color throughout the chart.

# %%
crossover_rows = []
for asset, params in cost_params.items():
    fixed_component = max(params["commission_bps"], params["spread_bps"])
    impact_scale = params["impact_eta"] * params["sigma"] * 10_000
    crossover_usd = params["adv_usd"] * (fixed_component / impact_scale) ** 2
    crossover_rows.append(
        {
            "asset_class": asset,
            "largest_fixed_component_bps": fixed_component,
            "impact_crossover_usd": crossover_usd,
        }
    )

crossover_df = pl.DataFrame(crossover_rows).sort("impact_crossover_usd")
crossover_df

# %% tags=["results"]
million_dominance = dominance.filter(pl.col("trade_size_usd") == 1_000_000).sort("asset_class")
million_reading = ", ".join(
    f"{row['asset_class']}: {row['dominant'].lower()}"
    for row in million_dominance.iter_rows(named=True)
)
display(
    Markdown(
        f"**Conditional reading:** At the $1 million scenario size, the largest components are "
        f"{million_reading}. These results follow from the displayed assumptions; they are not "
        "empirical claims about every venue or instrument."
    )
)

# %% [markdown]
# ## 4. Case Study Cost Assumptions
#
# Every case study in this book records its cost assumption in its own
# `config/setup.yaml`, and each one states it in the unit its market charges in.
# The section below reads all nine and reports which of them already carry a cost
# in basis points - the unit the rest of the book's backtests subtract - and which
# carry something that needs a price, a multiplier or an observed spread first.

# %%
CASE_STUDIES = [
    "etfs",
    "crypto_perps_funding",
    "nasdaq100_microstructure",
    "sp500_equity_option_analytics",
    "us_firm_characteristics",
    "fx_pairs",
    "cme_futures",
    "sp500_options",
    "us_equities_panel",
]

setup_data = {}
for cs_id in CASE_STUDIES:
    setup_path = get_case_study_source_dir(cs_id) / "config" / "setup.yaml"
    if not setup_path.exists():
        continue
    setup_data[cs_id] = yaml.safe_load(setup_path.read_text())

missing_setups = sorted(set(CASE_STUDIES) - set(setup_data))
if missing_setups:
    raise FileNotFoundError(f"Missing case-study setup files: {missing_setups}")
if len(setup_data) != len(CASE_STUDIES):
    raise ValueError("Case-study setup IDs are not unique")

print(f"Loaded cost configurations: {len(setup_data)}/{len(CASE_STUDIES)}")

# %% [markdown]
# ### Reading Each Configuration's Cost Unit
#
# The function below inspects one `costs:` block and reports the unit it is stated
# in, the basis-point value where the block already holds one, and the trade detail
# that is still missing where it does not.


# %%
def cost_schema_row(cs_id: str, setup: dict) -> dict:
    """Describe a case-study cost schema without inventing a common-unit value."""
    costs = setup.get("costs", {})
    if "round_trip_cost_bps" in costs:
        classification = (
            "round-trip bps",
            float(costs["round_trip_cost_bps"]),
            "direct configuration value",
        )
    elif "fee_schedule" in costs and "taker_bps" in costs["fee_schedule"]:
        classification = (
            "bps per side",
            float(costs["fee_schedule"]["taker_bps"]),
            "one-way taker scenario",
        )
    elif "per_share" in costs:
        classification = ("USD per share plus spread", None, "needs price and turnover")
    elif "commission_per_contract" in costs:
        classification = ("USD per contract plus ticks", None, "needs contract details")
    elif "spread_bps" in costs:
        classification = ("pair-specific spread range", None, "needs observed spread")
    elif "per_leg_cost_bps_range" in costs:
        classification = ("per-leg bps range", None, "no selected scenario")
    else:
        classification = ("component-specific", None, "needs trade details")

    native_unit, comparable_value, comparison_status = classification
    components = costs.get("components", [])
    component_names = list(components) if isinstance(components, dict) else components
    return {
        "case_study": cs_id,
        "cost_class": costs.get("class", "unspecified"),
        "components": ", ".join(component_names),
        "native_unit": native_unit,
        "configured_bps": comparable_value,
        "comparison_status": comparison_status,
    }


# %%
cost_schema_df = pl.DataFrame([cost_schema_row(cs_id, setup_data[cs_id]) for cs_id in CASE_STUDIES])
cost_schema_df

# %% [markdown]
# **Interpretation:** The `configured_bps` column is filled only where the
# configuration already states a basis-point figure. Every other row names what is
# missing, and the notebook that supplies it comes later in the chapter:
# `02_spread_estimation` measures the spread the FX and equity rows need, and
# `03_market_impact_calibration` fits the impact coefficient that turns an order
# size into a cost.

# %% [markdown]
# ## 5. Breakeven Alpha Analysis
#
# Breakeven alpha is the gross return needed to offset cost. Define annual
# traded-notional turnover as dollars traded during the year divided by average
# portfolio capital. For one-way cost per dollar traded:
#
# $$\alpha_{\text{breakeven}} =
# \text{annual traded-notional turnover}
# \times \frac{\text{one-way cost in bps}}{10{,}000}$$
#
# Both inputs are read off the grid below rather than measured from a strategy, so
# the answer is a hurdle for a strategy with that turnover and that cost, not an
# estimate for any particular one.

# %%
annual_turnover_grid = [1, 3, 6, 12, 24, 60]
one_way_cost_grid = [1, 3, 5, 10, 20]
breakeven_rows = [
    {
        "annual_traded_notional": turnover,
        "one_way_cost_bps": cost_bps,
        "breakeven_alpha_pct": breakeven_alpha(turnover, cost_bps) * 100,
    }
    for turnover in annual_turnover_grid
    for cost_bps in one_way_cost_grid
]
breakeven_df = pl.DataFrame(breakeven_rows)
breakeven_df

# %% [markdown]
# ### Breakeven Sensitivity Map
#
# Each annotation is computed from the displayed turnover and cost axes.

# %%
be_pivot = breakeven_df.pivot(
    on="one_way_cost_bps",
    index="annual_traded_notional",
    values="breakeven_alpha_pct",
).sort("annual_traded_notional")
be_data = be_pivot.drop("annual_traded_notional").to_numpy()
turnover_labels = be_pivot["annual_traded_notional"].to_list()
cost_labels = [int(column) for column in be_pivot.columns[1:]]

fig, ax = plt.subplots()
cost_cmap = LinearSegmentedColormap.from_list(
    "ml4t_cost",
    [COLORS["silver_muted"], COLORS["amber"], COLORS["copper"]],
)
im = ax.imshow(be_data, cmap=cost_cmap, aspect="auto")
ax.set_xticks(range(len(cost_labels)), labels=cost_labels)
ax.set_yticks(range(len(turnover_labels)), labels=turnover_labels)
ax.set_xlabel("One-way cost per dollar traded (bps)")
ax.set_ylabel("Annual traded notional / average capital (x)")
for row_idx in range(len(turnover_labels)):
    for col_idx in range(len(cost_labels)):
        ax.text(col_idx, row_idx, f"{be_data[row_idx, col_idx]:.2f}%", ha="center", va="center")

fig.colorbar(im, ax=ax, label="Breakeven annual alpha (%)")
add_message_title(
    ax,
    "Breakeven gross annual alpha by turnover and one-way cost",
    subtitle="Breakeven gross alpha under explicit turnover and cost scenarios",
    source="Scenario grid computed from the breakeven formula above",
)
show_with_alt(
    fig,
    "A grid of annual turnover by one-way cost, each cell labelled with the gross annual return needed to break even. The requirement rises from a fraction of a percent to double digits across the grid.",
)

# %% tags=["results"]
example_turnover = 12
example_cost_bps = 5
example_breakeven = breakeven_df.filter(
    (pl.col("annual_traded_notional") == example_turnover)
    & (pl.col("one_way_cost_bps") == example_cost_bps)
)["breakeven_alpha_pct"].item()
display(
    Markdown(
        f"**Sensitivity reading:** Trading {example_turnover} times average capital per year at "
        f"{example_cost_bps} bps per dollar requires {example_breakeven:.2f}% annual gross alpha "
        "just to cover transaction costs. For your own strategy, read the turnover off its "
        "realized fills and the cost off a model calibrated to the market it trades."
    )
)

# %% [markdown]
# ## 6. Borrow Costs for Long-Short Strategies
#
# Selling a stock short means borrowing it first, and the lender charges a fee for
# the loan for as long as the position is open. That fee is quoted as an annual rate
# on the value borrowed, so it is a running cost rather than a per-trade one, and it
# applies only to the short half of the book. Rates run from a few tens of basis
# points a year on an easily borrowed large-cap to several percent on a stock that
# is hard to locate, and they move with demand, so the curves below span a range
# rather than estimating a single rate.
#
# Every curve holds the portfolio fixed and varies only the borrow rate. Gross alpha
# on the horizontal axis is the return before any cost is subtracted; the vertical
# axis is what is left after trading cost and borrow. The settings below say what each
# of the fixed quantities decides.

# %%
borrow_rate_scenarios = [40, 100, 200, 500, 1000]
short_allocation = 0.50
annual_traded_notional = 3.6
one_way_trading_cost_bps = 20
trading_cost_drag_bps = annual_traded_notional * one_way_trading_cost_bps
gross_alphas = np.linspace(0, 700, 120)

print(
    f"Short side {short_allocation:.0%} of capital - borrow is charged on this half only\n"
    f"Trades {annual_traded_notional}x capital per year at {one_way_trading_cost_bps} bps "
    f"one way, so trading costs {trading_cost_drag_bps:.0f} bps per year\n"
    f"Borrow rates spanned: {borrow_rate_scenarios} bps per year on the amount borrowed"
)

fig, ax = plt.subplots()
for borrow_bps, color in zip(
    borrow_rate_scenarios,
    ml4t_palette(len(borrow_rate_scenarios), categorical=True),
    strict=True,
):
    net_alpha = gross_alphas - trading_cost_drag_bps - borrow_bps * short_allocation
    ax.plot(gross_alphas, net_alpha, color=color, label=f"Borrow: {borrow_bps} bps/year")

ax.axhline(0, color=COLORS["neutral"], linewidth=0.8)
ax.set_xlabel("Gross Alpha (bps/year)")
ax.set_ylabel("Net Alpha (bps/year)")
ax.legend(loc="upper left")
add_message_title(
    ax,
    "Net alpha against gross alpha, one line per borrow rate",
    subtitle="Net of trading-cost drag and of borrow charged on the short half of the book",
    source="Borrow-rate scenarios and portfolio assumptions stated above; not a market sample",
)

show_with_alt(
    fig,
    "Five lines of net alpha against gross alpha, one per borrow rate. All are parallel and shifted down; higher borrow rates cross zero at higher gross alpha.",
)

# %% tags=["results"]
example_gross_alpha_bps = 200
low_borrow_bps = borrow_rate_scenarios[0]
high_borrow_bps = borrow_rate_scenarios[2]
low_borrow_net = example_gross_alpha_bps - trading_cost_drag_bps - low_borrow_bps * short_allocation
high_borrow_net = (
    example_gross_alpha_bps - trading_cost_drag_bps - high_borrow_bps * short_allocation
)
display(
    Markdown(
        f"**Scenario reading:** At {example_gross_alpha_bps} bps gross alpha, the modeled net "
        f"alpha is {low_borrow_net:.0f} bps with a {low_borrow_bps} bps borrow rate and "
        f"{high_borrow_net:.0f} bps with a {high_borrow_bps} bps rate. Borrow is charged per "
        "security and per day, so a portfolio-level rate like these is a placeholder until the "
        "actual borrow quotes for the names being shorted are in hand."
    )
)

# %% [markdown]
# ## Key Takeaways
#
# 1. **Find out what a fee's denominator is before converting it.** A fee per share
#    becomes a fraction of notional only once you supply the share price; a fee per
#    contract needs the price and the contract multiplier. Write the trade detail
#    down rather than assuming a typical one, because the assumed value is what the
#    whole cost estimate then rests on.
#
# 2. **Multiply volume by price only when the two describe the same transaction.**
#    Share volume times the traded close is dollars traded. Share volume times a
#    split- and dividend-adjusted close is not, and a volume that counts price
#    updates rather than trades has no dollar equivalent at all. Check what the
#    price series in hand actually is before treating turnover as a number.
#
# 3. **Ask which cost component dominates at the size you intend to trade, not in
#    general.** Commission and spread are flat per dollar; impact grows with order
#    size, so the answer flips somewhere. Solve for the order size at which impact
#    overtakes the larger flat component and check which side of it you are on.
#
# 4. **Turn a per-trade cost into an annual hurdle by multiplying it by turnover.**
#    Trading capital over several times a year multiplies a cost that looked small
#    per trade into a return the strategy has to earn before it earns anything.
#
# 5. **Charge borrow separately from trading cost.** It accrues with time held and
#    only on the short side, so it does not scale with turnover and cannot be folded
#    into a per-trade figure.
#
# ### Known limitations
#
# - The six panels do not cover the same dates. The dollar-turnover comparison
#   therefore mixes observation windows, and a market that looks more active may
#   simply have been observed over a busier period.
# - The fee table records rates as published on one date. Schedules change, and the
#   tier a given account qualifies for changes what it is actually charged.
# - The scenario parameters in Sections 3, 5 and 6 are round figures, not estimates
#   fitted to these datasets. They fix the shape of the answer, not its magnitude.
# - The impact model assumes an order is worked over roughly a day at a constant
#   rate. An order executed faster pays more than the square-root law says, which is
#   what `05_almgren_chriss_optimal_execution` makes explicit.
#
# **Next**: `02_spread_estimation` estimates the spread from daily bars, and
# `03_market_impact_calibration` fits the impact coefficient used above.
#
# **Book**: Chapter 18, Sections 18.1 and 18.2.

```

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

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