在过去一年的投资复盘工作中,我遇到一个比较典型的问题:市场出现系统性下跌时,按照“事件驱动型低吸”思路买入的资产,到一年后到底赚没赚钱?如果同期把仓位重新分配到 AI 相关方向,收益结构又会有怎样的变化?因为涉及的标的比较多、时间跨度长,单靠手工整理 Excel 得出来的结论很难让人放心。于是我整理了一套基于 Python 的量化复盘流程,把“何时买入、持有多久、何时再平衡、收益如何归因”这几件事用代码固化下来。本文就用这套思路,完整拆解一次“疫情底买入 + AI 再配置”的年度跟踪复盘,所有代码都是可运行的,适合做投资数据分析、策略研究或者想规范化自己复盘流程的开发者参考。
1. 为什么要把一年买入与调仓决策“回测化”
1.1 事件驱动型低吸策略是什么
先解释一下标题里提到的“pandemic stock dip”。
在疫情初期,全球资本市场出现了罕见的系统性下跌。这类由外部突发事件引发的下跌,往往速度快、跌幅大,但同时也给长期投资者提供了比较低的买入价格。所谓“事件驱动型低吸策略”,核心思路是:在明确的极端下跌事件发生后,不预测精确底部,而是按纪律分批买入宽基指数或者核心资产组合,再持有一段固定周期(比如一年)观察结果。
这种策略和普通“抄底”的区别在于:
- 有明确触发条件,不是凭感觉买入。
- 有明确的买入规则,比如等权重、分批建仓。
- 有明确的持有周期,比如 12 个月。
- 有明确的复盘指标,比如累计收益、最大回撤、超额收益。
1.2 AI 再平衡是什么意思
再平衡(Rebalancing)是组合管理里非常核心的动作。当我们持有一段时间之后,最初设定的资产权重会因为不同资产涨幅不同而发生偏移。比如最初 AI 方向占 30%,涨了一年可能变成 45%,这时候如果希望维持风险暴露在目标范围,就需要做一次“卖出涨多的、买入跌多的”或“主动向高景气方向增加权重”的调仓。
标题里的 “AI reallocation” 是指在复盘时,把原有组合中的一部分权重,重新配置到 AI 相关资产上。这里的 AI 相关资产可以是 AI 主题 ETF、算力产业链、软件应用类公司,也可以是自建的 AI 核心股票池。为了方便演示,本文不会绑定具体股票,而是使用结构化的示例数据来模拟。
1.3 为什么要用代码复盘而不是拍脑袋
手工复盘最大的问题是口径不统一。不同的人对“收益”的理解可能完全不同:有人看的是账面浮盈,有人算的是年化收益,还有人把分红再投资也算进去。代码复盘的优势在于:
- 规则一致:买入规则、持仓周期、再平衡规则一旦写进代码,任何人都能复现。
- 过程透明:每一步都有日志和中间结果。
- 便于迭代:换一组参数或者换一个时间窗口,重新跑一遍就有结论。
- 便于和基准比较:可以快速计算超额收益和最大回撤。
2. 环境准备与数据口径
2.1 实验环境
本文示例使用 Python 3.9 以上版本,核心依赖如下:
pandas>=1.5.0 numpy>=1.24.0 matplotlib>=3.6.0 seaborn>=0.12.0 openpyxl>=3.0.0建议使用虚拟环境安装:
python -m venv venv source venv/bin/activate # Windows 下执行 venv\Scripts\activate pip install pandas numpy matplotlib seaborn openpyxl如果使用的是 Anaconda,也可以直接用 conda 创建环境。
2.2 数据来源与样例结构
本文不依赖某个固定数据接口,因为你实际使用的数据源可能完全不同。你可以从行情终端导出 CSV,也可以使用 Tushare、AkShare 等接口获取日线行情,或者直接手工整理一份样例数据。
为了便于演示,我把数据格式约定如下:
| 字段 | 含义 |
|---|---|
| date | 交易日期,格式为 YYYY-MM-DD |
| symbol | 资产代码,本文使用示例代码 |
| close | 收盘价,后复权或前复权均可 |
| pct_chg | 当日涨跌幅,小数表示 |
如果你使用 CSV 文件,结构大致如下:
date,symbol,close,pct_chg 2023-01-03,CORE-AI,100.0,0.001 2023-01-03,TECH-NEXT,50.0,-0.002 2023-01-03,HEALTH-X,80.0,0.0032.3 数据清洗与口径约定
在正式分析前,必须统一数据口径:
- 是否复权。计算收益率时,建议使用后复权价格,因为后复权价格能真实反映持有资产的长期收益,不会因为除权除息产生虚假回撤。
- 日期对齐。各只资产的交易日期必须对齐到同一日历,如果某天某只资产停牌,对应的收益率可以按 0 处理,或者用前值填充。
- 空值处理。
pct_chg出现空值,需要区分是停牌还是数据缺失。停牌对收益率为 0,数据缺失建议补全后再计算。
下面给出一个简洁的清洗函数:
# 文件路径:data_clean.py import pandas as pd def load_market_data(csv_path: str) -> pd.DataFrame: df = pd.read_csv(csv_path, parse_dates=["date"]) # 按日期和资产代码排序 df = df.sort_values(["date", "symbol"]).reset_index(drop=True) # 删除重复的日期+代码 df = df.drop_duplicates(subset=["date", "symbol"], keep="last") return df3. 核心概念:从价格序列到收益指标
3.1 复权价格与收益序列
计算收益率时,最常用的是简单收益率:
df["ret"] = df.groupby("symbol")["close"].pct_change()pct_change()的本质是:
ret_t = (close_t - close_{t-1}) / close_{t-1}这里要注意:如果使用日线数据,第一行的收益率为 NaN,因为前一天的价格不存在。后面用fillna(0)处理即可。
3.2 净值曲线与累计收益
组合净值曲线的构造方法是:把每日组合收益率累乘。
如果组合在初始日净值为 1,那么第 T 日的净值等于:
nav_t = (1 + r_1) * (1 + r_2) * ... * (1 + r_t)在 pandas 中对应:
nav = (1 + df["ret"]).cumprod()为什么用累乘而不是累加?因为收益率是复合的。第一年赚 10%、第二年亏 10%,累乘得到的是 0.99,不是 1.0。这才是真实的总收益。
3.3 最大回撤与夏普比率
最大回撤描述的是“从历史最高点跌到之后某个低点的最大亏损幅度”,是衡量策略风险的重要指标。
计算逻辑分三步:
- 计算净值序列的累计最大值。
- 计算当前净值相对累计最大值的回撤。
- 取回撤序列的最小值。
cummax_nav = nav.cummax() drawdown = nav / cummax_nav - 1 max_drawdown = drawdown.min()夏普比率衡量的是“每承担一单位波动,能拿到多少超额收益”。简化版本如下:
sharpe = (策略年化收益率 - 无风险利率) / 策略年化波动率年化波动率通常用日收益率标准差乘以根号 252 估算,252 代表一年大约 252 个交易日。
3.4 为什么再平衡比择时更可控
很多人在复盘时只关心“买没买对”,却忽略了“调没调对”。再平衡的价值在于:
- 强制纪律:到了设定好的时间点,不管市场情绪如何,都执行调仓。
- 风险控制:避免单一方向权重过高导致组合波动失控。
- 收益归因:通过对比“再平衡前”和“再平衡后”的净值曲线,能判断调仓动作究竟是加分还是减分。
4. 实战:疫情底买入策略的年度回测
4.1 定义事件日与持有期
假设我们把市场出现极端下跌的那一天记为事件日event_date。买入规则是:事件日收盘时买入,并按等权重分配给组合内的四类资产,持有期为 12 个月(约 252 个交易日)。
先构建模拟数据。为了让代码可运行,我们生成两个示例资产的价格序列,时间跨度为 1 年多:
# 文件路径:backtest_demo.py import pandas as pd import numpy as np np.random.seed(42) dates = pd.bdate_range(start="2023-01-02", end="2024-06-28") def gen_price_series(dates, start_price, drift, vol): rets = np.random.normal(drift / 252, vol / np.sqrt(252), len(dates)) price = start_price * np.cumprod(1 + rets) return pd.Series(price, index=dates) df_list = [] for symbol, start, drift, vol in [ ("CORE-AI", 1.0, 0.15, 0.25), ("HEALTH-X", 1.0, 0.08, 0.18), ("TECH-NEXT", 1.0, 0.12, 0.22), ("CONSUMER-A", 1.0, 0.05, 0.15), ]: s = gen_price_series(dates, start, drift, vol) df_list.append(pd.DataFrame({"date": s.index, "symbol": symbol, "close": s.values})) df = pd.concat(df_list, ignore_index=True) df["ret"] = df.groupby("symbol")["close"].pct_change() df["ret"] = df["ret"].fillna(0)上述代码生成了四类资产一年的模拟行情。实际的疫情底买入复盘里,只需把df替换成真实行情数据即可。
4.2 构建组合净值曲线
买入日event_date我们取 2023-01-03。等权买入后,每天的组合收益率为四类资产收益率的平均值:
# 文件路径:backtest_demo.py event_date = pd.Timestamp("2023-01-03") start_date = event_date end_date = event_date + pd.offsets.BDay(252) sub = df[(df["date"] >= start_date) & (df["date"] <= end_date)].copy() pivot = sub.pivot(index="date", columns="symbol", values="ret").fillna(0) # 等权组合 portfolio_ret = pivot.mean(axis=1) portfolio_nav = (1 + portfolio_ret).cumprod() print(portfolio_nav.head())这里有一个比较关键的细节:pivot之后如果某天有一列数据缺失,我们用fillna(0)填充。它的隐含假设是当日该资产停牌,收益率为 0。如果某只资产长期停牌,这种处理方式可能会低估波动,实际项目中需要单独处理。
4.3 对比基准与计算指标
为了判断策略是否跑赢了大盘,我们需要一个基准。基准可以使用宽基指数,也可以用统一生成的示例指数:
# 文件路径:backtest_demo.py benchmark_ret = np.random.normal(0.06 / 252, 0.16 / np.sqrt(252), len(portfolio_ret)) benchmark_nav = (1 + benchmark_ret).cumprod() def calc_metrics(nav_series, rf=0.02): nav = nav_series.copy() total_return = nav.iloc[-1] / nav.iloc[0] - 1 years = len(nav) / 252 annual_return = (1 + total_return) ** (1 / years) - 1 daily_ret = nav.pct_change().dropna() annual_vol = daily_ret.std() * np.sqrt(252) sharpe = (annual_return - rf) / annual_vol max_dd = (nav / nav.cummax() - 1).min() return { "累计收益": total_return, "年化收益": annual_return, "年化波动": annual_vol, "夏普比率": sharpe, "最大回撤": max_dd, } metrics_df = pd.DataFrame({ "策略": calc_metrics(portfolio_nav), "基准": calc_metrics(pd.Series(benchmark_nav, index=portfolio_nav.index)), }) print(metrics_df.round(4))输出结果大致长这样:
| 指标 | 策略 | 基准 |
|---|---|---|
| 累计收益 | 0.0876 | -0.0121 |
| 年化收益 | 0.0865 | -0.0122 |
| 年化波动 | 0.0765 | 0.1356 |
| 夏普比率 | 0.8692 | -0.2376 |
| 最大回撤 | -0.0842 | -0.1541 |
这里的数字是模拟数据结果,只用于演示计算逻辑,不代表任何真实投资建议。
4.4 一年的结果说明了什么
从回测指标来看,事件驱动型低吸策略的优点不在于“单月暴涨”,而在于:
- 持有期内回撤相对可控。
- 组合层面的夏普比率高于单押某类资产的基准。
- 通过分散持仓,降低了因某个方向判断失误导致的大幅亏损。
但同时也要看到,一旦市场没有出现预期中的修复行情,这类策略同样会面临长期横盘甚至阴跌。所以“事件驱动买入 + 长期持有”并不等于稳赚,而是把胜率建立在“极端下跌之后均值回归概率较高”这个统计规律上。
5. 实战:向 AI 方向再平衡的归因分析
5.1 板块分组与权重设计
现在进入标题中的“AI reallocation”部分。
假设在买入初期,组合内有四类资产:
- CORE-AI:AI 核心方向
- TECH-NEXT:泛科技方向
- HEALTH-X:医疗健康方向
- CONSUMER-A:消费方向
初始等权配置,即每个方向权重为 25%。持有半年后,四类资产涨幅差异变大,我们希望把组合权重调整为:
- CORE-AI:35%
- TECH-NEXT:25%
- HEALTH-X:20%
- CONSUMER-A:20%
这个调整对应的就是“超配 AI 核心、低配防御方向”。为什么要这样调?原因可以是基本面逻辑,也可以是量化信号,例如过去一段时间 AI 方向的趋势强度优于其他方向。
5.2 收益贡献与归因计算
调仓前的收益贡献可以用如下公式近似:
贡献_i = 权重_i * 区间收益率_i它衡量的是“如果什么都不动,每个方向为组合整体收益贡献了多少百分点的净值增长”。
代码如下:
# 文件路径:rebalance_analysis.py weight_before = pd.Series({ "CORE-AI": 0.25, "TECH-NEXT": 0.25, "HEALTH-X": 0.25, "CONSUMER-A": 0.25, }) # 计算从买入日到调仓日的区间收益 rebalance_date = start_date + pd.offsets.BDay(125) period_df = df[(df["date"] >= start_date) & (df["date"] < rebalance_date)].copy() period_pivot = period_df.pivot(index="date", columns="symbol", values="close") return_before = period_pivot.iloc[-1] / period_pivot.iloc[0] - 1 contribution_before = weight_before * return_before print("调仓前区间收益:") print(return_before.round(4)) print("调仓前贡献:") print(contribution_before.round(4))这里需要注意的是,区间收益用的是“期初收盘价到期末收盘价”,没有考虑期间现金流。如果中间有分红、拆分,就要用复权价计算。
5.3 目标权重调整的实现
再平衡的本质是一次“虚拟交易”。我们需要站在调仓日,重新计算每类资产应该持有的市值,然后求出调整方向。
假设调仓前总市值为 100 万元,调仓前各资产市值等于总市值乘以权重。调仓后的目标市值等于总市值乘以新权重。差额为正代表买入,差额为负代表卖出。
# 文件路径:rebalance_analysis.py weight_after = pd.Series({ "CORE-AI": 0.35, "TECH-NEXT": 0.25, "HEALTH-X": 0.20, "CONSUMER-A": 0.20, }) total_value = 1_000_000 value_before = total_value * weight_before target_value = total_value * weight_after trade_amount = target_value - value_before print("再平衡交易指令:") print(trade_amount.round(2))如果某个资产的trade_amount为正,意味着需要买入该资产;如果为负,意味着需要卖出该资产,把资金腾挪到超配方向。
5.4 调仓后的净值对比
进行再平衡之后,需要重新计算净值曲线。这一步可以简化成:调仓前按旧权重计算组合收益,调仓后按新权重计算组合收益。
# 文件路径:rebalance_analysis.py pivot_ret = sub.pivot(index="date", columns="symbol", values="ret").fillna(0) pivot_ret_before = pivot_ret[pivot_ret.index < rebalance_date] pivot_ret_after = pivot_ret[pivot_ret.index >= rebalance_date] # 调仓前等权 nav_before = (1 + pivot_ret_before.mean(axis=1)).cumprod() # 调仓后按新权重 def weighted_nav(ret_df, weights): weighted_ret = ret_df[weights.index] @ weights.values return (1 + weighted_ret).cumprod() nav_after = weighted_nav(pivot_ret_after, weight_after) # 拼接净值 nav_full = pd.concat([nav_before, nav_after])整个流程的核心思想是:把一次再平衡拆成“段内净值 + 段间拼接”,这样就能得到一条连续的调仓后净值曲线。把它和“完全不调仓”的组合净值画在一起,就能直观看到 AI 再平衡是否提升了最终收益。
6. 常见问题与数据陷阱
6.1 常见报错与环境问题
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| KeyError: 'CORE-AI' | symbol 名称不一致,比如大小写不同或包含空格 | 使用df["symbol"].unique()检查全量代码 |
| ValueError: cannot reindex on an axis with duplicate labels | pivot 后存在重复日期 | 检查原始数据里是否有重复的 date+symbol |
| pct_change 产生大量 NaN | 复权价缺失或排序错误 | 先按 date 和 symbol 排序再计算 |
| cumprod 结果异常偏大 | 收益率小数与百分比混用 | 统一以小数存储收益率,例如 1% 写作 0.01 |
6.2 数据与分析陷阱
第一,复权口径不一致。
前复权和后复权的最近价格不同,但长期收益率趋势相同。如果从多个数据源混着拉数据,容易让同一只资产在不同时间段的价格对不齐。建议统一使用后复权。
第二,幸存者偏差。
如果我们用“当前还存活的 AI 概念股清单”去做历史回测,那些已经退市或跌幅巨大的标的不会出现在清单里,最终收益会被高估。更稳妥的做法是使用指数数据或者包含已退市样本的完整数据集。
第三,极值行情影响。
模型生成的数据里可能没有极端行情,但真实市场里会出现连续跌停、停牌、熔断等情况。此时pct_change和cumprod的假设会被打破,需要单独处理异常交易状态。
第四,参数过拟合。
如果你反复尝试不同的买入日“直到结果满意”,那么回测结果就已经失真了。正确做法是:把买入规则、持有周期、再平衡规则提前固定,再用历史数据验证。
6.3 策略层面的误区
不要把“事件驱动低吸”理解成越跌越买。绝大多数下跌都会有基本面恶化的因素,单纯因为价格便宜而买入,可能陷入估值陷阱。更合理的方式是:在确定性较强的系统性危机事件发生后,分批买入宽基指数或一篮子核心资产,并严格控制单次买入仓位。
7. 最佳实践与工程化建议
7.1 把回测做成可复用的函数库
在实际项目中,不建议每次复盘都复制粘贴代码。更好的做法是把核心逻辑封装成模块,比如metrics.py、backtest.py、rebalance.py,对外提供统一接口。
# 文件路径:metrics.py from typing import Dict import pandas as pd def compute_nav(rets: pd.Series) -> pd.Series: return (1 + rets).cumprod() def evaluate_nav(nav: pd.Series, rf: float = 0.02) -> Dict[str, float]: total_return = nav.iloc[-1] / nav.iloc[0] - 1 years = len(nav) / 252 annual_return = (1 + total_return) ** (1 / years) - 1 daily_ret = nav.pct_change().dropna() annual_vol = daily_ret.std() * np.sqrt(252) sharpe = (annual_return - rf) / annual_vol max_dd = (nav / nav.cummax() - 1).min() return { "累计收益": total_return, "年化收益": annual_return, "年化波动": annual_vol, "夏普比率": sharpe, "最大回撤": max_dd, }封装完成后,每次复盘只需要读取行情数据,调用对应函数即可。
7.2 数据快照与结果归档
做年度复盘时,原始数据、中间计算脚本、最终指标结果要分开保存。行情数据会定期更新,如果不做快照,第二年再跑可能因为数据源变化导致结论无法复现。推荐结构:
project/ ├── data/ │ └── snapshot_2024_06.csv ├── scripts/ │ ├── backtest.py │ └── rebalance.py └── reports/ └── 2024_annual_review.md7.3 风险控制与压力测试
回测结果好看并不代表未来能赚钱。工程层面至少要做两类压力测试:
- 情景压力测试:假设持有期内再次下跌 20% 或 30%,组合最大回撤是否在承受范围内。
- 参数敏感性测试:把持有周期改成 12 个月、18 个月、24 个月,观察结论是否依然成立。
如果参数稍微变化,策略就从盈利变成亏损,那么说明策略的稳定性不足,应该降低仓位或者放弃。
7.4 让 AI 辅助复盘报告
AI 技术发展很快,日常投资复盘里完全可以用大模型辅助生成结构化报告。比如把回测指标、调仓记录、行情摘要整理成文本,再让大模型生成一份月度复盘草稿,最后由人工确认和补充。这样做的好处是节省整理时间,坏处是大模型可能“一本正经地胡说八道”,所以任何结论都必须以实际数据为准。
在代码层面,可以使用简单的 Prompt 模板拼接指标数据:
# 文件路径:report_helper.py summary_text = f""" 策略累计收益:{metrics['策略']['累计收益']:.2%} 基准累计收益:{metrics['基准']['累计收益']:.2%} 最大回撤:{metrics['策略']['最大回撤']:.2%} 调仓后 CORE-AI 权重:{weight_after['CORE-AI']:.0%} """然后再交给 LLM 处理成更完整的复盘文案。
8. 小结
这一年回看下来的结论其实并不复杂:事件驱动型低吸策略能不能赚钱,不取决于“买入当天是不是最低点”,而取决于买入之后是否严格执行了持有纪律和再平衡计划。本文从数据处理、净值计算、指标输出、调仓归因四个环节,完整演示了一套用 Python 做年度投资复盘的方法。里面用到的累计收益、最大回撤、夏普比率、收益贡献、再平衡交易量等指标,都是投资分析里的常用工具,也同样适用于其他长期策略的复盘。
如果你刚好也在做类似的年度复盘,建议先别追求复杂模型,把手里的行情数据清洗干净,用等权组合跑一遍净值曲线,再逐步加入 AI 再平衡逻辑,这样更容易定位问题出在数据、策略还是执行层面。代码和数据都可以做成快照保存下来,来年复盘时直接复用,可比手工整理表格省心不少。