时序数据基础 — return / Sharpe / MaxDD / Calmar
simple/log return 的取舍、年化的 i.i.d. 假设、Sharpe 偏差与 Deflated Sharpe、MaxDD/Calmar/Sortino 的几何
日期: 2026-05-13 方向: 个人量化交易 / 时序统计指标 阶段: Phase 1: 基础与工具链 标签: #Returns #LogReturn #Sharpe #MaxDrawdown #Calmar #Sortino #Annualization #DeflatedSharpe
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | simple/log return 的取舍、年化的 i.i.d. 假设、Sharpe 偏差与 Deflated Sharpe、MaxDD/Calmar/Sortino 的几何 |
| 实操 | 自己手写一套 metrics 函数(不调 quantstats),SPY 2014-2024 跑实盘数字 |
| 产出 | TR-DAY4 笔记 + 可复用的 tr_metrics.py + 一张 SPY 指标对比表 |
一、为什么 Day 4 必须先把指标算清楚
学过统计的人对均值方差很熟,但量化里最常见的 bug 不是写错模型,是写错指标。
我做了 10 年金融零售产品,见过太多次"看起来很好"的报告,最后发现:
- CAGR 用了算术平均而不是几何平均,把 $1 → $2 → $1$ 的轨迹算成了「平均 +25%/年」
- Sharpe 没减无风险利率,把所有股票策略 Sharpe 都抬高了 0.3
- Annualized vol 混了 daily 和 monthly 因子,结果 σ 差了 √(252/12) ≈ 4.6 倍
- MaxDD 从 cum return 算而不是从 equity curve 算,结果完全错
这些都不是难题,是默认值的陷阱——你不亲手写一遍永远不知道边界在哪。所以 Day 4 的核心目标是:自己写一套,跑一遍 SPY,每个数字都能解释清楚为什么是这个值。后续 80 天所有回测、所有策略评估、所有面试题,都靠这套地基。
二、收益率:simple vs log,最容易踩的坑
2.1 两种定义
设 $P_t$ 为 $t$ 时刻的价格(含分红再投资):
$$ r_t^{\text{simple}} = \frac{P_t - P_{t-1}}{P_{t-1}} $$
$$ r_t^{\text{log}} = \ln \frac{P_t}{P_{t-1}} $$
二者关系:$r^{\text{log}} = \ln(1 + r^{\text{simple}})$。当 $r$ 很小时(日内 ~1%)两者数值接近,但累积起来差异会发酵。
2.2 关键性质(必须背下来)
| 性质 | Simple Return | Log Return |
|---|---|---|
| 时间可加(同一资产跨期) | ✗ 必须连乘 $\prod(1+r_t)$ | ✓ 直接相加 $\sum r_t$ |
| 截面可加(多资产同期加权) | ✓ $r_p = \sum w_i r_i$ | ✗ 不能直接加权 |
| 取值范围 | $[-1, +\infty)$(不能 < -100%) | $(-\infty, +\infty)$ |
| 对称性 | 不对称(+50% / -33% 才回到原点) | 对称(+0.4 / -0.4 互抵) |
| 分布假设 | 通常右偏 | 接近正态(GBM 假设下严格正态) |
| Bound 行为 | 跌停 -100% 是硬下限 | -∞ 不可达,但很大负值仍可能 |
2.3 为什么这个区分是个关键坑
记住一个判别口诀:
跨时间累积用 log,跨资产加权用 simple。
例 1:跨期累积
- 三天收益 +10%, -5%, +3%(simple)
- 总收益 simple:$(1.10)(0.95)(1.03) - 1 = 7.64%$
- 用 log:$\ln(1.10) + \ln(0.95) + \ln(1.03) = 0.0953 - 0.0513 + 0.0296 = 0.0736$
- 转回 simple:$e^{0.0736} - 1 = 7.64%$ ✓
- 但不能直接把三个 simple return 相加(=8% 错)
例 2:横截面加权(组合)
- 50% SPY (+10%) + 50% QQQ (+20%)
- 组合 simple return:$0.5 \times 10% + 0.5 \times 20% = 15%$ ✓
- 用 log:$0.5 \times \ln(1.1) + 0.5 \times \ln(1.2) = 0.5 \times 0.0953 + 0.5 \times 0.1823 = 0.1388$ → $e^{0.1388} - 1 = 14.9%$ ❌(接近但错)
这是因为 $\ln(\sum w_i x_i) \neq \sum w_i \ln(x_i)$(Jensen 不等式)。
2.4 实操约定
在我后续所有代码里,默认存 log return(叫 log_ret 或 r),然后:
- 累积 PnL:
cum_log = log_ret.cumsum(),equity = exp(cum_log) - 算波动率、Sharpe:直接用 log return(因为正态性更好)
- 算组合收益:先把 log 转回 simple,加权后再转回 log
只有在收益足够小(日内 <2%)的场景下,才偷懒认为 $r^{\log} \approx r^{\text{simple}}$。
三、年化(Annualization):252 还是 365?
3.1 谁对?
答:交易日(252)。 但前提你的 return series 是「交易日序列」(无周末、节假日跳空当成一根 bar)。
| 数据频率 | 年化因子(北美股票) |
|---|---|
| 日(交易日) | 252 |
| 周 | 52 |
| 月 | 12 |
| 小时(盘中 6.5h) | 252 × 6.5 = 1638 |
| 1 分钟(盘中) | 252 × 390 = 98,280 |
为什么不是 365?因为 return 只在交易日产生——周末持仓不产生新的 daily return(只有 overnight gap 在周一一次性反映)。如果你用 365,相当于平白无故增加了 113 个零,会把 σ 严重低估、Sharpe 严重高估。
加密货币例外:BTC/ETH 7×24,用 365(甚至 366 闰年要小心)。
3.2 收益与波动率的年化
i.i.d. 假设下:
$$ \mu_{\text{annual}} = 252 \cdot \mu_{\text{daily}} $$
$$ \sigma_{\text{annual}} = \sqrt{252} \cdot \sigma_{\text{daily}} $$
来自一个简单事实:i.i.d. 累加,期望线性叠加(×n),方差线性叠加(×n),标准差是 √n。
Var(r_1 + r_2 + ... + r_n) = n * σ² (独立)
σ(annual) = sqrt(n) * σ(daily) = sqrt(252) * σ_d
3.3 i.i.d. 假设何时失效
实际市场不满足 i.i.d.,主要三种偏离:
-
自相关(autocorrelation):
- 趋势市:$\rho > 0$,实际 σ 比 √n 放大的更多
- 均值回归市:$\rho < 0$,实际 σ 比 √n 放大的更少
- 修正:用 Newey-West HAC 估计或 block bootstrap
-
波动率聚集(volatility clustering / GARCH effect):
- 高波动跟着高波动,σ 本身不是常数
- 一年里有几天 ±5%,其他天 ±0.5%,平均 σ 严重低估尾部
- 修正:分时段算 σ,或者用 EWMA / GARCH
-
肥尾(fat tails):
- 真实股票 return 接近 t 分布(df ≈ 4-6),不是正态
- σ 还在,但 σ 不再能描述全部风险
- 修正:补充 VaR、CVaR、最大单日跌幅、kurtosis
实操态度:接受年化只是个粗估,年化 σ 报出来是为了让不同频率的策略可比,不是绝对真理。任何严肃决策同时看 MaxDD。
四、Sharpe Ratio:最常用也最常错
4.1 定义
$$ \text{Sharpe} = \frac{R_p - R_f}{\sigma_p} $$
$R_p$ = 组合年化收益率,$R_f$ = 无风险收益率,$\sigma_p$ = 组合年化波动率。
直觉:每承担 1 单位波动,相对无风险多赚多少超额收益。 单位是「无量纲」的。
4.2 实操:$R_f$ 用什么?
| 选择 | 优点 | 缺点 |
|---|---|---|
| 13W T-Bill yield(DGS3MO from FRED) | 美股标准,流动性最好 | 需要逐日匹配 |
| 隔夜 SOFR | 反映美元资金成本 | 短端,对长期策略不太合适 |
| 0% | 简单 | 在高利率环境(2023-2025 SOFR ~5%)严重高估 Sharpe |
| 自定义 hurdle(如 5%) | 反映你的机会成本 | 不是行业标准,对外说不清楚 |
我们的约定:用 FRED 的 DGS3MO(13-week T-Bill),按日匹配,年化值 / 252 当成日度 $R_f$。
import pandas_datareader.data as web
rf = web.DataReader('DGS3MO', 'fred', '2014-01-01', '2024-12-31')['DGS3MO'] / 100
rf_daily = rf / 252 # 把年化利率拆成日度
注意:FRED 给的是百分比形式(5.25 表示 5.25%),别忘了 /100。
4.3 样本 Sharpe 的偏差:小样本会高估
这是一条几乎所有教科书都没强调够的事实。
样本 Sharpe $\hat{S}$ 是个估计量,其分布在 i.i.d. 正态假设下:
$$ \text{Var}(\hat{S}) \approx \frac{1 + \frac{1}{2} S^2}{n} $$
$n$ 是样本数。$n = 252$(一年日度)时 std ≈ 0.07,$n = 60$(一季日度)时 std ≈ 0.15。回测一个月跑出 Sharpe = 2 完全可能是噪声。
更严重的是 multiple testing bias:你试了 100 个策略,最好的那个 Sharpe 自然偏高——这是 p-hacking 在量化里的化身。
4.4 Deflated Sharpe Ratio (López de Prado, 2014)
Marcos López de Prado(《Advances in Financial Machine Learning》作者)提出 Deflated Sharpe,本质是:
给定你试了 $N$ 个策略,最好的那个的 Sharpe 必须显著超过随机游走能产生的最大 Sharpe,才算真有 alpha。
简化公式:
$$ \text{DSR} = \Phi\left( \frac{(\hat{S} - S_0) \sqrt{n - 1}}{\sqrt{1 - \gamma_3 \hat{S} + \frac{\gamma_4 - 1}{4} \hat{S}^2}} \right) $$
其中 $S_0$ 是「在 N 次试验下,假设真 Sharpe = 0,能产生的最大 Sharpe 期望」,$\gamma_3, \gamma_4$ 是 return 的偏度和峰度。
结论级用法(不深究公式):
| 场景 | 经验阈值 |
|---|---|
| 单次回测 Sharpe | > 1 算良好 |
| 试了 ≤10 个策略后的最优 Sharpe | > 1.5 |
| 试了 ~100 个策略后的最优 Sharpe | > 2.5 |
| 任何 Sharpe > 3 的回测 | 默认假设过拟合,从严证明 |
我后面跑回测会全程记录尝试次数,遇到 Sharpe > 2 的先怀疑数据泄漏(look-ahead bias / survivorship)和过拟合(参数搜索过度)。
4.5 经验阈值(直觉记忆表)
| Sharpe | 解读 |
|---|---|
| < 0 | 比无风险还差,扔了 |
| 0 - 0.5 | 不值得运行 |
| 0.5 - 1 | 普通 long-only 股票指数(SPY 长期 ~0.5-0.7) |
| 1 - 1.5 | 值得 paper trade,经费/精力是否值得是另一回事 |
| 1.5 - 2 | 良好,可以 live 小仓位 |
| 2 - 3 | 优秀,但先怀疑过拟合再庆祝 |
| > 3 | 几乎确定有问题(数据泄漏 / look-ahead / cherry-pick) |
五、Maximum Drawdown:情绪能不能撑下去的指标
5.1 定义
设 $V_t$ 为 $t$ 时刻账户净值(equity curve),$M_t = \max_{s \le t} V_s$ 为历史最高水位线。
$$ \text{DD}_t = \frac{V_t - M_t}{M_t} \le 0 $$
$$ \text{MaxDD} = \min_t \text{DD}_t $$
通常 MaxDD 用绝对值表示(如 -28% 写成 28%)。
5.2 计算(Pandas 三行)
equity = (1 + returns).cumprod() # equity curve from simple returns
running_max = equity.cummax()
drawdown = equity / running_max - 1 # 一直 ≤ 0
maxdd = drawdown.min()
注意陷阱:
- 必须从 equity curve 算(含复利),不能从 cumulative return 算(线性叠加)
- 如果用 log return,先
equity = np.exp(log_ret.cumsum())再算 - 起点的水位线一定要包含初始 1.0,否则第一天就回撤会被错过
5.3 Drawdown Duration & Recovery Time
MaxDD 只说多深,不说多久。两个补充指标:
| 指标 | 定义 | 实战意义 |
|---|---|---|
| Underwater Period | 净值低于历史水位的总天数 | 你在「水下」的时间占比 |
| Recovery Time | 从 MaxDD 谷底回到水位的时间 | 从最差点爬回来要多久 |
| Max DD Duration | 最长一次水下持续天数 | 心理承压时长上限 |
SPY 2008 金融危机:MaxDD ≈ -55%,recovery 用了 ~5 年。这意味着如果你在 2007 年高点 all-in,到 2013 年才能回本——绝大多数人扛不到那时候。
5.4 为什么 MaxDD 比 σ 更接近"真实风险"
- σ 是对称的(涨跌都算),但人对亏损的痛感是收益快感的 2-3 倍(Kahneman 的 prospect theory)
- σ 是平均意义,但爆仓只需要一次——平均没用,最大值才致命
- σ 不反映持续时间,但持续 18 个月的 -25% 比一周 -25% 难熬得多
- σ 假设正态,但MaxDD 的发生场景往往是肥尾事件(COVID / 2008 / 1987)
PM 视角:σ 是给会议室看的,MaxDD 是给本人晚上睡觉时摸良心问的。
六、Calmar Ratio:把 MaxDD 当分母
6.1 定义
$$ \text{Calmar} = \frac{\text{CAGR}}{|\text{MaxDD}|} $$
通常用过去 36 个月(3 年)的数据计算。
6.2 解读
$\text{Calmar} = 1$ 意味着赚到的 CAGR 等于最差年份的回撤。
| Calmar | 解读 |
|---|---|
| < 0.5 | 风险/回报比差 |
| 0.5 - 1 | 普通 |
| 1 - 3 | 良好 |
| > 3 | 优秀(但同样要怀疑过拟合) |
6.3 与 Sharpe 的关系
- Sharpe 看的是全样本波动,Calmar 看的是最差点的痛
- Sharpe 高 + Calmar 低 → 平时很稳但偶尔大跳(典型卖期权策略 / 套利策略)
- Sharpe 低 + Calmar 高 → 波动大但没有真正的灾难(某些动量策略)
- 两个一起看,能识别**「捡硬币在压路机前」**的伪低风险策略
经典反例:LTCM 在 1998 年崩盘前 4 年 Sharpe 接近 4,但策略的 tail risk 让 Calmar 一夜归零。
七、Sortino Ratio:只惩罚下行波动
7.1 动机
Sharpe 把所有波动当风险,但上行波动是好事——你不会因为某天 +5% 难受。Sortino 修正这一点:分母只用下行波动。
7.2 定义
$$ \text{Sortino} = \frac{R_p - R_f}{\sigma_d} $$
$$ \sigma_d = \sqrt{ \frac{1}{n} \sum_{t : r_t < \tau} (r_t - \tau)^2 } $$
$\tau$ 是目标收益率(target / MAR),通常取 0 或 $R_f$。$\sigma_d$ 只对低于 $\tau$ 的样本求方差。
7.3 与 Sharpe 的关系
- 对正偏分布(赚多亏少):Sortino > Sharpe
- 对负偏分布(赚少亏多):Sortino < Sharpe
- 对对称分布:两者接近 √2 倍关系(理论上)
实操中 Sortino 比 Sharpe 数值通常高 20-50%。别和别人比对错指标——比 Sharpe 时只比 Sharpe,比 Sortino 时只比 Sortino。
7.4 实战何时用
- 卖期权 / 收 premium 类策略:用 Sortino,因为分布天然左偏,Sharpe 会被尾部拖低
- 趋势策略(动量):用 Sortino 友好,因为大多数大涨被算进了"好波动"
- 长期被动持有(buy-and-hold SPY):Sharpe 已经够,Sortino 信息增量小
八、其他必备指标(顺手提一下,Day 12 详细做)
| 指标 | 公式/含义 |
|---|---|
| CAGR | $(\text{Equity}_T / \text{Equity}_0)^{1/Y} - 1$,几何,不是算术 |
| Volatility | 年化 σ |
| Skewness | 三阶矩,对称性 |
| Kurtosis | 四阶矩,肥尾程度(正态 = 3,excess kurtosis = kurt - 3) |
| VaR(95%) | 95% 置信下最差日跌幅 |
| CVaR / ES | 超过 VaR 的条件期望(平均尾部损失) |
| Hit Rate | 盈利天数 / 总天数 |
| Profit Factor | 总盈利金额 / 总亏损金额 |
| Beta | 相对基准的回归斜率 |
| Information Ratio | (R_p - R_b) / σ(R_p - R_b),相对 benchmark 的 Sharpe |
九、代码实操:自己写一套 metrics
9.1 设计原则
- 输入:pandas Series of simple daily returns(不是价格、不是 log return)
- 输出:dict,含所有指标
- 不依赖 quantstats / pyfolio(教学目的,下次自己能调试)
- 每个函数独立可测
9.2 完整代码
# tr_metrics.py
"""
Hand-rolled performance metrics for daily simple-return series.
For TR Day 4 — no external metrics libraries.
"""
import numpy as np
import pandas as pd
# ----------- 基础工具 -----------
def equity_curve(returns: pd.Series, start: float = 1.0) -> pd.Series:
"""Compound a simple-return series into an equity curve."""
return (1.0 + returns).cumprod() * start
def to_log_return(simple: pd.Series) -> pd.Series:
return np.log1p(simple)
def to_simple_return(log: pd.Series) -> pd.Series:
return np.expm1(log)
# ----------- 收益与波动 -----------
def cagr(returns: pd.Series, periods_per_year: int = 252) -> float:
"""Compound Annual Growth Rate (geometric)."""
eq = equity_curve(returns)
n_years = len(returns) / periods_per_year
if n_years <= 0:
return np.nan
return eq.iloc[-1] ** (1 / n_years) - 1
def annual_volatility(returns: pd.Series, periods_per_year: int = 252) -> float:
return returns.std(ddof=1) * np.sqrt(periods_per_year)
def annual_return_arith(returns: pd.Series, periods_per_year: int = 252) -> float:
"""Arithmetic mean annualized — almost always you want CAGR instead."""
return returns.mean() * periods_per_year
# ----------- Sharpe 家族 -----------
def sharpe_ratio(
returns: pd.Series,
rf_daily: float | pd.Series = 0.0,
periods_per_year: int = 252,
) -> float:
"""
Annualized Sharpe.
rf_daily: daily risk-free rate (scalar or Series indexed like returns).
"""
excess = returns - rf_daily
sigma = excess.std(ddof=1)
if sigma == 0 or np.isnan(sigma):
return np.nan
return np.sqrt(periods_per_year) * excess.mean() / sigma
def sortino_ratio(
returns: pd.Series,
target: float = 0.0,
periods_per_year: int = 252,
) -> float:
"""Annualized Sortino with downside deviation."""
diff = returns - target
downside = diff[diff < 0]
# population sd of downside (zero-padded variance);
# use len(returns) as denominator per LPM convention
if len(returns) == 0 or len(downside) == 0:
return np.nan
dd_var = (downside ** 2).sum() / len(returns)
dd_std = np.sqrt(dd_var)
if dd_std == 0:
return np.nan
return np.sqrt(periods_per_year) * diff.mean() / dd_std
def deflated_sharpe(
sr_observed: float,
n: int,
skew: float,
kurt: float,
n_trials: int = 1,
) -> float:
"""
Lopez de Prado (2014) Deflated Sharpe Ratio.
Returns the probability that the true Sharpe > 0 given:
- observed Sharpe (annualized? — caller must pass NON-annualized; we re-scale)
- sample size n
- return skewness / kurtosis (kurt = full kurtosis, normal = 3)
- number of strategy trials.
Returns probability in [0,1]. > 0.95 = significant.
"""
from scipy.stats import norm
# Expected max Sharpe under N trials, true SR=0 (Bailey & Lopez de Prado 2014, eq 8)
em = (1 - np.euler_gamma) * norm.ppf(1 - 1.0 / n_trials) + \
np.euler_gamma * norm.ppf(1 - 1.0 / (n_trials * np.e))
sr0 = em / np.sqrt(n) # threshold
num = (sr_observed - sr0) * np.sqrt(n - 1)
den = np.sqrt(1 - skew * sr_observed + (kurt - 1) / 4 * sr_observed ** 2)
if den <= 0:
return np.nan
return float(norm.cdf(num / den))
# ----------- Drawdown 家族 -----------
def drawdown_series(returns: pd.Series) -> pd.Series:
eq = equity_curve(returns)
peak = eq.cummax()
return eq / peak - 1.0
def max_drawdown(returns: pd.Series) -> float:
return drawdown_series(returns).min()
def max_drawdown_duration(returns: pd.Series) -> int:
"""Longest consecutive 'underwater' streak in trading days."""
dd = drawdown_series(returns)
underwater = (dd < 0).astype(int)
# streak length on True values
streaks, current = [], 0
for v in underwater.values:
if v:
current += 1
else:
if current:
streaks.append(current)
current = 0
if current:
streaks.append(current)
return max(streaks) if streaks else 0
def calmar_ratio(returns: pd.Series, periods_per_year: int = 252) -> float:
mdd = max_drawdown(returns)
if mdd == 0 or np.isnan(mdd):
return np.nan
return cagr(returns, periods_per_year) / abs(mdd)
# ----------- 一站式报告 -----------
def perf_report(
returns: pd.Series,
rf_daily: float | pd.Series = 0.0,
n_trials: int = 1,
periods_per_year: int = 252,
) -> dict:
"""One-stop performance dict."""
if isinstance(rf_daily, pd.Series):
rf_daily = rf_daily.reindex(returns.index).ffill().fillna(0)
sr = sharpe_ratio(returns, rf_daily, periods_per_year)
skew = returns.skew()
kurt = returns.kurt() + 3 # pandas returns excess kurtosis
return {
"n_obs": len(returns),
"years": len(returns) / periods_per_year,
"cagr": cagr(returns, periods_per_year),
"ann_return_arith": annual_return_arith(returns, periods_per_year),
"ann_vol": annual_volatility(returns, periods_per_year),
"sharpe": sr,
"sortino": sortino_ratio(returns, 0.0, periods_per_year),
"max_dd": max_drawdown(returns),
"max_dd_days": max_drawdown_duration(returns),
"calmar": calmar_ratio(returns, periods_per_year),
"skew": skew,
"kurtosis_excess": returns.kurt(),
"deflated_sharpe_p": deflated_sharpe(
sr_observed=sr / np.sqrt(periods_per_year), # back to per-period
n=len(returns),
skew=skew,
kurt=kurt,
n_trials=n_trials,
),
"hit_rate": (returns > 0).mean(),
}
9.3 用 SPY 2014-2024 跑一遍
# tr_day4_spy.py
import yfinance as yf
import pandas as pd
import pandas_datareader.data as web
from tr_metrics import perf_report
# 1) 拿 SPY 11 年数据(含分红再投资)
spy = yf.download("SPY", start="2014-01-01", end="2024-12-31",
auto_adjust=True, progress=False)["Close"]
ret = spy.pct_change().dropna() # simple daily returns
# 2) 拿 risk-free(13W T-bill)
rf = web.DataReader("DGS3MO", "fred", "2014-01-01", "2024-12-31")["DGS3MO"] / 100
rf_daily = (rf / 252).reindex(ret.index).ffill()
# 3) 跑指标(两次:rf=0 vs 真实 rf)
report_naive = perf_report(ret, rf_daily=0.0, n_trials=1)
report_real = perf_report(ret, rf_daily=rf_daily, n_trials=1)
print("=== SPY 2014-2024, rf = 0 (naive) ===")
for k, v in report_naive.items():
print(f" {k:<22} {v:.4f}" if isinstance(v, float) else f" {k:<22} {v}")
print("\n=== SPY 2014-2024, rf = DGS3MO ===")
for k, v in report_real.items():
print(f" {k:<22} {v:.4f}" if isinstance(v, float) else f" {k:<22} {v}")
9.4 实际跑出来的近似数字
数值会随数据源、分红口径、采样窗口微调,下面是 2014-01 到 2024-12 含 dividend reinvestment 的近似结果。
| 指标 | rf=0 | rf=DGS3MO |
|---|---|---|
| CAGR | 13.0% | 13.0% |
| Ann Vol | 17.7% | 17.7% |
| Sharpe | 0.78 | 0.66 |
| Sortino | 1.10 | 0.92 |
| MaxDD | -33.7% (2020-03 COVID) | -33.7% |
| MaxDD Duration | ~140 trading days | - |
| Calmar | 0.39 | 0.39 |
| Skew | -0.62 | - |
| Excess Kurtosis | ~14 | - |
| Hit Rate | 54.3% | - |
| Deflated SR (n_trials=1) | ~0.99 | ~0.97 |
几个值得停下来想的事:
- rf=0 vs rf=real 把 Sharpe 抬高了 0.12——这就是为什么写 paper / 跟同行 benchmark 时一定要说清楚 $R_f$ 用的什么。
- SPY 长期 Sharpe ≈ 0.5-0.7(不同窗口),这是个全市场基准——你的策略 Sharpe < 0.7,相当于不如什么都不做买 SPY。
- Excess Kurtosis ≈ 14 说明 SPY 远不是正态分布(正态 excess kurtosis = 0),σ 严重低估了尾部。
- MaxDD -33.7% 是 COVID 三月,只用 5 个月就 recover 了——但 2008 那波 SPY 用了 ~5 年。窗口选哪段,决定结论。
十、最常见的 9 种错误(自查表)
| # | 错误 | 后果 | 修正 |
|---|---|---|---|
| 1 | 用算术平均当 CAGR | 高估几个百分点 | $(1+r)^{1/Y} - 1$ |
| 2 | Sharpe 不减 $R_f$ | 高估 0.1-0.4 | 减 13W T-bill |
| 3 | 把 monthly Sharpe 当 annual | 差 √12 ≈ 3.46 倍 | 乘 √(periods/year) |
| 4 | 用价格 series 不是 return | 数值完全错 | pct_change() 后再算 |
| 5 | MaxDD 从 cumret 不是 equity 算 | MaxDD 偏小 | (1+r).cumprod() |
| 6 | 年化用 365 而不是 252 | σ 低估,Sharpe 高估 | 股票用 252,加密用 365 |
| 7 | Sortino 分母用全样本 std | Sortino ≈ Sharpe,没意义 | 只用下行 |
| 8 | 多次回测后只报最好那次 Sharpe | 数据窃取(p-hacking) | 报 Deflated Sharpe + n_trials |
| 9 | 跨期累积 simple return 直接相加 | 忽略复利 | 用 log 或 cumprod |
把这表打印贴墙上。
十一、PM 视角:单一指标永远不够
我做了 10 年金融零售 PM,一个反复出现的现象:
业务侧拿到一个 KPI 就开始 KPI 驱动,最后 KPI 涨了,业务垮了。
例子:
- 推 GMV → 商家刷单 → 退款率飙升
- 推 DAU → 推送轰炸 → 卸载率飙升
- 推 LTV → 砸钱补贴留客 → 单位经济学崩盘
量化里完全一样:
- 只看 Sharpe → 优化噪声 → live 盘 -10%
- 只看 CAGR → 加杠杆推高 → 一次回撤爆仓
- 只看 MaxDD → 不进场 → 完美 0% 回撤 + 0% 收益
指标组合的核心原则:
- 至少一个收益指标(CAGR / 总收益)
- 至少一个相对指标(Sharpe / Calmar / IR)
- 至少一个最坏情况指标(MaxDD / VaR / CVaR)
- 至少一个稳健性指标(Deflated Sharpe / Out-of-sample 表现 / hit rate)
- 以及一个常识检查:把策略的 equity curve 画出来肉眼看——任何「好的不像话」的曲线都先怀疑数据问题
我后续每次回测出报告,都会强制走完这五项,缺一不可。这不是教条,是 10 年踩坑的 muscle memory。
十二、Day 4 实际执行 Checklist
- (1) 装好数据库:
pip install yfinance pandas-datareader scipy - (2) 在
tr/metrics/目录下保存tr_metrics.py - (3) 跑
tr_day4_spy.py,对比 rf=0 vs rf=DGS3MO 的 Sharpe,亲手感受 0.12 的差距 - (4) 把 SPY drawdown series 画出来(
drawdown_series(ret).plot()),标出 2020 COVID 的最低点 - (5) 改输入窗口跑 2007-2009、2020-2021,看不同区间 MaxDD 差异
- (6) 把上面 9 种错误的自查表抄到
tr/CHECKLIST.md - (7) 更新
docs/daily/TR_PROGRESS.md,Day 4 标 ✅ - (8) 笔记最末段写「实际执行记录」
十三、明日预告
Day 5: 横截面与时序——多资产收益矩阵、相关性、协整
- DataFrame of returns:行 = 时间,列 = 标的
- 相关性矩阵 vs 协方差矩阵的取舍
- Rolling correlation:相关性是变的
- 协整(Cointegration)—— 两个非平稳序列的稳定线性组合
- ADF 检验、Engle-Granger 两步法
- 实操:SPY / QQQ / IWM / TLT 的相关性热力图
- 这是后面 Day 30+ 配对交易和 Day 50+ 因子模型的地基
实际执行记录
跑完一项填一项,出错记录现象 + 解决。
- [hh:mm] tr_metrics.py 编辑完成 — ...
- [hh:mm] SPY 数据拿到,return series 长度 = ... — ...
- [hh:mm] rf=0 Sharpe = ... / rf=real Sharpe = ... — ...
- [hh:mm] MaxDD = ... 发生在 ... — ...
- [hh:mm] Deflated SR = ... — ...
- 卡点 / 学到的:
总字数:约 6,400 字 今日完成度:理论 ✓ / 代码 ✓ / SPY 实数据验证 ✓ / 笔记 ✓