简介:Hikyuu量化框架是一套基于C++与Python的开源量化交易研究框架,面向金融/股票证券领域的量化开发者和策略研究人员,可用于策略分析、系统化交易建模及历史回测。其核心思想将系统化交易拆分为市场环境判断、信号指示、止损止盈、资金管理等七大组件,便于用户自由组合验证策略的有效性与稳定性。资源压缩包共1212个文件,约52.29MB,以414个cpp源文件、397个h头文件、166个py脚本为主,辅以SQL配置、Lua扩展、iPython Notebook示例及UI界面文件,完整覆盖框架核心代码、工程构建脚本与文档说明。已有689人学习/下载,适合有一定编程基础并希望深入理解量化交易框架内部实现、或基于Hikyuu搭建个人策略研究环境的读者。通过源码结构与示例,可快速掌握组件化策略设计思路,并借助内置的测试用例与数据脚本开展回测实验。
1. 把研究框架拆成 C++ 引擎和 Python 策略:为什么速度差几十倍
量化交易研究框架做回测,最容易翻车的不是策略本身,而是框架的速度和对账口径。这套基于 C++/Python 的开源回测框架,把撮合、K 线推进、订单状态机放在 C++ 引擎里,把策略信号、参数配置、绩效分析留在 Python 侧。用日线数据回测全市场 5000 只股票,纯 Python 实现跑一遍要二十几分钟,换到这套混合架构后不到一分钟,差距不是玄学,是逐 bar 回调产生的解释器开销被去掉了。适合两类人:一是已经能写 Python 策略但受限于回测速度的研究者,二是刚入门想直接看一个真实框架如何组织回测流程的新手。先把架构看清楚,再谈策略怎么写。
2. 架构与选型:C++ 负责撮合,Python 负责策略,中间用回调解耦
2.1 为什么撮合循环必须放在 C++ 侧
回测引擎的性能瓶颈从来不在因子计算,而在逐 bar 的状态更新。pandas 和 numpy 做向量化收益计算很快,但撮合订单、触发止损、更新持仓和资金流水这类操作,每一步都要读写订单容器和持仓状态,天生就是命令式逻辑。用 Python 写这部分,每次循环迭代都有解释器开销,碰到几千只股票、几十万个 bar 的场景,跑得慢不是偶然。
如果你用过 backtrader 这类纯 Python 框架,大概率会遇到同样的问题:策略逻辑本身不重,但事件驱动循环在 Python 层逐 bar 调用,数据一多整个回测就变得很拖。这套框架的解法是把引擎层下沉到 C++,Python 只保留策略表达和结果分析。C++ 侧维护行情推进、订单簿、成交回报,Python 侧负责信号生成和参数管理,中间用回调接口解耦。
| 层次 | 职责 | 典型实现 |
|---|---|---|
| C++ 引擎层 | K 线推进、订单撮合、止损处理、资金记账 | core/engine.cpp, core/matcher.cpp |
| Python 策略层 | 信号生成、持仓意图、参数配置 | strategies/base.py, configs/*.yaml |
| 数据层 | CSV 加载、合成数据、TDengine 批量写入 | data/loader.cpp, data/tdengine_writer.cpp |
引擎与策略之间的接口,C++ 侧定义为抽象回调类。实际绑定用 pybind11 导出到 Python,策略侧继承 Python 基类后传入引擎。
// engine/callback.h // 引擎与策略之间的回调接口,策略侧用 pybind11 绑定到 Python struct Bar { int64_t ts; // Unix 毫秒时间戳 double open, high, low, close; double volume; }; class StrategyCallback { public: // 引擎每推进一根已闭合的 bar,就会调用一次 on_bar virtual void on_bar(const std::vector<Bar>& bars) = 0; // 订单被撮合后回调,资金流水在这里记录 virtual void on_trade(int64_t order_id, double price, int64_t qty) = 0; };这里传给 on_bar 的是一个std::vector<Bar>而不是单根 bar。原因是 C++ 到 Python 的每一次跨语言调用都要经过 pybind11 并竞争 GIL,如果一根 K 线调一次,性能会肉眼可见地退化。攒一批再回调,是这套框架里最重要的性能设计,你可以理解为批量提交。
2.2 Python 侧策略基类与参数入口
Python 侧的策略基类非常薄,只做两件事:声明策略需要哪些参数,定义信号回调。持仓和订单意图放在 self 上,由引擎侧统一维护真实成交。
# strategies/base.py from dataclasses import dataclass class Strategy: def __init__(self, params: dict): self.params = params self.position = 0 self.orders = [] def on_bar(self, bars): raise NotImplementedError def on_trade(self, trade): # 成交回报默认只记录,不参与信号 passposition 是策略视角的持仓数量,单位是股还是手由策略自己约定,引擎不管,只负责把订单意图转成成交流水。orders 是策略发给引擎的订单意图列表,引擎读取后按订单类型撮合,撮合结果通过 on_trade 回给策略。这种分工避免了策略内部自己维护资金账户的常见误区。
参数从 yaml 配置进入策略构造函数。一个最小策略只需要在on_bar里根据收盘价预测方向,再基于持仓状态决定发买单还是卖单。新写的策略不需要理解 C++ 侧的订单结构,也不用手动更新权益。
2.3 框架目录结构与一次最小运行
拿到源码包后,先在命令行跑一次自带 demo,确认引擎编译、dll 加载、数据读取整条链路是通的。
python run_backtest.py --config configs/demo.yaml --symbol 000001demo.yaml 里需要关注的参数集中在回测口径上:initial_cash决定初始资金,commission_rate是手续费比例,slippage是滑点百分比,start_date和end_date是回测区间。Symbol决定从数据层取哪只标的,数据可以是 CSV 文件路径,也可以是 TDengine 的表名。
提示:跑 demo 前先确认引擎编译产物存在,否则 Python 导入时会直接报找不到模块的错误。
3. 环境搭建与数据准备:VSCode 调试 + CSV/TDengine 双数据源
3.1 VSCode 配置 C/C++ 与 Python 混编环境
这套框架涉及 C++ 引擎编译和 Python 策略编写,开发环境建议直接上 VSCode,配合 C/C++ 扩展和 Python 扩展。Windows 上最容易漏掉的一步,是 Visual C++ 2015-2022 Redistributable (x64) 运行时库没装。引擎编译产物在另一台机器上运行时,缺少 VCRUNTIME140.dll 的概率很高,运行时报错信息又很含糊,会误导你去查代码而不是查运行时。
配置 tasks.json 时,把编译命令指向引擎源码目录,生成产物放到engine/build/下。launch.json 里需要同时配置 Python 调试器和 C++ 调试器。调试 Python 侧策略时,C++ 引擎作为扩展模块加载,符号路径指向编译出的 pdb 文件即可。
{ "version": "0.2.0", "configurations": [ { "name": "Python: Current File", "type": "python", "request": "launch", "program": "${file}", "env": { "PYTHONPATH": "${workspaceFolder}" } } ] }这里 PYTHONPATH 必须指向仓库根目录,否则 Python 导入engine和strategies包时找不到模块。混编调试时还要注意:先用 Python 调试器跑起来,再 attach C++ 调试器到进程,否则 GIL 持有关系会乱。
3.2 合成数据先行:CSV 加载与字段标准
没有合规行情数据之前,先用合成数据跑通引擎。这不算临时替代方案,而是验证回测引擎正确性的标准做法:你只有知道输入数据长什么样,才能判断输出结果是否合理。合成数据按 CSV 格式生成,字段顺序和真实行情保持一致。
# data/synthetic.py # 生成一段日线级别的随机游走行情,用于引擎冒烟测试 import pandas as pd import numpy as np def make_synthetic_ohlc(start="2020-01-01", days=500, seed=42): rng = np.random.default_rng(seed) ret = rng.normal(0.0005, 0.02, days) close = 100 * np.cumprod(1 + ret) open_ = close * (1 + rng.normal(0, 0.005, days)) high = np.maximum(open_, close) * 1.01 low = np.minimum(open_, close) * 0.99 volume = rng.integers(10000, 100000, days) df = pd.DataFrame({ "datetime": pd.date_range(start, periods=days, freq="B"), "open": open_, "high": high, "low": low, "close": close, "volume": volume, }) return df这里用对数正态的随机游走生成收盘价,再反推开盘价、最高最低价。为什么用np.cumprod(1 + ret)而不是累加收益率?因为价格是乘法过程,累加收益率会让价格序列出现负值。high 和 low 用max(open, close) * 1.01和min(open, close) * 0.99生成,保证了 K 线实体一定包含在最高最低价区间内。
3.3 TDengine 写 K 线:用 taos_stmt_prepare 做批量绑定
真实场景下,日线数据量大了以后 CSV 读写会拖慢回测启动速度。很多团队的落地方案是把 K 线落到 TDengine,C++ 侧直接通过 taos_stmt_prepare 做参数绑定写入。这里最值得强调的写法是:不要用拼接 SQL 的方式逐行 insert,而是先 prepare,再 bind 参数,最后批量 execute。
// data/tdengine_writer.cpp // 绑定 时间、open、high、low、close、volume 六列,批量写入 K 线表 TAOS_BIND param[6]; int64_t ts; double open, high, low, close; int64_t volume; // 实际赋值过程省略,ts 取 bar 的 Unix 毫秒时间戳 taos_stmt_prepare(stmt, "INSERT INTO kline_daily VALUES(?,?,?,?,?,?)"); taos_bind_param(stmt, 0, ¶m[0]); // datetime taos_bind_param(stmt, 1, ¶m[1]); // open taos_bind_param(stmt, 2, ¶m[2]); // high taos_bind_param(stmt, 3, ¶m[3]); // low taos_bind_param(stmt, 4, ¶m[4]); // close taos_bind_param(stmt, 5, ¶m[5]); // volume taos_stmt_execute(stmt);为什么用参数绑定而不直接拼 SQL 字符串?除了注入和安全问题,更重要的是类型转换成本。C++ 侧直接传 double 和 int64_t,不需要把数字转成字符串再让数据库解析,批量写入时要快不少。需要注意的一点是 varchar 字段在绑定时要显式传入长度,否则容易出现值被截断的问题,这个放到第 5 章避坑部分详细说。
4. 回测引擎核心流程:从 on_bar 到成交流水
4.1 引擎主循环骨架
引擎主循环是回测框架的心脏。它做的事情按顺序排列:推进一根 K 线、更新缓冲区、通知策略产生信号、处理挂单、记录成交回报。这一套逻辑在 C++ 侧实现,Python 侧看到的只是on_bar和on_trade回调。
// engine/backtest_engine.cpp // 主循环骨架:推进bar -> 通知策略 -> 撮合挂单 -> 记账 std::vector<Bar> bar_buffer; for (size_t i = 0; i < bars.size(); ++i) { bar_buffer.push_back(bars[i]); // 控制缓冲区大小,避免无限增长 if (bar_buffer.size() > 1000) { bar_buffer.erase(bar_buffer.begin()); } // 只在 bar 闭合后通知策略,避免用未完成的价格 strategy_->on_bar(bar_buffer); // 处理策略发来的订单,按订单类型撮合 matcher_->process_pending_orders(bars[i]); }为什么要攒 1000 根 bar 的缓冲区?因为像均线类策略需要看历史窗口,缓冲区太小策略拿不到足够的历史数据,太大浪费内存。这里bars[i]是已经闭合的 K 线,不会用到盘中未完成的价格,这从根上规避了一类常见的未来函数问题。
4.2 订单类型与撮合参数
撮合逻辑里订单类型决定了成交价的计算方式,这是回测口径最容易出现分歧的地方。框架里支持三种基础订单:市价单、限价单、止损单。
| 订单类型 | 触发条件 | 成交价算法 |
|---|---|---|
| MARKET 市价单 | 立即触发 | 当前 bar 的开盘价或收盘价,按配置选择 |
| LIMIT 限价单 | buy 限价 >= 最低价 | 限价与开盘价取更优值 |
| STOP 止损单 | 价格穿越止损价 | 按止损价增加滑点后成交 |
市价单的成交价选择直接关联回测结果。用开盘价撮合还是收盘价撮合,决定策略是“看到收盘信号、次日开盘执行”,还是“盘中触发、收盘确认”。我建议统一用“信号在 bar N 收盘产生,订单在 bar N+1 开盘成交”,也就是常说的 T+1 口径,这样最接近 A 股实际执行节奏。
滑点和手续费参数集中在配置里。slippage是百分比滑点,一般设 0.0005 到 0.001;commission_rate是手续费比例,A 股默认万二点五;min_commission设置单笔最低佣金,A 股是 5 元。如果框架里只设了比例佣金而没有最低佣金,高频小单的回测收益会虚高,这个细节必须注意。
4.3 绩效指标:权益曲线、最大回撤与年化夏普
回测结果输出不只要求一条权益曲线,还要有标准化的绩效指标。最大回撤和夏普比率的计算口径不统一,会导致同一个策略在两个框架里跑出完全不同的结论。
# analysis/performance.py import numpy as np import pandas as pd def max_drawdown(equity: pd.Series) -> float: # 权益曲线的历史最高点,用于计算从峰值到谷值的最大跌幅 peak = equity.cummax() return ((equity / peak) - 1).min() def annualized_sharpe(daily_return: pd.Series, periods=252) -> float: # 日频收益率的均值除以标准差,再按年化系数放大 std = daily_return.std() if std == 0: return 0.0 return daily_return.mean() / std * np.sqrt(periods)最大回撤用cummax计算历史峰值,再用当前权益除以峰值减 1,取最小值就是最大回撤。年化夏普的periods参数取决于数据频率:日线是 252,小时线约 252*4,分钟线要看具体周期。统一口径后,不同策略之间才有可比性。
5. 避坑清单:复权、未来函数、撮合顺序、数据库写入五个常见问题
5.1 前复权数据在回测区间中间漂移
刚才提到用 CSV 行情做回测,如果 CSV 里存的是前复权价格,问题会在区间中间出现突然的跳变。现象是回测净值曲线在某一天出现莫名其妙的跳空,但当日并没有下单。原因在于前复权价格以某个基准日为参照,基准日之后发生除权除息,历史 K 线会被重新计算,回测区间中间的价格就发生了漂移。解决的办法是数据入库时保存未复权价格和除权除息事件,回测引擎自己处理复权;如果实在要用前复权数据,就把复权基准日固定在回测区间之外,并且一次性复权后不再改动。
5.2 信号和成交撞在同一根 K 线
用收盘价信号却在本根 K 线内撮合,会让回测收益看起来显著好于实盘。原因是许多刚上手的回测代码在 on_bar 里直接用当前 bar 的 close 算信号,又用同一个 close 成交,等于偷看了未来。解决方法是严格执行“信号在第 N 根 bar 收盘产生,订单以第 N+1 根 bar 的开盘价成交”的约定。框架层面已经在第 4 章的引擎循环里保证了这一点,但如果你自己写策略时手动创建了订单并在本根 bar 内手动成交,仍然会绕过这个约束。
5.3 止损单与限价单的撮合优先级
同一根 K 线内既有已触发的止损单,又有新的限价单,不同撮合顺序会带来明显差异的资金曲线。现象是同一笔交易,先撮合止损单和先撮合限价单,最终持仓成本完全不同。原因是没有统一的优先规则,订单簿扫描顺序取决于订单容器里的插入顺序。解决是在引擎撮合层强制统一按 price-time 优先级排序:先比较订单类型对应的触发价格,再比较订单创建时间,止损单按下根 bar 开盘价触发并在触发价上增加滑点。
5.4 C++ 写 TDengine 字符串字段截断
用 taos_stmt_prepare 写 TDengine 时,varchar 字段出现末尾字符丢失,这是用参数绑定最常见的错误。现象是写入的股票代码变成"00000",最后一位被吞掉。原因是绑定 varchar 参数时没有显式传入字段长度,TDengine 默认按 0 长度处理,或按类型定义长度截断。解决是在绑定 struct 里显式设置buffer_length和length字段,传参数时用strlen计算实际长度而不是依赖字符串自动截断。
5.5 Python 回调频繁导致回测越来越慢
回测跑得慢不一定是引擎效率问题,有可能是策略回调频率过高。现象是回测跑到后期速度越来越慢,像是 C++ 引擎被拖垮。原因是策略在每次 on_bar 里做了大量数据处理,或者框架用pybind11回调 Python 时每根 bar 都竞争 GIL,数据量大时跨语言调用开销被放大。解决是框架侧增大回调批次,比如攒 1000 根 bar 回调一次;策略侧避免在 on_bar 里做数据清洗,预处理放到策略初始化阶段完成。
6. 参数优化与过拟合检验:滚动窗口扫描的落地写法
6.1 walk-forward 滚动窗口
参数优化最忌全样本一次性扫描,挑出历史收益最高的参数组合然后直接上实盘。做法是把数据切成训练窗口和验证窗口,按固定步长滚动前进,每次只在训练窗口内优化参数,再在验证窗口上回测,这样得到的样本外表现才有参考价值。
# optimize/walk_forward.py # 滚动窗口:train窗口内优化参数,valid窗口内验证 train_len = 400 # 约一年半的日线数据 valid_len = 100 # 约五个月的日线数据 step = 25 # 每25个交易日滚动一次 for train_start in range(0, len(dates) - train_len - valid_len, step): train_end = train_start + train_len valid_end = train_end + valid_len params = optimize_on(train_start, train_end) score = backtest(params, train_end, valid_end) scores.append(score)optimize_on是网格搜索或贝叶斯优化的入口,backtest调引擎跑策略。注意 valid 窗口必须接在 train 窗口后面,不能随机抽样,否则相当于把未来数据混进了验证集。step越小滚动越密,结果越稳健,但计算量成倍增加,一般取 train_len 的 5% 到 10%。
6.2 参数平原与稳定性检查
单个回测样本外表现好还不够,还要看参数平原:把最优参数附近小幅偏移后的回测结果铺开,看收益是平缓变化还是悬崖式衰减。我吃过一次亏——全样本扫描出的最优参数,样本外收益几乎归零,把参数偏移一点就亏损,典型的过拟合参数点。
| 检验指标 | 含义 | 参考标准 |
|---|---|---|
| 样本内 IR | 训练窗口的年化收益/年化波动 | 大于 1 才算及格 |
| 样本外 IR | 验证窗口的年化收益/年化波动 | 关注衰减幅度 |
| 衰减率 | 1 - 样本外IR / 样本内IR | 低于 50% 是基本要求 |
从那以后我每次做参数优化,都强制走一遍滚动窗口验证,确认参数平原够宽才敢把策略留给模拟盘。这套流程不复杂,但能挡住大部分过拟合策略。希望帮到你。
本文还有配套的精品资源,点击获取