返回交易笔记
TR Day 34

实际成本 + 滑点估计细化

把 Day 22 的成本框架,从「单一保守值」升级为「分档敏感性 + 分布」,理解策略对 cost regime 的弹性

2026-06-12
Phase 2: 策略实战 + AI 信号
TransactionCostSlippageTurnoverTWAPSensitivityAnalysisMonteCarlo

日期: 2026-06-12 方向: Phase 2 / 成本敏感性 阶段: Phase 2: 策略实战 + AI 信号 标签: #TransactionCost #Slippage #Turnover #TWAP #SensitivityAnalysis #MonteCarlo


今日目标

类型内容
学习把 Day 22 的成本框架,从「单一保守值」升级为「分档敏感性 + 分布」,理解策略对 cost regime 的弹性
实操对 Day 28 双因子组合做完整成本敏感性 Monte Carlo,输出 Sharpe / CAGR / MaxDD 在 1bp / 3bp / 5bp / 10bp 下的分布
认知「策略 cost-sensitivity」是 trade-off 选型的核心维度——cost-sensitive 策略不是「不好」,而是对执行能力的隐性押注
产出TR-DAY34 笔记 + IBKR Tiered 精确佣金函数 + 多档滑点 sweep + 散户「合理保守值」决策表

一、为什么 Day 22 不够 — 把「单值」升级为「分布」

1.1 Day 22 的局限

Day 22 我们建立了第一版完整成本栈:

单边成本 ≈ 佣金 + spread cost + market impact + 监管费
散户「合理保守值」 = 10 bp(股票市价单单边)

这套框架的正确性是 OK 的,但用法上有两个严重缺陷:

缺陷表现后果
单值假设把 10 bp 当成「真值」无法回答「如果实盘是 6 bp 会怎样 / 是 14 bp 会怎样」
静态假设假设全年成本恒定忽略月初月末 / Fed 日 / 流动性事件下 spread 翻倍
无置信区间Sharpe 报「0.8」一个数不知道这是 0.5±0.3 还是 0.8±0.05

PM 视角的类比:这就像产品需求评审时只给「ROI = 1.5x」一个数字,而不是给「P50 = 1.5x, P10 = 0.6x, P90 = 2.8x」。单点估值是 demo 用的,分布估值才是决策用的

1.2 Day 34 要做的事

把成本从「一个数」变成「一组场景 × 一组概率」:

情景         单边成本    年内占比   说明
----------------------------------------------------
最优     →     1 bp     20%        SPY/QQQ 流动性最好时段
正常     →     3 bp     50%        SP100 大盘股、市价单、正常时段
偏紧     →     5 bp     20%        中盘 / 月末再平衡 / 季度末
压力     →    10 bp     10%        Fed 日 / 波动率 spike / 流动性枯竭

然后跑 Monte Carlo:每次回测随机抽 cost regime,看 Sharpe 分布是不是仍然能站在 1.0 上。

这才叫严肃的成本建模


二、IBKR Tiered 佣金的精确建模

2.1 IBKR Tiered 规则(美股)

Day 22 记过公式,今天写成可执行函数。规则:

Per-share charge   = $0.0035 / share
Minimum per order  = $0.35
Maximum per order  = 1.0% of trade value

Exchange / ECN fees: 单列,~$0.0001-0.003/share(与路由相关)
Pass-through fees:
  SEC fee (卖出)     = $22.10 / $1,000,000 = 0.00221% of sell value
  FINRA TAF (卖出)   = $0.000166 / share, max $8.30

注意三个容易踩坑的边界:

  1. min $0.35 是 per-order,不是 per-fill。如果一个 order 被拆成 3 个 fill,IBKR 通常会聚合按 order 算(但 dark pool 路由有例外)。
  2. max 1% 是 trade value 的 1%,不是 commission cap of $100。<100 股极便宜票(如 $1 股 50 股 = $50 trade)就会触发:本来 0.0035×50 = $0.175 但 min $0.35,而 1% of $50 = $0.50 没触顶——所以这单实际是 $0.35。
  3. Tiered 还有 monthly volume rebate:单月 ≥ 300k shares 之后 per-share 降到 $0.002。散户量级摸不到。

2.2 commission(shares, price) 函数

# tr_day34_commission.py
from dataclasses import dataclass

@dataclass
class IBKRTieredCost:
    """IBKR Pro Tiered 美股佣金 + 监管费精确建模。
    
    返回单笔的「全口径」commission(含监管费),单位 USD。
    side: 'buy' 或 'sell'(监管费只对 sell 收)。
    """
    per_share: float = 0.0035
    min_order: float = 0.35
    max_pct:   float = 0.01   # 1% of trade value
    sec_rate:  float = 22.10 / 1_000_000   # 0.00221% of sell value
    taf_per_share: float = 0.000166
    taf_max:   float = 8.30
    exchange_fee_per_share: float = 0.0003  # 经验中值(不同路由 0.0001-0.0005)

    def commission(self, shares: int, price: float, side: str = 'buy') -> float:
        assert shares > 0 and price > 0
        trade_value = shares * price

        # 1) per-share 主体
        base = shares * self.per_share

        # 2) min/max 约束
        base = max(self.min_order, base)
        base = min(self.max_pct * trade_value, base)

        # 3) Exchange / ECN 路由费(双向都有)
        exch = shares * self.exchange_fee_per_share

        # 4) 监管费(只 sell 收)
        if side.lower() == 'sell':
            sec = self.sec_rate * trade_value
            taf = min(self.taf_max, shares * self.taf_per_share)
        else:
            sec, taf = 0.0, 0.0

        return base + exch + sec + taf

    def commission_bps(self, shares: int, price: float, side: str = 'buy') -> float:
        """返回相对 trade value 的 bps,方便比较。"""
        total = self.commission(shares, price, side)
        return 1e4 * total / (shares * price)


# 一些 sanity check
cost = IBKRTieredCost()

# 案例 1: 买 100 股 SPY @ $500(trade value $50,000)
print(cost.commission(100, 500, 'buy'))            # ~$0.35 + $0.03 = $0.38
print(cost.commission_bps(100, 500, 'buy'))         # ~0.08 bps

# 案例 2: 卖 1000 股 AAPL @ $200(trade value $200,000)
print(cost.commission(1000, 200, 'sell'))           # $3.5 + $0.3 + $0.44 + $0.17 ≈ $4.41
print(cost.commission_bps(1000, 200, 'sell'))        # ~0.22 bps

# 案例 3: 买 10 股 NVDA @ $120(trade value $1,200,触发 min $0.35)
print(cost.commission(10, 120, 'buy'))              # max(0.035, 0.35) + 10*0.0003 = 0.353
print(cost.commission_bps(10, 120, 'buy'))           # ~2.94 bps 贵!

# 案例 4: 触发 1% max(极端)
print(cost.commission(5, 1.0, 'buy'))               # trade $5, 1% max = $0.05;min $0.35 > $0.05
                                                     # 但 max 限制是上界,min 是下界 — 这里 min 占优 $0.35?
                                                     # IBKR 实际规则:max 优先 — 0.05 才是终值
                                                     # 我们的实现里 max 在 min 之后做了 clip,所以输出 $0.05 ✓

关键观察(这条值得 hard-code 到 PM 备忘录里):

单笔大小全口径 bps(买)全口径 bps(卖)启示
<$1k5-30 bps5-30 bps小单不可行
$1k-5k1-5 bps1-5 bps临界区,月度组合勉强 OK
$5k-10k0.3-0.8 bps0.5-1.0 bps健康区间
$10k-50k<0.3 bps<0.5 bps佣金可忽略
>$50k<0.1 bps<0.3 bps散户里的「优等生」

结论Day 28 双因子组合的 Top 10 持仓平均 $500 / position($5,000 NAV / 10),佣金 ~1-5 bps 单边。这一项不是主要成本——主要成本是滑点。

2.3 容易漏算的「小钱」

  • 借券费:Day 22 已建模,今天不展开。但要记得 W-8BEN 不申报借券费降低吗? 不降低,这是 IBKR 内部费用,不走税。
  • Fund interest on T+1 cash:IBKR 对未投资现金给 SOFR - 1.5% 利息(>$10k 起付),<$10k 没有。$5k 账户拿不到。这是「沉默成本」
  • 数据订阅:Day 1 订阅了 $16/月 ≈ $192/年 = 3.84% / $5k 账户/年。如果策略毛年化是 12%,光数据费就吃掉 3.84 / 12 = 32% 的毛利——必须建模到 strategy P&L 里。

三、滑点模型细化 — 从「常数」到「conditional」

3.1 Bid-Ask Spread 的真实分布

Day 22 我们说「SP500 大盘股典型 spread 1-2 bp」,今天具体到两个 cap 区间

UniverseSpread 中位数Spread P90月末 / Fed 日 增量
SP100(mega cap)~5 bps~10 bps+50% (7.5 bps)
SP400(mid cap)~10 bps~25 bps+100% (20 bps)
Russell 2000 small cap~20-50 bps~100 bps+100-200%
SPY / QQQ ETF<1 bp~2 bps几乎无变化
期权(流动性好)5-15 bps30-50 bps+200% (盘前/盘后)

注意上面的「spread」是「bid-ask 差除以 mid 的全口径」,而**「成本影响」一般取一半(spread / 2)**,因为 mid-price 是回测的标准 benchmark,市价单成交在 ask(买)或 bid(卖),距离 mid 是 spread / 2。

所以散户市价单的「spread cost」 ≈ spread / 2

SP100  → 2.5 bps / 单边
SP400  → 5 bps   / 单边
SPY    → <0.5 bps / 单边

3.2 用 yfinance 估算 spread(粗略)

yfinance 没有直接的实时 spread,但日线 OHLC 可以近似估

import yfinance as yf
import pandas as pd
import numpy as np

def estimate_spread_bps(ticker: str, period: str = '1mo') -> dict:
    """用 Corwin-Schultz 估计 spread bps(高频估计法的日线近似)。
    
    Corwin & Schultz (2012) 提出用 high/low 估 effective spread:
      beta  = E[(ln(H_t/L_t))^2 + (ln(H_{t+1}/L_{t+1}))^2]
      gamma = E[(ln(max(H_t, H_{t+1}) / min(L_t, L_{t+1})))^2]
      alpha = (sqrt(2*beta) - sqrt(beta)) / (3 - 2*sqrt(2)) - sqrt(gamma / (3 - 2*sqrt(2)))
      spread ≈ 2 (exp(alpha) - 1) / (1 + exp(alpha))
    """
    df = yf.download(ticker, period=period, interval='1d', progress=False)
    h = np.log(df['High'])
    l = np.log(df['Low'])

    beta  = ((h - l) ** 2).rolling(2).sum()
    gamma = (np.log(df['High'].rolling(2).max() / df['Low'].rolling(2).min())) ** 2

    denom = 3 - 2 * np.sqrt(2)
    alpha = (np.sqrt(2 * beta) - np.sqrt(beta)) / denom - np.sqrt(gamma / denom)
    spread = 2 * (np.exp(alpha) - 1) / (1 + np.exp(alpha))
    spread_bps = (spread.dropna() * 1e4).clip(lower=0)

    return {
        'median_bps': float(spread_bps.median()),
        'p90_bps':    float(spread_bps.quantile(0.9)),
        'mean_bps':   float(spread_bps.mean()),
    }


# 对 SPY / AAPL / 一只 SP400 中盘 / 一只小盘做对比
for ticker in ['SPY', 'AAPL', 'ROKU', 'PLTR']:
    s = estimate_spread_bps(ticker, period='3mo')
    print(f"{ticker:6s}  median {s['median_bps']:6.1f} bps  P90 {s['p90_bps']:6.1f} bps")

示例输出(数量级,会因时段不同浮动):

SPY     median   0.8 bps  P90   2.1 bps
AAPL    median   4.2 bps  P90   9.8 bps
ROKU    median  12.4 bps  P90  31.5 bps
PLTR    median   8.3 bps  P90  18.7 bps

关键认知Corwin-Schultz 估计法系统性高估流动性差标的的 spread(因为 high/low 在低流动性下会被异常 print 拉宽),但对 SP100 的估计非常接近真实。这是个轻量级 sanity check 工具,不能替代 IBKR 实时 spread 数据

3.3 月初月末 / Fed 日 / 财报日的 spread 放大

经验规律(基于 SPGMI / Reuters 的若干历史研究):

事件spread 增量持续时间
月末 rebalance window(最后 2 天)+50% 到 +100%2-3 天
季度末+100% 到 +200%1 周
FOMC 决议日(14:00-16:00 ET)+100% 到 +300%数小时
Non-farm Payrolls 公布前 30 分钟+50% 到 +200%30-60 分钟
个股 earnings 公布前后 1 小时+200% 到 +500%数小时
VIX > 30 时段+50% 到 +100%VIX 高的整段

对回测的启示

  1. 月度 rebalance 默认下到月末最后一个交易日——这是 spread 最差的时点之一。学术回测常常用 month-end close 价,但实盘要么改到月初第二个交易日(spread 已恢复),要么用 TWAP 拆 3 天
  2. Day 28 的双因子组合 rebalance 频率是月度实际成本可能比 Day 22 假设的高 30-50%(因为月末效应)。这正是今天要测的。

3.4 滑点模型 v2 — Conditional

import numpy as np
import pandas as pd

class SlippageModel:
    """Conditional slippage:base + market regime + cap size。"""

    BASE_BPS = {  # 单边 mid-relative bps(不是 spread 全口径)
        'mega':   2.5,    # SP100
        'large':  3.5,    # SP500 但非 SP100
        'mid':    5.0,    # SP400
        'small': 12.0,    # Russell 2000
        'etf':    0.5,    # SPY/QQQ/IWM
    }

    REGIME_MULT = {
        'calm':       1.0,   # 60% of trading days
        'normal':     1.3,   # 25% of trading days
        'stressed':   2.5,   # 12% of trading days (VIX > 25)
        'crisis':     5.0,   # 3% of trading days (VIX > 40)
    }

    EVENT_MULT = {
        'month_end_window': 1.5,
        'fomc_day':         2.0,
        'earnings_day':     3.0,
        'normal':           1.0,
    }

    def __init__(self, seed: int = 42):
        self.rng = np.random.default_rng(seed)

    def slippage_bps(self,
                     cap_tier: str = 'mega',
                     regime: str = 'normal',
                     event: str = 'normal',
                     order_type: str = 'market') -> float:
        base = self.BASE_BPS[cap_tier]
        mult = self.REGIME_MULT[regime] * self.EVENT_MULT[event]
        s = base * mult

        # 限价单:spread 全省,但有 ~20% 不成交风险(机会成本不在这里建模)
        if order_type == 'limit':
            s *= 0.3

        # 加 10% 随机噪声
        s *= self.rng.normal(1.0, 0.1)
        return max(0.5, s)


# 实用:跑一个 rebalance 日的滑点分布
m = SlippageModel()
samples = [m.slippage_bps(cap_tier='mega', regime='normal', event='month_end_window')
           for _ in range(10_000)]
print(f"P50 = {np.percentile(samples, 50):.2f} bps")
print(f"P90 = {np.percentile(samples, 90):.2f} bps")
# P50 ≈ 4.9 bps, P90 ≈ 5.6 bps  → 月末再平衡的现实成本

四、多档滑点扫描 — 1bp / 3bp / 5bp / 10bp

把 Day 28 双因子组合在 4 个固定 cost regime 下跑一遍,得到 Sharpe 敏感性曲线

# tr_day34_cost_sweep.py
import pandas as pd
import numpy as np
from day28_pipeline import run_dual_factor_backtest   # 假设 Day 28 模块化输出

cost_regimes = {
    '1 bp  (best case)':   {'slippage_bps_round_trip': 2,  'comm_model': 'tiered'},
    '3 bp  (realistic)':   {'slippage_bps_round_trip': 6,  'comm_model': 'tiered'},
    '5 bp  (conservative)':{'slippage_bps_round_trip': 10, 'comm_model': 'tiered'},
    '10 bp (stressed)':    {'slippage_bps_round_trip': 20, 'comm_model': 'tiered'},
}

results = []
for label, cfg in cost_regimes.items():
    r = run_dual_factor_backtest(cost_config=cfg)
    results.append({
        'regime': label,
        'CAGR':   r['cagr'],
        'Vol':    r['vol'],
        'Sharpe': r['sharpe'],
        'MaxDD':  r['max_dd'],
        'Turnover (yr)': r['turnover_yearly'],
        'Cost drag': r['cost_drag_yearly'],
    })

print(pd.DataFrame(results).to_string(index=False))

预期输出(基于 Day 28 设定:年换手 ~240%,monthly rebalance,SP100 universe):

Cost regimeCAGRVolSharpeMaxDDCost drag
1 bp (best)13.5%11.0%1.23-16.2%-0.5%
3 bp (realistic)12.1%11.0%1.10-16.5%-1.4%
5 bp (conservative)10.8%11.0%0.98-16.8%-2.4%
10 bp (stressed)7.6%11.0%0.69-17.5%-4.8%
20 bp (worst-case)2.4%11.0%0.22-19.0%-9.6%

核心观察(这表是今天最有信息量的一张):

  1. Sharpe 在 1-5 bp 区间衰减是线性的(每 bp ≈ -0.06 Sharpe);5-10 bp 开始非线性(每 bp ≈ -0.06 Sharpe 但相对值放大)。
  2. MaxDD 几乎不变——成本是「侵蚀 alpha」,不是「放大风险」。这是关键区分。
  3. Cost drag = 年换手率 × 单边 round-trip 成本:240% × 2 × 1 bp = 0.5%; 240% × 2 × 10 bp = 4.8%。记这个公式
年化成本拖累 ≈ 年化换手率 × 2 × 单边成本(bp)

4.1 Sharpe 弹性的产品视角

定义 「成本弹性」 = ΔSharpe / Δcost_bp

| Day 28 双因子 | 弹性 ≈ -0.06 Sharpe / bp | | 月度低波动单因子 | 弹性 ≈ -0.04(换手低) | | 周度动量 | 弹性 ≈ -0.25(换手高) | | 日内反转 | 弹性 ≈ -2.0(换手极高) |

这个数能直接告诉你「策略在 cost 上的 trade-off 弹性」

  • 低弹性策略 = robust to execution(即便实盘比回测差 5 bp,结果不会崩)
  • 高弹性策略 = bet on execution(必须搭配 TWAP / 限价 / 大资金路由)

PM 视角的认知迁移这就是「margin sensitivity to ad cost」在量化里的形态。Web2 广告团队都熟悉「CAC payback 对 CTR / CVR / CPM 的弹性表」——量化的「Sharpe 对 bp 弹性表」是一回事。

4.2 我的「合理保守值」决策

回测里默认用哪一档?

资产默认 round-trip 成本说明
股票(SP100)10 bp5 bp spread × 2 + 佣金 - rebate ≈ 10 bp 单边 round-trip
股票(SP400 / 中盘)20 bpspread 大、impact 大
ETF(SPY/QQQ)2 bpspread <1 bp 主导,几乎免费
期权(流动性好)20 bp of premiumbid-ask 是 5-10 bps of premium,加佣金
加密(BTC/ETH on Coinbase)40 bpspread + slippage + maker/taker 0.4%
加密(山寨)>100 bp不要碰

Day 28 默认用 5 bp round-trip 是「乐观但可接受」。今天的 sweep 告诉我们:Day 28 报的 Sharpe 1.10 在「实际可能更差」的情境下会落到 0.69——Phase 2 paper trade 之前要心理上接受这个区间


五、换手率(Turnover)的隐性成本

5.1 把换手率算清楚

定义

单边 turnover = sum(|w_t - w_{t-1}|) / 2
年化 turnover = 月度 turnover × 12(如果 monthly rebalance)

Day 28 双因子组合的真实换手率分解

来源月度贡献年化贡献
动量信号轮换~12%~144%
低波动信号轮换~5%~60%
Vol target rescale~2%~24%
现金流入流出 / 分红再投~1%~12%
合计~20%~240%

5.2 换手率 vs Sharpe — 全 frontier

关键问题:「如果我把 rebalance 频率改成 quarterly,turnover 减半,Sharpe 会变好还是变差?

两个力量在拔河:

减少换手的「好处」减少换手的「坏处」
成本下降 → Sharpe ↑信号衰减更慢 → 但跟单不及时 → Sharpe ↓

回测一下:

freq_results = []
for freq in ['weekly', 'monthly', 'quarterly', 'semi-annual']:
    r = run_dual_factor_backtest(rebalance_freq=freq, cost_config={'slippage_bps_round_trip': 10})
    freq_results.append({'freq': freq, **r})
print(pd.DataFrame(freq_results).to_string(index=False))

预期输出

FreqTurnover/yrGross SharpeNet Sharpe (10bp)Cost drag
Weekly800%1.250.45-8.0%
Monthly240%1.180.98-2.4%
Quarterly90%1.050.95-0.9%
Semi-annual50%0.850.80-0.5%

核心观察

  1. Weekly 是坑:Gross Sharpe 看着最高(1.25),net 却是最差(0.45)。这就是「过度拟合换手」的典型。
  2. Monthly 和 Quarterly 几乎打平:在 10 bp 成本下,月度的 alpha 更新优势刚好补回成本劣势。如果成本是 20 bp,Quarterly 会赢。
  3. Semi-annual 是 robustness 下限:Sharpe 0.80 还能站,证明这个策略的核心 alpha 是真实的。

决策Day 28 选 monthly 是对的,但 Phase 2 paper trade 阶段并行跑 quarterly 版本作为 robustness check

5.3 PM 视角:换手率 = 运营 SLA 的隐性绑定

我做 10 年金融零售 PM,「运营频率 = 系统可靠性的隐性 SLA」

量化术语PM 等价物
月度 rebalance每月 1 次后台批处理
每周 rebalance每周 5 次实时调度
日内 rebalance7×24 实时风控引擎

每提高一个频率档,对系统的可靠性要求是数量级跳升。我已经知道——金融系统里「周末批处理出问题,周一补」是常态,但「实时系统宕机 30 秒,全天单据混乱」也是常态。量化策略选 monthly 不是因为月度信号更准,而是因为 monthly 给了我「容错的运营窗口」

这也是为什么 Phase 2 paper trade 我会选 monthly + quarterly 双轨——不是因为不相信周度信号,而是因为我现在还没有「能撑周度调度的基础设施」。


六、Implementation 优化 — 执行端的杠杆

成本不只是「策略选什么频率」决定的,还由「具体怎么下单」决定

6.1 TWAP / VWAP — 拆单的概念

Time-Weighted Average Price (TWAP):把一个大单按时间均匀拆开。

原单:买 1000 股 AAPL 在 14:30 一次成交
TWAP:买 100 股 / 分钟,从 14:30 到 14:39 共 10 笔

Volume-Weighted Average Price (VWAP):按当天成交量分布拆。

日内成交量分布:开盘 / 收盘高,午盘低
VWAP:在成交量高的时段下大头、低的时段下小头

核心收益:把「冲击成本」从「集中一次 50 bp」摊薄成「分散 10 次 5 bp 平均」。

散户量级用不上吗取决于单笔大小

单笔是否值得 TWAP
<$5k SPY 类 ETF不值得,spread <1 bp 直接市价
<$5k SP100 大盘不值得,市价单 ≈ 5 bps,TWAP 收益 <1 bp 但增加操作
$5k-50k 中盘股值得,可以用 IBKR 的 Adaptive 算法单(IBKR 内置 TWAP/VWAP)
>$50k 任何标的必须,否则冲击 >20 bp

对我们当前 <$5k 账户Phase 2 阶段不用 TWAP,但要在 Phase 3 / 资金 ≥$50k 时切换先把代码占位写好

# 占位:未来切到 TWAP / VWAP 时的执行接口
def place_order(ib, contract, qty, direction, exec_algo='market'):
    if exec_algo == 'market':
        order = MarketOrder(direction, qty)
    elif exec_algo == 'adaptive_fast':
        order = MarketOrder(direction, qty)
        order.algoStrategy = 'Adaptive'
        order.algoParams = [TagValue('adaptivePriority', 'Urgent')]
    elif exec_algo == 'twap':
        order = MarketOrder(direction, qty)
        order.algoStrategy = 'Twap'
        order.algoParams = [TagValue('startTime', '14:30:00'),
                            TagValue('endTime', '14:45:00'),
                            TagValue('allowPastEndTime', 'true')]
    return ib.placeOrder(contract, order)

6.2 限价单 vs 市价单 — 散户最重要的杠杆

这是散户能立刻拿到的成本优化

维度市价单限价单(mid)限价单(aggressive: bid+1 tick)
成交率100%~30%~85%
平均滑点(bp)spread/2 + 冲击-spread/2(可能拿 maker rebate)0 ~ spread/4
时间到成交<1 秒数分钟到不成交数秒到数分钟
适用场景信号衰减快 / 单子大不急的 rebalance默认推荐

经验法则

快信号(日内 / 事件驱动):市价单 — 不要省那 2 bp,错过更贵
慢信号(月度因子):限价单 aggressive — 省下来的 2-5 bp 全是利润
极慢信号(季度 rebalance):限价单 mid — 不成交就等下一天

Day 28 月度 rebalance 应该用 aggressive limit(bid+1 tick / ask-1 tick)。预期成本节省 30-50%,把 10 bp round-trip 压到 ~6 bp。

6.3 Rebalance 时点的微优化

**「14:00 ET 之后」**是常识里成本最低的窗口:

时段spreadimpact适合
Open (9:30-10:00)避开
Morning (10:00-12:00)一般
Lunch (12:00-13:30)中(流动性低)避开(看似 spread 小但深度差)
Afternoon (14:00-15:30)首选
Close (15:30-16:00)高(MOC 单冲击)避开(除非要 close print)

散户实操月度 rebalance 锁定在每月最后/第一个交易日的 14:30-15:00 ET 窗口


七、完整 Monte Carlo 代码 — Day 28 + Cost Regime 分布

把以上所有放在一起:

# tr_day34_montecarlo.py
import numpy as np
import pandas as pd
from day28_pipeline import run_dual_factor_backtest

# 1. Cost regime 分布(基于 Day 22 经验 + 今天 SP100 spread 估计)
COST_DISTRIBUTION = [
    {'name': 'best',       'prob': 0.20, 'rt_bps': 2,  'fomc_mult': 1.0},
    {'name': 'normal',     'prob': 0.50, 'rt_bps': 6,  'fomc_mult': 1.0},
    {'name': 'tight',      'prob': 0.20, 'rt_bps': 10, 'fomc_mult': 1.5},
    {'name': 'stressed',   'prob': 0.10, 'rt_bps': 20, 'fomc_mult': 2.5},
]

def sample_yearly_cost(rng):
    """每个月独立采样一个 regime,年化是 12 个月的加权平均。"""
    monthly_bps = []
    for _ in range(12):
        r = rng.choice(COST_DISTRIBUTION, p=[d['prob'] for d in COST_DISTRIBUTION])
        monthly_bps.append(r['rt_bps'] * r['fomc_mult'])
    return np.mean(monthly_bps)

# 2. Monte Carlo — 1000 次回测
N_SIMS = 1000
rng = np.random.default_rng(42)

results = []
for i in range(N_SIMS):
    cost_bps = sample_yearly_cost(rng)
    r = run_dual_factor_backtest(cost_config={'slippage_bps_round_trip': cost_bps})
    results.append({
        'sim_id':   i,
        'cost_bps': cost_bps,
        'CAGR':     r['cagr'],
        'Sharpe':   r['sharpe'],
        'MaxDD':    r['max_dd'],
    })

df = pd.DataFrame(results)

# 3. 输出 Sharpe 分布的关键 percentile
print("\n=== Sharpe 分布 ===")
for p in [5, 10, 25, 50, 75, 90, 95]:
    print(f"  P{p:2d} = {df['Sharpe'].quantile(p/100):.2f}")

print(f"\n  Mean Sharpe   = {df['Sharpe'].mean():.2f}")
print(f"  Median Sharpe = {df['Sharpe'].median():.2f}")
print(f"  P(Sharpe>1)   = {(df['Sharpe']>1).mean():.1%}")
print(f"  P(Sharpe>0.5) = {(df['Sharpe']>0.5).mean():.1%}")

# 4. 输出 Sharpe vs cost_bps 散点图(看弹性)
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10, 5))
ax.scatter(df['cost_bps'], df['Sharpe'], alpha=0.5, s=10)

# 拟合线性
z = np.polyfit(df['cost_bps'], df['Sharpe'], 1)
xs = np.linspace(df['cost_bps'].min(), df['cost_bps'].max(), 100)
ax.plot(xs, z[0]*xs + z[1], 'r-', label=f'slope = {z[0]:.3f} Sharpe / bp')

ax.set_xlabel('Round-trip cost (bps)')
ax.set_ylabel('Net Sharpe')
ax.set_title('Day 28 双因子组合的 Cost-Sharpe 弹性')
ax.legend()
ax.grid(True, alpha=0.3)
plt.savefig('day34_mc_elasticity.png', dpi=150)

预期输出

=== Sharpe 分布 ===
  P 5 = 0.42
  P10 = 0.55
  P25 = 0.78
  P50 = 1.02
  P75 = 1.15
  P90 = 1.22
  P95 = 1.27

  Mean Sharpe   = 0.96
  Median Sharpe = 1.02
  P(Sharpe>1)   = 53%
  P(Sharpe>0.5) = 89%

  弹性 slope ≈ -0.061 Sharpe / bp(和 Day 28 单点测量一致)

核心结论(这一段是今天写给自己的「决策依据」):

  1. Sharpe 的中位数 1.02——和 Day 28 单点测量 1.10 略低,但是更接近真实。
  2. P(Sharpe > 1) = 53%——「这个策略一半时间能跑出 Sharpe > 1」,但另一半时间会落到 0.5-1.0
  3. P(Sharpe > 0.5) = 89%——「这策略 89% 的概率能跑出 acceptable 表现」。这是 Phase 2 paper trade 的「go/no-go」依据
  4. P5 = 0.42——「**最坏 5%**情况下 Sharpe = 0.42」。即便这种情况,也没有负 Sharpe——策略本身是有信号的,不是 noise。

八、散户的「合理保守值」 — 我的决策表

把今天所有内容总结成一张随手可查的备忘录

资产单边 spread cost单边 commission单边 round-trip回测默认值
ETF (SPY/QQQ)0.5 bp<0.1 bp~1 bp2 bp
SP100 大盘股2.5 bp0.2 bp~3 bp5 bp
SP400 中盘股5 bp0.5 bp~6 bp10 bp
Russell 2000 小盘12 bp1 bp~14 bp25 bp
期权(流动性好)5 bp of premium0.5 bp~6 bp15 bp
期权(OTM 远期)50+ bp of premium1 bp~55 bp80 bp
加密(BTC/ETH on Coinbase)5 bp30 bp (maker/taker)~35 bp40 bp
加密(山寨)100+ bp30 bp~130 bp不回测

:以上是「回测默认保守值」,用于 sweep 时的中位档。Monte Carlo 应该围绕这些值 ±50% 抽样。

这张表的用法

  • 写新策略代码时——把对应资产的「回测默认值」hard-code 为 default_round_trip_bps 参数。
  • 看到别人 Sharpe 1.5 的回测时——问「成本用了多少」。如果他用 1 bp 而我用 10 bp,他的 Sharpe 1.5 对我等价于 ~0.9,已经很普通。
  • 跨资产组合时——按持仓权重加权计算综合成本:
    组合 cost = sum(w_i × cost_i)
    

九、PM 视角:「unit economics 敏感性分析」的迁移

9.1 量化的「成本敏感性」 = PM 的「最坏 case 增长」

我在金融零售产品的 10 年里,每一次大需求评审都要给三种 case:

案例量化等价
Base case:预期 ROI 1.5x中位 Sharpe 1.02
Best case:广告 CTR 高于预期P90 Sharpe 1.22
Worst case:CAC 翻倍P10 Sharpe 0.55

好 PM 的特征永远先讲 worst case,再讲 base,最后 best。坏 PM 反过来,结果是把团队带进「侥幸心理」陷阱

量化在这条原则上更不宽容:你可以容忍产品 worst case 损失 30%,但量化 worst case 是真金白银。所以回测必须默认偏保守实盘惊喜永远比意外好

9.2 「成本」不是「损耗」,是「Trade-off 的另一面」

很多人把成本看成「毛收益的减法」——错。成本是「这个策略对执行能力的隐性押注」:

策略成本敏感性押注内容
月度因子(Day 28)押 alpha 真实存在
周度动量押 alpha + 执行 OK
日内反转极高押 alpha + 执行优秀 + 算力 + colo
HFT超高押 alpha + 执行 + colo + FPGA + tech team

散户的「执行能力上限」就是 IBKR Adaptive 算法 + 限价单。所以散户的「可行策略空间」就被「成本敏感性」自动筛选了——不是「我选不做 HFT」,是「HFT 在我这里 unit economics 不成立」

这也是为什么 Day 28 选月度 + Day 34 验证 cost robustness 是关键的两步——前者选了一个 unit economics 健康的 weight class,后者证明这个 weight class 在压力下仍然 work

9.3 给自己的执行原则(这一条我会贴在桌面)

1. 任何策略,回测时默认成本 = 表里的「保守值」
2. 任何策略上线前,必须跑 Monte Carlo cost regime,
   且 P10 Sharpe 必须 > 0.5(最坏 10% 情况下还有 alpha)
3. 实盘 vs 回测的 Sharpe gap 超过 0.3,必须 root cause(不是 noise)
4. 不做任何「成本弹性 > -0.3 Sharpe/bp」的策略

十、Day 34 实际执行 Checklist

  • (0) 复习 Day 22 / Day 28 笔记,把「成本框架」串起来
  • (1) 实现 IBKRTieredCost 类,跑通 4 个 sanity check 案例
  • (2) 用 yfinance + Corwin-Schultz 估 5 只 ticker 的 spread(SPY / AAPL / ROKU / PLTR / 自选)
  • (3) 实现 SlippageModel v2 类,支持 conditional regime
  • (4) 把 Day 28 pipeline 模块化导出 run_dual_factor_backtest(cost_config=...) 接口
  • (5) 跑 4 档 cost sweep,输出 Sharpe / CAGR / MaxDD / Cost drag 表
  • (6) 跑 4 档 frequency sweep(weekly / monthly / quarterly / semi-annual)
  • (7) 跑 1000 次 Monte Carlo,输出 Sharpe 分布 percentile 和 cost-Sharpe 弹性散点图
  • (8) 把「散户合理保守值」决策表打印贴到桌面
  • (9) 更新 docs/daily/TR_PROGRESS.md Week 5 / Day 34 标 ✅
  • (10) 记录踩坑:本笔记最后加「实际执行记录」段

十一、明日预告

Day 35: Walk-Forward Analysis 实战验证 + 双因子组合 OOS 稳定性测试

  • 把 Day 25 的 WFA 框架用到 Day 28 双因子组合 + Day 34 的真实成本上
  • 5-fold rolling WFA:2014-2024 划分 IS / OOS
  • 关键测试:IS Sharpe / OOS Sharpe 比值是否 > 0.6(学术稳健性门槛)
  • 关键测试:OOS 期间最大 single-fold Sharpe 下降幅度
  • 决策:基于 WFA 结果决定「Day 28 + Day 34 cost 调整后的策略是否进入 Phase 2 paper trade
  • 写一个 fold-by-fold 可视化 + 子集稳定性热力图
  • 整理 Day 31-35 「成本 + WFA」整周笔记作为 Phase 2 Week 5 阶段性输出

实际执行记录

启动一项填一项,时间戳 + 卡点。

  • [hh:mm] IBKRTieredCost 实现 + sanity check — ...
  • [hh:mm] Corwin-Schultz spread 估计 — ...
  • [hh:mm] 4 档 cost sweep — ...
  • [hh:mm] Frequency sweep — ...
  • [hh:mm] Monte Carlo 1000 次 — ...
  • [hh:mm] 弹性散点图 — ...
  • 卡点 / 学到的:

总字数:约 6,200 字 今日完成度:理论 ✓ / 实操(你自己执行)/ 笔记 ✓