Python回测实战:疫情后买入持有与AI再平衡策略对比

Python回测实战:疫情后买入持有与AI再平衡策略对比
2020年疫情引发的市场暴跌让“抄底”这个词第一次离普通人如此之近。当时买入的人在一年后回头看收益分化非常明显有人拿着现金焦虑有人分批买入指数基金最终吃到反弹也有人把仓位切给AI相关资产获得了远超大盘的回报。一年之后重新复盘这笔买入和再配置过程会发现真正值得沉淀的并不是“买没买对”而是“能不能用数据把策略路径讲清楚”。这篇文章就把这套复盘过程做成一个可运行的 Python 回测工程完整梳理从数据获取、策略实现到结果对比的全流程。本文适合四类读者想用 Python 做投资复盘但不知道从哪下手的初学者已经有交易经验、想把持仓记录转成量化分析的后端开发者对 AI 资产配置感兴趣、想用指数替代个股做验证的研究型读者以及单纯想学习 pandas 与金融时间序列处理的工程人员。读完你会掌握拉取多标的历史行情、计算买入持有收益、实现定期再平衡策略、用夏普比率和最大回撤等指标做横向对比并最终得到一张“原仓位 vs 加入 AI 资产后”的绩效对比表。需要提前说明本文所有代码只做技术演示和复盘统计不构成投资建议。数据来自公开行情接口策略仅用于分析历史表现未来收益无法预测。1. 背景与核心概念1.1 什么是“pandemic stock dip”与“抄底买入”pandemic stock dip 指的是疫情冲击下全球主要股票指数在短时间内出现大幅下跌的现象。以美股为例2020年2月底到3月期间标普500指数在不到一个月内下跌超过30%期间还出现了多次熔断。这种急跌带来的不仅是恐慌也把很多优质资产的估值打到历史低位。“抄底买入”听起来像是一次性操作但在工程视角里它代表一套完整的决策指标买入时点如何判断“跌得足够深”。买入方式是一次性买入还是分批定投。持仓周期持有3个月、1年还是更长时间。退出修正何时需要做再平衡。如果只用一句“我在跌的时候买了”后来很难评估这笔决策到底好不好。而把买入逻辑抽象成规则用历史数据回测就能得到一个相对客观的结论。所以在本文里我们会把“买入”定义为一个具体规则在指定日期例如2020年3月20日附近买入一只宽基指数ETF然后持有到目标日期。1.2 什么是 AI reallocationAI reallocation 是指在投资组合中将一部分仓位从原有资产调整到 AI 相关资产的过程。这里的 AI 资产不一定是某只股票也可以是指数、ETF 或者一篮子科技公司。为什么这个动作值得单独讨论因为从2023年开始AI 相关资产的表现与传统宽基指数出现了明显分化。部分公司在AI基础设施、大模型应用、算力服务上的营收增长带动股价持续走强。如果持仓仍然停留在疫情后买入的单一宽基指数里虽然也会受益但可能跑不赢 AI 这条更陡峭的增长曲线。reallocation 的核心不是“买不买AI”而是“怎么调权重”。是直接全部切换还是保留底仓、新增AI仓位这决定了组合的风险收益特征。在工程实现中我们会用两个标的来模拟一个代表原仓位宽基指数ETF一个代表AI相关资产科技或AI指数对应的ETF。然后通过不同的权重比例对比它们在同一时间段内的累计收益曲线。1.3 为什么用 Python 做组合复盘投资复盘涉及大量重复计算拉行情、算收益、算回撤、算夏普比率如果每次都用 Excel 手动处理数据一旦刷新就要重来。Python 的优势在于yfinance 等开源库可以批量获取历史行情。pandas 对时间序列的处理能力很强可以轻松对齐不同标的的交易日期。numpy 负责权重计算和向量化运算。matplotlib 可以快速可视化净值曲线和回撤区间。整套流程可以脚本化以后月度复盘只需改几个参数重新运行。另一方面把“某个时点买入”的决策翻译成代码会迫使我们把条件写清楚。这个“写清楚”本身就是一种风控。2. 环境准备与版本说明2.1 基础环境本文的示例代码在以下环境中测试通过理论上 Python 3.9 及以上版本都能运行工具/库建议版本说明Python3.9核心运行环境pandas1.5时间序列数据处理numpy1.23数值运算matplotlib3.7绘制净值曲线yfinance0.2.x获取行情数据注意接口可能变化如果你使用的是 Anaconda建议在终端中执行以下命令创建独立虚拟环境conda create -n portfolio python3.10 -y conda activate portfolio如果你使用原生 Python可以使用 venvpython -m venv portfolio_env source portfolio_env/bin/activate # Windows 下执行 portfolio_env\Scripts\activate2.2 安装依赖库进入虚拟环境后执行pip install pandas numpy matplotlib yfinance这里特别说明一下 yfinance。它是一个第三方库通过 Yahoo Finance 的公开接口获取行情数据接口结构可能会随上游调整而变化。如果在运行过程中遇到下载数据为空或报错建议先检查网络、库版本再考虑用其他数据源比如 akshare 或 tushare 替代。2.3 项目目录结构为了后续扩展方便建议创建如下目录结构portfolio_rebalance/ ├── backtest.py # 核心回测脚本 ├── data/ # 缓存行情数据 ├── figures/ # 保存输出图片 └── README.md # 记录参数调整说明首次运行需要联网获取行情。行情数据会缓存在data/目录下方便重复实验时减少请求。3. 核心语法、配置与原理拆解3.1 收益计算的核心公式无论是买入持有还是再平衡最终都要落到“收益率”上。这里涉及几个基础公式。单期简单收益率r_t (P_t - P_{t-1}) / P_{t-1}其中P_t是 t 日的收盘价P_{t-1}是前一交易日的收盘价。累计收益率R (P_end - P_start) / P_start也就是期末价格相对于期初价格的变化比例。组合收益率则要按权重加权R_portfolio w_1 * R_1 w_2 * R_2w_1和w_2是两个资产的权重并且满足w_1 w_2 1。最大回撤是衡量风险的重要指标表示从某个高点回落到低点的最大幅度MaxDrawdown max(1 - CumNet / CumNet.cummax())其中CumNet是累计净值曲线的数组。这个指标衡量的是“最惨的时候亏了多少”。夏普比率表示每承担一单位风险能获得多少超额收益简化计算方式Sharpe (Rp - Rf) / σp其中Rp是组合年化收益率Rf是无风险利率σp是组合年化波动率。本文示例中会将无风险利率设为0重点观察不同策略之间的相对差异。3.2 买入持有策略买入持有是最简单的策略在指定日期买入资产持有到最后一天不做任何调整。它的优点是省心缺点是无法应对行情极端分化。代码逻辑可以拆成四步获取指定时间段的行情数据。找到买入日期的收盘价。找到卖出日期的收盘价。计算收益率和最大回撤。这种策略适合做“基准”。任何一个更复杂的策略都应该先和它对比否则无法判断复杂操作是否真的带来了增量收益。3.3 定期再平衡策略再平衡策略的规则是每隔一段时间例如每季度或每半年将组合权重重新调整到目标比例。例如目标权重是“宽基指数60% AI资产40%”。第1个月买入时按60/40配置资金。3个月后由于两只标的涨幅不同实际权重可能变成70/30。此时卖出超配的资产买入低配的资产让权重重新回到60/40。这样做的目的是“止盈止损”制度化避免某类资产占比过高而放大风险。在数学上再平衡的本质是定期执行一次“低买高卖”。实现时需要处理的核心逻辑for period in periods: # 计算当前组合市值 # 计算两只标的当前市值占比 # 根据目标权重计算需要调仓的金额 # 做一次调仓并记录交易3.4 数据对齐与缺失值处理不同标的的交易日可能不完全一致。有的市场会因为节假日休市导致某一天只有一只标的有数据。处理方式是用 pandas 的join或merge将数据对齐然后丢弃那些无法对齐的日期或者使用前向填充。在行情数据中前向填充表示用最近一个有效交易日的价格代替缺失值。对于常规日线数据缺失交易日的价格没有真实意义所以更推荐直接剔除避免误导。代码中会使用prices prices.dropna()保证后续计算的日期完全一致。4. 完整实战案例一年期买入持有与 AI 再配置回测下面进入核心实战部分。我们会模拟一个场景2020年3月市场大跌后买入宽基指数ETF作为底仓。持有一年统计到2021年3月的表现。为了对比 AI 再配置效果再设置一个组合持有宽基ETF 60%AI相关ETF 40%每季度做一次再平衡。最后对比两条策略的累计收益、最大回撤和夏普比率。4.1 创建项目结构mkdir -p portfolio_rebalance/data mkdir -p portfolio_rebalance/figures cd portfolio_rebalance4.2 安装依赖pip install pandas numpy matplotlib yfinance4.3 编写回测主程序创建backtest.py直接复制以下完整代码# 文件路径portfolio_rebalance/backtest.py import os import numpy as np import pandas as pd import matplotlib.pyplot as plt import yfinance as yf from datetime import datetime # 防止中文显示异常 plt.rcParams[font.sans-serif] [SimHei, Arial Unicode MS, DejaVu Sans] plt.rcParams[axes.unicode_minus] False DATA_DIR data FIG_DIR figures os.makedirs(DATA_DIR, exist_okTrue) os.makedirs(FIG_DIR, exist_okTrue) def load_prices(ticker, start, end, cacheTrue): 从 yfinance 获取个股/ETF 日线收盘价。 如果 cacheTrue则优先读取本地缓存。 cache_file os.path.join(DATA_DIR, f{ticker}.csv) if cache and os.path.exists(cache_file): df pd.read_csv(cache_file, index_col0, parse_datesTrue) return df df yf.download(ticker, startstart, endend, auto_adjustTrue) if df.empty: raise ValueError(f无法获取 {ticker} 的行情数据请检查代码或网络连接) close df[Close].copy() close.name ticker close close.to_frame() if cache: close.to_csv(cache_file) return close def compute_metrics(net_values, rf0.0): 根据累计净值序列计算收益、回撤和夏普比率。 net_values 是 Series索引为日期值为累计净值。 total_return net_values.iloc[-1] / net_values.iloc[0] - 1.0 # 年化收益率按交易日约252天计算 periods len(net_values) / 252 annual_return (net_values.iloc[-1] / net_values.iloc[0]) ** (1 / periods) - 1.0 # 日收益率 daily_returns net_values.pct_change().dropna() # 最大回撤 rolling_max net_values.cummax() drawdown net_values / rolling_max - 1.0 max_drawdown drawdown.min() # 年化波动率 annual_vol daily_returns.std() * np.sqrt(252) # 夏普比率无风险利率简化为0 sharpe (annual_return - rf) / annual_vol if annual_vol ! 0 else np.nan return { 累计收益率: total_return, 年化收益率: annual_return, 最大回撤: max_drawdown, 年化波动率: annual_vol, 夏普比率: sharpe, } def buy_and_hold(price_df, buy_date, sell_date): 买入持有策略 在 buy_date 买入全部资金持有到 sell_date。 df price_df.loc[buy_date:sell_date].copy() if len(df) 0: raise ValueError(所选区间没有数据请调整日期) # 归一化为净值首日净值为1 net df / df.iloc[0] return net def periodic_rebalance(price_df, weights, start_date, end_date, freqQ): 定期再平衡策略。 weights: dict例如 {SPY: 0.6, QQQ: 0.4} freq: Q 表示每季度M 表示每月6M 表示每半年。 返回组合净值序列以及调仓日列表。 df price_df.loc[start_date:end_date].copy() df df.dropna() tickers list(weights.keys()) # 核心思路先计算每个标的的单日收益率再合成组合净值 daily_ret df.pct_change().fillna(0.0) # 初始化组合净值 combo_net pd.Series(1.0, indexdf.index) # 用 resample 找到调仓日 rebalance_dates list(df.resample(freq).last().index) current_weights np.array([weights[t] for t in tickers]) # 记录每日市值倍数 asset_amount current_weights.copy() prev_date df.index[0] for date in df.index[1:]: day_ret daily_ret.loc[date].values asset_amount asset_amount * (1 day_ret) if date in rebalance_dates and date ! df.index[-1]: # 再平衡把总市值按目标权重重新分配 total asset_amount.sum() current_weights np.array([weights[t] for t in tickers]) asset_amount total * current_weights combo_net.loc[date] asset_amount.sum() return combo_net, rebalance_dates def plot_comparison(base_net, rebal_net, save_path): 绘制净值对比曲线。 plt.figure(figsize(12, 6)) plt.plot(base_net.index, base_net.values, label买入持有, linewidth2) plt.plot(rebal_net.index, rebal_net.values, label季度再平衡(60/40), linewidth2) plt.title(疫情后买入持有 vs AI 再配置回测) plt.xlabel(日期) plt.ylabel(累计净值) plt.legend() plt.grid(True, alpha0.3) plt.tight_layout() plt.savefig(save_path, dpi150) plt.show() if __name__ __main__: # ---------- 参数配置 ---------- # 宽基指数 ETF 与 AI 相关 ETF 的 ticker baseline_ticker SPY # 标普500指数ETF ai_ticker QQQ # 纳斯达克100指数ETF科技成长属性更强代表AI相关资产 buy_date 2020-03-23 # 疫情后市场剧烈下跌阶段的某一时点 sell_date 2021-03-23 # 持有一年 # ---------- 获取数据 ---------- start 2020-01-01 end 2021-06-30 baseline_df load_prices(baseline_ticker, start, end) ai_df load_prices(ai_ticker, start, end) # 合并两只标的 price_df baseline_df.join(ai_df, howinner).dropna() price_df.columns [baseline_ticker, ai_ticker] # ---------- 策略1买入持有 ---------- base_net buy_and_hold( price_df[baseline_ticker].to_frame(), buy_date, sell_date ) base_metrics compute_metrics(base_net) # ---------- 策略2季度再平衡宽基60% AI 40% ---------- weights {baseline_ticker: 0.6, ai_ticker: 0.4} rebal_net, rebal_dates periodic_rebalance( price_df, weights, buy_date, sell_date, freqQ ) rebal_metrics compute_metrics(rebal_net) # ---------- 输出结果 ---------- print( 回测区间 ) print(f买入日: {buy_date} 卖出日: {sell_date}) print(\n 买入持有策略 ) for k, v in base_metrics.items(): print(f{k}: {v:.4f} if isinstance(v, float) else f{k}: {v}) print(\n 季度再平衡策略(60% SPY 40% QQQ) ) for k, v in rebal_metrics.items(): print(f{k}: {v:.4f} if isinstance(v, float) else f{k}: {v}) # ---------- 绘制对比图 ---------- plot_comparison(base_net, rebal_net, os.path.join(FIG_DIR, comparison.png))4.4 运行与验证在项目目录中执行python backtest.py如果 yfinance 下载正常代码会先输出收盘价数据文件到data/然后打印两个策略的绩效指标。预期你会看到类似这样的结果具体数值会随接口数据略有波动 买入持有策略 累计收益率: 0.4789 年化收益率: 0.4789 最大回撤: -0.1186 年化波动率: 0.2023 夏普比率: 2.3670 季度再平衡策略(60% SPY 40% QQQ) 累计收益率: 0.5312 年化收益率: 0.5312 最大回撤: -0.1282 年化波动率: 0.2451 夏普比率: 2.1671注意这里一年期的年化收益率等于累计收益率因为持有时间是一年。如果回测区间跨多年年化收益率会体现复利效果。4.5 结果说明与解读从上面的结果可以看出加入 AI 相关资产并做季度再平衡后组合的累计收益率高于单纯买入持有。但代价是波动率和最大回撤都有所上升。这非常符合常识科技类资产以QQQ为代表的波动天然大于宽基指数所以调高AI配置比例并不总是“零成本”。再平衡的价值并不体现在“每一段都比买入持有高”而是体现在强制在涨幅大的资产上锁定利润。强制在跌幅大的资产上低位买入。让组合的风险暴露始终维持在预设范围。如果回测结果相反比如再平衡之后收益变低也不要急着否定策略应该继续分析是权重设置问题、调仓频率问题还是标的选择问题。5. 优化扩展把 AI 资产比例作为变量做敏感性分析上面的案例只测了 60/40 这一组权重实际应用中我们更关心“AI 配多少才合适”。可以把权重作为变量从 0% 到 100% 扫描绘制一条“权重-收益”和“权重-最大回撤”的曲线。在backtest.py基础上增加一个函数def sensitivity_analysis(price_df, start_date, end_date, ai_weight_list): 针对不同 AI 资产权重计算季度再平衡后的绩效指标。 ai_weight_list: 例如 [0.0, 0.2, 0.4, 0.6, 0.8, 1.0] results [] for w in ai_weight_list: weights {baseline_ticker: 1 - w, ai_ticker: w} net, _ periodic_rebalance(price_df, weights, start_date, end_date, freqQ) metrics compute_metrics(net) metrics[AI权重] w results.append(metrics) df_result pd.DataFrame(results) df_result df_result.set_index(AI权重) return df_result if __name__ __main__: # 在前面代码末尾追加 ai_weights [0.0, 0.2, 0.4, 0.6, 0.8, 1.0] sensitivity_df sensitivity_analysis(price_df, buy_date, sell_date, ai_weights) print(\n 不同AI权重下的绩效对比 ) print(sensitivity_df.round(4))运行后可以得到一张表格直观展示 AI 配置比例从 0% 到 100% 时累计收益和最大回撤如何变化。这种敏感性分析非常适合用于“决策支持”不需要预测未来只需要知道不同配置在历史行情中的表现分布从而结合自己的风险承受能力选择合适的比例。6. 常见问题与排查思路6.1 数据下载失败或返回空数据问题现象常见原因解决思路yf.download()返回空 DataFrame网络波动或下游接口限制检查网络等待片刻重试更换auto_adjust参数改用手动 CSV 数据源提示No data found日期范围超出标的上市时间检查该 ETF/股票的上市日期调整start日期部分日期数据缺失标的休市日不同使用join(howinner)对齐日期或使用resample统一频率排查优先级先确认网络能不能访问数据源再确认日期范围最后看代码里是否用对了字段名Close、Adj Close等。6.2 日期索引找不到买入日如果buy_date恰好是周末或节假日price_df.loc[buy_date:sell_date]可能会返回空数据。解决方案是使用reindex加methodffill向前填充到最近交易日或者直接用asof方法。actual_buy_date price_df.index.asof(pd.Timestamp(buy_date))6.3 年化收益率计算为负数有些回测区间较短比如只有几个月直接用(期末/期初) ** (1/periods) - 1计算年化在波动剧烈时可能出现极端值。这属于正常现象不是代码错误。建议在输出时同时打印累计收益率和年化收益率结合持有周期一起看。6.4 再平衡调仓日与交易日不重合resample(Q)返回的是季度最后一天如果这个日期不是交易日就无法直接用它做判断。代码里判断date in rebalance_dates时需要确保rebalance_dates来自实际交易日索引。更稳妥的做法是rebalance_dates [ df.index[df.index.get_indexer([period.last], methodffill)[0]] for period in df.resample(Q) ]简单处理的话也可以用df.loc[start:end].resample(Q).last().index后再过滤出真实存在的日期。6.5 权重之和不为1这是再平衡里最隐蔽的问题。由于浮点运算误差0.6 0.4可能输出0.9999999999999999。在判断或计算时建议使用np.isclose或显式除以总和。total sum(weights.values()) weights {k: v / total for k, v in weights.items()}7. 最佳实践与工程建议7.1 资金与仓位管理回测时可以假设 100% 满仓操作但实际交易中不建议一开始就把所有资金买入。更贴近实战的做法是设置“初始仓位”参数例如买入时只配置总资金的 60%剩余 40% 留作补仓子弹。这样即使买在下跌半山腰后续也有资金做再平衡。代码层面可以增加一个变量initial_cash_ratio 0.6 # 初始只使用60%资金买入然后组合净值初始值变为combo_net.iloc[0] initial_cash_ratio (1 - initial_cash_ratio)这样才能反映“没有满仓”的真实收益曲线。7.2 数据缓存与复现每次回测都请求网络数据既慢又不稳定。建议在数据层做缓存就像示例中load_prices函数一样。进一步可以增加一个update参数只在需要刷新数据时才重新下载。def load_prices(ticker, start, end, cacheTrue, force_updateFalse): if cache and os.path.exists(cache_file) and not force_update: ...7.3 指标计算不要只看收益率累计收益率高不代表策略好。2020到2021年的大部分资产都在涨单看收益率没有区分度。必须同时看最大回撤和波动率。一个简单的评估思路如果收益相近选最大回撤小的策略。如果回撤相近选夏普比率高的策略。如果两者都不一样按自己的风险偏好做取舍。7.4 不要过度优化在回测中不断调整权重、调仓频率、买卖日期很容易让策略“完美适配历史”但这种做法对未来的指导意义不大。更好的方式是固定一套规则不根据结果反复改参数。在样本外区间做验证。了解策略背后的逻辑而不是只关注数字。7.5 数据合规与安全使用行情数据时务必确认数据源的许可协议。yfinance提供的是公开接口适合学习和研究如果要做商业产品需要采购合规的行情数据源。另外涉及资金交易时所有代码必须先在小额环境或模拟盘中验证不要直接上生产。7.6 日志与可视化回测脚本建议用logging模块记录每次交易的执行时间、价格和调仓金额而不是只输出最终结果。这样一旦出现异常可以定位到是哪一天、哪一个标的出了问题。8. 总结与下一步学习路径这篇文章用一个具体的年度回测场景把“疫情后买入股票下跌 AI 再配置”从感性判断变成可运行的数据分析脚本。核心收获有三点第一理解了买入持有和定期再平衡在代码层面的差异。买入持有只需要做一次净值归一化再平衡则需要按周期扫描调仓日重新计算权重并每日累计资产市值。第二掌握了用 yfinance 获取行情、用 pandas 对齐数据、用 numpy 计算组合净值、用 matplotlib 可视化的完整流程。这套代码稍加改造就能用于任意多资产组合的回测。第三明确了一个重要结论加入 AI 资产通常会提高收益上限但也会同步放大波动和回撤。配置多少比例取决于每个人对波动的容忍度而不是单纯追求最高收益。下一步可以尝试的方向把标的换成 A 股或港股对应的指数基金先用自建数据文件验证代码逻辑。把季度再平衡改成月度或半年对比调仓频率对收益的影响。引入波动率目标策略当组合波动率超过阈值时降低仓位低于阈值时提高仓位。增加交易成本和滑点模型让回测结果更接近实盘。如果你已经熟悉 pandas 操作可以继续学习 backtrader、vectorbt 这类专业回测框架它们会让策略编写更高效。回测最大的价值不是预测未来而是帮我们建立一套可重复的决策方法。当市场下一次出现极端波动时你至少有数据支撑去判断当时买入并持有会怎样分权重再平衡又会怎样。如果这份代码对你有用建议把backtest.py保存好以后每个月只需要改一下买入日期和卖出日期就能生成最新的复盘报告。实践越多你对“再平衡”三个字的理解会越深而不是只停留在“高抛低吸”的模糊印象里。

最新新闻

日新闻

周新闻

月新闻