Chuyển đến nội dung
Tất cả tài liệu trong thư viện

Tần suất tái cân bằng, suy giảm tín hiệu và phân tích điểm hòa vốn chi phí giao dịch

Notebook Machine Learning for Trading

Tóm tắt

Ghi chép này xem xét nhịp tái cân bằng ảnh hưởng thế nào đến vòng quay, hiệu suất gộp và kết quả đã điều chỉnh chi phí của danh mục ETF động lượng xếp hạng cao. Ghi chép ước tính vòng quay từ thay đổi trọng số mục tiêu trong quá khứ theo lịch hằng ngày, hằng tuần, hai tuần một lần và hằng tháng, rồi áp dụng các giả định minh họa về chênh lệch giá, tác động thị trường và hoa hồng. Alpha hòa vốn được tính bằng cách nhân vòng quay một chiều hằng năm với chi phí khứ hồi, tạo ra ngưỡng lợi nhuận gộp cần thiết để bù chi phí giao dịch. Một phân tích kịch bản riêng xem xét suy giảm tín hiệu và chi phí duy trì có thể làm thay đổi nhịp tái cân bằng phù hợp như thế nào; ghi chép cũng so sánh Sharpe gộp và ròng giữa các lịch. Bài tập lịch sử dùng mẫu và danh mục cố định cùng giá đóng cửa đã điều chỉnh bởi nhà cung cấp; đây là mô tả, không dùng tập giữ lại nguyên trạng. Các mức chi phí được đặt ra để giảng giải chứ không phải ước tính cho nhà đầu tư cụ thể, còn điểm chi phí duy trì là đại diện chứ không phải alpha đã hiệu chỉnh. Bài học thực tế là cần dựa quyết định về tần suất vào độ bền tín hiệu được ước tính độc lập và ước tính chi phí có thể thực thi, chứ không chỉ vào lợi nhuận gộp.

Ý chính

  • Tái cân bằng thường xuyên hơn có thể làm tăng vòng quay mà không giữ lại được nhiều tín hiệu hữu dụng hơn.
  • Alpha hòa vốn tỷ lệ thuận với vòng quay một chiều hằng năm đã đo và chi phí khứ hồi giả định.
  • Ước tính vòng quay danh mục cần tính đến độ trôi trọng số giữa các lần tái cân bằng theo lịch.
  • So sánh ETF lịch sử chỉ mang tính mô tả, không xác định nhịp tái cân bằng tối ưu cho vận hành thực tế.
  • Các kịch bản suy giảm tín hiệu chỉ là minh họa có điều kiện; để triển khai cần ước tính chi phí thực tế và thời gian bán rã tín hiệu.

Thẻ

Toàn văn
# Frequency-Dependent Transaction Costs


# Frequency-Dependent Transaction Costs

**Docker image**: `ml4t`

This notebook demonstrates how rebalancing cadence changes both captured signal and transaction
costs in a self-contained historical illustration.

**Key Insight**: Faster trading does not automatically capture more usable signal. It raises
turnover, while the signal's decay determines whether acting sooner offsets that extra cost.

**Topics Covered:**
- Break-even alpha analysis: minimum alpha needed to cover costs


- Scenario-preferred rebalancing cadence given cost structure

**Learning Objectives**
- Translate turnover assumptions into break-even alpha thresholds
- Compare gross and net Sharpe across historical rebalancing cadences
- Model the interaction between signal decay and transaction costs
- Use a persistence-cost scenario to explain when a faster signal may still be worth trading

**Book Reference:** Chapter 18: Section 18.8 (Designing practical cost guardrails)

**Prerequisites:** Read [`01_cost_taxonomy`](01_cost_taxonomy.ipynb) for breakeven framing and
[`10_gross_vs_net_performance`](10_gross_vs_net_performance.ipynb) for the full net-of-cost waterfall.

## Setup

```python
"""Frequency-Dependent Transaction Costs - Rebalancing frequency vs cost tradeoff."""

from dataclasses import dataclass

import numpy as np
import plotly.graph_objects as go
import polars as pl
from IPython.display import Markdown, display
from plotly.subplots import make_subplots

from data import load_etfs
from utils.reproducibility import set_global_seeds
from utils.style import COLORS, ml4t_palette, show_plotly_with_alt
```

The historical illustration uses one fixed ETF universe and four cadences. The cost, decay and
persistence-cost sections are descriptive scenarios.

```python
SEED = 42
ETF_SYMBOLS = ["SPY", "QQQ", "IWM", "XLF", "EEM", "XLE", "XLU", "FXI"]
GROSS_START_DATE = "2019-01-01"
GROSS_END_DATE = "2023-12-31"
MOMENTUM_LOOKBACK = 63  # trading days (~quarter)
TOP_N = 3  # equal-weight top-N by trailing momentum
SCENARIO_GROSS_SHARPE = 2.0
SCENARIO_ANNUAL_VOL = 0.15
EXAMPLE_DECAY_RATE = 0.05
DECAY_RATES = [0.01, 0.03, 0.05, 0.10, 0.20]
```

```python
set_global_seeds(SEED)
```

## 1. Cost Model Assumptions

We parameterize transaction costs as a function of turnover:
- **Spread cost**: Half the bid-ask spread (paid on each trade)
- **Market impact allowance**: Fixed bps per one-way trade in each scenario
- **Commissions**: Fixed bps per trade

Total cost per round-trip = 2 × (half-spread + impact + commission)

```python
@dataclass
class CostAssumptions:
    """Illustrative transaction cost assumptions."""

    name: str
    spread_bps: float  # Half-spread per trade
    impact_bps: float  # Market impact per trade
    commission_bps: float  # Commission per trade

    @property
    def total_one_way(self) -> float:
        """Total cost per one-way trade in bps."""
        return self.spread_bps + self.impact_bps + self.commission_bps

    @property
    def round_trip(self) -> float:
        """Total round-trip cost in bps."""
        return 2 * self.total_one_way
```

### Illustrative Cost Scenarios

These high-, medium-, and low-friction stacks are teaching assumptions, not estimates for named
investor types. Each component is a one-way cost; doubling their sum gives the round-trip cost
applied to the notebook's one-way turnover convention.

```python
HIGH_FRICTION_COSTS = CostAssumptions(
    name="High-friction scenario",
    spread_bps=3.0,
    impact_bps=2.0,
    commission_bps=0.0,
)

MEDIUM_FRICTION_COSTS = CostAssumptions(
    name="Medium-friction scenario",
    spread_bps=1.0,
    impact_bps=3.0,
    commission_bps=0.5,
)

LOW_FRICTION_COSTS = CostAssumptions(
    name="Low-friction scenario",
    spread_bps=0.2,
    impact_bps=0.5,
    commission_bps=0.1,
)

COST_SCENARIOS = [HIGH_FRICTION_COSTS, MEDIUM_FRICTION_COSTS, LOW_FRICTION_COSTS]
```

```python
pl.DataFrame(
    [
        {
            "Scenario": c.name,
            "Spread (bps)": c.spread_bps,
            "Impact (bps)": c.impact_bps,
            "Commission (bps)": c.commission_bps,
            "Round-trip (bps)": c.round_trip,
        }
        for c in COST_SCENARIOS
    ]
)
```

**Finding**: The scenario table is the whole problem setup in miniature.
Frequency only creates value if the gross signal is large enough to survive the
assumed round-trip cost profile.

## 2. A Historical Momentum Illustration at Four Cadences

Rather than assume turnover per cadence, we measure it in a historical illustration. We
equal-weight the top-ranked subset by lagged trailing momentum within a fixed ETF universe and
compare several cadences on provider-adjusted closes. The universe and sample are fixed teaching
inputs, not a point-in-time membership screen or an untouched holdout. The comparison is descriptive
and does not estimate a production-optimal cadence. Between scheduled rebalances, realized asset
returns drift the portfolio weights; the next turnover charge compares the new target with those
pre-trade drifted weights.

```python
def momentum_frequency_backtest(
    prices: np.ndarray, rebalance_days: int, lookback: int, top_n: int
) -> tuple[np.ndarray, np.ndarray]:
    """Run a top-N trailing-momentum portfolio at a fixed rebalance cadence.

    `prices` is a (T, S) array of daily closes. A signal observed through close
    t-1 is executed at close t, and the resulting weights earn the t-to-t+1
    return. Returns aligned daily gross returns and one-way turnover.
    """
    if lookback < 1 or rebalance_days < 1:
        raise ValueError("lookback and rebalance_days must be positive")
    n_days, n_assets = prices.shape
    if not 1 <= top_n <= n_assets:
        raise ValueError("top_n must be between one and the number of assets")
    if n_days <= lookback + 2:
        raise ValueError("prices do not cover the lookback and execution lag")

    rets = prices[1:] / prices[:-1] - 1
    held = np.zeros(n_assets)
    port_returns = []
    one_way_turnover = []
    first_execution = lookback + 1
    for t in range(first_execution, n_days - 1):
        daily_turnover = 0.0
        if (t - first_execution) % rebalance_days == 0:
            signal_end = t - 1
            mom = prices[signal_end] / prices[signal_end - lookback] - 1
            new_w = np.zeros(n_assets)
            new_w[np.argsort(mom)[-top_n:]] = 1.0 / top_n
            daily_turnover = 0.5 * np.abs(new_w - held).sum()
            held = new_w
        period_return = float((held * rets[t]).sum())
        port_returns.append(period_return)
        one_way_turnover.append(daily_turnover)
        ending_values = held * (1 + rets[t])
        held = ending_values / ending_values.sum()
    return np.asarray(port_returns), np.asarray(one_way_turnover)
```

The reporting helper below annualizes the daily return series using sample volatility. Keeping
this calculation separate makes the cost-accounting path above independently testable.

```python
def annualized_sharpe(returns: np.ndarray) -> float:
    """Annualized Sharpe of a daily return series."""
    volatility = returns.std(ddof=1)
    return float(returns.mean() / volatility * np.sqrt(252)) if volatility > 0 else 0.0
```

```python
_panel = load_etfs(symbols=ETF_SYMBOLS, start_date=GROSS_START_DATE, end_date=GROSS_END_DATE)
_wide = (
    _panel.sort("symbol", "timestamp")
    .pivot(values="close", index="timestamp", on="symbol")
    .sort("timestamp")
    .drop_nulls()
)
_symbols = sorted(ETF_SYMBOLS)
_wide = _wide.select("timestamp", *_symbols)
_prices = _wide.select(_symbols).to_numpy()
assert set(_panel["symbol"].unique()) == set(ETF_SYMBOLS)
assert _panel.select(pl.struct("symbol", "timestamp").n_unique()).item() == _panel.height
print(
    f"Loaded {_wide.height} sessions x {_prices.shape[1]} ETFs "
    f"({GROSS_START_DATE}..{GROSS_END_DATE})"
)

# Measure annual turnover and gross Sharpe for each cadence in the historical illustration.
FREQUENCIES = {
    "Daily": {"trading_days_per_rebalance": 1, "rebalances_per_year": 252},
    "Weekly": {"trading_days_per_rebalance": 5, "rebalances_per_year": 52},
    "Biweekly": {"trading_days_per_rebalance": 10, "rebalances_per_year": 26},
    "Monthly": {"trading_days_per_rebalance": 21, "rebalances_per_year": 12},
}
for freq, params in FREQUENCIES.items():
    port, one_way_turnover = momentum_frequency_backtest(
        _prices, params["trading_days_per_rebalance"], MOMENTUM_LOOKBACK, TOP_N
    )
    params["gross_returns"] = port
    params["one_way_turnover"] = one_way_turnover
    params["annual_turnover"] = float(one_way_turnover.mean() * 252)
    params["gross_sharpe"] = annualized_sharpe(port)
    params["gross_return"] = float(port.mean() * 252)
    params["annual_vol"] = float(port.std(ddof=1) * np.sqrt(252))
```

```python
frequency_metrics = pl.DataFrame(
    [
        {
            "Frequency": freq,
            "Rebal/Year": p["rebalances_per_year"],
            "Annual TO (x)": round(p["annual_turnover"], 1),
            "Gross SR": round(p["gross_sharpe"], 2),
            "Ann Vol (%)": round(p["annual_vol"] * 100, 1),
        }
        for freq, p in FREQUENCIES.items()
    ]
)
frequency_metrics
```

```python
_daily = FREQUENCIES["Daily"]
_monthly = FREQUENCIES["Monthly"]
display(
    Markdown(
        f"""**Finding**: Turnover is measured, not assumed. In this fixed historical sample, """
        f"""the lagged momentum rule turns over {_monthly["annual_turnover"]:.1f}x annually at """
        f"""monthly cadence and {_daily["annual_turnover"]:.1f}x at daily cadence. Its gross """
        f"""Sharpe is {_monthly["gross_sharpe"]:.2f} monthly and """
        f"""{_daily["gross_sharpe"]:.2f} daily. These are descriptive full-sample estimates, """
        """not performance on an untouched holdout."""
    )
)
```

## 3. Break-Even Alpha Analysis

The break-even alpha is the minimum gross alpha needed to cover transaction costs:

$$\text{Break-even Alpha} = \text{Annual One-way Turnover} \times \text{Round-trip Cost}$$

One-way turnover is half the absolute weight change. Multiplying it by round-trip cost charges
both purchase and sale legs without double-counting. If gross alpha is below this threshold, the
scenario's cost estimate exceeds the strategy's expected return.

```python
def calculate_break_even_alpha(annual_turnover: float, round_trip_cost_bps: float) -> float:
    """
    Calculate minimum alpha needed to break even.

    Args:
        annual_turnover: One-way annual turnover as decimal (e.g., 2.5 = 250%)
        round_trip_cost_bps: Round-trip cost in basis points

    Returns:
        Break-even alpha in basis points (annualized)
    """
    return annual_turnover * round_trip_cost_bps
```

```python
be_rows = []
for freq, params in FREQUENCIES.items():
    row = {"Frequency": freq}
    for costs in COST_SCENARIOS:
        label = costs.name.removesuffix(" scenario")
        row[label] = round(calculate_break_even_alpha(params["annual_turnover"], costs.round_trip))
    be_rows.append(row)
pl.DataFrame(be_rows)
```

**Finding**: Break-even alpha grows linearly with turnover. The daily
schedule only makes sense when the signal is both strong and short-lived; slower
cadences preserve more of the edge under the stated cost scenarios.

## 4. Net Sharpe by Frequency in the Historical Illustration

Each cadence carries its own measured daily gross return and turnover path. We subtract the
scenario's cost on each rebalance day before annualizing the net return, volatility, and Sharpe.
This preserves the timing and volatility contribution of trading costs. It remains an in-sample
illustration rather than an estimate of future performance.

```python
def real_net_by_frequency(cost_assumptions: CostAssumptions) -> pl.DataFrame:
    """Net performance per cadence after charging each observed rebalance."""
    results = []
    for freq, params in FREQUENCIES.items():
        gross_daily = params["gross_returns"]
        daily_cost = params["one_way_turnover"] * cost_assumptions.round_trip / 10000
        net_daily = gross_daily - daily_cost
        annual_cost = float(daily_cost.mean() * 252)
        net_return = float(net_daily.mean() * 252)
        net_vol = float(net_daily.std(ddof=1) * np.sqrt(252))
        results.append(
            {
                "frequency": freq,
                "gross_sharpe": params["gross_sharpe"],
                "gross_return": params["gross_return"],
                "annual_turnover": params["annual_turnover"],
                "annual_cost": annual_cost,
                "net_return": net_return,
                "net_sharpe": annualized_sharpe(net_daily),
                "cost_pct_gross": (
                    annual_cost / params["gross_return"]
                    if params["gross_return"] > 0
                    else float("inf")
                ),
                "net_vol": net_vol,
            }
        )
    return pl.DataFrame(results)
```

### Analytical Helper for the Signal-Decay Section

A parametric net-Sharpe-by-frequency curve used later (Section 7) to study how
signal decay shifts the scenario-preferred cadence. It applies a *single* gross Sharpe to
every cadence's measured turnover.

```python
def simulate_frequency_comparison(
    gross_sharpe: float,
    annual_vol: float,
    cost_assumptions: CostAssumptions,
) -> pl.DataFrame:
    """Net performance across cadences for a hypothetical gross Sharpe."""
    results = []
    for freq, params in FREQUENCIES.items():
        gross_return = gross_sharpe * annual_vol
        annual_cost = params["annual_turnover"] * cost_assumptions.round_trip / 10000
        net_return = gross_return - annual_cost
        results.append(
            {
                "frequency": freq,
                "gross_sharpe": gross_sharpe,
                "gross_return": gross_return,
                "annual_turnover": params["annual_turnover"],
                "annual_cost": annual_cost,
                "net_return": net_return,
                "net_sharpe": net_return / annual_vol if annual_vol > 0 else 0,
                "cost_pct_gross": annual_cost / gross_return if gross_return > 0 else float("inf"),
            }
        )
    return pl.DataFrame(results)
```

```python
results_df = pl.concat(
    [
        real_net_by_frequency(costs).with_columns(pl.lit(costs.name).alias("cost_type"))
        for costs in [HIGH_FRICTION_COSTS, MEDIUM_FRICTION_COSTS]
    ]
)

results_df.filter(pl.col("cost_type") == HIGH_FRICTION_COSTS.name).select(
    "frequency",
    pl.col("gross_sharpe").round(2),
    pl.col("net_sharpe").round(2),
    (pl.col("annual_cost") * 100).round(1).alias("cost_drag_%"),
)
```

```python
_high_friction_results = results_df.filter(pl.col("cost_type") == HIGH_FRICTION_COSTS.name)
_best_historical = _high_friction_results.sort("net_sharpe", descending=True).row(0, named=True)
display(
    Markdown(
        f"""**Finding**: Under the illustrative {HIGH_FRICTION_COSTS.round_trip:.1f} bps """
        f"""round-trip stack, {_best_historical["frequency"].lower()} has the highest net Sharpe """
        """in this full-period sample. This is a descriptive result, not a selected production """
        """cadence."""
    )
)
```

## 5. Visualization: Frequency vs Net Sharpe

```python
fig = make_subplots(
    rows=1,
    cols=2,
    subplot_titles=["High friction", "Medium friction"],
    shared_yaxes=True,
)

freq_order = ["Monthly", "Biweekly", "Weekly", "Daily"]

for col, cost_type in enumerate([HIGH_FRICTION_COSTS.name, MEDIUM_FRICTION_COSTS.name], 1):
    subset = results_df.filter(pl.col("cost_type") == cost_type).sort(
        pl.col("frequency").map_elements(lambda x: freq_order.index(x), return_dtype=pl.Int64)
    )
    fig.add_trace(
        go.Scatter(
            x=subset["frequency"].to_list(),
            y=subset["net_sharpe"].to_list(),
            mode="lines+markers",
            name="Net Sharpe",
            line=dict(color=COLORS["blue"], width=3),
            marker=dict(size=10),
            showlegend=(col == 1),
        ),
        row=1,
        col=col,
    )
    fig.add_trace(
        go.Scatter(
            x=subset["frequency"].to_list(),
            y=subset["gross_sharpe"].to_list(),
            mode="lines+markers",
            name="Gross Sharpe",
            line=dict(color=COLORS["amber"], width=2, dash="dash"),
            marker=dict(size=8),
            showlegend=(col == 1),
        ),
        row=1,
        col=col,
    )
```

### Add an Illustrative Sharpe Hurdle

```python
for col in [1, 2]:
    if col == 1:
        fig.add_hline(
            y=0.5,
            line_dash="dash",
            line_color=COLORS["neutral"],
            annotation_text="Illustrative hurdle",
            annotation_position="top left",
            row=1,
            col=col,
        )
    else:
        fig.add_hline(y=0.5, line_dash="dash", line_color=COLORS["neutral"], row=1, col=col)
    fig.add_hline(y=0, line_dash="dot", line_color=COLORS["negative"], row=1, col=col)

fig.update_layout(
    title=(
        "Gross and net Sharpe by rebalancing cadence, two friction scenarios"
        f"<br><sup>Fixed ETF illustration, {GROSS_START_DATE} to "
        f"{GROSS_END_DATE}; {MOMENTUM_LOOKBACK}-day signal lagged one close</sup>"
    ),
    yaxis_title="Sharpe Ratio",
    height=500,
    showlegend=True,
    legend=dict(orientation="h", yanchor="bottom", y=1.06, xanchor="center", x=0.5),
    margin=dict(t=160, b=65),
)
fig.update_xaxes(title_text="Rebalancing cadence")

show_plotly_with_alt(
    fig,
    "Two panels sharing a vertical Sharpe axis, one per friction scenario, each with a dashed "
    "gross line and a solid net line over the four cadences from monthly to daily. Both lines "
    "fall from left to right in both panels, the net line faster than the gross one, so the gap "
    "between them is widest at the daily end. The two panels are almost indistinguishable from "
    "one another. A dashed horizontal reference line marks the illustrative hurdle, and the net "
    "line crosses below it between the weekly and daily points.",
)
```

**Finding**: The gap between the gross (dashed) and net (solid) lines is the cost
drag, and it widens toward daily cadence in this sample. Even before costs, the
gross line slopes down as cadence accelerates, so the two effects reinforce rather
than offset. This full-period comparison is descriptive, not a holdout ranking.

The two panels are nearly identical, and that is worth reading rather than skipping: the
high- and medium-friction stacks differ by a single basis point per round trip, which at this
strategy's turnover is far too little to separate the net lines. What moves the Sharpe here is
the cadence, not the cost assumption. The stacks have to differ by more than that before the
choice between them changes an answer.

## 6. Cost Erosion Analysis

How much of the gross alpha is consumed by costs at each frequency?

```python
erosion = results_df.filter(pl.col("cost_type") == HIGH_FRICTION_COSTS.name).sort(
    pl.col("frequency").map_elements(lambda x: freq_order.index(x), return_dtype=pl.Int64)
)

fig = go.Figure()
fig.add_trace(
    go.Bar(
        x=erosion["frequency"].to_list(),
        y=[r * 100 for r in erosion["gross_return"].to_list()],
        name="Gross Return",
        marker_color=COLORS["blue"],
    )
)
fig.add_trace(
    go.Bar(
        x=erosion["frequency"].to_list(),
        y=[r * 100 for r in erosion["net_return"].to_list()],
        name="Net Return",
        marker_color=COLORS["amber"],
    )
)
fig.update_layout(
    title=(
        "Gross and net annual return by rebalancing frequency"
        "<br><sup>High-friction scenario; costs charged on each observed rebalance</sup>"
    ),
    yaxis_title="Annual Return (%)",
    xaxis_title="Rebalancing Frequency",
    barmode="group",
    height=450,
    legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="center", x=0.5),
    margin=dict(t=105),
)
show_plotly_with_alt(
    fig,
    "Paired bars of gross and net annual return at each of the four rebalancing frequencies. "
    "Both bars shorten from monthly through daily, and the net bar falls further than the gross "
    "one, so the pair is closest at monthly cadence and furthest apart at daily.",
)
```

## 7. Frequency Choice Under Signal Decay

The historical rule above does not estimate a signal-decay function. To show when faster trading
*can* pay, this section switches
to a **hypothetical fast-decaying signal**: a fixed gross Sharpe whose captured
alpha decays exponentially with the delay between rebalances. This is a parametric
study layered on the measured per-cadence turnover, not a calibrated ETF result.

```python
def evaluate_decay_scenario(
    gross_sharpe: float,
    annual_vol: float,
    cost_assumptions: CostAssumptions,
    signal_decay_rate: float = 0.1,
) -> dict:
    """Compare scenario net Sharpe after applying a specified signal decay."""
    results = []

    for freq, params in FREQUENCIES.items():
        days_delay = params["trading_days_per_rebalance"]
        decay_factor = np.exp(-signal_decay_rate * days_delay)
        effective_gross_sharpe = gross_sharpe * decay_factor

        sim = simulate_frequency_comparison(effective_gross_sharpe, annual_vol, cost_assumptions)
        freq_result = sim.filter(pl.col("frequency") == freq).to_dicts()[0]
        freq_result["effective_gross_sharpe"] = effective_gross_sharpe
        freq_result["decay_factor"] = decay_factor
        results.append(freq_result)

    results_df = pl.DataFrame(results)
    preferred = results_df.sort("net_sharpe", descending=True).row(0, named=True)

    return {
        "all_results": results_df,
        "preferred_frequency": preferred["frequency"],
        "preferred_net_sharpe": preferred["net_sharpe"],
    }
```

```python
# Example: hypothetical fast-decaying signal
result = evaluate_decay_scenario(
    gross_sharpe=SCENARIO_GROSS_SHARPE,
    annual_vol=SCENARIO_ANNUAL_VOL,
    cost_assumptions=HIGH_FRICTION_COSTS,
    signal_decay_rate=EXAMPLE_DECAY_RATE,
)

result["all_results"].select(
    "frequency",
    pl.col("effective_gross_sharpe").round(2).alias("eff_gross_sr"),
    pl.col("decay_factor").round(3),
    pl.col("annual_cost").round(4),
    pl.col("net_sharpe").round(2),
)
```

```python
display(
    Markdown(
        f"""**Finding**: At the stated {EXAMPLE_DECAY_RATE:.0%} daily decay and """
        """high-friction assumptions, the """
        f"""scenario-preferred cadence is {result["preferred_frequency"].lower()} with an """
        f"""approximate net Sharpe of {result["preferred_net_sharpe"]:.2f}. This is a sensitivity """
        """calculation, not an ETF performance estimate."""
    )
)
```

## 8. Sensitivity Analysis: Cost vs Signal Decay

The scenario-preferred frequency depends on:
1. Cost structure (higher costs favor lower frequency)
2. Signal decay rate (faster decay favors higher frequency)

```python
# Grid search over decay rates
sensitivity_results = []

for decay in DECAY_RATES:
    for costs in [HIGH_FRICTION_COSTS, LOW_FRICTION_COSTS]:
        result = evaluate_decay_scenario(
            gross_sharpe=SCENARIO_GROSS_SHARPE,
            annual_vol=SCENARIO_ANNUAL_VOL,
            cost_assumptions=costs,
            signal_decay_rate=decay,
        )
        sensitivity_results.append(
            {
                "decay_rate": decay,
                "cost_type": costs.name,
                "preferred_freq": result["preferred_frequency"],
                "preferred_net_sharpe": result["preferred_net_sharpe"],
            }
        )

sensitivity_df = pl.DataFrame(sensitivity_results)
```

```python
fig = go.Figure()
for index, costs in enumerate([HIGH_FRICTION_COSTS, LOW_FRICTION_COSTS]):
    subset = sensitivity_df.filter(pl.col("cost_type") == costs.name).sort("decay_rate")
    fig.add_trace(
        go.Scatter(
            x=(subset["decay_rate"] * 100).to_list(),
            y=subset["preferred_freq"].to_list(),
            mode="lines+markers",
            name=f"{costs.name} ({costs.round_trip:.1f} bps round-trip)",
            line=dict(
                color=COLORS["blue"] if index == 0 else COLORS["amber"],
                width=3 if index == 0 else 2,
                dash="solid" if index == 0 else "dash",
            ),
            marker=dict(symbol="circle" if index == 0 else "diamond", size=9),
        )
    )
fig.update_layout(
    title=(
        "Scenario-preferred cadence against assumed signal decay"
        f"<br><sup>Hypothetical gross Sharpe {SCENARIO_GROSS_SHARPE:.1f} and "
        f"{SCENARIO_ANNUAL_VOL:.0%} volatility; historical turnover inputs</sup>"
    ),
    xaxis_title="Assumed signal decay per day (%)",
    yaxis_title="Scenario-preferred cadence",
    height=450,
    legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="center", x=0.5),
    margin=dict(t=110),
)
fig.update_yaxes(categoryorder="array", categoryarray=freq_order)
show_plotly_with_alt(
    fig,
    "A step plot of the preferred cadence against assumed daily signal decay, one line per "
    "friction scenario, on a categorical cadence axis. The low-friction line sits on the daily "
    "category across the whole decay range. The high-friction line starts one category below it "
    "at the slowest decay and joins it at the second decay point, staying there afterwards.",
)
```

**Finding**: The crossover is conditional on the stated decay, gross Sharpe, volatility, cost,
and historical-turnover assumptions. It demonstrates the direction of the tradeoff rather than
estimating a universally preferred frequency.

## 9. Persistence-Cost Score: Alpha-to-Go Intuition

Formal alpha-to-go is a dynamic-optimization quantity that depends on forecasts, risk,
holdings, and the execution-cost model. This notebook does not estimate that model. Instead, it
uses a dimensionless teaching proxy to isolate the intended comparative statics for an AR(1)
persistence parameter $\varphi$ and a cost-pressure parameter $\Gamma$:

$$S(\varphi, \Gamma) = \frac{\varphi}{1 - \varphi + \Gamma}$$

The score rises with persistence and falls with cost pressure. It is not calibrated in bps, is
not a retention fraction, and can exceed one. It supports scenario ranking only; it does not
measure realized net alpha or reproduce Paleologo's full alpha-to-go optimization.

```python
# Persistence-cost score heatmap
phi_values = np.linspace(0.1, 0.99, 50)  # persistence
gamma_values = np.linspace(0.01, 1.0, 50)  # unitless cost-pressure parameter
PHI, GAMMA = np.meshgrid(phi_values, gamma_values)

persistence_cost_score = PHI / (1 - PHI + GAMMA)
score_ticks = np.array([0.1, 0.5, 1.0, 5.0, 10.0, 40.0])

fig = go.Figure(
    data=go.Heatmap(
        z=np.log10(persistence_cost_score),
        customdata=persistence_cost_score,
        x=np.round(phi_values, 2),
        y=np.round(gamma_values, 2),
        colorscale=[
            [0.0, COLORS["silver_muted"]],
            [0.5, COLORS["amber"]],
            [1.0, COLORS["blue"]],
        ],
        colorbar=dict(
            title="Unitless score<br>(log scale)",
            tickvals=np.log10(score_ticks),
            ticktext=[f"{tick:g}" for tick in score_ticks],
        ),
        hovertemplate=(
            "Persistence=%{x:.2f}<br>Cost pressure=%{y:.2f}"
            "<br>Score=%{customdata:.2f}<extra></extra>"
        ),
    )
)
fig.update_layout(
    title=(
        "Teaching score over signal persistence and cost pressure"
        "<br><sup>Illustrative proxy only; not a calibrated alpha-to-go estimate</sup>"
    ),
    xaxis_title="Signal Persistence (φ)",
    yaxis_title="Unitless Cost Pressure (Γ)",
    height=500,
    margin=dict(t=105),
)
show_plotly_with_alt(
    fig,
    "A heatmap of the score over signal persistence on the horizontal axis and cost pressure on "
    "the vertical, shaded on a logarithmic colour scale. The surface is pale over most of the "
    "grid and darkens sharply into the bottom-right corner, where persistence is highest and "
    "cost pressure lowest; the darkening runs almost entirely along the persistence axis.",
)
```

**Interpretation**: The score is highest at high persistence and low cost pressure, in the
bottom-right of the heatmap. Its scale is deliberately not interpreted as a fraction of alpha.
The logarithmic color scale keeps the rest of the surface visible despite the sharp corner peak;
it changes only the color mapping, not the score or its ordering. The surface demonstrates that
persistence and cost can change a signal ranking.

```python
# Illustrative signal reranking demo
signals = pl.DataFrame(
    {
        "signal": ["Momentum 1m", "Momentum 6m", "Value", "Quality"],
        "raw_ic": [0.04, 0.03, 0.025, 0.02],
        "persistence": [0.3, 0.85, 0.95, 0.92],
        "gamma": [0.8, 0.3, 0.1, 0.05],
    }
)
signals = signals.with_columns(
    (
        pl.col("raw_ic") * pl.col("persistence") / (1 - pl.col("persistence") + pl.col("gamma"))
    ).alias("priority_score")
)
signals = signals.with_columns(
    pl.col("raw_ic").rank(descending=True).alias("raw_rank"),
    pl.col("priority_score").rank(descending=True).alias("score_rank"),
)
```

```python
fig = go.Figure()
rank_colors = ml4t_palette(4, categorical=True)
for row, color in zip(signals.sort("raw_rank").iter_rows(named=True), rank_colors, strict=True):
    fig.add_trace(
        go.Scatter(
            x=["Raw IC rank", "Persistence-cost score rank"],
            y=[row["raw_rank"], row["score_rank"]],
            mode="lines+markers+text",
            name=row["signal"],
            line=dict(color=color, width=2),
            marker=dict(size=9),
            text=[row["signal"], row["signal"]],
            textposition=["top center", "middle right"],
            cliponaxis=False,
        )
    )
fig.update_layout(
    title=(
        "Signal rank by raw IC and by the persistence-cost score"
        "<br><sup>Illustrative inputs; the score is not a measured cost-adjusted IC</sup>"
    ),
    xaxis_title="Ranking basis",
    yaxis_title="Rank (1 = highest)",
    yaxis=dict(autorange="reversed", tickmode="linear", dtick=1),
    height=500,
    showlegend=False,
    margin=dict(t=105, l=125, r=145),
)
show_plotly_with_alt(
    fig,
    "A slope chart with one line per signal running between two ranking columns, raw IC on the "
    "left and the persistence-cost score on the right, with rank one at the top and every line "
    "labelled at both ends. The lines cross heavily: the signal ranked first on raw IC falls to "
    "last, the one ranked last rises to second, the one ranked third rises to first, and only "
    "the second-ranked signal stays near where it started, slipping one place to third.",
)
```

**Interpretation**: In these hypothetical inputs, short-horizon momentum starts with the highest
raw IC but ranks last on the persistence-cost proxy. Value and quality move up because their
assumed persistence is higher and cost pressure is lower. The exercise demonstrates sensitivity
to assumptions; it is not an empirical comparison of these signals.

## 10. Summary Statistics

```python
# Final summary table uses each cadence's measured gross returns and turnover.
summary_data = []
for costs in [HIGH_FRICTION_COSTS, MEDIUM_FRICTION_COSTS]:
    for freq, params in FREQUENCIES.items():
        be_alpha = calculate_break_even_alpha(params["annual_turnover"], costs.round_trip)
        net_row = (
            real_net_by_frequency(costs).filter(pl.col("frequency") == freq).row(0, named=True)
        )

        summary_data.append(
            {
                "Cost Scenario": costs.name.removesuffix(" scenario").title(),
                "Frequency": freq,
                "Annual TO (x)": round(params["annual_turnover"], 1),
                "Gross SR": round(params["gross_sharpe"], 2),
                "Break-even Alpha (bps)": round(be_alpha),
                "Net Sharpe": round(net_row["net_sharpe"], 2),
            }
        )

summary_df = pl.DataFrame(summary_data)
summary_df
```

**Finding**: The summary table compresses the notebook into a usable trading
rule. Frequency choice should be driven by net Sharpe and break-even alpha
jointly, not by gross performance or turnover in isolation.

## 11. Key Takeaways


```python
_daily_high = _high_friction_results.filter(pl.col("frequency") == "Daily").row(0, named=True)
_monthly_high = _high_friction_results.filter(pl.col("frequency") == "Monthly").row(0, named=True)
# Through the same function the break-even table calls, rather than inlining its body here.
# The two agree today because the body is that product; they stop agreeing the moment it is
# not, and nothing would report the sentence and the table disagreeing.
_daily_break_even = calculate_break_even_alpha(
    _daily["annual_turnover"], HIGH_FRICTION_COSTS.round_trip
)
_monthly_break_even = calculate_break_even_alpha(
    _monthly["annual_turnover"], HIGH_FRICTION_COSTS.round_trip
)
display(
    Markdown(
        f"""
1. **Break-even alpha scales with measured turnover**: daily turnover is
   {_daily["annual_turnover"]:.1f}x and requires {_daily_break_even:.0f}
   bps under the high-friction scenario; monthly turnover is {_monthly["annual_turnover"]:.1f}x
   and requires {_monthly_break_even:.0f} bps.

2. **The historical cadence comparison is descriptive**: in the fixed {GROSS_START_DATE} to
   {GROSS_END_DATE} sample,
   monthly net Sharpe is {_monthly_high["net_sharpe"]:.2f} versus
   {_daily_high["net_sharpe"]:.2f} daily under the high-friction stack. No untouched holdout or
   production-optimal cadence is claimed.

3. **Cost labels are scenarios, not trader estimates**: each stack is a transparent parameterization
   that readers can replace with their own spread, impact, and commission estimates.

4. **Signal decay can change the ranking**: the parametric study shows when acting sooner can offset
   extra turnover, conditional on the stated gross Sharpe, volatility, decay, and cost assumptions.

5. **The persistence-cost score is a teaching proxy**: it demonstrates comparative statics and
   reranking, but it is neither calibrated alpha-to-go nor measured cost-adjusted IC.

6. **Practical rule**: increase frequency only when an independently estimated signal half-life and
   implementable cost model support the extra turnover.
"""
    )
)
```

**Next**: See [`10_gross_vs_net_performance`](10_gross_vs_net_performance.ipynb) for full gross-to-net waterfall analysis.
**Book**: Chapter 18, Section 18.8 discusses practical guardrails for execution costs.
![notebook output](figures/p1_1.png)
![notebook output](figures/p1_2.png)
![notebook output](figures/p1_3.png)
![notebook output](figures/p1_4.png)
![notebook output](figures/p1_5.png)

Hiển thị toàn văn kèm ghi nguồn theo giấy phép của tài liệu gốc. Giấy phép: MIT

Bản tóm tắt này do tác nhân nghiên cứu của Stratmill biên soạn từ tài liệu gốc; đây không phải bản sao của tài liệu.