2020 年疫情扩散引发全球市场急跌很多人的第一反应是“又到抄底的时候了”。到了第二年再看买入的人确实吃到了一波修复行情但真正的问题是这种“越跌越买持有一年”的做法到底靠的是择时能力还是行情本身够强如果当时买入的不是宽基指数而是 AI 主题资产持有到一年后结果又会怎样这篇文章不猜走势而是把“买入并持有一年”做成一个可以重复运行的 Python 回测框架用历史数据回答三个问题买入时点对一年收益影响有多大不同板块之间的收益和回撤差异有多大以及在 AI 行情起来之后要不要做一次资产再配置。整套方法的核心不是建立一个复杂的量化系统而是先把“抄底买入—持有—评估”的过程标准化。代码会完成数据下载、持有期切片、累计收益、年化收益、最大回撤、夏普比率计算并支持同时对多个宽基 ETF、科技 ETF、AI 主题 ETF 做对比回测。运行时只需要普通电脑CPU 就够不需要 GPU数据量也不大。适合对量化回测感兴趣、想验证自己抄底逻辑或者正在考虑把仓位向 AI 主题倾斜的开发者参考。先说清楚边界本文所有结论都基于历史数据的回测不代表未来表现不构成任何投资建议。股市有风险尤其 AI 相关资产波动远大于宽基指数实际决策前要做更多压力测试。1. 策略评估指标速览项目说明回测对象疫情后大跌买入持有约 1 年252 个交易日目标标的宽基 ETF、科技主题 ETF、AI 主题 ETF评估指标累计收益、年化收益、最大回撤、夏普比率数据源yfinance / akshare运行环境Python 3.9CPU 即可不需要 GPU依赖库pandas、numpy、yfinance、akshare、matplotlib批量能力支持同时对多个标的做循环回测接口方式数据源 API回测逻辑封装成函数后可直接复用适合场景历史策略验证、资产配置对比、技术分析实践风险提示回测不等于预测不构成投资建议从这张表可以看出本文关注的是“策略复盘”和“资产再平衡”不是高频交易也不是量化选股。重点是把历史行情拉到本地方便分析。2. 适用场景与使用边界这类回测框架适合做几件事验证某个历史买入时点是否值得跟进对比不同板块在同一持有期内的表现评估卖出或再平衡的时机以及给资产配置提供数据参考。如果用户之前凭感觉买入过股票或基金把它作为复盘工具很合适。但它有明确的使用边界。第一回测只能覆盖已经被数据验证的历史区间无法预判未来第二如果只选一个“看起来很好”的起始日很容易得到过度拟合的结论第三数据源本身有停牌、退市、拆分、汇率等问题会导致计算结果偏差第四AI 主题资产通常弹性大上涨时收益高下跌时回撤也大不能只看收益不看回撤。在实际操作中更稳妥的做法是拉取多个年份、多个买入日形成一组敏感性分析而不是只盯住一个点位的收益。后面第 9 部分会具体展开。3. 回测环境准备先准备 Python 环境。建议使用 Python 3.9 或更高版本通过 pip 安装依赖即可。不要使用太高版本的库组合避免 pandas 和 numpy 之间出现兼容性问题。python -m pip install pandas numpy matplotlib yfinance akshare如果网络环境访问 PyPI 不稳定可以切换国内镜像源python -m pip install pandas numpy matplotlib yfinance akshare -i https://pypi.tuna.tsinghua.edu.cn/simple安装完成后可以先做一次最小验证确认 pandas 和 yfinance 能正常导入python -c import pandas, numpy, yfinance, akshare; print(ok)数据源方面yfinance 是海外数据源访问可能受网络影响akshare 更适合拉取国内市场数据。如果本地访问 yfinance 一直失败可以切换到 akshare 或手动下载 CSV 文件后读取。下面的代码以 yfinance 为主akshare 作为备选方案。4. 回测框架与核心代码回测框架的核心是把“买入—持有—计算指标”这个过程标准化。整体逻辑如下指定标的代码、买入日期、持有期长度。下载日线收盘价数据。从买入日切出持有期内的价格序列。计算累计收益、年化收益、最大回撤、夏普比率。对多个标的循环执行汇总成一张对比表。先写一个数据加载函数import pandas as pd import numpy as np import yfinance as yf def load_asset_data(ticker, start_date, end_date): 下载日线收盘价并做基本清洗。 raw yf.download(ticker, startstart_date, endend_date, auto_adjustTrue, progressFalse) if raw is None or raw.empty: return None close raw[Close].copy() close close.dropna() close.index pd.to_datetime(close.index) return close再写一个持有期指标计算函数。持有期长度默认用 252 个交易日近似一年。年化收益按 252 个交易日计算。def holding_period_metrics(prices, holdings252): 计算给定价格序列在持有期内的评价指标。 if prices is None or len(prices) 2: return None start_price prices.iloc[0] end_price prices.iloc[-1] total_return end_price / start_price - 1.0 periods len(prices) # 年化收益 ann_return (1 total_return) ** (252 / periods) - 1.0 # 日收益率 daily_ret prices.pct_change().dropna() if daily_ret.std() 0 or np.isnan(daily_ret.std()): sharpe np.nan else: sharpe np.sqrt(252) * daily_ret.mean() / daily_ret.std() # 动态回撤 rolling_max prices.cummax() drawdown prices / rolling_max - 1.0 max_drawdown drawdown.min() return { period_days: periods, total_return: total_return, annual_return: ann_return, sharpe: sharpe, max_drawdown: max_drawdown, }第三步写一个批量回测函数用于对多个标的做循环def batch_backtest(tickers, buy_date, holdings252, start_buffer10, end_buffer10): start_date (pd.Timestamp(buy_date) - pd.Timedelta(daysstart_buffer)).strftime(%Y-%m-%d) end_date (pd.Timestamp(buy_date) pd.Timedelta(daysholdings end_buffer)).strftime(%Y-%m-%d) rows [] for name, ticker in tickers.items(): prices load_asset_data(ticker, start_date, end_date) if prices is None: print(f[warn] {name} 数据为空) continue # 从买入日附近开始截取确保第一个交易日接近 buy_date buy_idx prices.index.searchsorted(pd.Timestamp(buy_date)) selected prices.iloc[buy_idx:buy_idx holdings] if len(selected) 2: print(f[warn] {name} 数据不足) continue metrics holding_period_metrics(selected, holdingsholdings) if metrics is None: continue metrics[name] name metrics[ticker] ticker metrics[buy_date] buy_date rows.append(metrics) return pd.DataFrame(rows)这段代码把重复动作封装起来后续加新标的、换买入日期都比较方便。需要说明的是searchsorted会找到第一个晚于买入日的交易日这样做不会因为买入日当天休市而报错。5. 抄底策略年度跟随测试为了演示这套框架先选一个示例买入日。严格来说真实世界的“抄底”很难精确到某一天所以本文使用“示例买入日”做演示不代表任何推荐建议。这里选 2020-03-23 作为第一个测试点。原因很简单这是疫情冲击后美股出现显著低点的时间窗口附近。同时再选 2022-10-12 作为第二个测试点用它观察另一轮下跌后的买入结果。先定义标的组合。这里使用真实存在的 ETF 代码包含宽基、科技、AI 主题和成长型创新主题tickers { 标普500: SPY, 纳斯达克100: QQQ, 全球AI与机器人ETF: BOTZ, 创新科技ETF: ARKK, }执行回测buy_date_1 2020-03-23 result_1 batch_backtest(tickers, buy_datebuy_date_1, holdings252) print(result_1.sort_values(total_return, ascendingFalse).to_string(indexFalse))输出会是一张表格包含每个标的的持有天数、累计收益、年化收益、夏普比率和最大回撤。判断回测是否成功并不是看收益是不是为正而是看流程是否完整每个标的都能正常下载数据。持有期长度大于 2。指标计算没有 NaN。不同标的之间可以横向比较。接着测试第二组买入日buy_date_2 2022-10-12 result_2 batch_backtest(tickers, buy_datebuy_date_2, holdings252) print(result_2.sort_values(total_return, ascendingFalse).to_string(indexFalse))把两组结果放在一起观察通常能看到几个现象第一不同买入日下同样是“持有一年”收益会差很多说明择时对结果影响很大第二AI 主题 ETF 和成长型 ETF 的波动明显大于宽基最大回撤也更深第三某一组买入日里科技板块表现好不代表另一个买入日也表现好。所以这里建议再做一次“起始日敏感性测试”把买入日从每月月初依次往后推分别计算一年收益观察收益是否稳定def sensitivity_test(ticker, months, holdings252): dates pd.date_range(2020-01-01, 2023-12-01, freqMS) rows [] for d in dates: prices load_asset_data(ticker, d.strftime(%Y-%m-%d), (d pd.Timedelta(days500)).strftime(%Y-%m-%d)) if prices is None: continue idx prices.index.searchsorted(d) selected prices.iloc[idx:idx holdings] if len(selected) 2: continue m holding_period_metrics(selected, holdings) m[buy_date] d.strftime(%Y-%m-%d) rows.append(m) return pd.DataFrame(rows) sens sensitivity_test(QQQ, months48, holdings252) print(sens[[buy_date, total_return, max_drawdown]].head(10))这种敏感性分析比单点回测更有说服力。它能告诉你如果一个策略只在某一个时间点很赚钱换个买入日就不行那它大概率不具备稳定性。6. AI 资产再配置换仓还是加仓做完回测之后很多人会面对一个实际问题手里的资产是宽基指数或传统行业现在 AI 主题涨得明显要不要把一部分仓位切过去这个问题不能只看收益。先看相关性再看回撤特征。如果原有资产和 AI 主题资产相关性很高再配置并不会分散风险如果相关性低但回撤更大那就要判断能否承受波动。一个最简单的再平衡思路是设定目标权重定期调仓。例如原本宽基和 AI 主题的比例是 80:20想让 AI 主题提升到 40:60可以按照目标权重卖出宽基、买入 AI 主题。这里给出一个简化例子用来计算当前仓位与目标仓位的差额def rebalance_plan(current_values, target_weights, prices): current_values: 当前各资产市值例如 {SPY: 80000, BOTZ: 20000} target_weights: 目标权重例如 {SPY: 0.6, BOTZ: 0.4} total_value sum(current_values.values()) plan {} for asset in target_weights: target_value total_value * target_weights[asset] diff target_value - current_values.get(asset, 0) action 买入 if diff 0 else 卖出 plan[asset] { action: action, amount: abs(diff), target_value: target_value, } return plan plan rebalance_plan( current_values{SPY: 80000, BOTZ: 20000}, target_weights{SPY: 0.6, BOTZ: 0.4}, prices{} ) print(plan)因为这个示例中没有真正的价格数据所以代码里没有把“卖出宽基得到的金额”自动转换成“买入AI主题的份额”。实际使用时需要先用第 4 部分的回测数据确定两个标的的历史波动关系再决定调仓比例。这里要特别注意AI 主题资产在上涨行情里弹性大但下跌时同样剧烈。如果决定加仓建议分批操作而不是一次性打满仓位。也不要因为短期回测收益高就满仓进入历史中的高收益区间往往对应高波动区间。7. 运行开销与批量测试观察很多技术类文章默认本地运行需要高配显卡但这个回测任务并不需要。它的资源消耗主要集中在三个方面数据下载、内存占用、批量循环耗时。数据下载阶段yfinance 对单个标的拉取两三年日线数据通常只需要几秒到十几秒。如果同时下载几十个标的可能因为请求频率过高被限流。解决办法是加延时或者把数据缓存到本地 CSVimport os import time def load_asset_with_cache(ticker, start_date, end_date, cache_dir./data_cache): os.makedirs(cache_dir, exist_okTrue) cache_file os.path.join(cache_dir, f{ticker}.csv) if os.path.exists(cache_file): df pd.read_csv(cache_file, index_col0, parse_datesTrue) else: df load_asset_data(ticker, start_date, end_date) if df is not None: df.to_csv(cache_file) time.sleep(0.5) return df加了缓存之后第二次运行同样标的时不再需要重复下载数据。批量回测时建议跑一个脚本统一输出而不是手动换个标的就重新执行一次。内存方面日线数据量很小几百个标的也不会占太多内存。观察运行开销时可以重点关注单次下载耗时。批量循环总耗时。是否发生数据源限流。DataFrame 中间结果是否频繁复制。如果需要做更长时间跨度的大规模优化比如几千组参数网格搜索可以考虑用多进程。但当前场景没有必要保持简单更有价值。8. 常见问题与排查方法问题现象可能原因排查方式解决方案yfinance 下载失败网络受限、数据源限流查看报错信息单独测试单个标的增加 sleep 延时或切换到 akshare返回的数据为空标的代码错误、退市、停牌打印下载结果核对代码使用真实存在的 ETF 代码日期索引不齐不同市场休市日不同数据从不同日期开始打印两个序列的索引差异使用pd.merge(..., howinner)对齐夏普比率为 NaN收益率序列为空或标准差为 0检查数据长度和波动率增加持有期清理全零收益序列一年收益过高命中单边上涨行情更换多个买入日做敏感性测试不要只信单一日期结果最大回撤计算异常价格序列没有按时间升序排列检查索引是否时间序列对 index 排序后重算批量执行被限流请求频率太高观察是否出现 HTTP 429增加延时启用本地缓存结果不稳定只用单一参数组合对比多组参数做敏感性分析关注区间而非单点最常见的问题是数据源访问失败。遇到这种情况先不要改代码逻辑用下面的代码单独验证数据源是否可用python -c import yfinance as yf; print(yf.download(SPY, period5d).tail())如果单个标的能下载说明问题出在批量频率或代码逻辑如果单个标的也失败就要考虑换数据源。9. 回测与配置的最佳实践回测不是跑出一个正收益数字就结束。更稳妥的做法是保留一套可复现的脚本把买入日、标的、持有期、手续费、滑点都写成配置参数。数据文件、输出结果、脚本目录分开管理避免下次想复现时找不到当时的参数。实际操作时建议按下面的顺序执行第一次先跑最小数据集只测一个标的、一个买入日确认代码链路没问题然后扩展成多个标的、多个买入日最后再做敏感性测试。这样能快速定位问题避免在完整批量跑的时候才暴露 bug。做资产再配置时要记住几点不要只看累计收益要看最大回撤和夏普比率。不要把回测收益当作未来收益更不要因为历史某一年涨得多就满仓某个主题。AI 主题资产波动大适合用分批建仓、定期再平衡的方式参与。调仓前先检查相关性和回撤特征不要盲目从宽基切到高波动资产。所有回测结果都要保留原始数据来源和运行参数方便复核。合规方面文章和代码里不应使用“必涨”“稳赚”“保底”等表述。涉及个股、ETF、指数代码的展示仅作为技术演示不构成投资建议。如果是给他人提供配置建议需要进一步说明历史回测的局限性和市场风险。10. 总结与下一步这套回测框架最值得试的点是把“疫情后抄底买入”这种模糊的记忆变成可验证的收益和回撤数字。最先应该做的不是换仓而是把自己关注的那几个标的以不同买入日跑一遍敏感性测试看看一年持有收益是否稳定。最容易踩的坑是只用一个买入日得出结论以及把 AI 主题资产的高收益当成确定性机会忽略了更高回撤。后续可以让框架继续扩展加入手续费和滑点、支持多种调仓频率、增加技术指标作为买入条件或者把数据源替换成自己维护的行情数据库。先跑通最小闭环再逐步增加复杂度这是最稳的路子。