Phase 1 综合 — 动量 + 低波动 双因子组合 v1
多因子组合的相关性逻辑、IC 加权 / 等权 / Risk Parity 三种合成方式取舍、Fama-French 5 因子归因法
日期: 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.8 | 0.4-0.7 | 0.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。原因:
- Phase 1 的目标是「跑通 + 验证流程」,不是「调到最优」
- 方案 B/C 加的参数都会引入额外的过拟合风险(Day 24 的 IC 估计噪声章节有说过)
- 学术研究(DeMiguel 2009 "Optimal vs Naive Diversification")证明:1/N 等权在很多场景比所谓最优组合更稳健——他们用了 7 个不同的数据集 + 14 种「最优」配置方法,发现 1/N 在 OOS Sharpe 上几乎从未被打败
- Day 65 之后我们可以做 IC 加权 v2 对比
- 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 主要指标
| 指标 | 双因子组合 | 等权 SP100 | SPY |
|---|---|---|---|
| CAGR | 13.8% | 11.4% | 12.1% |
| Annualized Vol | 11.6% | 16.2% | 15.8% |
| Sharpe | 1.18 | 0.70 | 0.77 |
| Sortino | 1.72 | 0.95 | 1.05 |
| MaxDD | -18.4% | -32.1% | -33.7% |
| Calmar | 0.75 | 0.36 | 0.36 |
| Alpha vs SPY | +2.9% | -0.1% | 0.0% |
| Beta vs SPY | 0.68 | 0.99 | 1.00 |
| Info Ratio | 0.61 | -0.04 | — |
关键观察:
- Sharpe 1.18 落在预期 1.0-1.3 区间内——说明实现没有明显 bug,也没有过拟合迹象。
- MaxDD -18.4% 显著优于 SPY 的 -33.7%——主要受益于 2020 年 3 月低波动腿的保护。
- Beta 0.68——组合更稳,但完全脱离市场是不可能的(任何 long-only 美股策略 Beta 都 > 0.5)。
- Alpha +2.9%/年(after cost)——比目标区间下沿略好。
5.2 成本拆解
| 项目 | 11 年累计 | 占初始资金比例 | 年化 drag |
|---|---|---|---|
| 佣金(IBKR Tiered) | $2,840 | 2.84% | ~26 bp/年 |
| 滑点(5 bp 单边) | $11,200 | 11.2% | ~102 bp/年 |
| 合计 | $14,040 | 14.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 Sharpe | OOS CAGR | OOS MaxDD |
|---|---|---|---|---|---|
| 1 | 2014-2016 | 2017 | 1.45 | +20.1% | -3.2% |
| 2 | 2015-2017 | 2018 | 0.32 | -2.4% | -10.5% |
| 3 | 2016-2018 | 2019 | 2.10 | +25.6% | -5.8% |
| 4 | 2017-2019 | 2020 | 0.88 | +14.2% | -18.4% |
| 5 | 2018-2020 | 2021 | 1.95 | +24.8% | -4.1% |
| 6 | 2019-2021 | 2022 | 0.45 | +4.1% | -12.7% |
| 7 | 2020-2022 | 2023 | 1.62 | +18.7% | -7.0% |
| 8 | 2021-2023 | 2024 | 1.78 | +21.4% | -5.2% |
| OOS 拼接 | — | — | 1.04 | +15.2% | -18.4% |
| IS 全样本 | 2014-2024 | — | 1.18 | +13.8% | -18.4% |
关键发现:
- OOS Sharpe 1.04 vs IS Sharpe 1.18,衰减 12%——远好于「典型 30-50% 衰减」。原因:策略几乎不调参(仅 Vol Target 和 lookback 是固定的),所以 IS 和 OOS 本质上是一样的。
- Fold 2 (2018) 和 Fold 6 (2022) Sharpe 偏低——这两年都是熊市/震荡,符合「低波动腿出力但绝对收益低」的结构。
- 如果未来某一年 OOS Sharpe < 0,需要回到模型诊断:是市场结构变了,还是策略本身有问题?
- 结论:这个策略没有过拟合。如果有,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-RF | 0.68 | 28.4 | 市场 Beta ≈ 0.68(与 metrics 表一致) |
| SMB (Size) | -0.15 | -3.2 | 略偏大盘股(SP100 本来就是) |
| HML (Value) | +0.04 | 0.9 | 不显著,组合不偏 value |
| RMW (Quality) | +0.18 | 4.1 | 略偏 quality(低波动股本来就 quality 高) |
| CMA (Conservative) | +0.22 | 5.0 | 显著偏保守投资(低波动腿的副作用) |
关键洞察:
- Alpha 在 5 因子归因后仍然显著 (t=2.8)——这是真 alpha,不是 hidden factor。
- Quality (RMW) 和 Conservative (CMA) 暴露说明低波动腿其实在「免费搭车」其他因子。这是好事也是坏事:好事是免费的多样化,坏事是 alpha 一部分可能来自这些 factor 而不是「low vol anomaly」本身。
- 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-Forward | A/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 + 5bp | Day 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 前必须解决:
- PIT Universe:换成 SP100 历史成分(NorgateData 或自建)
- 自动化月度 rebalance:写一个 cron job,每月最后一个交易日 EOD 后自动生成订单
- 单股权重上限:加
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% | 检查数据源 / 信号计算 |
| 月度 turnover | 25-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:00 | 10:15 | 75min | ✅ |
| 写组合构造 + Vol Target 模块 | 10:15 | 11:45 | 90min | ✅ |
| 实现 Backtest engine + 成本模型 | 13:00 | 14:30 | 90min | ✅ |
| 跑 2014-2024 完整回测 | 14:30 | 15:00 | 30min | ✅ Sharpe 1.18 符合预期 |
| Walk-Forward 8 fold | 15:00 | 16:00 | 60min | ✅ OOS 衰减 12% |
| FF5 归因 + Active Share | 16:00 | 16:45 | 45min | ✅ alpha t=2.8 显著 |
| 画图(NAV / DD / WFA) | 16:45 | 17:30 | 45min | ✅ |
| 写笔记 | 19:00 | 22:00 | 180min | ✅ |
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 长什么样。」