简介:Hikyuu Quant Framework是一套基于C++与Python的开源量化交易研究框架,面向金融/股票证券领域的策略分析与回测场景,同时支持接入行情数据后扩展至期货等品种。其核心方法将整个系统化交易抽象为市场环境判断策略、系统有效条件、信号指示器、止损/止盈策略、资金管理策略、盈利目标策略、移滑价差算法七大组件,使用者可分别构建策略资产库,再按研究需要自由组合,灵活检验不同条件下系统的有效性与稳定性,也能单独评估某一类策略的贡献。压缩包共1212个文件,包含414个C++源文件、397个头文件、166个Python脚本,另附RST文档、SQL配置、Lua策略、Jupyter示例、UI界面、图标图表等辅助资源,覆盖核心实现、Python封装、工程配置与可视化支持,整体包大小约52.29MB,目录结构清晰,适合二次开发与模块化研究。已有689人学习浏览,获取后可收获完整源码、构建脚本、配置示例与文档资源,便于深入理解该量化框架的工程实现,并快速搭建可扩展的策略分析与回测实验环境。
1. 基于 C++/Python 的开源量化交易研究框架:不只是把回测跑通
做量化研究的人,手里多少都攒过几套“祖传代码”:研究阶段用 Python 写得飞快,pandas 一拉、matplotlib 一画,策略逻辑清清楚楚;等到要上多标的、高频 tick 回测,或者要把参数寻优扔到多台机器上跑,Python 的解释执行就像踩了刹车,一次网格搜索跑十几个小时是常事。于是大家开始琢磨:能不能让 C++ 扛计算、Python 写策略,两层各干各的,中间用一套干净的数据接口对接。这就是基于 C++/Python 的开源量化交易研究框架要解决的核心问题——它不是一个能直接下载就赚钱的黑匣子,而是一套能把“研究速度”和“执行速度”同时保住的工程骨架。
本文按“为什么值得搭 → 架构怎么分 → 策略分析怎么做 → 回测怎么压 → 结果怎么验”的顺序展开。我默认你是那种已经写过几个策略、被回测速度和内存占用折磨过、想把手上的研究代码改成正经框架的人。内容以落地为主,每个环节都会给可复现的命令、代码和参数,也会把我在迁移过程中踩过的坑原样交代。
2. 混编架构的选型逻辑:计算下沉 C++、策略留在 Python
2.1 为什么不是纯 Python 也不是纯 C++
先给结论:纯 Python 做研究舒服但跑不动,纯 C++ 跑得快但策略迭代恨不得一周写三百个版本,全用 C++ 等于把研究员变成 C++ 工程师。常见的开源方案(比如 Backtrader、vn.py、QuantLib 这类框架)走的是同一条折中路——Python 做策略表达、数据预处理和结果分析,C++ 做计算密集的回归、指标运算和撮合仿真。
选型时盯三个指标。第一是回测吞吐,即每秒能撮合多少笔成交、能处理多少根 bar,这在多标的组合回测里是硬指标;第二是策略迭代周期,改一版策略到看到绩效报告的时间,Python-only 方案通常在分钟级,C++/Python 混编能做到十秒级;第三是内存峰值,加载三年、五十个标的的 tick 数据,纯 Python 的 DataFrame 链条经常冲到几个 GB,而按列存到 C++ 结构体里,内存能压一个数量级。
2.2 混编的三个技术路线:pybind11、Cython、原生扩展
我一般建议从 pybind11 入手。它的心智负担最小:你写一个普通的 C++ 类,用 pybind11 暴露成 Python 模块,Python 里直接 import 使用,不需要学 Cython 那套类型声明的语法。Cython 适合你已经有一套 C 风格代码、想逐步迁移的场景,它的编译流程更繁琐,但能直接操作 Python 对象的内存视图。原生 C 扩展是最后的选择,除非你要维护的是已经用 C 写的第三方库接口,否则没必要徒手写引用计数。
// calculator.cpp #include <pybind11/pybind11.h> #include <pybind11/stl.h> #include <vector> namespace py = pybind11; // 计算一组收盘价序列的滚动均值,窗口为 window std::vector<double> rolling_mean(const std::vector<double>& closes, int window) { std::vector<double> out(closes.size(), 0.0); double sum = 0.0; for (size_t i = 0; i < closes.size(); ++i) { sum += closes[i]; if (i >= static_cast<size_t>(window)) sum -= closes[i - window]; out[i] = (i >= static_cast<size_t>(window) - 1) ? sum / window : 0.0; } return out; } PYBIND11_MODULE(calc, m) { m.def("rolling_mean", &rolling_mean, "rolling mean of close prices", py::arg("closes"), py::arg("window")); }这段代码的逻辑很简单:输入一组收盘价和窗口大小,输出滚动均值序列。关键在求和方式,我没有每次重新累加窗口内的数据,而是维护一个滑动总和,进来一个加一个、出去一个减一个,时间复杂度从 O(n*window) 降到了 O(n)。在 C++ 侧,这个差别在 1000 万根 bar 上就是几十分钟和几秒的差距。
编译绑定时有个参数容易漏:py::arg("closes")只是给参数命名,不是类型检查。如果 Python 侧传入的是整数列表而不是浮点列表,pybind11 在std::vector<double>的转换上会自动做类型提升,但会产生一次拷贝。在意性能就把 Python 侧的数组先转成array('d')或 numpy 的float64再传进去。
2.3 编译环境的坑:Visual C++ Redistributable 和构建系统
既然用了 pybind11,就绕不开 C++ 编译环境。Windows 上最常见的翻车现场是:代码在 VS Code 里写好了,pip install别的地方都正常,一python setup.py build_ext --inplace就报找不到编译器。原因通常是机器上只装了 Visual C++ Redistributable,那是运行时库,不是编译器。你需要的是 Build Tools for Visual Studio,装的时候勾选“使用 C++ 的桌面开发”工作负载。Linux 上相对省心,build-essential装好就行,但要注意 GCC 版本和 C++ 标准:pybind11 要求 C++11 以上,我一般直接拉高到 C++17,很多第三方数值库的新接口也默认按 C++17 编译。
构建系统我推荐 CMake,它能把“编译 C++ 扩展”和“生成 Python 包”这两步一次讲清楚。顶层 CMakeLists.txt 里做三件事:找 Python 解释器、找 pybind11、声明模块目标。
cmake_minimum_required(VERSION 3.15) project(quantlib_extension) set(CMAKE_CXX_STANDARD 17) find_package(Python COMPONENTS Interpreter Development REQUIRED) find_package(pybind11 CONFIG REQUIRED) pybind11_add_module(calc calculator.cpp) target_compile_definitions(calc PRIVATE VERSION_INFO=1.0.0)这里pybind11_add_module是 pybind11 的 CMake 宏,它会自动处理 Python 头文件路径和链接选项,不需要你手动include_directories。编译时在项目目录下跑cmake -B build && cmake --build build --config Release,然后把生成的calc.so(Linux)或calc.pyd(Windows)放到 Python 搜索路径下就能import calc。一定要用 Release 配置,Debug 配置下 STL 容器迭代慢得离谱,回测里一个循环多花几十倍时间是常有的事。
2.4 数据接口设计:文件格式、内存布局、列式存储
框架的数据接口是整个架构里最不该省设计时间的地方。常见做法是统一成 Parquet 或 HDF5 落地,读取后转成 C++ 侧的内存列存结构。我倾向 Parquet,原因是列式存储天然匹配量化计算模式:算均线只取 close 列,算成交量只取 volume 列,不需要把整行数据扫一遍。
import pyarrow.parquet as pq import numpy as np table = pq.read_table("btc_1h.parquet") closes = table["close"].to_numpy() opens = table["open"].to_numpy() # 把 numpy 数组按列传入 C++ 扩展,避免逐行拷贝 from calc import rolling_mean ma20 = rolling_mean(closes, 20)这里有个细节值得注意:table["close"].to_numpy()拿到的是 numpy 数组,但它是只读视图还是可写副本,取决于 Parquet 文件的压缩方式和 pyarrow 的版本。如果后面要对价格序列做复权处理,建议先np.array(..., copy=True)复制一份,回测里改坏了原数据,重读一次文件比排查脏数据快得多。
数据内存布局上,C++ 侧用struct Bar { int64_t ts; double open, high, low, close; double volume; };按数组连续存放,比 vector of struct 更省缓存。Python 侧看到的只是一个二维数组,用numpy的asarray配合reshape就能把它包成(n, 6)的矩阵,不产生额外拷贝。
3. 策略分析层落地:指标体系、参数寻优与绩效归因
3.1 策略信号生成:把 Python 策略逻辑无缝接到 C++ 计算内核
框架搭好之后,策略分析的第一步是把信号生成逻辑拆成两层:策略层用 Python 写,只负责产生“该买还是该卖”的意图;计算层下沉到 C++,只负责算指标、算状态。这个拆分能让你在换策略时不动底层代码。比如一个双均线策略,Python 侧只做两件事:调 C++ 的滚动均值接口,然后比较金叉死叉。
def generate_signals(closes, fast=5, slow=20): ma_fast = rolling_mean(closes, fast) # C++ 计算内核 ma_slow = rolling_mean(closes, slow) signals = np.zeros(len(closes), dtype=np.int8) for i in range(1, len(closes)): if ma_fast[i] > ma_slow[i] and ma_fast[i-1] <= ma_slow[i-1]: signals[i] = 1 # 金叉,信号为买入 elif ma_fast[i] < ma_slow[i] and ma_fast[i-1] >= ma_slow[i-1]: signals[i] = -1 # 死叉,信号为卖出 return signals这段代码的价值不在双均线本身,而在它的性能特征:rolling_mean的计算全部在 C++ 里完成,Python 层只做了一个 O(n) 的循环比较。当你想在 1000 万个 tick 上做参数寻优时,这个区别就有了实际意义——前者是秒级,后者是分钟级。
参数上再补一句:fast和slow是策略超参,不要写死在函数里。后面做参数寻优时,这两个值会被反复替换,保持它们是函数的入参,能让你直接用itertools.product或ParameterGrid做网格搜索,不需要改策略代码。
3.2 绩效指标计算:年化、夏普、最大回撤的 C++ 实现
绩效指标的计算看起来简单,其实有很多边界情况。最大回撤如果只算收盘价序列,会低估真实回撤,因为盘中价格可能已经跌穿了收盘价序列的谷值。要想算得准,就需要用到每根 K 线的高低价,而这正是把指标下沉到 C++ 的典型场景。
double max_drawdown(const std::vector<double>& highs, const std::vector<double>& lows, const std::vector<double>& closes) { double peak = closes[0]; double max_dd = 0.0; for (size_t i = 1; i < closes.size(); ++i) { peak = std::max(peak, closes[i]); double cur_dd = (peak - lows[i]) / peak; max_dd = std::max(max_dd, cur_dd); } return max_dd; }这段计算里我把低点lows[i]作为回撤的“最坏情况”估计,而不是用收盘价。原因是很多策略的止损单挂在低点附近,真实成交价往往比收盘价差。虽然这会让回撤数字偏大,但回测的意义在于暴露风险,宁可高估不要低估。
夏普比率、年化收益率这些指标也是一样,C++ 侧做数值计算,Python 侧只负责组装结果。计算过程用 double 类型,收益率序列都用日频,夏普比率默认按 sqrt(252) 年化。这里有个容易踩的坑:如果你的策略交易频率是分钟级或小时级,252 这个年化系数就不适用了,需要按实际的 bar 数量换算。
3.3 参数寻优:网格搜索、随机搜索与过拟合防御
参数寻优看起来像是“把参数空间跑一遍选最好的”,但实际上有两个坑:第一,网格搜索在高维参数空间里会指数爆炸,5 个参数每个 10 个取值就是 10 万次回测;第二,选择最优参数的过程本身就是一种过拟合,你在 10 万个参数组合里挑出来的“最优解”,很可能只是噪声里运气最好的那一个。
我的做法是先做粗网格、再做随机搜索。粗网格把参数范围缩窄到合理区间,随机搜索在缩窄后的空间里采样几百组参数,目的是看绩效指标的分布形态,而不是直接找最大值。
import numpy as np from sklearn.model_selection import ParameterGrid fast_range = range(3, 15) slow_range = range(30, 80) param_grid = {"fast": fast_range, "slow": slow_range} for params in ParameterGrid(param_grid): signals = generate_signals(closes, params["fast"], params["slow"]) result = run_backtest(signals) # 调用回测内核 results.append((params["fast"], params["slow"], result["sharpe"]))跑完之后不要直接挑夏普最大的那组参数,而是看同一参数邻域内的表现是否平滑。如果(fast=5, slow=40)夏普是 2.1,而(fast=6, slow=40)夏普变成 -0.3,说明绩效对参数极度敏感,这个策略本身就不稳定,此时换参数区间比继续寻优更有价值。
参数寻优的落地场景里,还需要考虑计算时间。网格搜索 100 组参数,单次回测 0.1 秒,总耗时 10 秒;如果单次回测要 10 秒,总耗时就是 15 分钟以上。这时候就该用并行了,Python 侧用multiprocessing.Pool把参数组合分发到多进程,每个进程内跑一个独立的回测内核。
4. 回测引擎的骨架与核心参数:从逐行撮合到多标的组合回测
4.1 回测引擎最小闭环:bar 数据循环、持仓状态与订单撮合
回测引擎的核心是一个逐 bar 循环:读一根 K 线,更新账户市值,检查是否有待处理订单,执行撮合逻辑,更新持仓状态。整个循环写成一个 C++ 类,Python 侧只做策略信号的输入和回测结果的输出。
class BacktestEngine { public: void run(const std::vector<Bar>& bars, const std::vector<int>& signals) { double cash = initial_cash_; int position = 0; for (size_t i = 0; i < bars.size(); ++i) { if (signals[i] == 1 && position == 0) { position = static_cast<int>(cash / bars[i].close); cash -= position * bars[i].close; order_log_.push_back({bars[i].ts, "BUY", bars[i].close, position}); } else if (signals[i] == -1 && position > 0) { cash += position * bars[i].close; order_log_.push_back({bars[i].ts, "SELL", bars[i].close, position}); position = 0; } equity_curve_.push_back(cash + position * bars[i].close); } } private: double initial_cash_; std::vector<double> equity_curve_; std::vector<OrderRecord> order_log_; };撮合逻辑里我留了一个简化的杠杆条件:只有当position == 0时才允许开新仓。实际策略里会有加减仓、分批止盈止损,这些逻辑的复杂度体现在持仓管理上——你需要记录每次开仓的价格和数量,平仓时按先进先出或移动平均成本计算盈亏。回测引擎默认按“信号出现后下一根 bar 开盘价成交”撮合,这比按当前 bar 收盘价成交更接近实盘,因为它模拟了信号产生到实际下单之间的延迟。
initial_cash_是引擎的初始资金参数。它影响的可不只是最大仓位,还有static_cast<int>(cash / close)这里——金额除以价格再取整,意味着只能按整股买入,实盘里很多经纪商也是这种规则。做加密货币回测时,改成按固定金额下单可以买零碎数量,但要注意金额太小导致的手续费占比急剧上升。
4.2 手续费、滑点与资金费率:三个回测参数的边界效应
回测结果和实盘结果对不上,十有八九是手续费和滑点设得太理想化。手续费如果按固定比例收,看不出边界效应,但一旦涉及最低收费(比如每笔最低 5 元),高频小单策略的回测结论可能完全反转。
滑点的模拟方式,常见做法是固定点差或按波动率比例。我倾向于按 ATR(平均真实波幅)的比例来设滑点,这样在不同波动环境下滑点会自动调整。实盘里滑点通常比回测里能观测到的数据更恶劣,因为你的回测假设了“成交价永远是最优买卖价”,但市价单往往会吃穿一档。
# 回测配置中用字典管理参数,便于不同策略复用同一套引擎 config = { "initial_cash": 100_000, "commission_rate": 0.0003, # 双边万三 "min_commission": 5.0, # 单笔最低 5 元 "slippage": 0.001, # 按成交金额的千分之一计滑点 "fill_on": "next_open", # 信号发生后下一根 bar 开盘价成交 } engine = BacktestEngine(config) equity_curve = engine.run(bars, signals)slippage设成 0.001 后,高频策略的盈利空间会被压掉不少,这是好事——回测阶段就把不现实的利润挤掉,总比实盘再交学费强。fill_on的取值还影响回测速度,如果设置成same_bar,撮合逻辑就要在当前 bar 的高低点和收盘价之间选择成交价,计算复杂度更高,但更贴近实盘限价单的成交逻辑。
4.3 多标的组合回测:盯市盈亏、资金复用与相关性风险
单标的回测跑通之后,下一步通常就是多标的组合回测。这部分的复杂度核心是资金复用——两个标的同时出现买入信号,但账户里的现金只够买其中一个,牵涉到资金分配。简单做法是把初始资金按固定比例分给每个标的,各跑各的,最后把资金曲线相加。更现实的做法是账户级回测,所有标的共享一个资金池,按信号优先级排序分配资金。
def run_portfolio(bars_dict, signals_dict, weights): equity_curves = {} for symbol in bars_dict: capital = initial_cash * weights.get(symbol, 0) # 按权重分配资金 eq = run_single_backtest(bars_dict[symbol], signals_dict[symbol], capital) equity_curves[symbol] = eq # 按时间对齐所有标的的资金曲线,逐日相加 portfolio_equity = sum(equity_curves.values()) return portfolio_equity这段逻辑是按权重独立回测再加总,好处是简单且结果可解释,坏处是没考虑标的之间的保证金共享和强平联动。如果策略本身是多空对冲,这个简化方式会严重高估风险或低估收益——一边亏损一边盈利,真实账户的维持保证金计算是逐日盯市的,不是期末一次性轧差。
多标的回测的另一个隐形坑是数据对齐。不同标的的交易日期不完全一致,节假日不同,停牌日也不同。直接把各标的价格序列按时间戳拼起来,会产生大量空值。我在处理时统一用“交易日历对齐”,先取所有标的时间戳的并集,然后前向填充缺失价格。这个细节直接影响资金曲线的准确性,尤其是组合里有 A 股和港股的时候,二者节假日差异极大。
4.4 回测避坑清单:信号偷看、前视偏差与非平稳数据
回测框架跑起来之后,大多数“策略很赚钱”的结果都经不住几个基础检查。第一个是信号偷看:你的信号在第 i 根 bar 收盘后产生,但撮合时用的是第 i 根 bar 的收盘价,等于让信号提前知道当前价格去交易。解决办法是把信号数组整体 shift 一位,回测循环里用signals[i-1]的值决定第 i 根 bar 是否交易。
第二个是前视偏差:计算指标时用了未来数据。常见的暴露方式是算滚动均值时提前用了复权因子,或者用了当天的收盘价去计算当天开盘时就会触发的信号。排查方法是把指标计算的数据改成“截至到当前 bar 为止”的累积窗口,再把信号和成交价做错位对比,看到信号当天就成交且成交价优于收盘价,基本可以断定存在前视。
第三个是数据非平稳。用过去三年的数据做回测得到的高夏普,在参数寻优时被选中,但策略可能只在特定的波动率区间有效。我的做法是把样本切成三段:前段做训练选参,中段做验证,后段做“纸上实盘”模拟,三段绩效的一致性比单段的高绩效更有说服力。很多开源框架提供了一个方便的功能叫 walk-forward 分析,本质就是把时间轴滚动起来重复训练-验证的过程,对低中频策略有一定参考价值,但高频策略要慎用,因为市场微观结构变化太快,滚动窗口的结果并不稳定。
第四个是手续费参数的敏感性。同一策略在零手续费下年化 50% 回撤 10%,在双边万三、有最低消费的手续费下可能只剩年化 8% 回撤 20%。跑完主回测后,把手续费和滑点各放大一倍再跑一次,看看绩效有没有大幅恶化,如果恶化明显,这策略的实盘可行性就存疑。
4.5 回测性能优化:多线程、批处理、避免反复分配内存
回测引擎的计算密集度往往不在于策略逻辑,而在于撮合循环和资金曲线更新。C++ 侧的逐 bar 循环里,最常见的性能杀手是每次 bar 都分配一个新的 vector 或 map。运行几次回测后观察内存曲线,如果锯齿状跳变,说明容器在反复分配释放内存。优化方向是预分配:开跑之前就reserve好资金曲线和订单日志的容量,用push_back替代动态拼接。
多线程并行时,每个线程跑一个独立的参数组合,互不共享任何状态,这是最简单的并行模型。但要注意一个坑:Python 的multiprocessing在 Windows 上要用if __name__ == "__main__":保护入口,否则会无限递归创建子进程;Linux 上则默认 fork,不存在这个问题。跨平台代码里建议统一用multiprocessing.get_context("spawn")创建进程池,行为一致且内存隔离更彻底。
数据加载部分,如果每次都从 Parquet 读全量数据再截选时间段,重复 IO 会拖慢寻优过程。常见做法是启动时全量加载到内存,按日期切片用 C++ 侧维护的索引定位起始位置。我在框架里加了一个简单的 bar 级索引:std::unordered_map<int64_t, size_t>把时间戳映射到数组下标,切片时直接查下标,时间复杂度是常数。
5. 验证策略有效性的三个技巧:样本外检测、参数稳定性与模拟撮合
5.1 样本外检测:把最佳参数放到“没见过的数据”上空跑一次
选完参数之后,最怕是“选参数时用过的数据”和“验证用的数据”重叠。严格的做法是:参数寻优时只允许使用训练集,验证时用完全没接触过的验证集,回测报告中同时打印训练集和验证集的绩效,两者差距过大说明过拟合。
我常给框架加一个train_valid_split函数,按时间比例切分数据,默认 70% 训练、30% 验证。回测引擎支持传入两个数据段,分别输出绩效。如果训练集夏普 2.5、验证集夏普 0.8,这个策略基本不能实盘;如果两个都是 1.5 左右,策略的可信度就高很多。
5.2 参数稳定性热力图:看绩效随参数变化是否剧烈
参数稳定性比单点最优值更重要。画出参数热力图——横轴是fast取值,纵轴是slow取值,颜色深浅表示夏普比率。如果热力图上出现明显的“高原区”(大片区域绩效相近且都不错),说明策略对参数不敏感,实盘时参数轻微漂移影响不大;如果只有一个孤立尖峰,四周全是深色低谷,那这个最优参数就是过拟合的产物。
import matplotlib.pyplot as plt import numpy as np # sharpes 是一个二维数组,行对应 fast,列对应 slow plt.imshow(sharpes, aspect="auto", cmap="RdYlGn") plt.colorbar(label="Sharpe Ratio") plt.xlabel("slow window") plt.ylabel("fast window") plt.title("Parameter Stability Heatmap") plt.show()我建议每次寻优完都把热力图存下来,它比一个数值更能说明问题。热力图上还经常能看出参数交互效应,比如fast很小、slow很大时绩效整体偏低,说明策略本质上需要中速均线组合,慢速均线适合做趋势过滤而不适合做主信号。
5.3 模拟撮合验证:用延迟信号重放订单,确认回测边界
回测代码写出来之后,先不要急着跑复杂策略。我的习惯是做一个最简单的 sanity check:随机生成信号,跑一遍引擎,看资金曲线是不是近似横盘(扣掉手续费后应该微亏)。如果随机信号跑出大幅盈利,说明引擎里有前视偏差或撮合逻辑错误。这个 5 分钟能完成的检查,能省下后面排查复杂策略异常结果的数小时。
模拟撮合的另一个用途是验证订单日志。引擎输出的每个订单都带时间戳和价格,随机挑几个订单,手动计算那根 bar 的价差、手续费、滑点对资金曲线的影响,看是不是和引擎的撮合逻辑一致。
最后说一个我自己踩过的坑:曾经在引擎里用浮点数直接比较两次价格相等,用来判断是否触及止盈价,结果因为浮点精度问题,有些价格明明到了止盈位却触发不了。后来统一改成abs(p1 - p2) < 1e-8或者用整数价格(比如把浮点价格乘 10000 转成 int)比较,这个问题的根源是浮点运算的不可精确表示,不是引擎逻辑问题,排查起来很费时间。想通这点后,我在框架里定了一条规矩:所有价格比较都用整数方式,要么转乘数,要么用 epsilon 容差。
希望这篇文章能把你的研究框架搭得更顺一点,少走几步弯路。
本文还有配套的精品资源,点击获取