你的策略回测很赚钱实盘为什么亏7 个陷阱逐个拆解回测年化 30%、夏普 2.5、最大回撤 8%。你兴奋地开了实盘三个月后一看——亏了 12%。这不是段子。这是量化新手最普遍的经历。回测和实盘之间的鸿沟不是因为市场变了而是因为回测里有大量你没意识到的作弊。这些作弊不是故意的而是写代码时的默认假设太理想了。这篇文章用 AlphaFeed 的数据把 7 个最常见的陷阱逐个拆解每个都有代码演示——让你看到修正前和修正后的收益差距有多大。陷阱 1用收盘价成交你买不到收盘价这是最常见的回测作弊。你的代码大概率长这样# 用收盘价计算信号又用同一天的收盘价假设成交df[signal](df[ma20]df[ma60]).astype(int)df[position]df[signal]# 今天信号今天就用df[ret]df[close].pct_change()df[strategy_ret]df[position]*df[ret]问题你在用今天的收盘价做判断然后假设自己能以今天的收盘价买入。但实际上收盘价是 15:00 的最后一笔成交——你看到这个价格的时候已经买不到了。修正信号延迟一天。df[position]df[signal].shift(1).fillna(0)# 今天的信号明天才执行差距有多大用 AlphaFeed 拉数据跑一下importpandasaspdimportnumpyasnpfromalphafeedimportAlphaFeed afAlphaFeed()dfaf.klines.get(600519.SH,period1d,count1000,adjustforward,to_dataframeTrue)dfdf.sort_values(trade_date).reset_index(dropTrue)df[ma20]df[close].rolling(20).mean()df[ma60]df[close].rolling(60).mean()df[signal](df[ma20]df[ma60]).astype(int)df[ret]df[close].pct_change().fillna(0)# 作弊版当天信号当天用df[cheat_ret]df[signal]*df[ret]cheat_equity(1df[cheat_ret]).cumprod()# 正确版信号延迟一天df[position]df[signal].shift(1).fillna(0)df[honest_ret]df[position]*df[ret]honest_equity(1df[honest_ret]).cumprod()print(f作弊版累计收益:{cheat_equity.iloc[-1]-1:.2%})print(f正确版累计收益:{honest_equity.iloc[-1]-1:.2%})print(f差距:{(cheat_equity.iloc[-1]-honest_equity.iloc[-1])/honest_equity.iloc[-1]*100:.1f}%)光这一个修正收益可能就缩水 20%–40%。陷阱 2忽略交易成本每次交易都在亏钱A 股每次交易的真实成本费用项比例说明券商佣金~万 2.5双边买卖各收一次印花税千 1单边只有卖出时收合计~千 1.5 / 次买入卖出一轮看着不多如果你的策略一年交易 100 次成本就是 15%。importpandasaspdimportnumpyasnp# 接上面的代码cost_per_trade0.0015# 单边成本df[trade]df[position].diff().abs()# 1 发生交易df[cost]df[trade]*cost_per_trade df[net_ret]df[honest_ret]-df[cost]net_equity(1df[net_ret]).cumprod()total_tradesdf[trade].sum()total_costdf[cost].sum()print(f总交易次数:{total_trades:.0f})print(f总交易成本:{total_cost:.2%})print(f扣成本前收益:{honest_equity.iloc[-1]-1:.2%})print(f扣成本后收益:{net_equity.iloc[-1]-1:.2%})交易频率越高成本侵蚀越严重。日线级别的均线策略还好如果你做分钟线策略一天交易 10 次光成本就能让策略从盈利变亏损。陷阱 3滑点你想买 100 元实际成交 100.5 元回测默认以你想要的价格成交。但实际上你挂 100 元买入可能成交在 100.2 元你挂 50 元卖出可能成交在 49.8 元股票越不活跃滑点越大用盘口数据估算真实滑点fromalphafeedimportAlphaFeed afAlphaFeed()symbols[600519.SH,000001.SZ,300750.SZ,601318.SH]forsyminsymbols:depthaf.depth.get(sym)bid1depth.bid_prices[0]ask1depth.ask_prices[0]mid(bid1ask1)/2spread_bps(ask1-bid1)/mid*10000print(f{sym}: 买一{bid1:.2f}卖一{ask1:.2f}价差{spread_bps:.1f}bps)大盘蓝筹的价差可能只有 2–5 bps万分之二到五但小盘股可能有 20–50 bps。如果你的策略选出的全是小盘股滑点会严重侵蚀收益。回测中加入滑点slippage0.001# 假设单边滑点 0.1%df[net_ret_with_slippage]df[honest_ret]-df[trade]*(cost_per_tradeslippage)slippage_equity(1df[net_ret_with_slippage]).cumprod()print(f加滑点后收益:{slippage_equity.iloc[-1]-1:.2%})陷阱 4幸存者偏差你回测的股票是活下来的你用今天的沪深 300 成分股做历史回测——但 5 年前的成分股和今天不一样。那些被踢出指数的烂股票在你的回测里根本没出现过。更极端的例子你从不会选到已经退市的股票来回测因为它们在你的数据里已经不存在了。但在那个时间点它们是可以被选中的。这个问题没有完美的解决方案但可以缓解用更大的股票池做回测不要只测成分股在回测期开始时的全市场股票池中选股而不是用今天的列表回溯fromalphafeedimportAlphaFeed afAlphaFeed()# 用全市场池做选股而不是预设的成分股列表all_cnaf.quotes.get(universesCN_Stock,to_dataframeTrue)print(f当前全市场 A 股数量:{len(all_cn)})# 在这个基础上按条件筛选而不是从一个预定义的好股票列表开始陷阱 5涨停买不到信号触发了但你进不去A 股涨停后无法买入。你的回测可能会出现这种情况策略发出买入信号的那天股票刚好涨停——回测里你完美买入了但实际上你一手都买不到。importpandasaspdfromalphafeedimportAlphaFeed afAlphaFeed()dfaf.klines.get(002594.SZ,period1d,count1000,adjustnone,to_dataframeTrue)dfdf.sort_values(trade_date).reset_index(dropTrue)df[ret]df[close].pct_change()# 检查有多少天是涨停无法买入limit_up_days(df[ret]0.095).sum()print(f涨停天数:{limit_up_days})print(f占比:{limit_up_days/len(df):.1%})# 回测修正涨停日的买入信号应该被跳过df[can_buy]df[ret]0.095# 涨停日不能买df[position_corrected]df[position]*df[can_buy].shift(1).fillna(True).astype(int)同理跌停日你也卖不出去——回测里你可以止损卖出但实盘中跌停意味着挂单也卖不掉。陷阱 6过拟合参数是调出来的不是发现的你把均线参数从 (20, 60) 调到 (17, 43)收益从 15% 涨到 28%。你以为自己发现了更好的参数实际上你只是在历史数据上做了过拟合。检验过拟合的方法样本外测试。importpandasaspdimportnumpyasnpfromalphafeedimportAlphaFeed afAlphaFeed()dfaf.klines.get(600519.SH,period1d,count2000,adjustforward,to_dataframeTrue)dfdf.sort_values(trade_date).reset_index(dropTrue)df[ret]df[close].pct_change().fillna(0)# 分成训练集和测试集splitint(len(df)*0.6)traindf.iloc[:split].copy()testdf.iloc[split:].copy()deftest_strategy(data,short_w,long_w):datadata.copy()data[ma_s]data[close].rolling(short_w).mean()data[ma_l]data[close].rolling(long_w).mean()data[signal](data[ma_s]data[ma_l]).astype(int)data[position]data[signal].shift(1).fillna(0)data[strat_ret]data[position]*data[ret]equity(1data[strat_ret]).cumprod()returnequity.iloc[-1]-1# 在训练集上扫描参数best_ret-999best_params(0,0)results[]forsinrange(5,31,5):forlinrange(30,81,10):ifsl:continuerettest_strategy(train,s,l)results.append({short:s,long:l,train_return:ret})ifretbest_ret:best_retret best_params(s,l)print(f训练集最优参数: MA{best_params[0]}/{best_params[1]}, 收益:{best_ret:.2%})# 用最优参数在测试集上验证test_rettest_strategy(test,best_params[0],best_params[1])print(f测试集表现: 收益:{test_ret:.2%})iftest_retbest_ret*0.5:print(⚠️ 测试集表现远不如训练集大概率过拟合)eliftest_ret0:print( 测试集亏钱策略不可靠)else:print(✅ 测试集表现合理参数可能有一定泛化能力)如果训练集上年化 30% 但测试集上亏钱你的好策略只是数据拟合。陷阱 7回测用前复权但前复权是会变的前复权价格会随着每次分红送转而重新计算。这意味着你今天拉到的前复权数据和三个月前拉到的不一样——历史价格被修改了。fromalphafeedimportAlphaFeed afAlphaFeed()# 同一只票前复权 vs 不复权df_fwdaf.klines.get(600519.SH,period1d,count500,adjustforward,to_dataframeTrue)df_noneaf.klines.get(600519.SH,period1d,count500,adjustnone,to_dataframeTrue)df_fwddf_fwd.sort_values(trade_date).reset_index(dropTrue)df_nonedf_none.sort_values(trade_date).reset_index(dropTrue)# 对比第一天的价格print(f前复权第一天收盘价:{df_fwd[close].iloc[0]:.2f})print(f不复权第一天收盘价:{df_none[close].iloc[0]:.2f})print(f差异:{df_fwd[close].iloc[0]/df_none[close].iloc[0]-1:.2%})实盘影响如果你的策略在某个价位设了止损但下次分红后前复权价格变了那个止损位也变了。你回测时看到的在 1500 元止损可能实盘中实际上是在 1480 元触发的。建议回测时统一用一种复权方式并在研究记录里注明。如果对绝对价格敏感比如止损止盈用不复权数据更真实。完整的去作弊回测模板把所有修正加在一起importpandasaspdimportnumpyasnpfromalphafeedimportAlphaFeed afAlphaFeed()dfaf.klines.get(600519.SH,period1d,count1000,adjustforward,to_dataframeTrue)dfdf.sort_values(trade_date).reset_index(dropTrue)# 策略信号df[ma20]df[close].rolling(20).mean()df[ma60]df[close].rolling(60).mean()df[signal](df[ma20]df[ma60]).astype(int)# 修正1信号延迟一天df[position]df[signal].shift(1).fillna(0)# 修正2涨停日不能买入用不复权检测df_rawaf.klines.get(600519.SH,period1d,count1000,adjustnone,to_dataframeTrue)df_rawdf_raw.sort_values(trade_date).reset_index(dropTrue)df_raw[raw_ret]df_raw[close].pct_change()df[can_buy](df_raw[raw_ret]0.095).astype(int)# 如果昨天的信号是买入但今天涨停了则无法执行buy_signal(df[position]df[position].shift(1).fillna(0))df.loc[buy_signal(df[can_buy]0),position]0# 收益计算df[ret]df[close].pct_change().fillna(0)df[trade]df[position].diff().abs().fillna(0)# 修正3交易成本cost_per_trade0.0015# 修正4滑点slippage0.001df[strategy_ret]df[position]*df[ret]-df[trade]*(cost_per_tradeslippage)df[equity](1df[strategy_ret]).cumprod()df[buyhold](1df[ret]).cumprod()# 结果total_retdf[equity].iloc[-1]-1bh_retdf[buyhold].iloc[-1]-1peakdf[equity].cummax()max_dd(df[equity]/peak-1).min()sharpedf[strategy_ret].mean()/df[strategy_ret].std()*np.sqrt(252)ifdf[strategy_ret].std()0else0print(f 去作弊后的真实回测结果 )print(f策略收益:{total_ret:.2%})print(f买入持有:{bh_ret:.2%})print(f最大回撤:{max_dd:.2%})print(f夏普比率:{sharpe:.2f})print(f总交易成本:{(df[trade]*(cost_per_tradeslippage)).sum():.2%})七个陷阱的影响排名陷阱对收益的影响修正难度未来函数用收盘价成交⭐⭐⭐⭐⭐ 最大简单shift 一行过拟合⭐⭐⭐⭐⭐ 最大中等需要样本外测试忽略交易成本⭐⭐⭐⭐ 很大简单加一个扣减忽略滑点⭐⭐⭐ 中等简单用盘口估算涨停买不到⭐⭐ 看策略中等幸存者偏差⭐⭐ 看股票池较难复权数据变化⭐ 较小注意即可结语回测赚钱不代表策略好回测亏钱也不代表思路错。关键是你的回测有多真实。每去掉一个作弊回测收益都会缩水。这让人沮丧但也在帮你一个扣掉所有成本、修正所有偏差之后仍然盈利的策略才值得你投入真金白银。AlphaFeed 提供的数据在这里的作用是让你可以同时拉前复权和不复权数据、用盘口数据估算滑点、用全市场行情避免幸存者偏差。数据维度越丰富你的回测就越接近真实。相关链接AlphaFeed 官网https://alphafeed.org/Python SDK 快速开始https://docs.alphafeed.org/zh-Hans/sdk/python-quickstart