1. 为什么我要自己搭一套回测系统最早动了自建回测系统这个念头是因为用现成平台跑策略时总有一种“隔着毛玻璃看东西”的感觉。你提交一段逻辑平台返回一条资金曲线中间到底怎么成交的、滑点怎么算的、停牌那天持仓怎么处理的一概不告诉你。策略赚钱了不知道赚在哪亏钱了也找不到原因这种状态做久了心里发虚。后来我干脆花了两周时间从零搭了一套属于自己的股票策略回测系统数据自己拉、撮合自己写、绩效自己算整条链路全部透明。跑通之后最大的感受是回测这件事难的从来不是策略本身而是数据、撮合、绩效这三块地基。这套系统能做什么简单说你给它一个选股逻辑和一个买卖规则它帮你算出这段时间里如果真按这个规则操作账户会变成什么样。适合谁参考适合已经会写一点 Python、对 pandas 不陌生、想认真做量化但不想被黑盒平台绑架的人。如果你连 DataFrame 的 merge 都用不利索建议先把数据处理基础打一打再回来不然下面很多坑你会踩得莫名其妙。我把它拆成四层数据层、策略层、撮合层、绩效层。这四层之间的接口设计比每一层内部怎么写更重要。下面我按搭建顺序把每一层的设计思路、关键代码、踩过的坑全部摊开讲。2. 整体架构设计与技术选型思路2.1 四层架构的职责边界先讲清楚每一层干什么不然后面代码会写成一锅粥。数据层负责把原始行情整理成回测能直接吃的格式核心是一张按日期和股票代码索引的宽表或者长表。策略层只做一件事给定截止到某一天的历史数据输出今天该买哪些、该卖哪些。撮合层拿到策略的意图结合当天的价格、成交量、涨跌停状态决定这笔单子能不能成交、以什么价格成交。绩效层拿撮合成交后的每日持仓和资金算出收益率、回撤、夏普这些指标。为什么要把撮合单独抽一层因为很多人图省事直接在策略里用收盘价买卖回测结果漂亮得不像话实盘一上就崩。把撮合独立出来你才能强制自己面对滑点、手续费、涨跌停买不进这些现实问题。2.2 为什么不用现成框架而选择手写backtrader、vnpy 这些框架我都用过功能确实全但有两个问题让我最终选择手写。第一是调试成本框架内部封装太深一旦结果和预期不符你得一层层扒源码时间全耗在理解框架上而不是策略上。第二是灵活性我想加一个“财报发布后三天内不交易”这种自定义规则在框架里得改源码或者写一堆 hook自己写就是加个 if。手写不代表什么都从零造。pandas 做数据处理、numpy 做数值计算、matplotlib 画图这些轮子直接用。我唯一坚持手写的是撮合逻辑和绩效计算因为这两块直接决定回测可信度必须每一行都看得懂。2.3 数据存储格式的选择数据存什么格式直接决定回测跑得快不快。我试过三种CSV、SQLite、HDF5。CSV 最直观但读取慢十万行数据读一次要好几秒SQLite 查询灵活但按日期切片效率一般最后我选了 parquet列式存储读某几列特别快压缩率也高。一份五年的日线数据全市场大概几百万行parquet 存下来不到 200MB读进内存两秒内搞定。提示如果你只做几十只股票的回测CSV 完全够用别为了用 parquet 而用 parquet。工具是服务于场景的不是反过来。3. 数据层从原始行情到回测可用的数据3.1 数据来源与获取方式数据来源我分两类。一类是免费接口比如一些财经网站提供的日线数据下载适合个人练手缺点是字段不统一、偶尔缺数据。另一类是付费数据源字段规范、有复权因子、有停牌标记做严肃回测建议用这类。不管哪类来源拿到手的第一件事是统一字段名。我定的标准是date、code、open、high、low、close、volume、amount、adj_factor。adj_factor 是复权因子这个字段极其重要后面会专门讲。获取数据我写了个简单的循环按股票代码逐个拉拉完存成 parquet。这里有个坑不要一次性拉全市场所有股票接口会限流甚至封你。我的做法是分批拉每批 50 只批之间 sleep 一秒稳得很。3.2 复权处理前复权还是后复权这是新手最容易搞错的地方。前复权是以最新价格为基准往前调整后复权是以上市首日为基准往后调整。回测必须用后复权原因是前复权的价格会随着新数据到来而变化你今天回测用的价格和明天回测用的价格不一样结果自然对不上。具体算法后复权价格 原始价格 × 复权因子。复权因子从数据源拿拿不到就自己算。自己算的逻辑是每次除权除息日用当天的前收盘价和除权参考价算出一个比例累乘起来就是复权因子。# 后复权价格计算示例 df[close_adj] df[close] * df[adj_factor] df[open_adj] df[open] * df[adj_factor] # high、low 同理我踩过的坑有一次忘了对 open 做复权只复权了 close结果策略用开盘价买入时价格是错的回测收益虚高了一大截。所以复权要复就全复OHLC 四个价格一个都不能漏。3.3 停牌与涨跌停标记停牌和涨跌停如果不处理回测会严重失真。停牌期间股票根本没法交易但如果你数据里还有价格策略可能就在停牌日发出买卖信号撮合层还傻乎乎地按停牌前的价格成交这就离谱了。我的处理方式是在数据层加两个布尔字段is_suspended 和 is_limit_up/is_limit_down。停牌判断简单当天成交量为零或者数据源直接标记。涨跌停判断稍微麻烦需要用前收盘价算出涨停价和跌停价再和当天最高最低价比较。注意ST 股票的涨跌停幅度是 5%普通股票是 10%科创板创业板是 20%这个规则要按标的类型区分。# 涨跌停判断以普通股票 10% 为例 df[pre_close] df[close_adj].shift(1) df[limit_up] round(df[pre_close] * 1.1, 2) df[limit_down] round(df[pre_close] * 0.9, 2) df[is_limit_up] df[close_adj] df[limit_up] df[is_limit_down] df[close_adj] df[limit_down]注意涨跌停价格要四舍五入到分不同交易所规则略有差异做精细回测时最好按交易所规则分别处理。3.4 数据对齐与缺失值处理多只股票回测时交易日历对齐是个大问题。有的股票停牌有的正常交易如果你直接用 pandas 的 DataFrame 拼接停牌那天会出现 NaN。NaN 怎么处理不能简单 fillna因为停牌日的价格本来就不该存在。我的做法是先构建全市场统一的交易日历然后把每只股票的数据 reindex 到这个日历上停牌日标记为 NaN 并同时把 is_suspended 设为 True。策略层看到 is_suspended 为 True 就跳过这只股票撮合层看到 NaN 就拒绝成交。这样逻辑就闭环了。4. 策略层信号生成与仓位管理4.1 策略接口设计策略层的接口我设计得极简输入是一个包含历史数据的字典输出是一个目标持仓字典。为什么用目标持仓而不是买卖信号因为目标持仓天然支持调仓你不需要关心今天买多少卖多少只需要告诉系统“我今天想持有这些股票各多少仓位”撮合层自己去算差额。class Strategy: def generate_target(self, date, data): date: 当前日期 data: 截止到 date 的历史数据 返回: {code: weight} 目标权重字典 raise NotImplementedError这个设计的好处是策略逻辑和交易执行彻底解耦。你想换一个撮合规则策略代码一行不用改。4.2 一个均线策略的完整实现拿最经典的双均线策略举例5 日均线上穿 20 日均线买入下穿卖出。用目标持仓接口写出来是这样class MAStrategy(Strategy): def __init__(self, short5, long20, top_n10): self.short short self.long long self.top_n top_n def generate_target(self, date, data): signals {} for code, df in data.items(): if len(df) self.long: continue ma_short df[close_adj].rolling(self.short).mean() ma_long df[close_adj].rolling(self.long).mean() if ma_short.iloc[-1] ma_long.iloc[-1] and ma_short.iloc[-2] ma_long.iloc[-2]: signals[code] ma_short.iloc[-1] / ma_long.iloc[-1] # 用均线比值做强度 # 按强度排序取前 top_n selected sorted(signals.items(), keylambda x: x[1], reverseTrue)[:self.top_n] if not selected: return {} weight 1.0 / len(selected) return {code: weight for code, _ in selected}这里用均线比值做排序是个小技巧比单纯按涨幅排序更稳定因为它衡量的是趋势强度而不是短期爆发力。4.3 仓位管理与风险控制满仓单票是回测大忌一个黑天鹅就能让你回撤 50%。我的仓位管理有三条硬规则单票不超过 20%行业不超过 40%总仓位根据市场状态动态调整。市场状态怎么判断我用全市场股票在 20 日均线之上的比例作为温度计比例高于 60% 满仓40% 到 60% 半仓低于 40% 空仓。这套规则写进策略层还是独立出来我建议独立成一个 RiskManager 类策略只负责选股风控负责砍仓位。这样你想调整风控规则时不会动到选股逻辑。class RiskManager: def adjust(self, target, market_temp): if market_temp 0.4: return {} if market_temp 0.6: return {k: v * 0.5 for k, v in target.items()} # 单票上限 return {k: min(v, 0.2) for k, v in target.items()}4.4 避免未来函数未来函数是回测里最隐蔽的杀手。什么叫未来函数就是你在今天用了明天才知道的信息。比如你用当天的收盘价决定买入然后又用当天的收盘价成交这就是未来函数因为现实中你不可能在收盘的同一瞬间既知道收盘价又完成交易。我的规避方法是策略在 T 日收盘后生成信号撮合在 T1 日开盘执行。这样信号和执行之间隔了一个晚上完全符合现实。代码上体现为策略层拿到的数据截止到 T 日撮合层用 T1 日的开盘价。提示如果你非要用收盘价成交那信号必须基于 T-1 日及之前的数据生成T 日收盘执行。两种方式选一种千万别混。5. 撮合层让回测贴近真实交易5.1 撮合引擎的核心逻辑撮合层每天做四件事第一拿到策略的目标持仓和当前持仓算出每只股票需要买卖的数量第二检查每笔交易是否可执行停牌、涨跌停、资金是否够第三按规则确定成交价格第四更新持仓和资金。顺序很重要。先算差额再检查可执行性最后才成交。我见过有人先成交再检查资金结果回测里出现了负现金这种 bug 不仔细看根本发现不了。def match(self, date, target, positions, cash, data): # 第一步算目标股数和当前股数的差额 total_value cash sum(pos[shares] * data[code][open_adj] for code, pos in positions.items()) orders {} for code, weight in target.items(): target_value total_value * weight target_shares int(target_value / data[code][open_adj] / 100) * 100 current_shares positions.get(code, {}).get(shares, 0) orders[code] target_shares - current_shares # 第二步检查并执行 for code, delta in orders.items(): if delta 0: continue row data[code] if row[is_suspended]: continue if delta 0 and row[is_limit_up]: continue # 涨停买不进 if delta 0 and row[is_limit_down]: continue # 跌停卖不出 # 第三步按开盘价加滑点成交 price row[open_adj] * (1 self.slippage if delta 0 else 1 - self.slippage) # 第四步更新 ...5.2 滑点与手续费的计算滑点我设的是双边千分之一手续费万三加印花税千一卖出时收。这两个参数看着小长期回测下来影响巨大。我做过对比一个年化 20% 的策略加上千一滑点和万三手续费后年化掉到 15% 左右。所以别嫌麻烦该扣的一定要扣。滑点的建模方式有两种固定比例和固定金额。固定比例适合流动性好的大盘股固定金额适合小盘股。我一般用固定比例简单够用。如果你做高频或者小盘股策略建议用更精细的冲击成本模型。5.3 涨跌停与停牌的成交处理涨停买不进、跌停卖不出这个规则必须硬编码进撮合层。但有个细节如果涨停打开了盘中打开其实是可以成交的。日线数据看不到盘中情况所以我的处理是保守起见涨停一律不买、跌停一律不卖。宁可少赚不可失真。停牌的处理更简单直接跳过。但要注意停牌复牌后的情况有的股票停牌几个月复牌后连续涨停你的策略如果在停牌前发出买入信号复牌后根本买不进。这种极端情况回测里要能体现出来否则实盘会措手不及。5.4 资金管理与持仓更新资金管理我分两块可用资金和冻结资金。买入时冻结资金成交后扣减卖出时增加可用资金。为什么要分冻结因为 T1 制度下今天卖出的股票明天才能取钱今天买入的股票明天才能卖。这个规则不模拟回测和实盘会差很多。持仓更新要记录每只股票的买入成本、持有天数、当前市值。持有天数用于计算年化收益买入成本用于计算个股盈亏。这些字段在绩效层都要用到所以在撮合层就要维护好。6. 绩效层从资金曲线到归因分析6.1 核心绩效指标计算绩效层拿到的输入是每日的总资产序列。基于这个序列我算这些指标累计收益率、年化收益率、最大回撤、夏普比率、胜率、盈亏比。年化收益率的计算要注意不是简单地把总收益除以年数。正确算法是 (1 总收益) ^ (252 / 交易天数) - 1。夏普比率用日收益率算年化时乘以根号 252。最大回撤用累计最大值减去当前值再除以累计最大值取最大那个。def max_drawdown(equity): cummax equity.cummax() drawdown (cummax - equity) / cummax return drawdown.max() def sharpe(returns, rf0.03): excess returns - rf / 252 return excess.mean() / excess.std() * np.sqrt(252)6.2 收益归因钱到底从哪赚的光看总收益不够得知道钱从哪来。我做了三个维度的归因按个股、按行业、按持有天数。按个股归因看哪些票贡献最大按行业归因看是不是押中了某个板块按持有天数归因看是短线还是长线赚钱。归因的实现方式是记录每笔交易的完整生命周期买入日期、卖出日期、买入价、卖出价、盈亏金额。然后按不同维度 groupby 求和。这个表做出来策略的优缺点一目了然。归因维度分析目的关键字段个股找出核心盈利标的code, pnl行业判断板块集中度industry, pnl持有天数区分短线长线贡献hold_days, pnl月份看策略季节性month, pnl6.3 回测报告的可视化报告我固定画四张图资金曲线、回撤曲线、月度收益热力图、个股盈亏分布。资金曲线叠加基准指数一眼看出超额收益。回撤曲线看风险控制。月度热力图看策略稳定性。个股盈亏分布看是少数票撑起收益还是普遍盈利。画图用 matplotlib 就够了别上 plotly回测报告不需要交互静态图更清爽。中文字体记得设置不然标题全是方框。plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False6.4 基准对比与超额收益基准我一般用沪深 300 或者中证 500看策略风格。超额收益的计算有两种减法策略收益减基准收益和除法策略收益除以基准收益。我习惯用减法更直观。但要注意基准本身也有回撤如果策略在基准大跌时跌得少即使绝对收益为负超额收益也是正的。所以看超额收益要结合绝对收益一起看不能只看一个。7. 常见问题与排查技巧实录7.1 回测结果好得不真实怎么办先查三件事有没有未来函数、有没有用复权价、有没有扣手续费。这三样占虚假高收益的九成原因。未来函数最常见的是用当天收盘价成交复权问题最常见的是只复权了 close 没复权 open手续费问题最常见的是压根没扣。排查方法把策略信号打印出来随机挑几天手动核对当天的数据是否真的能产生这个信号。再挑几笔交易手动算一遍成交价和手续费看和系统算的是否一致。手动核对虽然笨但最可靠。7.2 数据缺失导致的回测中断数据缺失有两种整只股票缺失和某几天缺失。整只缺失一般是代码写错了或者数据源没有这只票检查代码列表。某几天缺失一般是停牌这个正常按停牌处理即可。还有一种隐蔽的缺失某天数据存在但字段是空的。这种最坑因为不会报错但计算时会出 NaNNaN 一路传播下去最后资金曲线全是 NaN。我的做法是在数据层加一个校验函数每天检查 OHLCV 是否有空值有空值就报警。7.3 回测速度太慢的优化思路回测慢一般是两个原因数据读取慢和循环太慢。数据读取慢就换 parquet 格式前面讲过了。循环太慢就把能向量化的操作全部向量化比如计算均线用 rolling 而不是 for 循环。还有一个技巧是预计算。策略里用到的技术指标如果每天都要重算一遍很浪费。可以在数据层就把常用指标算好存起来策略层直接取。我预计算了 MA、RSI、MACD 这几个常用指标回测速度提升了三倍多。7.4 常见问题速查表问题现象可能原因排查方向收益异常高未来函数/未复权/未扣费检查信号生成日期和成交日期资金曲线有 NaN数据缺失检查 OHLCV 空值回测结果每次不一样数据顺序不稳定固定随机种子排序后再计算涨停还能买入涨跌停判断未生效检查涨跌停价格计算和标记回测速度极慢循环未向量化用 rolling/apply 替代 for持仓数量为负卖出未检查持仓卖出前检查 shares 卖出量提示每次改完代码先跑一个只有 10 只股票、1 年数据的小回测确认逻辑没问题再跑全量。全量回测动辄十几分钟用小样本快速验证能省大量时间。8. 我在这套系统上踩过的几个真实坑第一个坑是复权因子更新。有段时间我发现同一个策略跑出来的结果和上周不一样查了半天才发现是数据源的复权因子更新了。解决办法是把复权因子也存下来回测时用固定版本的因子不要每次重新拉。第二个坑是股票代码变更。有的股票会改代码比如重组后换了个代码如果你按代码关联数据改代码前后的数据就断了。解决办法是维护一张代码映射表把历史代码映射到当前代码。第三个坑是内存溢出。全市场十年的分钟级数据直接读进内存能把 16G 的机器撑爆。解决办法是分块读取回测时只加载需要的日期区间和股票池不要一次性全加载。第四个坑是绩效计算的除零错误。如果策略某段时间空仓日收益率序列里会出现 0算夏普比率时标准差为 0直接报错。解决办法是在算夏普前检查标准差是否为零为零就返回 0。这套系统我现在还在用每次改策略都会先在上面跑一遍。它不完美比如还没支持分钟级回测也没做多因子归因但核心链路是通的、可信的。自己搭系统的最大收获不是省了多少钱而是对每一个数字的来源都心里有数。你如果也在搭类似的系统建议先把撮合层写扎实这一层写好了上面策略怎么换都不慌。
