单元 03:从价格计算收益
上一单元得到了一列顺序正确、通过质量检查的收盘价。但“价格从 100.80 变成 105.50”还不能直接回答:这段时间赚了百分之多少?每天的涨跌如何连接成完整结果?
本单元将价格转换为收益率,再把每日收益复合成一条净值曲线。过程中我们会用一个三点价格序列手工验算,并用首尾价格再次复核程序结果。
本单元目标
完成本单元后,你将能够:
- 计算并解释简单收益率(Simple Return)的方向和日期归属;
- 说明为什么第一条价格没有对应收益率;
- 使用连乘计算累计收益率(Cumulative Return)和净值曲线;
- 区分简单收益率与对数收益率(Log Return);
- 区分算术平均收益与几何平均收益;
- 用手工小样本和首尾价格验证计算结果;
- 解释价格口径、分红和交易日数量如何影响收益。
最终产物由下面的命令生成:
uv run python examples/unit03/calculate_returns.py前置条件
- 已完成单元 02;
- 能解释
symbol、date和close; - 已执行
uv sync --locked; - 后续命令均在项目根目录运行。
场景:价格变化不等于收益表达
假设某资产的价格从 100 元上涨到 110 元,变化了 10 元;另一个资产从 10 元上涨到 11 元,只变化了 1 元。虽然价格变化额不同,两者都上涨了 10%。
价格带有单位,例如 CNY/份;收益率是相对变化,没有价格单位。把价格转换为收益率后,才更适合比较不同价格水平的资产与不同时间段。
收盘价 P(t-1)、P(t)
↓ 相邻价格相比
单期收益 R(t)
↓ 按时间连乘
累计收益 C(t) 与净值 W(t)先确认价格口径
本单元的 DEMO_ETF 是合成数据,没有分红和复权含义。真实资产发生现金分红、拆分或合并时,直接使用未复权收盘价可能把公司行动误当成涨跌。
研究价格表现还是包含现金分配的总回报,决定了应该使用哪种价格或收益口径。接入真实数据时,要先阅读供应商对复权因子、分红再投资和费用的定义。
理解一段收益属于哪一天
设:
:交易日 的收盘价,单位为 CNY/份; :上一个交易日的收盘价,单位相同; :从 日收盘到 日收盘的简单收益率,没有单位。
简单收益率的公式是:
样例中 2024-01-02 的收盘价为 100.80,2024-01-03 为 101.70,因此:
这条收益记录在 2024-01-03,表示“从上一交易日收盘到今天收盘”的变化。它不是只发生在收盘那一刻,也不是 2024-01-04 的收益。
收益需要两个价格
第一行只有 NaN,而不是填成 0。
填 0 表示“已经确认这段收益为零”,与“没有足够信息计算”不是一回事。净值起点可以单独设为 1,不需要篡改第一条收益。
把多日收益连接起来
前面算出了一天的收益。观察多天时,不能把每日收益直接相加,因为每一天的涨跌都以当时的价格为基数。
例如,价格依次为 100 → 110 → 99:
| 区间 | 价格变化 | 简单收益 |
|---|---|---|
| 第 1 日 | 100 → 110 | +10% |
| 第 2 日 | 110 → 99 | -10% |
第二日下跌的 10% 以 110 为基数,减少的是 11,而不是 10。因此这两日的总收益不是 +10% - 10% = 0%,而是:
这里的 1 + 10% 表示价格变为上一日的 1.10 倍,1 - 10% 表示随后变为上一日的 0.90 倍。前一期的结果成为下一期计算涨跌的基数,这种逐期连接方式就是复利(Compounding)。在收益序列中,复利意味着连乘,不是把百分比相加。
“复利”在这里描述的是价值如何跨期变化,并不表示资产额外支付了银行利息。上涨和下跌都遵循相同的计算方式。
为什么亏损后需要更大的涨幅才能回本?
价格从 100 下跌 50% 后只剩 50。要从 50 回到 100,需要上涨 50,也就是以 50 为基数上涨 100%。
| 亏损幅度 | 回到起点所需涨幅 |
|---|---|
| -10% | +11.11% |
| -20% | +25.00% |
| -50% | +100.00% |
收益率的分母随价格变化,因此涨跌百分比不是对称的。
用 pandas 计算单期收益
打开 examples/unit03/calculate_returns.py。读取函数只保留本单元需要的证券代码、日期和收盘价,并检查:
- 数据不为空;
- 必要值没有缺失;
- 每次只处理一个标的;
- 日期没有重复;
- 收盘价大于 0;
- 计算前按日期升序排列。
核心计算先取得上一交易日的收盘价:
previous_close = result["close"].shift(1)
price_ratio = result["close"] / previous_close
result["simple_return"] = price_ratio - 1shift(1) 把价格向下移动一行,让当前行的 close 与上一行的 close 对齐。它表达的是“上一条已排序记录”,所以单元 02 的日期排序和重复检查是收益计算的前提。
也可以使用 pandas 的 pct_change() 完成同样的计算:
result["simple_return"] = result["close"].pct_change(fill_method=None)这里明确使用 fill_method=None,避免在价格缺失时悄悄沿用旧价格。示例程序展开公式,是为了让分子、分母和时间方向更容易检查。
从每日收益得到净值
刚才的价格为 100 → 110 → 99。如果把每个价格都除以起点 100,就会得到:
1.00 → 1.10 → 0.99这只是换了一把尺子:1.10 表示资金是起点的 1.10 倍,0.99 表示资金是起点的 0.99 倍。把起点统一设为 1 后形成的序列,就是净值曲线(Wealth Index / Equity Curve)。它方便比较初始价格或初始资金不同的对象。
本单元把初始净值设为 1:
之后每一期:
把全部收益展开,就是:
其中:
:截至交易日 的净值,没有单位; :第 个收益区间的简单收益率; :将每个 连乘。
代码使用 cumprod() 做逐行累计乘法:
result["wealth_index"] = (
1 + result["simple_return"].fillna(0)
).cumprod()
result["cumulative_return"] = result["wealth_index"] - 1这里仅在构造净值时把第一行缺失收益当作净值起点,原始 simple_return 仍然保留 NaN。
若初始资金是 10,000 元,在暂不考虑交易成本、税费和现金分红的教学假设下,某日账户的理论价值可以写成:
但净值 1.046627 不代表赚了 1.046627 元,也不代表价格是 1.046627 元;它表示初始资金增长到了原来的 1.046627 倍。
用首尾价格复核累计收益
如果中间没有现金流,逐日收益连乘应该与首尾价格之比完全一致:
本单元首日和末日收盘价分别为 100.80 和 105.50:
程序中的 summarize_returns() 会将它与净值曲线末端的累计收益比较。如果两者不一致,说明日期顺序、收益公式或累计方法至少有一处错误。
验证公式不能验证输入数据
首尾价格复核能够发现计算链路中的不一致,但不能证明价格本身正确,也不能证明复权和分红口径符合研究问题。计算验证与数据质量审计解决的是不同问题。
认识对数收益率
对数收益率(Log Return)定义为:
其中
代码为:
result["log_return"] = price_ratio.map(math.log)对数收益最重要的性质是可以跨时间相加:
把累计对数收益转回普通累计收益,需要:
简单收益与对数收益在涨跌较小时数值接近,但并不相等。简单收益更容易解释为账户某期的百分比变化;对数收益便于跨时间相加,也常用于统计建模。本教程后续回测净值时会继续使用简单收益和连乘。
不要把对数收益直接当百分比盈亏
累计对数收益为 10% 时,普通累计收益是
区分算术平均与几何平均
日简单收益的算术平均是:
它回答“这些单期收益的平均值是多少”。几何平均收益是:
它回答“如果每期都以同一个复合增长率变化,什么增长率能到达相同终值”。其中
本样例有 10 个价格,但只有 9 个收益区间:
| 指标 | 结果 |
|---|---|
| 日简单收益算术平均 | 0.5105% |
| 日复合收益几何平均 | 0.5076% |
波动存在时,几何平均通常不高于算术平均。不能用“日收益算术平均 × 天数”替代真实累计收益;累计结果应通过连乘得到。
关于年化与“252 个交易日”
将日频结果年化时,经常假设一年有 252 个交易日。例如把已实现的区间复合增长率按相同速度延伸,可以写成:
其中
但 252 只是某些市场日频研究中的常用近似,不是永远成立的常数。市场、年份、停牌规则和数据频率都会改变期数。本样例只有 9 个收益区间,把它年化会放大短期偶然波动,因此本单元不把年化结果当作有意义的绩效结论。
运行完整分析
执行:
uv run python examples/unit03/calculate_returns.py输出表格的前几行应为:
date close simple_return wealth_index
2024-01-02 100.8 -- 1.000000
2024-01-03 101.7 0.8929% 1.008929
2024-01-04 101.2 -0.4916% 1.003968
2024-01-05 102.4 1.1858% 1.015873汇总结果应为:
价格观测: 10 个
收益区间: 9 个
累计收益: 4.6627%
日简单收益算术平均: 0.5105%
日复合收益几何平均: 0.5076%
收益明细: outputs/unit03/returns.csv
分析图表: outputs/unit03/returns_and_wealth.pngreturns.csv 包含以下列:
| 字段 | 含义 |
|---|---|
symbol | 证券标识 |
date | 当前收益区间的结束交易日 |
close | 当日收盘价,CNY/份 |
simple_return | 从上一交易日收盘到当日收盘的简单收益率 |
log_return | 同一区间的对数收益率 |
wealth_index | 初始值为 1 的累计净值 |
cumulative_return | 从起点到当日的累计简单收益 |
打开 outputs/unit03/returns_and_wealth.png,图中依次展示收盘价、每日简单收益和净值。
- 价格曲线有 CNY/份的单位,起点是 100.80;
- 收益率围绕 0 波动,每根柱子描述一个相邻交易日区间;
- 净值曲线起点为 1,展示一单位初始资金的相对变化。
本样例只使用收盘价变化,没有加入交易成本、税费、现金流和仓位变化。因此这条净值是资产价格收益的净值,不是完整交易策略的账户净值。
常见陷阱
日期没排序就计算
shift(1) 只认识上一行,不理解哪一天更早。日期乱序会把不相邻的交易日连接起来,计算仍能运行,但含义已经错误。
把第一条收益填成 0
第一条收益缺少前置价格,应该是未知。净值需要一个起点时单独设为 1,不要改变原始收益序列的含义。
把每日收益直接相加
简单收益跨时间应连乘。直接求和忽略了每期本金变化,在波动较大或时间较长时误差会明显增加。
混用未复权价与复权价
不同价格口径计算出的收益代表不同问题。尤其在分红和拆分附近,混用口径会制造虚假跳变。
把净值当成真实账户余额
本单元净值没有资金单位,也没有仓位、成交、费用和现金流。只有在明确初始资金和交易假设后,才能转换为模拟账户价值。
用极短样本推断长期表现
9 个日收益足够验证程序,不足以证明资产的长期收益特征,更不能据此承诺未来表现。
验证结果
完成标准:
- 第一条简单收益和对数收益保持缺失,净值从 1 开始;
- 2024-01-03 的简单收益为 0.8929%;
- 10 个价格对应 9 个收益区间;
- 每日简单收益连乘后的累计收益为 4.6627%;
- 末端净值 1.046627 与
105.50 / 100.80一致; - 能解释为什么“先涨 10%、再跌 10%”的结果是亏损 1%;
- 能说明简单收益与对数收益如何跨时间累计。
小结
本单元完成了第一条收益计算链路:
已检查且按日期排序的收盘价
↓
相邻价格之比 - 1
↓
简单收益率 + 对数收益率
↓
简单收益连乘
↓
累计收益 + 初始值为 1 的净值
↓
手工样本 + 首尾价格双重复核下一单元将在这条可靠的价格与收益序列上计算移动平均线,开始区分指标、信号和持仓。
练习
基础练习:手工验证两段收益
使用价格 100 → 120 → 90,先在纸上计算:
- 两段简单收益率;
- 最终累计收益;
- 末端净值;
- 两段对数收益之和,以及转回普通累计收益后的结果。
再把这三个价格传给 calculate_returns(),比较程序输出。
进阶练习:观察波动对几何平均的影响
构造两组日收益:
- A:每天都是 1%;
- B:一天 11%,另一天 -9%。
两组收益的算术平均相同。分别计算两期后的净值和几何平均,解释为什么终值不同。
延伸练习:核实真实数据的收益口径
选择一个真实数据供应商,只阅读文档,不必下载数据。记录其普通收盘价和复权收盘价的定义,并回答:
- 现金分红如何体现在复权价格中?
- 拆分发生时历史价格如何调整?
- 复权因子是否会因后续公司行动而变化?
- 用该字段计算的是价格收益还是包含分红再投资的总回报?
无法从文档确认的内容应明确标记为未知,不要自行补充假设。