简介这是一份基于vnpy进行二次开发的量化交易实践资源面向熟悉Python并希望深入金融量化领域的开发者、分析师与投资者聚焦自动化选股、策略回测与机器学习模型集成。资源内容涵盖交易接口对接、数据管理、回测引擎扩展及性能优化等模块结合C扩展可满足高频计算场景需求并提供了从数据获取到策略验证的完整路径。包内共1656个文件以h与hpp头文件、cpp源文件、py脚本及ipynb笔记为主辅以dll动态库和配置文件压缩包整体约59.07MB目录结构清晰便于按模块定位与二次开发。机器学习部分演示了线性回归、支持向量机、随机森林等算法在价格预测与多因子选股中的应用并涉及交叉验证、正则化防过拟合以及集成学习组合策略有助于提升模型稳健性。资源已有598人学习适合用于搭建自动化交易系统、扩展策略库并将AI技术落地到实际投资决策。1. vnpy二次开发选股、回测、机器学习三个模块怎么协同才不翻车如果你已经用vnpy跑通过几套CTA策略再看“基于vnpy的二次开发选股、回测、机器学习”这个标题大概率会有同一个困惑vnpy原生不是已经带回测吗为什么还要再开发一层答案是vnpy自带的CTA策略模板是单标的、单策略、信号即下单的模型而股票选股天然是“先把几十只股票打分选出来再统一分配资金”的组合决策。这个标题背后是一套以vnpy回测引擎为执行底座、上面挂因子选股和机器学习信号的综合方案核心价值是让你不用换框架就能把模型驱动的股票池决策接进成熟的回测环境。适合有Python基础、正从规则型CTA转向股票因子策略的量化从业者——新手能照步骤搭起来熟手可以直接改参数和撮合逻辑。2. 选股模块独立成池因子打分脚本输出Top N候选池再交给vnpy回测2.1 为什么选股要独立在vnpy之外数据口径与交易时点错位vnpy的架构里一个策略实例只绑定一个合约它处理的是这个合约的K线事件然后发单到网关。如果你把选股逻辑直接写进策略模板每个标的行为例都会重复跑一遍全市场打分数据量大时回测会明显变慢。更麻烦的是选股经常要用的财务、市值数据和行情数据更新频率、复权口径都不一样混在事件循环里处理出的错往往很难查。我一般把选股做成完全独立的任务每天收盘后或每周跑一次拉全市场日线加市值数据算因子、打总分输出一个Top N的候选池CSV。vnpy策略只负责“读池子、按白名单过滤、执行交易”。这样回测和实盘共用同一个池子文件选股逻辑可以单独调参验证完全不动vnpy核心。这个结构还有一个好处后面换数据服务商或换因子只改选股脚本不影响交易策略。2.2 因子打分选股的完整脚本输入日线数据输出Top N候选池常见的做法是先把每只股票的历史日线落盘成CSV再写一个打分脚本批量处理。下面的脚本就是候选池生成器的骨架它按动量、波动率倒数、市值对数三个因子加权打分输出得分最高的30只股票到candidate_pool.csv。import pandas as pd import numpy as np from pathlib import Path # 假设 data/ 目录下每只股票一个 CSV # 列date, open, high, low, close, volume, amount, mv # mv 是流通市值选股日必须用截至当天已经确认的数据 def load_stock(symbol: str) - pd.DataFrame: df pd.read_csv(fdata/{symbol}.csv, parse_dates[date]) return df.sort_values(date).reset_index(dropTrue) def score_stock(df: pd.DataFrame, w_momentum: float, w_vol_inv: float, w_ln_mv: float) - float: # 动量因子过去20日涨跌幅需要至少21根K线 momentum df[close].iloc[-1] / df[close].iloc[-21] - 1 # 波动率倒数过去20日日收益标准差越小分数越高 daily_ret df[close].pct_change().tail(20) vol_inv 1 / (daily_ret.std() 1e-8) # 小市值偏好市值取对数后参与打分 ln_mv np.log(df[mv].iloc[-1] 1) return (w_momentum * momentum w_vol_inv * vol_inv w_ln_mv * ln_mv) symbols [p.stem for p in Path(data).glob(*.csv)] rows [] for sym in symbols: df load_stock(sym) if len(df) 60: # 数据量不足的股票排除 continue score score_stock(df, 0.4, 0.3, 0.3) rows.append({symbol: sym, score: score}) pool pd.DataFrame(rows).sort_values(score, ascendingFalse) pool.head(30).to_csv(candidate_pool.csv, indexFalse)三个因子的权重0.4/0.3/0.3只是示例含义分别是趋势、风险、市值偏好的强弱。动量因子捕捉上涨惯性波动率倒数偏向稳定品种市值对数则把注意力引向小盘。需要注意量纲问题动量是百分比小数如0.05波动率倒数可能是几十甚至上百市值对数是十几直接加权会导致市值因子主导打分。正式环境里每个因子先做z-score标准化再加权这个脚本只是跑通流程的最小版本。2.3 候选池接入vnpy回测白名单过滤与多标的循环候选池生成后回测阶段就是对池内每只股票逐个跑vnpy的BacktestingEngine。vnpy原生引擎是单标的的组合回测就自己写循环逐标的跑完再汇总统计。from vnpy_ctastrategy.backtesting import BacktestingEngine # 取候选池前20只进入回测 candidates pd.read_csv(candidate_pool.csv).head(20)[symbol].tolist() total_returns {} for sym in candidates: engine BacktestingEngine() engine.set_parameters( vt_symbolf{sym}.XXXX, # 替换为真实股票代码和交易所后缀 intervalInterval.DAILY, # 日线即可股票日频策略不需要分钟线 startdatetime(2023, 1, 1), enddatetime(2024, 12, 31), rate0.0005, # 单边费用含印花税分摊 slippage0.02, # 保守滑点 size100, # A股一手100股 pricetick0.01, capital1_000_000 # 单标的初始资金设为等额 ) engine.add_strategy(MyStrategy, {}) engine.load_data() engine.run_backtesting() df engine.calculate_result() total_returns[sym] df.loc[总收益率, result] # 等权组合收益 各标的收益的平均 print(组合年化收益约, sum(total_returns.values()) / len(total_returns))注意engine要在循环里每次重新创建不能复用同一个实例。vnpy的BacktestingEngine会在run_backtesting时累积内部状态重复使用会把上次的持仓和信号带进下一只股票结果完全失真。另外set_parameters里的vt_symbol后缀要正确股票回测一般用交易所后缀区分写错会导致load_data拉不到数据而静默返回空K线回测结果全是0。这类问题不会报错只能靠检查净值曲线发现。提示回测前先确认数据服务商提供的区间覆盖start到end。区间不足时vnpy不会提醒只会返回空数据。3. 回测模块二次开发日频组合回测的撮合循环与三个必调参数3.1 vnpy回测引擎的撮合假设价格成交逻辑在股票日频上的误差vnpy CTA回测是事件驱动每根K线推给策略的on_bar策略发单后引擎按bar的价格加减滑点撮合。CTA常见的是分钟级或tick级数据成交价格误差有限但股票日频策略直接用日bar的收盘价撮合等于“看到了收盘价再下单却按收盘价成交”。这是最典型的前视偏差来源回测收益会明显虚高越短周期的因子虚高越严重。所以股票日频回测要做一处本地改造信号在T日收盘后生成成交放到T1日开盘价。vnpy自带的BacktestingEngine没有直接暴露这种“次日开盘撮合”的开关二次开发就是绕开CTA引擎的默认撮合写一个适合股票调仓频率的循环。这个循环不需要处理tick级小数逻辑简单很多。3.2 改成日频调仓回测T日收盘信号、T1开盘撮合的最小循环下面是我常用的日频组合回测循环。它同时处理了三个关键点信号只用到T日之前的数据、成交用次日开盘价、加了一字板和换手过滤。这个循环可以独立跑也可以作为改造vnpy回测的参考骨架。import pandas as pd from pathlib import Path def run_rotation(model, pool, data_dir: Path, fee_rate0.0005, min_turnover0.05): # 每只股票一个日线CSV索引为date含 open/high/low/close/amount daily {sym: pd.read_csv(data_dir / f{sym}.csv, index_coldate, parse_datesTrue) for sym in pool} trade_dates pd.date_range(20230101, 20241231, freqB) cash 1_000_000 qty {sym: 0 for sym in pool} # 持仓股数 cur_weights {sym: 0.0 for sym in pool} nav pd.Series(dtypefloat) for date in trade_dates: # 决策周一收盘后用截止到上周五的数据出信号 if date.weekday() 0: signal_weights dict(cur_weights) # 复制一份避免影响当前持仓 for sym in pool: hist daily[sym].loc[:date].iloc[:-1] # 剔除当天防未来函数 if len(hist) 21: continue x build_features(hist) # 特征函数与训练时保持一致 prob model.predict_proba(x)[0, 1] signal_weights[sym] 1.0 if prob 0.6 else 0.0 # 换手过滤目标权重和当前权重差距过小就不动 turnover sum(abs(signal_weights.get(s, 0) - cur_weights.get(s, 0)) for s in set(signal_weights) | set(cur_weights)) if turnover min_turnover: signal_weights cur_weights # 撮合用当日开盘价成交一字板不成交 for sym in pool: target_w signal_weights.get(sym, 0) if target_w 0: continue row daily[sym].loc[date] if row[open] row[high] row[low]: continue # 一字板实际买不进 target_qty int(cash * target_w / row[open] / 100) * 100 delta_qty target_qty - qty[sym] if delta_qty 0: cash - delta_qty * row[open] * (1 fee_rate) qty[sym] delta_qty elif delta_qty 0: cash (-delta_qty) * row[open] * (1 - fee_rate) qty[sym] delta_qty # 收盘后按收盘价记录组合净值 equity cash sum(qty[s] * daily[sym].loc[date, close] for s in pool if qty[s] 0) nav.loc[date] equity cur_weights {s: qty[s] * daily[sym].loc[date, close] / equity for s in pool} return nav撮合顺序是这个循环的核心周一收盘后才有完整数据算出信号周二开盘去成交这和你手工交易的时间线完全一致。build_features必须是你在训练模型时用的同一套特征构造逻辑否则推理输入错位模型输出没有意义。一字板过滤用open high low做近似判断停牌日则需要额外维护一个停牌名单这里没展开。3.3 三个必调参数手续费率、滑点、最小换手过滤股票日频策略一年换手几十次手续费和滑点的设定直接决定回测结果靠不靠谱。我一般固定用下面这组起点参数再根据实际品种微调。参数取值建议原因rate 手续费率0.0005万五股票双边佣金一般万三以内卖出另有印花税把印花税摊进单边费率更省事slippage 滑点0.02两跳实际成交价比你看到的信号价差1到2个最小变动价位设成0会让回测虚高min_turnover 最小换手0.055%目标权重和当前持仓差异小于5%时不调仓避免小额调仓被手续费吃光换手过滤的代码很短但很多新手会漏掉turnover sum(abs(new_weights[s] - old_weights[s]) for s in pool) if turnover min_turnover: new_weights old_weights # 差异太小干脆不操作回测报告里的换手率也要重点看不是只看收益。如果策略年化收益高但换手率几百倍把费率调高后收益塌掉说明策略本质是在赚交易频率的钱实盘会被成本吞噬。4. 机器学习决策接入策略特征、标签与模型推理的完整链路4.1 特征与标签怎么构造未来N日收益当监督信号时间对齐是核心机器学习在选股里的角色不是预测股价而是预测“未来一段时间是否上涨”。常见做法是把未来5日收益率大于某个阈值的样本标为正类让模型学习特征到涨跌概率的映射。为什么用5日而不是1日单日收益噪声太大模型学到的大多是噪声5日能过滤掉部分日内反转噪音正样本比例也更稳定。阈值取2%是一个相对保守的门槛不同市场环境下正样本占比会浮动训练前先打印一下label的均值确认类别平衡度。特征统一用公开日线就能算出来的动量、波动率、成交金额均值和市值对数。好处是数据获取成本低坏处是信号同质化严重——大家都在用这些因子模型提升空间有限。先跑通链路再逐步加独有数据源。4.2 训练脚本按时间切分样本单股票内部算特征防泄漏训练脚本要做两处强制约束特征和标签必须在单只股票内部计算防止pandas的shift和pct_change跨股票串行训练集和验证集按时间顺序切分绝不随机打乱。import pandas as pd import numpy as np import joblib from lightgbm import LGBMClassifier HORIZON 5 # 未来5日 THRESHOLD 0.02 # 5日收益超2%标记为正样本 def add_features(df: pd.DataFrame) - pd.DataFrame: df df.copy() df[ret_5] df[close].pct_change(5) df[ret_20] df[close].pct_change(20) df[vol_20] df[close].pct_change().rolling(20).std() df[amount_ma5] df[amount].rolling(5).mean() df[ln_mv] np.log(df[mv] 1) return df def add_label(df: pd.DataFrame) - pd.DataFrame: # 标签必须在单只股票内部 shift防止跨股票串数据 future_ret df[close].shift(-HORIZON) / df[close] - 1 df[label] (future_ret THRESHOLD).astype(int) return df frames [] for sym in symbols: # symbols 由第2章脚本提供 raw load_stock(sym) df add_features(raw).dropna() df add_label(df).dropna() df[symbol] sym frames.append(df) all_data pd.concat(frames, ignore_indexTrue) all_data all_data.sort_values(date, ignore_indexTrue) # 按时间排序 # 前70%时间段训练后30%时间段验证 split_idx int(len(all_data) * 0.7) train all_data.iloc[:split_idx] test all_data.iloc[split_idx:] feature_cols [ret_5, ret_20, vol_20, amount_ma5, ln_mv] model LGBMClassifier(n_estimators300, learning_rate0.05, num_leaves15, random_state42) model.fit(train[feature_cols], train[label]) importances pd.Series(model.feature_importances_, indexfeature_cols).sort_values(ascendingFalse) print(importances) joblib.dump({model: model, feature_cols: feature_cols, train_cutoff: all_data[date].iloc[split_idx]}, stock_model.pkl)这里最关键的是concat之后重新按日期排序再做前70%切分。如果不排序concat顺序是“先股票A全时间段再股票B全时间段”前70%行对应的不是早期时间而是前一批股票的所有数据验证集和训练集会混入同时间段的不同股票回测时模型相当于偷偷见过验证期附近的横截面信息。n_estimators300对日频数据偏高通常先试100观察验证集AUC不再上升就停。num_leaves15是限制模型复杂度的手段叶子太少欠拟合太多就过拟合15到31之间是常见搜索区间。4.3 策略里加载模型做推理决策日才算特征概率阈值转目标仓位训练完成后模型文件放进vnpy策略目录。策略在on_init时一次性加载模型不要把joblib.load写在on_bar里否则每根K线都做一次磁盘IO回测会慢到无法接受。下面是嵌入策略的骨架。from collections import deque from vnpy_ctastrategy import StrategyTemplate class MLStockStrategy(StrategyTemplate): model_file stock_model.pkl predict_threshold 0.6 def on_init(self): pkl joblib.load(self.model_file) self.model pkl[model] self.feature_cols pkl[feature_cols] self.history deque(maxlen40) # 40根K线足够算20日特征 def on_bar(self, bar: BarData): self.history.append(bar) if len(self.history) 21: return # 只在周一出信号其余交易日沿用上次仓位 if bar.datetime.weekday() ! 0: return df bars_to_df(self.history) # 自己实现的转换保留 close/amount/mv x build_features(df)[-1:][self.feature_cols] prob self.model.predict_proba(x)[0, 1] target self.predict_threshold if prob self.predict_threshold else 0.0 self.set_target_weight(target) def set_target_weight(self, w: float): # 简化版权重管理把当前持仓调整到目标权重 w # 内部按总资金和目标价折算股数再调用 buy/sell 发单 target_qty int(self.capital * w / self.last_price / 100) * 100 diff target_qty - self.pos if diff 0: self.buy(self.last_price, diff) elif diff 0: self.sell(self.last_price, -diff)bars_to_df是自己写的一个小工具函数把deque里的BarData对象转成DataFrame只保留close、amount、mv等列。注意特征构造的输入窗口长度deque最大40但build_features里pct_change(20)至少要21根留足裕量。行为上“周一推理平时沿用”把推理次数压缩到每周一次回测速度提升明显也更接近实际操盘节奏——普通人不会每天都调仓。5. 二次开发避坑5个让回测漂亮实盘崩盘的问题及排查回测做得漂亮、实盘就崩几乎是股票策略开发里的默认剧本。下面五条是我排查过很多次后归纳出来的按“现象→原因→解决”列出方便直接对照检查。5.1 未来函数标签错位让回测年化虚高现象回测年化40%以上最大回撤不到5%资金曲线接近直线实盘却完全对不上。原因最常见的错位是“当日收盘价”同时用于计算特征和作为成交价。你在T日收盘后算完信号却按T日收盘价成交等于用已知信息交易。另一个隐蔽来源是标签构造时用了shift(-5)但没有保证shift是在单只股票内部完成的导致跨股票串数据。解决统一时间对齐规则——特征严格用T日及以前的数据信号在T日收盘后生成成交用T1开盘价。在回测循环里加一行调试输出把“T日信号、T1成交价、T1成交数量”打印出来核对三个时点是否错位比看任何指标都直接。5.2 复权口径不一致除权日价格对不上账面现象某只股票回测明细里除权除息日前后出现异常跳空买卖价格和K线显示价格对不上。原因数据源默认给前复权选股脚本用的CSV和后端数据接口用的可能是两套复权口径。除权日当天前复权的历史价格被整体修正而成交价如果取自另一套未复权数据就会在断层上成交。解决全链路统一使用后复权或统一前复权选股、训练、回测读同一份数据文件。除权日附近强制不调仓避免在价格断层上成交。每次更换数据源后随机抽几只股票检查除权日的价格连续性。5.3 涨跌停和停牌理想撮合带来的虚假成交现象小盘股策略回测胜率极高实盘却很平庸——涨停买不进、跌停卖不出、停牌根本无交易。原因vnpy默认撮合假设任何价格都能成交实际上一字涨停时挂单排在队伍末尾根本成交不了跌停时卖单同样出不去。解决在撮合循环里加一字板判断开盘价、最高价、最低价相等时跳过当天调仓。停牌日需要单独维护名单数据服务商一般会提供停复牌标记拉取后存成字典撮合前先查一遍。5.4 模型推理拖慢回测特征重复计算的黑匣子现象明明只有20只股票日频策略回测却跑了一晚上。原因特征在每个交易日对每只股票重复计算模型也在每根K线里反复加载。很多人把joblib.load写进on_bar回测性能直接崩掉这就是个黑匣子表面上看不出逻辑错误就是慢。解决特征计算结果用字典缓存key是“日期股票代码”同一天重复使用模型在on_init里加载进内存推理只放在决策日非决策日沿用上次信号。以上三点做到回测时间能快一到两个数量级。5.5 过拟合样本内惊艳、样本外打回原形现象训练集收益神级验证集立刻衰退怎么调参都救不回来。原因最常见的是随机切分数据集——随机洗牌后训练集和测试集包含重叠时间段模型直接记住了时间段噪声。其次是不加约束的超参搜索几百组参数里总能挑出一组样本内漂亮的。解决严格按时间顺序切分验证集时间必须晚于训练集滚动验证用连续多个时间段反复测试只保留每个时间段都稳定的参数超参搜索控制在3到5组别追求样本内最优。6. 进阶滚动训练与特征漂移预警让模型不是一次性玩具6.1 滚动训练与模型版本自动加载静态训练出的模型放到第二年会明显失效滚动训练是让机器学习模块可长期运行的底线。常见做法是每月最后一个交易日用最近12个月数据重训模型文件名带训练日期如stock_model_20250131.pkl策略每天检查模型目录发现新文件就重新加载。latest_model max(MODEL_DIR.glob(stock_model_*.pkl), keylambda p: p.stat().st_mtime) if latest_model ! self.current_model: self.model joblib.load(latest_model) self.current_model latest_model这个懒加载方式不用重启vnpy进程训练脚本单独跑策略自动热更新。不同训练日期的模型不要覆盖同一个文件保留历史模型文件方便回测时对比“用旧模型和用新模型的结果差异”。6.2 特征漂移预警模型该重新训练的信号模型失效前通常有先兆top特征分布和训练集越差越远。训练时把各特征的均值train_mean和标准差train_std存成json策略实时计算当前特征相对训练集的z-score最大值超过2就说明市场风格可能变了。drift (current_features - train_mean) / train_std if drift.abs().max() 2: self.target_weight 0.0 # 特征漂移过大先降仓等重训我自己的习惯是模型训练时间超过90天就强制停用信号宁可空仓也不硬做。滚动训练、漂移预警、到期降仓这三件事配合起来机器学习选股才算真正脱离了“回测黑匣子”阶段变成一个可持续维护的生产组件。这个坑吃多了之后就明白模型不重训收益曲线迟早给你上一课。希望帮到你。本文还有配套的精品资源点击获取