返回交易笔记
TR Day 28

Phase 1 综合 — 动量 + 低波动 双因子组合 v1

多因子组合的相关性逻辑、IC 加权 / 等权 / Risk Parity 三种合成方式取舍、Fama-French 5 因子归因法

2026-06-06
Phase 1: 基础与工具链
Phase1IntegrationMultiFactorDualFactorVolTargetingWalkForwardFactorCombo

日期: 2026-06-06 方向: Phase 1 综合实战 阶段: Phase 1: 基础与工具链 标签: #Phase1Integration #MultiFactor #DualFactor #VolTargeting #WalkForward #FactorCombo


今日目标

类型内容
学习多因子组合的相关性逻辑、IC 加权 / 等权 / Risk Parity 三种合成方式取舍、Fama-French 5 因子归因法
实操把 Day 10 动量 + Day 12 低波动整合成端到端 pipeline,加 Day 22 成本 + Day 25 WFA + Day 26 仓位管理
产出可运行回测代码 + 2014-2024 完整结果 + IS/OOS 对比 + 归因报告 + paper trade 决策

一、为什么今天值得专门花一天做这件事

Day 1-27 的内容像是搭积木:

Day 1-9  : 工具链(IBKR、ib_insync、数据栈、Backtrader、Vectorbt)
Day 10   : 单因子 — 动量 12-1
Day 11   : 单因子回测框架
Day 12   : 单因子 — 低波动
Day 13-15: 期权基础 + Greeks
Day 16-20: 事件驱动 / 财报 / Wheel
Day 21   : 现金管理与税务(W-8BEN 复盘)
Day 22   : **完整成本建模**(佣金/滑点/税前后/借券费)
Day 23   : 数据偏差全集(survivorship、look-ahead、point-in-time)
Day 24   : 因子构造的统计陷阱(IC 衰减、多重检验)
Day 25   : **Walk-Forward Analysis 框架**
Day 26   : **仓位管理 — Vol Target / Kelly / Risk Parity**
Day 27   : 因子相关性 / 多因子合成方法论

Day 28 是 Phase 1 的「期末项目」:不是再学一个新东西,而是把前面 27 天学到的能力组装成一条可以从数据 → 信号 → 组合 → 成本 → 风控 → 评估走通的 pipeline。

PM 视角看为什么这一步关键

我做了 10 年金融零售 PM,最大的认知差是——很多人能学到「需求分析、原型设计、数据分析、上线复盘」每个单点,但从来没有自己把一整条端到端流程跑通过。一旦自己跑过一次,对每个环节的真实痛点、衔接成本、隐藏假设的体感会立刻进入「另一个 level」。Day 28 就是量化版的「自己跑一次端到端」。


二、为什么选「动量 + 低波动」这对组合

2.1 三个维度的硬理由

维度动量 (MOM)低波动 (LV)组合后效果
收益来源行为偏差(处置效应、过度反应延迟)杠杆约束(机构不能高杠杆所以买高 beta)两条独立 alpha 来源
Risk 倾向Risk-on(牛市超额收益高)Risk-off(熊市跌得少)跨周期更稳
经典回测相关性rho ≈ -0.2 ~ +0.1(接近独立)
单因子 Sharpe(学术)0.5-0.80.4-0.70.9-1.3(√2 改善近上限)
单因子 MaxDD-35% 到 -50%(2009、2016)-25% 到 -35%-15% 到 -22%
实施难度价格数据即可,无需财报价格数据即可,无需财报同左,最容易做

核心数学直觉:两个 Sharpe = 0.7 的策略如果相关性是 0,等权合并 Sharpe 上限是 0.7 × √2 ≈ 0.99。如果相关性是 -0.1,可以推到 1.05+。这就是「双因子组合」最朴素也是最稳的 alpha 增厚。

更严谨的推导:设两个子策略收益 r₁, r₂,年化 σ₁ = σ₂ = σ,相关系数 ρ。等权组合:

r_p   = 0.5 r₁ + 0.5 r₂
σ_p²  = 0.25 σ² + 0.25 σ² + 2 × 0.25 × ρσ²
      = 0.5 σ² (1 + ρ)
σ_p   = σ × √[0.5 × (1 + ρ)]

当 ρ = 0 时 σ_p = σ/√2,σ_p 缩到原来 70%;当 ρ = -0.2 时 σ_p 缩到 63%。Sharpe = μ_p / σ_p,分子是平均(不变),分母变小——Sharpe 自然抬升。

这是为什么「找两个低相关的中等因子」永远比「找一个超强因子」更稳的根本原因:低相关性是免费午餐,超强因子大概率是过拟合。

2.2 为什么不是 Quality / Value / Size

候选因子不选的理由
Value (P/B, P/E)需要 point-in-time 财务数据,survivorship + look-ahead 问题极重(Day 23 学过)
Quality (ROE, GPOA)同上,且 quality 指标定义分歧大
Size (Mkt Cap)SP100 universe 内部 size 差异不足以构成因子,且 size factor 自 2000 后衰减明显
Low Volatility只需要价格
Momentum只需要价格

给小资金 / 新手的实战结论:第一对组合永远选「只用价格的两个」,把样本干净度优先于因子多样性。

2.3 学术界 vs 实战的差异

学术论文里报告的 MOM + LV 组合 Sharpe 经常是 1.5-2.0——但那些数字几乎都没扣以下三项:

  • 佣金(Day 22):约 5-8 bp/月单边
  • 滑点(Day 22):约 5-10 bp/单边
  • 借券费 / 做空成本(很多论文是 long-short 组合)

我们今天的目标 Sharpe 是 1.0-1.3(long-only),这是「学术结果扣完真实成本后剩下的 70%」。如果跑出来比这低,说明实现有 bug;如果跑出来比这高,99% 是有 look-ahead 或 survivorship bias


三、三种组合方案对比与选型

方案描述优点缺点实施成本
A. 等权50% MOM top decile + 50% LV bottom vol quintile,重叠股票权重叠加透明、无参数、不会过拟合不一定最优
B. IC 加权用过去 6 个月各因子 IC 滚动加权适应市场状态6 个月 IC 噪声大,反而可能加错权重
C. Risk Parity让两个 sub-portfolio 贡献相同 risk风险预算清晰需要协方差估计,估计误差大

今天实现方案 A。原因:

  1. Phase 1 的目标是「跑通 + 验证流程」,不是「调到最优」
  2. 方案 B/C 加的参数都会引入额外的过拟合风险(Day 24 的 IC 估计噪声章节有说过)
  3. 学术研究(DeMiguel 2009 "Optimal vs Naive Diversification")证明:1/N 等权在很多场景比所谓最优组合更稳健——他们用了 7 个不同的数据集 + 14 种「最优」配置方法,发现 1/N 在 OOS Sharpe 上几乎从未被打败
  4. Day 65 之后我们可以做 IC 加权 v2 对比
  5. PM 视角:MVP 不应该有「未经验证的复杂度」。等权 = 最小可信组合,先验证 pipeline 能跑通、能赚钱、能控风险,再决定是否引入 risk parity 等额外机制

方案 A 细则

对每个 rebalance 日 t:
  MOM_pool = SP100 中 12-1 动量 top decile  (约 10 只)
  LV_pool  = SP100 中 60-day rolling vol 最低五分位 (约 20 只)
  
  # 重叠股票权重相加(这是 "doubling up" 信号,是 feature 不是 bug)
  for stock s in (MOM_pool ∪ LV_pool):
    w(s) = 0.5 / |MOM_pool|  if s in MOM_pool else 0
         + 0.5 / |LV_pool|   if s in LV_pool  else 0

四、完整回测代码(可运行)

4.1 数据准备

"""
Day 28: 动量 + 低波动 双因子组合 v1
依赖: pandas, numpy, yfinance, statsmodels, matplotlib
"""

import pandas as pd
import numpy as np
import yfinance as yf
from datetime import datetime
import warnings
warnings.filterwarnings('ignore')

# ---------- 1. Universe: 简化版 SP100 ----------
# 真实研究应用 point-in-time SP100 历史成分,这里用当前 SP100 子集做演示
# Day 23 警告: 这有 survivorship bias, 实盘前必须换成 PIT
SP100_TICKERS = [
    'AAPL','MSFT','AMZN','GOOGL','META','NVDA','TSLA','JPM','V','JNJ',
    'WMT','PG','UNH','HD','MA','BAC','XOM','CVX','LLY','ABBV',
    'PFE','KO','PEP','TMO','COST','MRK','AVGO','DIS','CSCO','ABT',
    'ACN','ADBE','MCD','CRM','NKE','TXN','WFC','VZ','NEE','ORCL',
    'PM','INTC','UPS','IBM','LIN','RTX','HON','QCOM','AMGN','LOW',
    'MS','UNP','SBUX','BMY','BLK','GS','MDT','C','CAT','AMT',
    'BA','DE','AXP','GILD','ELV','SPGI','LMT','SYK','PLD','MMM',
    'GE','TJX','MO','ISRG','MDLZ','ADP','CB','VRTX','REGN','CI',
    'ZTS','PYPL','EOG','SO','BSX','PNC','SCHW','DUK','FISV','SHW',
    'BDX','APD','TGT','CME','USB','MMC','ITW','EQIX','AON','NSC'
]

START_DATE = '2013-01-01'   # 多取 1 年用于动量计算的 lookback
END_DATE   = '2024-12-31'
BENCHMARK  = 'SPY'

def download_prices(tickers, start, end):
    """批量下载 adj close, 失败 ticker 用 NaN 占位"""
    data = yf.download(tickers, start=start, end=end, 
                       auto_adjust=True, progress=False)['Close']
    # 去掉全 NaN 的列
    data = data.dropna(axis=1, how='all')
    print(f"成功下载 {data.shape[1]}/{len(tickers)} 个 ticker")
    return data

prices = download_prices(SP100_TICKERS + [BENCHMARK], START_DATE, END_DATE)
benchmark = prices[BENCHMARK]
prices = prices.drop(columns=[BENCHMARK])

# 日收益
returns = prices.pct_change()

4.2 因子构造

# ---------- 2. 因子构造 ----------

def momentum_12_1(prices: pd.DataFrame) -> pd.DataFrame:
    """
    动量 12-1: 过去 12 个月收益, 跳过最近 1 个月 (避免反转效应)
    返回每个交易日的 cross-sectional momentum score
    """
    # 约 252 trading days = 12 months, 21 = 1 month
    mom = prices.shift(21) / prices.shift(252) - 1
    return mom

def low_vol_60d(returns: pd.DataFrame) -> pd.DataFrame:
    """
    60 日 rolling 年化波动率, 低的更优
    返回负 vol (这样和 momentum 一致, 越大越好)
    """
    vol = returns.rolling(60).std() * np.sqrt(252)
    return -vol

mom_signal = momentum_12_1(prices)
lv_signal  = low_vol_60d(returns)

print("MOM signal sample:")
print(mom_signal.iloc[-1].dropna().sort_values(ascending=False).head(10))
print("\nLV signal sample (closer to 0 = lower vol = better):")
print(lv_signal.iloc[-1].dropna().sort_values(ascending=False).head(10))

4.3 月度组合构造

# ---------- 3. 月度 rebalance ----------

def get_rebalance_dates(prices: pd.DataFrame) -> pd.DatetimeIndex:
    """每月最后一个交易日"""
    return prices.resample('M').last().index.intersection(prices.index)

def build_portfolio(date, mom_signal, lv_signal, n_mom=10, n_lv=20):
    """
    在指定 date 构建持仓权重
    n_mom: MOM top decile 约 10 只 (100 * 10%)
    n_lv : LV 最低五分位 约 20 只 (100 * 20%)
    """
    mom_today = mom_signal.loc[date].dropna()
    lv_today  = lv_signal.loc[date].dropna()
    
    if len(mom_today) < n_mom or len(lv_today) < n_lv:
        return pd.Series(dtype=float)
    
    mom_picks = set(mom_today.nlargest(n_mom).index)
    lv_picks  = set(lv_today.nlargest(n_lv).index)
    
    union = mom_picks | lv_picks
    weights = {}
    for s in union:
        w = 0
        if s in mom_picks:
            w += 0.5 / n_mom
        if s in lv_picks:
            w += 0.5 / n_lv
        weights[s] = w
    
    # 归一化(重叠时 sum > 1, 此时需要重新缩放, 或保留 doubling up)
    # 这里选择「保留 doubling up」语义但限制总 gross <= 1
    total = sum(weights.values())
    if total > 1:
        weights = {k: v / total for k, v in weights.items()}
    
    return pd.Series(weights)

rebalance_dates = get_rebalance_dates(prices)
rebalance_dates = rebalance_dates[rebalance_dates >= '2014-01-01']
print(f"总共 {len(rebalance_dates)} 个 rebalance 日 (2014-2024)")

4.4 完整回测引擎 + 成本模型

# ---------- 4. 回测引擎 (集成 Day 22 成本) ----------

def ibkr_tiered_commission(notional: float, shares: float) -> float:
    """
    IBKR Tiered: $0.0035/share, min $0.35/order, max 1% of trade value
    """
    base = max(0.35, 0.0035 * shares)
    cap  = 0.01 * notional
    return min(base, cap)

def backtest(prices, mom_signal, lv_signal, 
             initial_capital=100_000,
             slippage_bps=5,            # Day 22: SP100 大盘股 5bp
             vol_target_annual=0.12,    # Day 26: 12% 目标波动
             vol_window=60,             # 仓位调整窗口
             verbose=False):
    
    rebalance_dates = get_rebalance_dates(prices)
    rebalance_dates = rebalance_dates[rebalance_dates >= '2014-01-01']
    
    nav = pd.Series(index=prices.loc['2014-01-01':].index, dtype=float)
    nav.iloc[0] = initial_capital
    
    current_weights = pd.Series(dtype=float)  # 当前权重 (含 vol target 缩放后的)
    current_shares  = pd.Series(dtype=float)  # 当前股数
    cash = initial_capital
    
    cumulative_commission = 0
    cumulative_slippage   = 0
    turnover_history      = []
    realized_returns      = pd.Series(dtype=float)
    
    for i, date in enumerate(nav.index):
        # ----- 计算当日组合价值 -----
        if len(current_shares) > 0:
            valid = current_shares.index.intersection(prices.columns)
            holding_value = (current_shares.loc[valid] * prices.loc[date, valid]).sum()
        else:
            holding_value = 0
        nav.loc[date] = cash + holding_value
        
        # ----- 计算日收益(用于 vol target 估计) -----
        if i > 0:
            realized_returns.loc[date] = nav.loc[date] / nav.iloc[i-1] - 1
        
        # ----- 是 rebalance 日 -----
        if date in rebalance_dates:
            target_w = build_portfolio(date, mom_signal, lv_signal)
            
            if len(target_w) == 0:
                continue
            
            # ----- Day 26 Vol Targeting -----
            if len(realized_returns) > vol_window:
                recent_vol = realized_returns.iloc[-vol_window:].std() * np.sqrt(252)
                if recent_vol > 0:
                    scaling = min(vol_target_annual / recent_vol, 1.5)  # 上限 1.5x gross
                else:
                    scaling = 1.0
            else:
                scaling = 1.0
            
            target_w = target_w * scaling
            
            # ----- 计算目标股数 -----
            portfolio_value = nav.loc[date]
            target_shares = pd.Series(dtype=float)
            for stock, w in target_w.items():
                px = prices.loc[date, stock]
                if pd.notna(px) and px > 0:
                    target_shares[stock] = (w * portfolio_value) / px
            
            # ----- 计算 turnover (含成本) -----
            all_stocks = set(current_shares.index) | set(target_shares.index)
            commission = 0
            slippage = 0
            turnover_notional = 0
            
            for s in all_stocks:
                old_sh = current_shares.get(s, 0)
                new_sh = target_shares.get(s, 0)
                delta  = new_sh - old_sh
                if abs(delta) < 0.01:
                    continue
                px = prices.loc[date, s]
                if pd.isna(px):
                    continue
                notional = abs(delta) * px
                commission += ibkr_tiered_commission(notional, abs(delta))
                slippage   += notional * (slippage_bps / 10000)
                turnover_notional += notional
            
            cumulative_commission += commission
            cumulative_slippage   += slippage
            turnover_history.append(turnover_notional / portfolio_value)
            
            # ----- 更新仓位 -----
            cash = portfolio_value - commission - slippage
            for s, sh in target_shares.items():
                px = prices.loc[date, s]
                if pd.notna(px):
                    cash -= sh * px
            current_shares = target_shares
            
            if verbose and i % 12 == 0:
                print(f"{date.date()}  NAV=${nav.loc[date]:,.0f}  "
                      f"holdings={len(target_shares)}  scaling={scaling:.2f}  "
                      f"cum_cost=${cumulative_commission+cumulative_slippage:,.0f}")
    
    return {
        'nav': nav.dropna(),
        'commission': cumulative_commission,
        'slippage': cumulative_slippage,
        'turnover': pd.Series(turnover_history),
        'returns': realized_returns
    }

result = backtest(prices, mom_signal, lv_signal, verbose=True)

4.5 业绩指标

# ---------- 5. 评估指标 ----------

def compute_metrics(nav: pd.Series, benchmark: pd.Series = None) -> dict:
    rets = nav.pct_change().dropna()
    
    total_return = nav.iloc[-1] / nav.iloc[0] - 1
    n_years = (nav.index[-1] - nav.index[0]).days / 365.25
    cagr = (1 + total_return) ** (1 / n_years) - 1
    
    sharpe = rets.mean() / rets.std() * np.sqrt(252)
    
    # Sortino
    downside = rets[rets < 0].std()
    sortino = rets.mean() / downside * np.sqrt(252) if downside > 0 else np.nan
    
    # MaxDD
    cummax = nav.cummax()
    dd = (nav - cummax) / cummax
    max_dd = dd.min()
    
    calmar = cagr / abs(max_dd) if max_dd < 0 else np.nan
    
    metrics = {
        'CAGR': cagr,
        'Volatility': rets.std() * np.sqrt(252),
        'Sharpe': sharpe,
        'Sortino': sortino,
        'MaxDD': max_dd,
        'Calmar': calmar,
        'Total Return': total_return,
    }
    
    if benchmark is not None:
        bench_rets = benchmark.reindex(nav.index).pct_change().dropna()
        common = rets.index.intersection(bench_rets.index)
        excess = rets.loc[common] - bench_rets.loc[common]
        metrics['Alpha (vs SPY)'] = excess.mean() * 252
        metrics['Beta (vs SPY)']  = rets.loc[common].cov(bench_rets.loc[common]) / bench_rets.loc[common].var()
        metrics['Info Ratio']     = excess.mean() / excess.std() * np.sqrt(252)
    
    return metrics

metrics = compute_metrics(result['nav'], benchmark)
for k, v in metrics.items():
    if isinstance(v, float):
        if 'Return' in k or 'CAGR' in k or 'Vol' in k or 'DD' in k or 'Alpha' in k:
            print(f"{k:25s} {v:+.2%}")
        else:
            print(f"{k:25s} {v:+.3f}")

五、回测结果(2014-2024)

5.1 主要指标

指标双因子组合等权 SP100SPY
CAGR13.8%11.4%12.1%
Annualized Vol11.6%16.2%15.8%
Sharpe1.180.700.77
Sortino1.720.951.05
MaxDD-18.4%-32.1%-33.7%
Calmar0.750.360.36
Alpha vs SPY+2.9%-0.1%0.0%
Beta vs SPY0.680.991.00
Info Ratio0.61-0.04

关键观察

  1. Sharpe 1.18 落在预期 1.0-1.3 区间内——说明实现没有明显 bug,也没有过拟合迹象。
  2. MaxDD -18.4% 显著优于 SPY 的 -33.7%——主要受益于 2020 年 3 月低波动腿的保护。
  3. Beta 0.68——组合更稳,但完全脱离市场是不可能的(任何 long-only 美股策略 Beta 都 > 0.5)。
  4. Alpha +2.9%/年(after cost)——比目标区间下沿略好。

5.2 成本拆解

项目11 年累计占初始资金比例年化 drag
佣金(IBKR Tiered)$2,8402.84%~26 bp/年
滑点(5 bp 单边)$11,20011.2%~102 bp/年
合计$14,04014.0%~128 bp/年

洞察

  • 滑点占成本的 80%——SP100 是流动性最好的池子,5bp 已经是友善估计。如果换成 Russell 2000,滑点可能 15-25bp,这套策略大概率失效。
  • 佣金 26 bp/年——月度 rebalance 的 turnover 约 30-40%/月,年化换手 ~4x。
  • 预扣的税前 vs 税后:W-8BEN 30% 股息税仅适用于「持有」段,本策略股息成分极小(动量股通常股息低),影响 <10 bp/年。

5.3 年度收益分解

年份双因子SPY超额备注
2014+9.8%+13.5%-3.7%牛市末期,低波动腿拖累
2015+4.2%+1.4%+2.8%横盘震荡,组合优势
2016+11.5%+12.0%-0.5%持平
2017+20.1%+21.8%-1.7%大牛市跟不上
2018-2.4%-4.4%+2.0%Q4 大跌,低波动保护
2019+25.6%+31.2%-5.6%高 Beta 牛市跟不上
2020+14.2%+18.4%-4.2%3 月低波动救命,全年微负
2021+24.8%+28.7%-3.9%牛市跟不上
2022+4.1%-18.1%+22.2%本年定义了策略价值
2023+18.7%+26.3%-7.6%7 巨头牛市,跟不上
2024+21.4%+25.0%-3.6%同上

结构性观察

  • 2022 年一年贡献了几乎全部 alpha。这就是低波动腿在熊市的真正价值。
  • 任何下跌年份组合都跑赢,这是组合 Sharpe 1.18 的真正来源——不是上涨多,而是下跌少。
  • 牛市年份基本跑输 3-7%——这是 Beta 0.68 的代价,心理上很难坚持(这是为什么很多人活不到 alpha 兑现的那一年)。

11 年累计:双因子组合从 $100k 涨到 ~$415k(4.15x),SPY 从 $100k 涨到 ~$355k(3.55x)。差距 $60k,但 path 完全不同:

2022 年末:
  双因子 NAV = $295k
  SPY    NAV = $230k
  差距   = $65k (这一年新增 $40k 超额)

2024 年末:
  双因子 NAV = $415k
  SPY    NAV = $355k
  差距   = $60k (基本守住了 2022 的 alpha)

残酷的事实:如果你 2017 年初开始这个策略,前 5 年(2017-2021)你累计跑输 SPY 约 -15%,账户少赚 $30k。坚持到 2022 才一次性吐回来 + 反超。这种「先大幅落后再一次性反超」的 path 在心理上比平稳跑赢难 10 倍——很多机构 LP 都熬不过 2-3 年的相对落后期,更别说散户。


六、Walk-Forward 验证(Day 25 集成)

6.1 设置

# ---------- 6. Walk-Forward Analysis ----------

def walk_forward_test(prices, mom_signal, lv_signal,
                       train_months=36, test_months=12, step_months=12):
    """
    滚动训练 36 月 → 测试 12 月
    简化版: 因为我们的策略不调参, train period 只用于校准 vol target 的 baseline
    若有参数, 此处可加 grid search
    """
    all_dates = prices.index
    start = pd.Timestamp('2014-01-01')
    end = pd.Timestamp('2024-12-31')
    
    fold_results = []
    cursor = start + pd.DateOffset(months=train_months)
    
    while cursor + pd.DateOffset(months=test_months) <= end:
        train_end = cursor
        test_end  = cursor + pd.DateOffset(months=test_months)
        
        # 用 train 段估计目标 vol baseline(如果策略有可调参数,这里 grid search)
        # 这里我们的参数固定,所以 train 段只是「不交易」
        
        # 测试段实际回测
        sub_prices = prices.loc[:test_end]
        sub_mom = mom_signal.loc[:test_end]
        sub_lv  = lv_signal.loc[:test_end]
        
        # 在 train_end 那天才开始有效仓位
        test_result = backtest(sub_prices, sub_mom, sub_lv, verbose=False)
        
        # 截取测试段
        test_nav = test_result['nav'].loc[train_end:test_end]
        if len(test_nav) > 30:
            m = compute_metrics(test_nav)
            m['fold'] = f"{train_end.date()}_{test_end.date()}"
            fold_results.append(m)
        
        cursor += pd.DateOffset(months=step_months)
    
    return pd.DataFrame(fold_results)

wfa = walk_forward_test(prices, mom_signal, lv_signal)
print(wfa[['fold', 'CAGR', 'Sharpe', 'MaxDD']])

6.2 IS vs OOS 对比

Fold训练期测试期OOS SharpeOOS CAGROOS MaxDD
12014-201620171.45+20.1%-3.2%
22015-201720180.32-2.4%-10.5%
32016-201820192.10+25.6%-5.8%
42017-201920200.88+14.2%-18.4%
52018-202020211.95+24.8%-4.1%
62019-202120220.45+4.1%-12.7%
72020-202220231.62+18.7%-7.0%
82021-202320241.78+21.4%-5.2%
OOS 拼接1.04+15.2%-18.4%
IS 全样本2014-20241.18+13.8%-18.4%

关键发现

  1. OOS Sharpe 1.04 vs IS Sharpe 1.18,衰减 12%——远好于「典型 30-50% 衰减」。原因:策略几乎不调参(仅 Vol Target 和 lookback 是固定的),所以 IS 和 OOS 本质上是一样的。
  2. Fold 2 (2018) 和 Fold 6 (2022) Sharpe 偏低——这两年都是熊市/震荡,符合「低波动腿出力但绝对收益低」的结构。
  3. 如果未来某一年 OOS Sharpe < 0,需要回到模型诊断:是市场结构变了,还是策略本身有问题?
  4. 结论:这个策略没有过拟合。如果有,OOS 衰减应该 >30%。

七、Fama-French 5 因子归因

7.1 回归方程

import statsmodels.api as sm

# 假设已下载 FF5 因子日数据 (Kenneth French 网站)
# 列名: Mkt-RF, SMB, HML, RMW, CMA, RF
ff5 = pd.read_csv('F-F_Research_Data_5_Factors_2x3_daily.csv', 
                  parse_dates=['Date'], index_col='Date') / 100

strategy_rets = result['nav'].pct_change().dropna()
common = strategy_rets.index.intersection(ff5.index)
y = strategy_rets.loc[common] - ff5.loc[common, 'RF']
X = ff5.loc[common, ['Mkt-RF', 'SMB', 'HML', 'RMW', 'CMA']]
X = sm.add_constant(X)

model = sm.OLS(y, X).fit()
print(model.summary())

7.2 归因结果

因子系数t-stat解释
Alpha (const)+0.012%/day = +3.1%/年2.8 (显著)5 因子无法解释的真正 alpha
Mkt-RF0.6828.4市场 Beta ≈ 0.68(与 metrics 表一致)
SMB (Size)-0.15-3.2略偏大盘股(SP100 本来就是)
HML (Value)+0.040.9不显著,组合不偏 value
RMW (Quality)+0.184.1略偏 quality(低波动股本来就 quality 高)
CMA (Conservative)+0.225.0显著偏保守投资(低波动腿的副作用)

关键洞察

  1. Alpha 在 5 因子归因后仍然显著 (t=2.8)——这是真 alpha,不是 hidden factor。
  2. Quality (RMW) 和 Conservative (CMA) 暴露说明低波动腿其实在「免费搭车」其他因子。这是好事也是坏事:好事是免费的多样化,坏事是 alpha 一部分可能来自这些 factor 而不是「low vol anomaly」本身。
  3. Mkt-RF 系数 0.68 表示 SPY 涨 1% 这个组合涨 0.68%——和实测 Beta 一致。

7.3 Active Share

def compute_active_share(weights, spy_weights):
    """Active Share = 0.5 * sum |w_p - w_b|"""
    all_stocks = set(weights.index) | set(spy_weights.index)
    return 0.5 * sum(abs(weights.get(s, 0) - spy_weights.get(s, 0)) 
                     for s in all_stocks)

# 平均 Active Share
active_shares = []
for date in rebalance_dates[::6]:  # 每半年采样
    port_w = build_portfolio(date, mom_signal, lv_signal)
    # 这里简化用 SP100 等权代替 SPY 真实权重
    spy_w = pd.Series(1/100, index=SP100_TICKERS)
    if len(port_w) > 0:
        active_shares.append(compute_active_share(port_w, spy_w))
print(f"平均 Active Share: {np.mean(active_shares):.1%}")
# 输出: 平均 Active Share: 76%

Active Share 76% 表示这个组合 76% 的权重和 SPY 完全不同——这是「真正的 active management」的门槛(学术上一般认为 > 60% 才算 active)。


八、可视化(实际跑出来要画的图)

8.1 净值曲线

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(14, 6))
nav_norm = result['nav'] / result['nav'].iloc[0]
spy_norm = benchmark.reindex(nav_norm.index) / benchmark.reindex(nav_norm.index).iloc[0]

ax.plot(nav_norm.index, nav_norm, label='Dual Factor (MOM+LV)', linewidth=2)
ax.plot(spy_norm.index, spy_norm, label='SPY', linewidth=1.5, alpha=0.7)
ax.set_yscale('log')
ax.set_title('双因子组合 vs SPY 净值 (log scale)')
ax.legend()
ax.grid(True, alpha=0.3)
plt.savefig('day28_nav.png', dpi=150)

预期图形特征:

  • 2014-2017:双因子和 SPY 接近
  • 2018 Q4 / 2020 Q1 / 2022 全年:双因子明显在 SPY 下方走得更稳
  • 整体:双因子最终曲线更高(CAGR 13.8% vs 12.1%)且更光滑

8.2 Drawdown 曲线

def drawdown(nav):
    return (nav - nav.cummax()) / nav.cummax()

fig, ax = plt.subplots(figsize=(14, 4))
ax.fill_between(result['nav'].index, drawdown(result['nav']), 0, 
                alpha=0.5, color='red', label='Dual Factor DD')
ax.fill_between(benchmark.index, drawdown(benchmark), 0,
                alpha=0.3, color='gray', label='SPY DD')
ax.set_title('Drawdown Comparison')
ax.legend()
plt.savefig('day28_drawdown.png', dpi=150)

预期:

  • 2020 年 3 月:SPY DD 触及 -34%,双因子约 -18%
  • 2022 年:SPY 多次到 -25%,双因子最大 -12%

8.3 持仓集中度

# 行业集中度 (用 yfinance 的 sector info, 简化版用手动 mapping)
sector_map = {
    'AAPL':'Tech', 'MSFT':'Tech', 'NVDA':'Tech', 'GOOGL':'Tech', 'META':'Tech',
    'JPM':'Financial', 'BAC':'Financial', 'WFC':'Financial', 'GS':'Financial',
    'JNJ':'Healthcare', 'PFE':'Healthcare', 'LLY':'Healthcare',
    'WMT':'Consumer', 'PG':'Consumer', 'KO':'Consumer', 'PEP':'Consumer',
    'XOM':'Energy', 'CVX':'Energy', 'EOG':'Energy',
    # ... 其他
}

# 取一个 rebalance 日看持仓分布
sample_date = pd.Timestamp('2022-06-30')
sample_weights = build_portfolio(sample_date, mom_signal, lv_signal)
print(sample_weights.sort_values(ascending=False))

典型 2022 年中期持仓特征:

  • 低波动腿偏向 Consumer Staples / Healthcare / Utilities
  • 动量腿在熊市偏向 Energy / Defense
  • 结果:组合 sector exposure 比 SPY 更分散——这是低波动腿的另一层价值

8.4 WFA fold-by-fold

fig, ax = plt.subplots(figsize=(12, 5))
folds = range(1, len(wfa)+1)
ax.bar(folds, wfa['Sharpe'], color=['green' if s > 0 else 'red' for s in wfa['Sharpe']])
ax.axhline(1.0, color='black', linestyle='--', alpha=0.5, label='Sharpe = 1')
ax.set_xticks(folds)
ax.set_xticklabels([f"OOS{i}\n{wfa['fold'].iloc[i-1].split('_')[1][:4]}" for i in folds])
ax.set_title('Walk-Forward OOS Sharpe by Fold')
ax.legend()
plt.savefig('day28_wfa.png', dpi=150)

九、PM 视角:从「策略」到「产品」的迁移

9.1 这一步学到的「产品级」能力

能力对应今日产出PM 工作中的映射
端到端 pipeline数据 → 信号 → 组合 → 成本 → 评估用户调研 → 需求 → 设计 → 开发 → 上线 → 复盘
多模块解耦mom_signal / lv_signal / backtest / metrics 独立模块化产品架构、可独立迭代
真实成本建模IBKR Tiered + 5bp 滑点 + W-8BEN不要 demo 完就乐观,真实环境一定差
OOS 验证Walk-ForwardA/B 测试、灰度发布、不在 IS 上下结论
归因分析FF5 + Active Share数据驱动的「为什么 work」「什么时候不 work」
决策框架paper trade 决策「能上线吗 / 还差什么」的 go/no-go

9.2 和金融零售 PM 的工具体系对比

我做 10 年金融零售 PM,工具栈是:Notion + Figma + Miro + Mixpanel + Tableau + SQL。这次 27 天学到的量化工具栈 = Python (pandas/numpy/statsmodels) + IBKR API + Backtrader/Vectorbt + Dune (链上类比) + Backblaze (数据存储)。

最大的认知是:金融零售 PM 的「需求 → 上线 → A/B → 数据回流」闭环,和量化的「假设 → 信号 → 回测 → WFA → 实盘 → 监控」闭环,本质上是同一个迭代框架。差别在于:

  • 量化的「数据回流」快得多(每天都有新数据)
  • 量化的「A/B 失败成本」实际是钱(不是 PM 的 KPI)
  • 量化的「监督回路」不能宽容直觉(你以为对的可能是过拟合)

这种「冷酷反馈」反而是好事——逼着我把 PM 思维里那些「软指标 + 政治叙事」全部剥离掉。

9.3 「学到了」vs「能用了」的差距

回顾 Day 1-27 的笔记,我以为自己「学到了」的东西,今天实际用起来才发现有断层:

Day 几学的我以为我会了今天实际遇到的坑
Day 10 动量12-1 公式简单shift(21) 还是 shift(22)?月初还是月末算?skip 是否含当月部分?这些细节没搞清楚
Day 12 低波动rolling std × √252用 daily return 还是 log return?两者长期累计差异有 0.5%/年
Day 22 成本IBKR Tiered 公式记得实际跑发现 min order = $0.35 这条在 turnover 高时占总佣金 40%
Day 25 WFA框架理解了实际写时 fold 切片 + 索引对齐 debug 了 2 小时
Day 26 Vol Target公式简单实际怎么定 scaling 上限?1.5x 是拍脑袋的,要不要做更严格的

这种「学会理论 → 实操卡住 → 倒回去补」的循环,可能是 Phase 1 最有价值的部分。 PM 工作里我做需求分析常说「魔鬼在细节里」——量化把这句话照进了现实,每一个 shift 偏一格、每一个边界处理不严谨,都会让结果偏 5-15%。

9.4 这套 pipeline 能复用到哪里

场景复用度
切换 universe(SP500 / Russell 2000 / 港股)~90%(改 ticker list + 数据源)
加新因子(quality / value)~70%(新的 signal 函数 + 组合权重逻辑)
换 rebalance 频率(周/季度)~95%(改 resample 规则)
Long-Short 化~50%(需要加做空成本、保证金管理)
切到加密货币~40%(需要换数据源 + 链上成本模型 + 7×24 rebalance)

核心结论:这套 framework 80% 可复用——这就是「工程能力的复利」。


十、「这能 paper trade 吗」决策

10.1 决策矩阵

检查项状态备注
策略有正期望(IS Sharpe > 1)✅ Sharpe 1.18
OOS 衰减 < 30%✅ 12% 衰减远好于阈值
成本扣到位✅ IBKR Tiered + 5bpDay 22 标准
最大回撤可承受✅ -18.4%<$5k 资金可承受 $1k 浮亏
Active Share > 60%✅ 76%
FF5 alpha 显著 (t > 2)✅ t = 2.8
Universe 是否 PIT(无 survivorship)当前是 current SP100实盘前必须改
月度 rebalance 自动化当前手动跑需要写定时任务
实时数据订阅❌ 当前 yfinance EOD上 paper 时必须订阅 OPRA + L1
风控护栏(单股上限 / 停损)⚠️ 应加 5% 单股 cap

10.2 结论:可以 paper trade,但有 3 个 must-fix

Yes,可以作为 Phase 2 Week 5 的实盘 paper 策略,但 paper trade 前必须解决

  1. PIT Universe:换成 SP100 历史成分(NorgateData 或自建)
  2. 自动化月度 rebalance:写一个 cron job,每月最后一个交易日 EOD 后自动生成订单
  3. 单股权重上限:加 weight[s] = min(weight[s], 0.10) 防止某次 doubling up 把单股权重推到 20%+

Nice-to-have(不阻塞 paper,但 90 天后做实盘要补):

  • Live data 实时计算(OPRA + L1)
  • 异常监控告警(持仓偏离目标 > 2% 时 push 通知)
  • 月度归因报告自动生成(PDF)

10.3 Paper Trade 期间要监控什么

Paper trade 的目的不是「看赚不赚钱」(虚拟账户的盈亏没意义),而是验证回测与实盘的偏差是否在容忍区间内。要监控的指标:

指标容忍偏差超出怎么办
月度收益 vs 回测预测±2%检查数据源 / 信号计算
月度 turnover25-45%超出说明信号波动加大
单股最大权重≤ 10%超出说明组合构造有 bug
单日最大滑点≤ 15 bp超出说明流动性出问题或下单逻辑差
持仓数量15-25 只超出说明 universe 或 signal 异常
月度 rebalance 执行延迟< 24 小时超出说明自动化 pipeline 出问题

Paper trade 至少跑 3 个月才能初步判断「实盘是否值得上」。如果 paper 期间任何一个指标连续 2 个月超出容忍区间,回 backtest 找原因,不要硬上实盘


十一、明日预告 — Day 29: Phase 1 总结文档

明天进入 Phase 1 的总结日,不再学新东西,而是把 28 天的内容沉淀成一篇「Phase 1 Portfolio」文档:

章节内容
1. Phase 1 学到了什么工具链 / 因子 / 期权 / 事件驱动 / WFA / 风控 七大块的浓缩
2. 作品集4 个核心 deliverable: Day 11 单因子框架 / Day 20 事件驱动 / Day 28 双因子 / Day 26 风控模板
3. 数据 & 工具栈定型IBKR + Python 栈 + Backtrader / Vectorbt + Dune(链上备用)
4. 28 天的认知更新(5 条)1) 滑点比佣金贵;2) OOS 衰减是常态;3) survivorship bias 比想象的严重;4) Vol Target 比 Equal Weight 改善 Sharpe 20-30%;5) Active Share > 60% 是有效门槛
5. Phase 2 切入点Day 29-56 进入实盘 paper trade 阶段,本日的双因子作为 day-1 paper 策略
6. 给雇主的「能力卡片」1 页纸总结:能做什么、用什么工具、有什么作品

Day 29 的目标是把这 28 天封装成一个可以挂在简历上的项目


十二、执行记录

任务时长状态
整合 Day 10 + Day 12 因子代码09:0010:1575min
写组合构造 + Vol Target 模块10:1511:4590min
实现 Backtest engine + 成本模型13:0014:3090min
跑 2014-2024 完整回测14:3015:0030min✅ Sharpe 1.18 符合预期
Walk-Forward 8 fold15:0016:0060min✅ OOS 衰减 12%
FF5 归因 + Active Share16:0016:4545min✅ alpha t=2.8 显著
画图(NAV / DD / WFA)16:4517:3045min
写笔记19:0022:00180min

Bug 记录

  • 第一版 build_portfolio 没归一化,单股权重曾达到 25%,结果一只股的尾部把整个组合带偏 → 改成「总 gross > 1 时缩放」修复
  • WFA 跑了 6 分钟还没结束,定位到 backtest 每次重新跑全段历史 → 改成「只在 fold 段切片」加速 10x
  • FF5 数据时区错位导致 240 个空值 → ff5.index = ff5.index.tz_localize(None) 修复

心理状态

  • 跑出 Sharpe 1.18 + alpha 显著的瞬间是这 28 天最爽的时刻
  • 但 2014 / 2016 / 2017 / 2019-2021 / 2023-2024 连续 7 年跑输 SPY 的回测线——预演了实盘里我会经历的心理压力,必须提前做好准备
  • 2022 年 +22% 超额是「为这套策略续命」的一年——如果实盘前 3 年正好是 2017 / 2019 / 2021 这种纯牛市,我可能撑不到 2022 就放弃了

今日金句

「双因子组合的 alpha,不是在牛市里赚的,而是在熊市里没亏的。」 「Sharpe 1.18 的代价是连续 7 年跑输基准——这才是真实世界的 alpha 长什么样。」