简介:基于ZipLine本地化改造的Python开源量化平台框架,专为A股市场设计,面向量化投资者、数据分析师及策略研究开发者。原版ZipLine主要服务美股,由于A股实行T+1交易、不同交易时段以及分红配股等特殊机制,该资源在源码层面对数据源接入、交易约束和事件处理做了针对性调整,支持用户直接导入历史行情,在Python环境中执行回测、模拟交易和策略可视化。资源包共366个文件,以225个Python脚本为核心,涉及回测引擎、策略模板、数据接口和图表绘制等模块;另有15份Excel数据表、多个配置文件及shell/bat辅助脚本,压缩包仅3.42MB,便于快速下载和源码剖析。目前已有4382人学习/下载,适合具备Python及pandas基础、希望将量化框架迁移到A股场景的开发者。值得入手的是,其中完整呈现了Zipline本地化改造的关键差异点,包含A股数据对接、交易费用与滑点设定、T+1买卖约束以及分红配股处理等实现细节,随附的表格和脚本可帮助读者快速验证策略逻辑,并为后续自主扩展提供可复用的框架参考。
1. Python 开源量化平台框架(支持A股)基于ZipLine修改:为什么这条路值得走
拿原版 Zipline 直接喂 A 股日线数据,回测通常能跑通,但跑出来的净值曲线十有八九是假的。原因是 Zipline 原生按美股规则设计:T+0、一股起买、没有涨跌停拦截、没有印花税。A 股换成 T+1、100 股一手、10%/20% 涨跌停之后,原版框架等于在一个「规则更宽松的市场」里替你撮合,很多实盘根本做不出来的交易,回测里全成交了。标题里「基于 ZipLine 修改」的价值就在这:Zipline 的事件驱动骨架、账户模型和 Pipeline 因子体系是成熟可用的,你不需要从零写回测引擎,只需要把数据层和交易规则层换掉。这篇笔记写给想在 Python 生态里搭一套支持 A 股的量化回测平台、又不打算自研引擎的从业者。
2. Zipline 的骨架与 A 股化改造边界:事件驱动、账户模型、可替换的四层接口
2.1 事件驱动内核:策略回调、调度函数与账本分离
Zipline 的核心不是一个「跑循环」的工具,而是一套事件驱动架构。策略代码只负责表达意图:initialize 里初始化状态,schedule_function 注册定期调仓任务,handle_data 处理每个交易周期的数据。真正干活的是一套内部循环:数据到达、事件分发、订单进入 Blotter、撮合引擎成交、Broker 更新资金与持仓、绩效模块记录净值。这套分工让「策略逻辑」和「交易规则」解耦,也正是它能被改造成 A 股框架的前提。
我一般会把 Zipline 的代码结构拆成三块看:第一块是 DataPortal,负责给策略提供行情、历史窗口和当前价格;第二块是 TradingAlgorithm 主循环,负责调度和执行策略回调;第三块是 Blotter 与 ExecutionHandler,负责订单管理和成交回报。A 股化改造时,第三块动得最多,第一块要动数据格式,第二块基本不用碰。理解了这个边界,你就不会去改那些不该改的部分,节省大量时间。
初次接触的人容易把精力花在读主循环源码上,这是低效路径。更快的办法是先跑通一个最简单的策略,然后沿着一条订单从提交到成交的链路去读代码:order 函数把订单放进 Blotter,Blotter 在每次 bar 到达时把订单交给执行模型,执行模型按成交量、限价、滑点模型计算出成交价和成交量,最后 Broker 更新持仓。这条链路读明白,后面改 T+1、改涨跌停、改一手 100 股时,你才知道该在哪个环节下手。
2.2 四条改造边界:数据层、规则层、执行层、绩效层
按我的经验,Zipline 的 A 股化改造可以严格分成四层,顺序不能乱。第一层是数据层:行情源换成 A 股日线或分钟线,交易日历换成上交所深交所日历,股票代码解析换成 6 位代码。第二层是规则层:T+1 持仓锁定、涨跌停价格计算、最小交易单位 100 股、ST/停牌过滤。第三层是执行层:一字板不成交、停牌不成交、成交量约束。第四层是绩效层:基准从标普变成沪深300或上证指数,费用模型加入佣金与印花税。
这四层有依赖关系。数据层没改对,后面三层改得再细也是白费——比如用前复权数据去算涨跌停价,算出来是错的。规则层没改对,执行层做的拦截就失去依据。所以我的建议是先做数据层,验证价格和日期没问题,再动规则层,最后做执行层和绩效层。每层做完跑一个已知结果的样本案例做验证,不要四层全部改完再统一测试,那样出了问题很难定位。
2.3 一个小例子跑通最简回测:从管线定义到订单输出
改造开始前,先确认你的 Zipline 环境能跑通一个最简策略。下面这段代码是改造后我在模拟项目X里用的骨架,依赖自定义的 A 股交易日历和 symbol 解析:
def initialize(context): # 自定义 symbol 解析:把 "000001.SZ" 映射成内部 Asset 对象 context.stock = symbol('000001.SZ') context.history_window = 60 def handle_data(context, data): # 每个交易日收盘后触发 if data.can_trade(context.stock): px = data.history(context.stock, 'close', context.history_window, '1d') ma = px.mean() if data.current(context.stock, 'close') > ma: order_target_percent(context.stock, 0.9) else: order_target_percent(context.stock, 0.0)这段代码的逻辑:60 日均线之上持仓 90%,之下清仓。order_target_percent是 Zipline 最常用的调仓函数,按目标仓位比例计算需要买卖的股数,你不用自己算差额。can_trade是我在改造里加的过滤,用来判断股票当天是否可交易,原版没有这个接口,改造时可以自己在数据层提供。跑这个策略的命令大致是:
zipline run -f simple.py --bundle my_a_share_bundle --start 2020-01-01 --end 2023-12-31 --output result.pickle注意--bundle参数指向你注册的 A 股数据包。这里的my_a_share_bundle是你通过自定义 ingest 生成的数据包名称,原版 Zipline 自带的 bundle 数据源不包含 A 股数据,第 3 章会详细讲数据接入。能跑通这个最小例子,说明框架核心链路正常,改造才谈得上开始。
3. 把 A 股行情接进 Zipline:数据摄取脚本与复权因子的正确落地
3.1 数据格式:为什么存未复权价格而不是前复权
这是 A 股化改造里第一个、也是最容易埋雷的决策。很多初学者图省事,直接从行情 API 拉前复权价格写进数据包,跑出来回测净值异常平滑,到实盘一验证就露馅。前复权的本质是把历史价格按后续的除权除息事件做了调整,也就是说,你在回测中「当天」看到的价格,实际上包含了未来分红送转的信息,这是典型的前视偏差。
对比一下三种方案:前复权价、后复权价、原始价加复权因子。前复权价会随最新行情漂移,同样的历史日期,不同时间拉到的价格不一样;后复权价单调连续,不会漂移,但和真实成交价对不上;原始价加复权因子最接近真实交易场景——成交按原始价计算,分红送转按照因子在除权日调整持仓和现金。
| 存储方式 | 历史价格是否漂移 | 是否含前视信息 | 成交价是否真实 | 除权除息处理成本 |
|---|---|---|---|---|
| 前复权价 | 漂移 | 含 | 不真实 | 低,但错 |
| 后复权价 | 不漂移 | 不含 | 不真实 | 中 |
| 原始价+复权因子 | 不漂移 | 不含 | 真实 | 高,但正确 |
我最终选的方案是原始价加复权因子。回测时行情数据源给的是原始价,成交、止损、涨跌停计算全部按原始价来;除权除息日,根据复权因子调整持仓数量(送转)和现金(分红),同时把该股票的涨跌停价基准从前收盘价更新为除权参考价。这套模型最贴近真实 A 股交易规则。缺点是实现成本高,但这是值得的,否则后面所有规则改造都是建立在错误数据上的。
3.2 写一个自定义 bundle 摄取脚本:日线入库与交易日历生成
Zipline 的 bundle 机制允许你通过 ingest 函数把自定义数据写进本地存储。常见做法是先从行情数据源拉数据,再转成 Zipline 的内部格式。下面是我在模拟项目X里用的摄取流程骨架,数据源用开源行情接口,中间落地成 DataFrame:
import pandas as pd from datetime import datetime def fetch_daily_bars(symbol, start_date, end_date): # 从开源行情接口拉日线数据,返回 DataFrame # 字段:date, open, high, low, close, volume, amount df = api.daily(symbol=symbol, start_date=start_date, end_date=end_date) df = df[['date', 'open', 'high', 'low', 'close', 'volume', 'amount']] df['date'] = pd.to_datetime(df['date']) return df.sort_values('date')拿到日线 DataFrame 后,要做三件事:清洗缺失值、检查价格非负、把 date 列对齐交易日历。这里卡住新手最多的问题是不连续停牌:某只股票停牌几个月,日线文件里没有数据,但框架的交易日历是按全市场口径走的,停牌期间不能简单填上一日收盘价,否则回测里会出现停牌股被成交的幻觉。我的做法是保留停牌缺口,让数据层在查询时返回不可交易标记,而不是人为前向填充。
交易日历方面,Zipline 原装的是纽约证券交易所日历,A 股必须替换。常见做法是拉取行情服务商提供的交易日列表,生成一个 CSV,再写一个自定义日历类读取。判断一个日期是不是 A 股交易日,不能只看周一到周五,还要剔除法定节假日和调休休市日。这个逻辑建议直接依赖数据源提供的交易日历,不要自己用假期表推算,调休规则每年都可能改,自己维护必踩坑。
3.3 复权因子与 adjustment 表的生成:从除权除息事件到调整记录
原始价入库后,还需要一张「复权因子表」来承载除权除息事件。这张表至少包含四个字段:股票代码、除权除息日、送转比例、每股派息金额。送转比例描述的是「1 股变几股」,比如 10 送 10 就是 1 股变 2 股;派息金额描述每股分多少钱。有了这张表,回测引擎在除权日就知道该对持仓做什么操作。
生成调整记录的核心逻辑大致如下:
def build_adjustments(events): """ events: 数据源返回的除权除息事件列表,每项包含 symbol, ex_date, bonus_ratio, dividend_cash bonus_ratio 表示每股送转后的股份放大倍数,如 10送10 -> 2.0 """ adjustments = [] for ev in events: if ev.bonus_ratio != 1.0: # 送转:持仓数量按比例放大 adjustments.append({ 'date': ev.ex_date, 'symbol': ev.symbol, 'action': 'split', 'ratio': ev.bonus_ratio }) if ev.dividend_cash > 0: # 现金分红:按持仓数量乘以每股派息 adjustments.append({ 'date': ev.ex_date, 'symbol': ev.symbol, 'action': 'dividend', 'cash_per_share': ev.dividend_cash }) return adjustments这套逻辑的关键在于:送转和现金分红要分开处理。送转影响持仓数量,按比例放大后持仓市值不变;分红影响现金,按持仓数量乘以每股派息入账。在 Zipline 原版里,split 和 dividend 是两种不同的调整类型,改造时可以直接映射。除权除息日当天,股票价格会跳空低开,如果不做这个调整,净值曲线会出现一个假的跌幅,策略误以为大跌而加仓,回测结果就失真了。
关于复权因子表,还要注意数据源的除权除息事件里可能包含配股。配股比送转复杂:股东可以选择不参与配股,但配股除权后股价会变化,不参与配股的持仓相当于被动承受价格折算。我的一般处理是先把配股事件忽略,跑通主流程,后续再根据策略是否参与配股来决定是否精细化处理。多数散户资金规模的策略,配股影响很小,不值得为此阻塞主流程。
4. 交易规则改造:T+1、涨跌停、最小交易单位与停牌处理
4.1 T+1 持仓锁定:给订单系统加一个「当日不可卖」约束
原版 Zipline 允许当天买入的股票当天卖出,这是美股 T+0 规则。A 股要改成当日买入的仓位不能卖出,这属于规则层改造,落点应该在订单执行链路里。我一般会在 Broker 里维护一张「当日买入数量表」,键是股票代码,值是当天累计买入的股数,每个交易日开盘前清零。
class AShareBroker: def __init__(self): # asset_id -> 当日已买入股数 self.bought_today = {} # asset_id -> 已提交但未成交的买单数量 self.open_buy_orders = {} def get_sellable(self, position): """计算可卖数量:持仓总量 - 当日买入数量""" total = position.amount bought = self.bought_today.get(position.asset, 0) return max(total - bought, 0) def process_order(self, order, position): if order.direction == 'SELL': sellable = self.get_sellable(position) if order.amount > sellable: # 按可卖量截断,并记录日志 order.amount = int(sellable) self.log.warning(f"T+1 截断: {order.asset} 可卖{sellable},原单{order.amount}")这里有几个细节容易出错。第一,当日买入表要在订单成交后更新,而不是在提交时更新,否则撤单再重挂会把未成交的部分也锁进去。第二,如果策略用order_target_percent把仓位从 80% 降到 30%,它会产生一个卖出订单,这个卖出订单如果超过可卖数量,应该截断而不是拒绝整个订单,否则当天的调仓目标无法完成,趋势策略会一直持仓过重。第三,T+1 锁定对「当天买入又卖出」的订单没拦截时,问题往往发生在order_target_value这类自动计算差额的函数上,它可能先生成一个买单,成交后又生成一个卖单来调整比例,两天一挤就突破锁定,这类问题只能靠回测日志定位。
4.2 涨跌停成交拦截:用前一交易日收盘价计算涨停价与跌停价
涨跌停逻辑属于执行层,处理的是「价格到了涨跌停价,买不进或卖不出」的问题。A 股主板涨跌停幅度是 10%,创业板和科创板是 20%,ST 股是 5%,北交所是 30%。不同板块不同幅度,建议做成配置表,不要写死在代码里。判断当天是否涨停,核心是用前一交易日收盘价计算涨跌停价格。
LIMIT_RATIOS = { 'MAIN': 0.10, 'GEM': 0.20, # 创业板 'STAR': 0.20, # 科创板 'ST': 0.05, } def calc_limit_prices(prev_close, board_type): ratio = LIMIT_RATIOS.get(board_type, 0.10) # A股涨跌停价四舍五入到分 limit_up = round(prev_close * (1 + ratio), 2) limit_down = round(prev_close * (1 - ratio), 2) return limit_up, limit_down def should_reject(order, bar, prev_close, board_type): limit_up, limit_down = calc_limit_prices(prev_close, board_type) if order.direction == 'BUY' and bar.close >= limit_up: return True # 涨停,买单无法成交 if order.direction == 'SELL' and bar.close <= limit_down: return True # 跌停,卖单无法成交 return False这里我要强调一个血泪经验:算涨跌停价必须用前一交易日的收盘价,而且是未复权的原始价。用前复权价算,遇到除权除息日会错得离谱。也不要直接用当天的high或low去判断涨停,一字板经常open == high == low == limit_up,但非一字板也可能开盘后冲上涨停价又被砸下来,收盘价不一定在涨停价。判断是否涨停应该看:当天high是否触及涨停价,且封板时间是否持续到尾盘。回测框架很难精确模拟封板时间,我的折中方案是:high >= limit_up且close >= limit_up * 0.999才视为有效涨停。
4.3 一手 100 股与调仓函数:round_lot 会把目标仓位变成什么
A 股所有买入订单必须是 100 股的整数倍,卖出时可以卖出不足一手的零股。这个规则看起来简单,但实现细节影响很大。Zipline 原版按美股习惯,一股一股买卖,改造后必须加一个 round_lot 函数。我建议把取整规则做成可配置的,回测和实盘用同一套逻辑。
def round_lot(shares, lot_size=100, method='floor'): """ 把目标股数取整到 100 股的整数倍 method: floor 向下取整 / round 四舍五入 """ if shares < lot_size: return 0 if method == 'floor': return int(shares // lot_size) * lot_size else: return int(round(shares / lot_size)) * lot_size取整方向对回测结果的影响在小资金策略上极为明显。假设你的策略按order_target_percent(context.stock, 0.3)调仓,账户资金 10 万元,目标仓位 3 万元,某股票价格 30 元,目标股数是 1000 股,刚好是整数;但如果目标算出来是 1037 股,向下取整变成 1000 股,四舍五入变成 1000 股,差别不大。资金量越小,这种取整损失占比越高。另一个坑是:卖出时不能简单取整,零股必须全部卖出,否则反复调仓几年,账户里会积压一堆 1 股、37 股这种零股,显示持仓混乱,影响资金利用效率。我会在卖出逻辑里加一个判断:剩余持仓如果小于一手,直接全卖。
4.4 停牌、一字板与流动性约束的组合处理
停牌是 A 股回测里最容易被忽略的规则。股票停牌期间,没有行情数据,原版 Zipline 如果遇到数据缺失,会用前一日数据填充,这在 A 股回测里会虚增交易机会。我的做法是让数据层在停牌日返回一个不可交易标记,执行层遇到这个标记直接拒绝订单。
流动性约束是另一个必要补充。原版回测假设你能按当天的 VWAP 成交量成交任意数量,这在大多数小盘股上不成立。我会在回测配置里加一个 volume_limit 参数,表示单笔成交不超过当日成交量的百分比:
def apply_volume_limit(order, bar, max_volume_pct=0.01): """订单成交量不能超过当日成交量的一定比例""" max_shares = int(bar.volume * max_volume_pct) if order.amount > max_shares: # 截断成 max_shares,并记录原始数量用于冲击成本评估 order.amount = max_shares order.partial_reason = 'volume_limit'一字板和 T+1 的组合问题也常在这里暴露:涨停一字板你买不进,但回测里如果没拦截,策略可能在涨停价成交,然后第二天高开卖出,产生一笔实盘根本不可能有的收益。所以我的建议是按 4.2 的顺序先把涨跌停拦截写好,再叠加流动性约束。否则你排查回测收益虚高时,会分不清是哪个环节造成的。
停牌、一字板、流动性约束这三者的处理,本质上都是同一个原则:回测能成交的交易,一定要是实盘同样条件下能成交的。贯彻这个原则,回测结果的参考价值才会大幅提升。
5. Zipline A 股化避坑:回测曲线漂亮的五个翻车现场
5.1 前视偏差:前复权数据让因子「偷看」了未来
现象:回测净值曲线异常平滑,除权除息日附近没有明显波动,策略收益远超预期。某开发者在模拟项目X里用前复权数据直接入库,回测年化做到 42%,实盘跑三个月只有 11%。
原因:前复权价格包含未来除权除息信息。策略在 2020 年某一天读到的价格,实际上是经过了 2021 年、2022 年除权调整后的价格,历史价格被「改写」了。因子测算时用到的涨跌幅、动量、估值分位,全部被未来信息污染。
解决:改用第 3.3 章的原始价 + 复权因子方案,把除权除息事件放在持仓和现金上处理,而不是修改历史价格。改完后用一只送转过的股票验证:除权日前一天和除权日当天的净值应该平稳过渡(市值不变,持仓数量调整)。
5.2 涨停一字板照样成交:回测多赚的那一部分是幻觉
现象:策略在涨停一字板当天「买入成功」,第二天高开卖出,贡献了大部分利润。细看交易明细,发现很多笔订单的成交价等于涨停价,且成交量很小甚至为 0。
原因:执行层没有判断涨跌停。涨停一字板意味着有人愿意以涨停价买入,但几乎没人卖出,买单排不上队。Zipline 默认撮合逻辑不关心这个问题,只要有 bar 数据它就按成交量撮合。
解决:加 4.2 的拦截逻辑,close >= limit_up时拒绝买单,close <= limit_down时拒绝卖单。同时把成交量约束打开:涨停板成交量通常极小,volume_limit 会自动把买单成交量压到接近 0,双重保险。
5.3 T+1 没生效:策略每天进出赚「模拟T+0」的钱
现象:某日线策略持仓周期平均只有 2 天,但回测里经常出现当天买入当天卖出的交易,收益曲线看起来像高频策略。
原因:Zipline 原版按美股 T+0 设计,Blotter 不区分当天买入的仓位。如果调仓函数里写了先卖后买的逻辑,或者两个信号在同一根 bar 触发,就会产生当日买卖。
解决:按 4.1 加 T+1 拦截。改完以后查看交易明细,应该没有任何一笔卖出订单的股数包含当日买入的部分。我习惯在回测日志里加一行警告:每次卖出被截断时打日志,跑几个策略样本,确认日志里没有异常截断。
5.4 成交量约束失效:小盘股被一根大单全吃
现象:策略在某个日均成交额只有 500 万的股票上,一笔买了 300 万,回测实际成交价贴着 VWAP,滑点很小。实盘同样下单,价格被打出好几个百分点。
原因:默认执行模型按当天 VWAP 成交,不管订单量占成交量的比例。小盘股流动性差,大单成交会推动价格移动,带动冲击成本。Zipline 原版如果不改,这个坑几乎必踩。
解决:设置 volume_limit 为 1% 到 3% 并启用滑点模型。滑点模型按订单量占日成交量比例线性放大滑点。注意 volume_limit 设置太保守会让回测交易量严重缩小,策略信号频繁但成交不活跃,需要根据策略持仓周期和标的选择合适的参数。
5.5 费用模型缺印花税:高频策略的收益虚高
现象:两个换手率差别很大的策略,回测收益差距不大,实盘结果差距显著。换手率高的策略回测年化 25%,实盘只剩 5%。
原因:Zipline 默认费用模型只收佣金,A 股卖出还有印花税。原版框架里没有印花税配置,高频交易的回测费用严重低估。
解决:自定义 commission 模型,佣金按成交金额比例加最低收费,卖出方向加印花税:
def a_share_commission(order, price, shares, is_sell=False): amount = price * shares commission = max(5.0, amount * 0.0003) # 佣金,最低5元 if is_sell: commission += amount * 0.0005 # 印花税,卖出方向 return commission印花税税率随政策调整,不要把数字写死在代码里,做成配置项。改完费用模型后,重新评估策略换手率与收益的关系:一个年换手率 20 倍的策略,费用会显著侵蚀收益,如果回测没体现,说明费用模型还有问题。
6. 验证回测可信度:用事件核对与留白检验校准你的改造
6.1 除权除息事件核对:检查持仓与现金是否按预期变动
改造完所有规则后,第一件事不是看收益曲线,而是做事件核对。选一只近期有送转或分红的股票,在回测中单独运行一个「买入并持有」策略,检查除权日当天的持仓和现金变动。
def dividend_event_check(context, data): pos = context.portfolio.positions[context.stock] # 除权日前记录持仓数量 context.record(before_shares=pos.amount)跑完以后核对三点:分红日现金增加额是否等于持仓数量乘以每股派息;送转日持仓数量是否按比例放大;除权日净值是否保持连续,没有因为价格跳空而产生虚假盈亏。这三点都对了,数据层和调整逻辑才可靠。
6.2 留白检验:剔除未来信息后收益曲线差多少
留白检验是我用来识别前视偏差的笨办法,但很有效。做法是:把策略里用到的所有数据字段做两个版本——完整版和「截至当日收盘」版,后者的历史窗口只到今天为止,不包含任何未来信息。两个版本跑完对比收益曲线。
如果两条曲线几乎重合,说明你的因子没有明显前视偏差;如果收益差异巨大,说明数据层仍有未来信息泄漏。留白检验不是自动化工具,要手写两套数据访问逻辑,但这是值得的。我见过太多回测曲线很好看的策略,做完留白检验收益直接腰斩,这种策略拿去实盘是灾难。
6.3 一笔订单的完整核对:从 order 到 fill 到费用
最后做一次订单级核对。挑一个调仓日,打印当日的一笔买单完整链路:订单提交时间、目标股数、round_lot 后的实际股数、成交价、成交量约束是否触发、佣金和印花税是多少。核对表大致如下:
| 字段 | 预期值 | 回测值 |
|---|---|---|
| 目标仓位 | 30% | 30% |
| 原始股数 | 1037 | 1037 |
| round_lot 后 | 1000 | 1000 |
| 成交价 | 当日 VWAP | 与预期一致 |
| 成交量约束 | 未触发 | 未触发 |
| 佣金+印花税 | 按费率计算 | 与实际一致 |
核对的每一步不通过,就回溯到对应的改造层修。我早期图省事,跳过这些核对直接看净值曲线,结果模型跑得挺漂亮,上小资金试跑才暴露问题。后来我养成了一个习惯:每改完一处规则,先跑事件核对和订单核对,再谈收益。这套流程走下来,回测结果才敢信。希望帮到你。
本文还有配套的精品资源,点击获取