简介面向A股量化策略开发者的ZipLine本地化Python框架重点解决原版仅适配美股、无法应对T1交易规则、A股交易时段以及A/H差异等问题。内含完整回测与数据导入能力用户可用pandas处理历史行情、计算因子再通过matplotlib生成收益曲线和交易信号适合具备Python基础并希望系统研究A股策略回测的投资者和研究人员。压缩包共366个文件大小3.42MB。225个py文件为核心源码涵盖数据源对接、回测引擎、策略模板及事件处理xlsx文件用于参数与行情配置txt、state等文件记录说明和状态存档sh/bat脚本便于快速部署环境。当前已有4382人学习下载。通过该框架可获得的不仅是可运行的本地化回测系统还包括A股分红配股处理、交易费用与滑点设置等实战逻辑以及matplotlib可视化报告示例源码目录模块化清晰方便按需扩展和二次开发。1. 基于 Zipline 修改 A 股框架先分清是改接口还是改交易逻辑Zipline 是最早开源、在美股里被大量验证的事件驱动回测引擎但直接拿它跑 A 股第一周就会翻车默认日历按美股排费用按零佣金算没有印花税、没有 T1、也没有涨跌停。即使拿到的是一份号称“支持 A 股、基于 Zipline 修改”的开源量化平台框架也一定要把撮合和账单逻辑重新验证一遍。这篇文章沿着“把美股回测引擎迁到 A 股”的主线展开依次讲交易日历、数据接入、撮合规则要动什么以及哪里最容易产出“很美但不可信”的数字。适合想快速得到可验证回测结果的从业者你不必从零写撮合层但要清楚改 Zipline 时该守住哪些边界。2. 把 Zipline 交易日历换成 A 股节律会话、时区与策略调度2.1 交易日历是 A 股化改造的第一道分水岭Zipline 对整个回测过程有一个核心抽象——交易日session。每个 session 都有开盘时间、收盘时间数据和订单撮合都沿着 session 推进。美股把这一切设计得很规整一个 session 内时间连续数据点和撮合事件按时间顺序推进。A 股则完全不同上午 9:30 到 11:30、下午 13:00 到 15:00 两段午间休市一个半小时开盘前还有集合竞价。如果拿到一个基于 Zipline 修改的框架却继续沿用美股默认日历会出现三类典型失真。第一午休期间策略发出订单撮合引擎把它成交在 12:59 的价格上而真实的交易所根本不运行这段行情。第二交易日边界错位之后“昨日持仓”“当日买入”“可卖数量”这三者的归属日期互相错乱执行 T1 校验时就无法判断。第三时区不对会导致分钟 Bar 的时间戳经过 UTC 转换后出现排列错乱画出来的净值曲线看似连贯实际上日与日之间的切片是错位的。所以 A 股化改造的第一步不是写策略而是把“今天是不是交易日、当前时间能不能下单”还原成交易所的真实定义。你可以自己维护节假日表也可以借助开源日历包中的中国交易所日历做校准。关键是校准之后让回测引擎只在一个 session 的合法时段内撮合盘中休市时间一律不产生行情、不接受订单。2.2 最小可用的 A 股交易日历两段式会话与节假日过滤下面是一个基于 Zipline 的交易日历类骨架。很多基于 Zipline 修改的项目会直接在日历模块里替换这个类。代码本身是概念实现实际接入时要以你手中框架版本的字段名为准。import pandas as pd from zipline.utils.calendars import TradingCalendar class CNTradingCalendar(TradingCalendar): 极简A股日历把每个交易日拆成上午/下午两个时段。 实际项目接入时请替换为本地节假日表。 property def name(self): return XSHG property def tz(self): return Asia/Shanghai property def open_times(self): # 每个交易时段: (时段标识, 开盘偏移) return ( (None, pd.Timedelta(hours9, minutes30)), (None, pd.Timedelta(hours13, minutes0)), ) property def close_times(self): return ( (None, pd.Timedelta(hours11, minutes30)), (None, pd.Timedelta(hours15, minutes0)), ) property def day_offsets(self): # session 的边界偏移用于计算隔夜持仓归属 return (pd.Timedelta(hours-18), pd.Timedelta(hours18)) def is_session(self, dt): # 先用固定周末过滤再接法定节假日 if dt.weekday() 5: return False return dt.date() not in self.holiday_dates这里 open_times 与 close_times 组成配对第一组代表上午 9:30 到 11:30第二组代表下午 13:00 到 15:00。is_session 是判断某天是否交易日的最直观入口真正的节假日表应该来自交易所每年公布的安排而不是硬编码。注意时间字段全部用 Timedelta 而不是具体日期时间这样日历可以按年份复用。接入之后建议做三件事验证。第一打印一个交易日的 session 起止时间确认午休不产生 bar第二用最近一年的分钟线数据跑一遍回测看数据时间戳是否全部落入 open_times 与 close_times 定义的区间内第三检查隔夜持仓是否被正确记为昨日持仓。多数“框架装上但数字诡异”的问题最后都定位在这一步因为日历错了后面所有计算结果都是错上加错。2.3 策略生命周期initialize 与 before_trading_start 怎么对到 A 股节奏Zipline 的策略接口由三个常用回调组成initialize 负责初始化上下文before_trading_start 在每个交易日开始前运行一次handle_data 在每个行情 bar 推进时被调用。A 股改造中这些函数依然保留但使用方式要按 A 股的节奏调整。def initialize(context): # 选股范围先做流动性过滤减少后段撮合噪声 context.universe AShareUniverse( min_market_cap5e9, # 总市值下限单位元 exclude_stTrue, # 剔除ST类股票 exclude_new_stock250 # 剔除上市不满250日的次新 ) context.weight 0.2 context.order_interval 10 # 每10分钟扫一次信号 def before_trading_start(context, data): # 开盘前更新信号读取隔夜公告、集合竞价结果、停牌列表 context.signal calculate_signal(context.universe, data) def handle_data(context, data): # 每个bar触发这里只做调仓与下单 for asset in context.signal.assets: order_target_percent(asset, context.signal.weight[asset])initialize 里圈定的 universe 是账户层面的选股集合。我一般会把 ST 剔除、次新股过滤、退市整理股过滤放在这里而不是等到撮合阶段再做因为回测期间这些股票的历史数据往往会带来不能成交、连续跌停等噪声。before_trading_start 适合做“每日只算一次”的工作例如读取昨日收盘公告、更新停牌列表如果把这些放进 handle_data同一条逻辑会被执行几百次既慢又容易产生重复下单。handle_data 则应该尽量薄只做订单下发和必要的仓位检查。调仓目标用 order_target_percent 比手算目标手数更稳因为它天然处理了“净值变化后重新平衡”的问题。2.4 代码与资产映射6 位代码加交易所后缀的归一化Zipline 里 Asset 对象由 symbol 和交易所字段共同构成。A 股常见的 600000、000001 并不能简单作为唯一键因为历史上有过更名、迁址、暂停上市再恢复的情况代码唯一性其实由“市场 代码 有效期”共同决定。我在接入某开源框架时会在数据加载层建一张辅助映射表。ASSET_MAP { 600000.SH: { symbol: 600000, exchange: XSHG, start_date: 1999-11-10, end_date: None, }, 000001.SZ: { symbol: 000001, exchange: XSHE, start_date: 1991-04-03, end_date: None, }, } def normalize_symbol(raw_symbol: str) - str: # 统一转成 600000.SH 或 000001.SZ 再交给 Asset 构建 code raw_symbol.zfill(6) if code.startswith((60, 68, 90)): return f{code}.SH return f{code}.SZ这个映射解决两个问题一是避免 600000 和 000000 这类代码在混合数据里撞 key二是回测中对同一只股票做“上市首日买入”时能通过 start_date 判断该资产在当时是否已经存在。把这段代码放进数据加载层后续所有策略、组合、风控逻辑都统一使用标准化后的 symbol能少踩很多低级错误。3. 数据接入改造复权、停牌、涨跌停怎么对齐到 BarData3.1 回测用前复权还是后复权不要迷信“默认”A 股数据最核心的坑是复权。分红送股之后除权除息日的价格会突然跳空如果不做处理任何均线、动量、价差类策略都会被凭空制造出的“跌幅”带偏。常见做法有两种口径前复权是以最新价格为基准把历史价格往下调整后复权是以最早期价格为基准把之后价格往上调整。回测中我建议优先用后复权价做计算再用前复权价做展示。原因是后复权价是“历史沿一个方向累积”的结果数据随日期增加单调延伸不会因为行情更新而整体重算前复权价则会随行情变化反复修正历史数据导致同一个策略在相同历史区间上多次回测得到不同的入场信号这是典型的未来函数来源。Zipline 本身的调整机制走的是“原始价格 调整因子表”的路线这一层设计其实比很多直接把价格改掉的数据源干净。因此接入时不要贪图省事直接喂前复权价格。常见的做法是数据库里同时存储未复权价和复权因子dividend、split 两条字段送入回测引擎时只填未复权价由引擎在每次取数时用因子表做调整。这样做的好处是回测中可以随时切换口径不需要重新灌数据。3.2 停牌与涨跌停标记缺失交易日不等于没有行情A 股停牌是数据接入最大的不确定性来源。停牌期间不复权的价格往往停在停牌前最后一天成交量变成 0甚至整个交易日直接缺失。很多基于 Zipline 修改的框架默认用“向前填充”把停牌日补成前一天收盘价这在撮合层面会带来一个严重后果订单本应无法成交却被引擎以“最新价”撮合掉了。涨跌停也需要显式标记。涨停时买单难以成交跌停时卖单难以成交默认撮合模型不会感知这一点。处理办法是在数据层为每个资产每天生成两个附加字段 limit_up 和 limit_down它们不是价格而是布尔标记。这样策略层和撮合层都能查询“这个 bar 是否处于涨跌停状态”再决定是否允许订单进入撮合队列。我在接入某图像处理 Demo 的兄弟项目时把停牌处理策略统一为停牌日不生成 bar保持前一日收盘价作为“引用价”但不参与撮合复牌日恢复行情。这个规则在分钟线和日线上都统一适用避免盘中突然出现一根“假成交”把净值曲线拉出异常尖峰。3.3 数据适配代码把本地 K 线序列送进 Zipline 的 BarData下面是一段数据适配的骨架代码。它解决的问题是把本地一份含开盘、最高、最低、收盘、成交量的 DataFrame 转成 Zipline 能读取的 bar 格式并同时带上复权因子和停牌标记。import pandas as pd def to_zipline_bar(df: pd.DataFrame, asset_code: str) - pd.DataFrame: frame df.copy() frame.index pd.to_datetime(frame.index, utcTrue) frame frame[[open, high, low, close, volume]] # 复权因子字段: dividend 是每股分红, split 是送转股比例 # 没有除权事件时 dividend0, split1 frame[dividend] 0.0 frame[split] 1.0 # 停牌标记: 成交量为0且开高低收全为停牌前价格时视为停牌 frame[is_suspended] ( (frame[volume] 0) (frame[high] frame[low]) ) return frame这里 dividend 和 split 是 Zipline 调整机制里约定俗成的字段名分别表示每股现金分红和拆股/送股比例。is_suspended 是我加的扩展字段回测引擎在撮合前会先看这个字段为 True 就拒绝新订单。成交量 0 的判断不能单独用因为部分停牌数据源会把成交清零但最高最低价保持正常宽度需要把“量 0”和“高低价重合”两个条件同时满足才更可靠。接入后还要做一次数据体检抽样几个除权日对比复权前后的价格跳空抽查长停牌股票确认复牌日的 bar 时间戳与真实交易日一致。这一步做不到位后面所有策略回测都建立在错误的 K 线序列上属于典型的“垃圾进、垃圾出”。4. 撮合规则建模T1、交易成本与滑点在 A 股回测里的落地4.1 T1 与最小交易单位默认撮合为什么不能直接信任Zipline 的美股默认撮合模型允许当日买入当日卖出允许买入任意零股也没有持仓冻结的概念。这在 A 股回测里会产生严重的虚高收益策略可以每天开盘买入、尾盘卖出反复交易同一笔资金回测净值曲线会非常漂亮但真实账户根本做不到。A 股是 T1当日买入的股票下一交易日才能卖出卖出所得资金当日可以继续买股票但当日不能转出账户。最小交易单位方面买入委托必须是 100 股或其整数倍卖出时如果账户里剩余不足 100 股的零股可以一次性卖出全部零股但不能拆开卖一部分。改造撮合层时不建议在每个策略里写死这些规则而应该放进撮合引擎统一执行。常见做法是在 Zipline 里实现一个 TradingControl 类在下单环节前拦截检查。下面是一个概念实现。class AShareTPlusOneRule: 把T1规则挂在撮合引擎之前拦截当日买入卖出。 def __init__(self): self.today None def before_order(self, asset, amount, is_buy, portfolio): if is_buy: return True, None # 卖出时必须检查可卖数量 pos portfolio.positions.get(asset) if pos is None or pos.amount 0: return False, 无持仓不能卖出 # 可卖数量 总持仓 - 今日买入数量 sellable pos.amount - pos.today_bought if amount sellable: return False, 卖出数量超过可卖数量 return True, None代码里的 pos.today_bought 需要在每次成交回调里累加。实现时可以在成交回报函数里对当日买入的资产做计数到下一交易日开盘前清零。注意这里“可卖数量”是用总持仓减去当日买入量而不是直接用昨日持仓因为如果昨天你已经持有 300 股今早再买 100 股那这 100 股必须冻结到明天能卖的仍是昨日那 300 股里未动过的部分。这个细节是 A 股 T1 校验最容易写错的地方。4.2 佣金、印花税与最低收费别按美股零佣金跑回测Zipline 默认的交易成本模型是零佣金、无印花税这在 A 股回测里会产生巨大偏差。A 股的真实成本大致包括三块券商佣金、印花税、过户费。佣金通常按成交金额的万分之二点五到万分之三收取单笔最低五元印花税只在卖出时收取目前是单边征收费率需要按真实结算规则配置过户费在过去按成交面额收取最新规则按成交金额的一定比例计收具体以结算公司文件为准。基于 Zipline 修改的框架里费用模型通常实现一个 calculate 方法。下面是一段可改的佣金模型骨架。class AShareCommissionModel: def __init__(self, commission_rate2.5e-4, min_commission5.0, stamp_tax_rate5e-4): self.commission_rate commission_rate self.min_commission min_commission self.stamp_tax_rate stamp_tax_rate def calculate(self, order, transaction): amount transaction.price * transaction.amount fee max(self.min_commission, amount * self.commission_rate) if transaction.amount 0: # 卖出单边征收印花税 fee amount * self.stamp_tax_rate return fee参数解释commission_rate 指券商佣金费率万 2.5 是比较常见的默认值min_commission 是单笔最低五元这个参数对小额交易影响极大一笔五千元的交易按万 2.5 算只要 1.25 元但实际券商收五元导致小单交易成本被低估四倍stamp_tax_rate 是卖出印花税率只在 amount 小于 0 时生效。这里 amount 的符号是 Zipline 约定负数为卖出。佣金模型接入后建议做一次对账把回测报告里每一笔交易的费用加总和券商交割单的计费逻辑逐笔比较。我见过不少项目把“最低五元”漏了随后策略改为高频小单时回测年化收益凭空多出几个点。4.3 滑点模型参数A 股短周期回测该调到什么程度Zipline 默认提供固定滑点和成交价偏移两种模型。美股的流动性深度和 A 股不同A 股短线策略尤其要谨慎。常见做法是把滑点设成固定金额例如每笔成交价相对信号价偏移一分钱也可以用百分比模型按成交价的千分之一到千分之二做偏移。def set_slippage(context): from zipline.finance.slippage import FixedSlippage context.set_slippage(FixedSlippage(spread0.01))spread 表示理想成交价到实际成交价之间的单边偏移单位是元。0.01 意味着买入价比信号价高一分钱卖出价比信号价低一分钱。对日线级策略这个偏移基本无感但分钟级策略一天交易 10 次以上时每次撮合都多了两分钱的往返成本累计下来非常可观。建议按策略平均持仓天数来调节持仓大于 5 日spread 用 0.005 即可持仓 1 到 3 日用 0.01 起步持仓只有几分钟到几十分钟的日内策略至少要试 0.02 以上才能覆盖冲击成本。滑点参数调完以后可以做一个快速验证把回测里成交金额最大的前 20 笔打印出来手动算一下买卖价差是否和真实盘口量级一致。如果回测成交价总是比当时 bar 的最低价还低、比最高价还高说明数据里混进了不该出现的跨价成交需要回到数据层排查时间戳对齐问题。5. A 股回测排查实录五条典型的“数字很美但不可信”踩坑记录5.1 除权日净值断崖下跌实际账户却没什么损失现象回测净值曲线在每年 6 月到 7 月总出现一次陡峭下杀跌幅和当天某重仓股的大比例分红送股挂钩但复权后的真实收益并没有这么大。原因数据接入时只做了“价格除以复权因子”的简单处理而回测引擎在除权日同时保留了未复权价和复权因子两套口径冲突导致 bar 价格连续但账户价值计算跳空。解决把数据源统一成“未复权价 因子表”的方式让引擎内部完成复权计算同时确认 dividend 和 split 字段的日期归到除权日当天而不是公告日或除息日。5.2 回测里涨停板还能买进跌停板还能卖出现象策略在涨停日大量买入某只股票回测显示全部成交收益曲线继续上行实盘里该股票开盘即封死涨停根本排不上单。原因数据没标记涨跌停状态撮合引擎把 bar 的最新价当作可成交价照常撮合。解决数据层增加 limit_up、limit_down 两个标记字段撮合前检查若是涨停且订单方向为买入拒绝成交若是跌停且方向为卖出也拒绝成交。实际改造时还要区分“一字板”和“放量开板”的情况最简单的近似方案是判断开盘价是否等于最高价或最低价再叠加成交量放大条件做二次确认。5.3 当日买卖频繁触发回测收益虚高现象同一个策略不加规则前年化收益 35%加上 T1 规则后骤降到 18%说明回测里大量收益来自 T0 翻转交易。原因默认撮合模型不限制日内买卖策略可以在日内反复低买高卖A 股实盘做不到。解决在第 4 章的 T1 规则基础上还要检查调仓频率。我的习惯是在下单函数里记录每个资产的当日买入量下一交易日开盘前统一清零并把“当日买入后当日卖出”的订单直接判为无效。加完这个规则之后再对比一次回测报告里的换手率A 股日线策略的合理年化换手通常在 10 倍上下超过 30 倍就要怀疑规则有没有真正生效。5.4 分钟线回测在 9:15 就开始成交现象分钟级回测里出现 9:15、9:20 的成交记录但这些时间点 A 股只有集合竞价不产生连续竞价成交。原因日历替换后分钟 bar 的时间戳没有压缩到 open_times 定义的两个区间内数据源里把集合竞价的预处理数据也当成了正式 bar。解决在数据接入层做时间过滤只保留 9:30 到 11:30、13:00 到 15:00 两个时间窗口的 bar同时把 9:25 的集合竞价价作为当日开盘参考价存入单独字段不参与撮合。改完之后写一条断言回测启动日志里最早的成交时间不得早于 9:30否则直接报错。5.5 对账单里的持仓成本与实际成交价对不上现象回测结束后导出的持仓明细里某只股票的成本价明显偏离每日成交均价有时甚至出现负数成本。原因分红送股的数据没有同步到持仓成本计算中现金分红被漏记或重复记。解决在成交回报之外单独维护一个“事件流水表”把 dividend 事件按除权日写入现金分红直接计入当日现金余额送股则按比例增加持仓数量并同步调整成本。Zipline 原版对 dividend 的处理比较粗基于 Zipline 修改的框架如果不重点改这里长期回测的期末净值误差会越来越大。验证方法很简单手工选取一只高分红股票把回测期内的历次分红金额加总和账户流水里的股息收入对比。6. 让回测更可信参数敏感性测试与极端行情过滤的收尾技巧最后分享一个从业习惯任何策略在正式跑全量回测之前我都会先做一轮“参数敏感性测试”。做法是把佣金费率、滑点偏移、T1 开关这三组参数各设两到三个档位组合跑 6 到 8 次观察收益和回撤的分布。如果收益率在佣金万 2.5 与万 3、滑点 0.01 与 0.02 之间波动超过三成说明策略对交易成本过于敏感实盘大概率跑不出回测结果。CONFIG_GRID [ {commission: 2.5e-4, spread: 0.01, t_plus_1: True}, {commission: 2.5e-4, spread: 0.02, t_plus_1: True}, {commission: 3.0e-4, spread: 0.01, t_plus_1: True}, {commission: 2.5e-4, spread: 0.01, t_plus_1: False}, ]跑完这张表我优先看两组对比T1 开关差异最大的策略说明收益主要来自交易频率而非信号质量佣金档位差异大的策略说明赚钱逻辑对摩擦成本太敏感。筛选契约是最差参数档位下仍不能亏损这个策略才值得继续打磨。极端行情过滤方面我会在 universe 里提前剔除停牌超过 30 日的股票和上市不满一年的次新这类资产在回测数据里经常出现连续无成交或一字跌停对净值曲线的扰动远超信号本身的意义。这一整套验证做完回测报告才敢拿给实盘资金看。踩过太多次“回测漂亮、实盘翻车”的坑之后我现在对任何单一参数组合下的高收益都保持怀疑宁可多花半天跑参数网格也不愿意拿着黑匣子一样的回测结果上战场。希望帮到你。本文还有配套的精品资源点击获取