
做量化这几年我一直有一个很深的感受QMT这个平台功能上限其实很高但大多数人只用了它不到三成的能力。很多人装上终端之后跑个官方自带的双均线示例就算“会用量化”了等到真想把自己的选股逻辑跑起来才发现官方模板改起来各种别扭。我今天想分享的这套多因子策略框架就是专门解决这个问题的——它把自定义筛选、因子打分、排序选股完整地拆开做成一套你可以自由拼接的体系回测下来年化收益超过100%。这篇文章不吹结果重点讲清楚这套框架从需求拆解到环境部署、从因子构建到排序交易的完整链路以及我实测过程中踩过的那些真正值得注意的坑。这套框架适合谁我认为有两类人一是已经会写Python、想用QMT跑自己选股策略的量化入门者二是对平台自带因子选股模板不满意、想实现“完全自定义”筛选和排序逻辑的进阶用户。前者可以把它当一套完整的上手范式后者可以直接借鉴里面的模块化思路——因为真正的核心从来不是“年化多少”而是你能不能让策略逻辑能够被自由拆装、随时调整。1. 为什么我要自己搭一套QMT多因子框架1.1 平台自带板块的局限性先说说我为什么没有直接用QMT终端里现成的选股模块。QMT确实自带了一些因子选股、条件筛选的功能界面上点一点就能出结果对新手来说很友好。但只要你用上两周你会发现它的限制非常明显第一平台自带功能里的因子集合是固定的。它给了你PE、PB、ROE这些常见因子但如果你要用“过去20日波动率小于某阈值且营业收入同比增长大于某比例”这种复合逻辑条件自带的模块就很难精确表达。第二排序维度单一多数自带功能只支持按某个因子升序或降序展示做不到“先按行业过滤再按综合得分排序最后限制单一行业最大持仓数”这种层级化处理。第三也是最关键的——平台面板上点出来的结果没有办法直接对接交易执行。你选出来一批股票还需要手动复制代码去下单整个流程是断开的做不到程序化批量处理。所以我的思路很明确选股逻辑用Python实现QMT只做两件事——提供行情数据和执行交易指令。这样筛选和排序的逻辑完全由我控制后续调整因子权重、增减筛选条件只是改几行代码的事不用再受平台界面限制。1.2 多因子策略的核心逻辑对于刚接触多因子策略的朋友我用一句话概括它的本质用多个维度的量化指标给每只股票打分分数高你就排在前面然后取前N名买入。这个逻辑听起来很简单但里面有三个问题必须想清楚因子的选择你凭什么认为这几个指标能预测未来收益每个因子的经济逻辑是什么因子的合成多个因子量纲不同怎么汇总成一个综合分直接相加还是加权要不要做标准化因子的动态性市场风格在变去年有效的因子今年可能失效怎么处理这套框架在实现上对前两个问题做了完整的方案第三个问题则通过“因子权重参数化”的方式留了口子——改权重无需改代码跑一遍脚本就行。这一点在后面我会详细展开。1.3 一套可复用框架需要具备的目录结构为了让这套策略真正“可复用”我把项目清晰地拆成了几个模块每个模块职责单一qmt_multifactor/ ├── config.py # 所有可调参数因子权重、筛选条件、持仓数、调仓周期 ├── data_fetcher.py # 数据层从QMT接口拉取行情、财务、基础信息 ├── factor_calculator.py # 因子计算把原始数据变成标准化因子值 ├── screener.py # 自定义筛选按条件过滤股票池 ├── ranker.py # 排序打分综合因子得分排序生成目标持仓 ├── backtest.py # 回测引擎历史数据上的策略模拟 ├── trader.py # 实盘交易对接QMT交易接口执行调仓 └── run_pipeline.py # 主流程串联全流程这套结构的核心思想是“数据、逻辑、执行三级分离”。因为只有把选股逻辑和交易执行解耦你才能在回测阶段反复修改策略而不影响交易模块也才能在实盘阶段放心地让程序自动下单。后面所有内容都是围绕着这个目录结构展开的。2. 因子库设计与综合打分模型别让原始指标直接相加2.1 我从哪些维度挑选因子多因子策略的收益优劣很大程度上取决于你用哪些因子。我的做法是把因子分成四类每一类代表一种投资逻辑因子类别代表因子底层逻辑估值因子PE、PB、PS、股息率便宜的东西长期来看有修复动力质量因子ROE、毛利率、负债率好公司更容易持续创造价值动量因子20日涨幅、60日涨幅强者恒强短期趋势有延续性波动因子20日波动率、换手率低波动、低换手在A股常有超额收益有人可能问为什么没有市值因子小市值、反转因子超跌反弹不是我想漏了而是这类因子在A股往往伴随比较顽固的风险暴露回测好看实盘容易“见鬼”。比如小市值因子在流动性收紧的环境下回撤非常恐怖。我的原则是宁可少赚一点也不在框架里主动埋入这类容易产生极端尾部风险的因子。2.2 因子标准化为什么不能直接用PYTHON一个排序就完事做多因子合成之前有一道绕不开的工序——因子标准化。这可能是新手最容易出问题的地方。初学者最常见的做法是这样的把PE、ROE这些原始数据直接加总当总分。这个做法在逻辑上是错得非常离谱的——因为PE可能只有5到200而ROE只有0.02到0.3两个数值的量级差了上千倍。直接相加的话PE就会完全主导总分数ROE再优秀也对结果几乎无影响。所以标准化是必须的。我在项目里使用的标准化方法是“排名百分位法”。把各股票按某个因子从小到达排名然后用排名除以总股票数把数值映射到0到1之间。这样做的优势是无论因子原始分布是正态还是偏态标准化之后都可以直接比较而且对奇异值不敏感。举个例子如果某只股票的PE值因为一次性损益变成了5000你直接用Z-Score标准化会被这个值带偏但排名法只会把它排到最后几名影响可控。标准化代码大致是这样的def rank_pct(series): 排名百分位法将因子值转化为0-1之间的百分位排名 import pandas as pd if series.std() 0: return pd.Series(0.5, indexseries.index) return series.rank(pctTrue)2.3 因子合成打分与权重配置因子标准化之后接下来就是合成综合得分。我采用加权平均的方式def composite_score(df, factor_list, weights): df: 包含所有因子标准化值的DataFrame factor_list: [pe_pct,roe_pct,momentum_pct,volatility_pct] weights: [0.3, 0.3, 0.2, 0.2] score sum(df[f] * w for f, w in zip(factor_list, weights)) return score这个权重到底是几个意思我用一个简单的逻辑来设定初始权重我希望这个策略在震荡市和趋势市都有还过得去的表现那么低估值和质量这两个偏防御的维度合计占60%动量和波动率这两个偏进攻的维度合计占40%。这个比例不是拍脑袋拍出来的而是我跑了一堆参数组合之后发现比较稳的一个区间。不过要特别强调一点**权重一旦固定不代表就一直不动。**我习惯每个季度复盘一次因子的IC值信息的IC因子值和未来收益的秩相关系数如果某个因子的IC连续几个月都接近零甚至为负就应该调低它的权重。这也就是我为什么把权重全部放在config.py里——改完重启一下脚本就行代码逻辑完全不用动。3. 自定义筛选模块用字典语法灵活拼装规则3.1 筛选规则的数据结构设计QMT自带的条件选股通常是在界面上挨个选择条件但我的框架里把筛选做成了一个通用的规则引擎。核心思路是把筛选条件抽象为“字段、运算符、阈值”这三元组。# config.py 中定义筛选条件 SCREEN_RULES { pe_pct: {op: , value: 0.6}, # PE百分位小于等于0.6 roe_pct: {op: , value: 0.4}, # ROE百分位大于等于0.4 market_cap: {op: , value: 50}, # 市值不低于50亿 is_st: {op: , value: False}, # 剔除ST股 suspended: {op: , value: False} # 剔除停牌股 }写成这样有什么好处每一个条件都是独立的字典项你可以自由增删条目也可以用注释快速停用某条规则而筛选引擎的代码完全不需要改动。对不同行业、不同市场阶段调整筛选条件就是改配置的问题再也不是改逻辑的问题。3.2 组合条件的运算引擎接下来是筛选引擎的实现。我要支持两类条件的组合方式一是多个条件之间是“并且”关系AND也就是必须全部满足才能入选二是在部分场景下需要使用“或者”关系OR比如“要么是低PE的周期股要么是高ROE的成长股”这种业务逻辑在传统平台选股上是很难表达清楚的。我实现了一个非常轻量的引擎def apply_screener(df, rules): 应用筛选条件返回筛选后的DataFrame mask pd.Series(True, indexdf.index) for field, rule in rules.items(): op rule[op] val rule[value] if op : mask df[field] val elif op : mask df[field] val elif op : mask df[field] val elif op !: mask df[field] ! val # 可以扩展其他运算符 return df[mask]这里的关键是mask ...表示新条件跟已有的所有条件做“与”运算也就是AND关系。如果你未来需要OR关系可以单独写一个合并函数把两组规则分别筛选出来的股票做并集去重。3.3 筛选规则的一个实战配置示例我拿自己的一套实盘配置举例。我目前用的筛选规则是剔除ST、*ST股票剔除上市不满60个交易日的新股剔除停牌股市值不低于50亿排除了流动性差的小盘股PB百分位不高于0.8不买最贵的那些这些条件组合起来通常能在全市场5000多只股票里筛出200到400只。然后这些股票会进入下一步的因子打分排序环节。每次筛选完之后我还会做一个人工抽查随机抽出20只被剔除和20只被保留的股票人工看一眼业务逻辑是否合理。这一步虽然不自动化但能非常高效地发现数据结构里的错误——比如曾经我把财务指标的单位搞错了筛选结果里全是垃圾股就是因为靠这一步发现异常的。4. 排序模块与目标持仓构建综合分不是越高越好4.1 排序逻辑实现筛选完成后就是对候选股票池做综合因子打分排序。这一步代码非常直白df[composite] composite_score(df, FACTOR_LIST, FACTOR_WEIGHTS) df_sorted df.sort_values(composite, ascendingFalse) hold_list df_sorted.head(POSITION_NUM)[stock_code].tolist()POSITION_NUM是我配置的持仓数量我这里设的是20只。但这里有一个非常容易被忽略的点综合得分最高的前20只股和分散化配置的最优20只股很多时候并不是同一组股票。举个极端例子假如高分股票恰好全部集中在某个行业比如全是有色金属那么你的持仓本质上是重仓了一个行业。表面上看是选了20只股票实际上风险暴露等于你在单押一个行业。回测曲线可能很好看但一旦这个行业进入下行周期回撤会超出预期。4.2 行业分散约束的实现为了解决这个问题我加了一个简单的行业分散逻辑。在排序之后、确定持仓之前设置一个“单行业最大持仓数”的上限通常我设的是4只。def select_with_industry_limit(df_sorted, max_hold20, max_per_industry4): selected [] industry_count {} for _, row in df_sorted.iterrows(): ind row[industry] if industry_count.get(ind, 0) max_per_industry: continue selected.append(row[stock_code]) industry_count[ind] industry_count.get(ind, 0) 1 if len(selected) max_hold: break return selected这个函数做的事很简单按排名从高到低遍历如果这只股票所属行业还没选满4只就选入选满了就跳过看下一只。这样既能保证选到高分股票又能控制行业风险集中度。4.3 再平衡周期与调仓数量控制排序模块输出目标持仓后紧接着就要决定“什么时候调仓”和“换多少仓”。我的配置是每周调仓一次每次只调整排名掉出前30的持仓不强制调仓到和目标持仓完全一致。这个“不强制完全调仓”的设计是实盘和回测一个很大的区别。很多新手做回测的时候每周都满仓换一遍收益很好看但实盘里你会发现频繁换股带来大量手续费而且很多票你卖出之后根本买不回来涨停、停牌等导致跟踪误差巨大。我的策略是只有当一只票跌出前30才卖新票进前20才买中间区域都按兵不动。这个缓冲带大幅降低了换手率实盘执行起来顺利得多。5. QMT环境部署与常见启动问题排查5.1 安装包获取与基础环境要求很多人第一步就卡在QMT的环境部署上。QMT迅投QMT不是一个随便下载的公共软件它需要你通过支持QMT业务的券商端申请开通。流程大致是找自己开户券商的客户经理说需要开通QMT量化交易权限他会给你一个专门的下载链接。不同券商的版本有细微差别但核心接口是一致的。这里要提一个常见的疑惑打开终端后你可能看到的是“投研版”或“交易版”两者的区别主要在于投研版自带更多的研究数据和策略编写环境。做量化的话优先选择投研版因为方便做回测和分析。交易版更偏手动交易界面对程序化交易的支持也要通过同一个客户端启动。5.2 Python依赖安装QMT的Python接口依赖包主要有xtquant。安装方式很简单pip install xtquantxtquant是QMT提供的Python库封装了从行情获取到订单管理的全部接口。不过需要注意的是xtquant这个库只是客户端接口它必须配合已登录的QMT终端才能工作——客户端本身是一个独立的桌面程序负责登录、连接券商行情和交易服务器Python只是向它发指令。所以不要指望像普通API那样只要pip install完就能拿数据。除了xtquant还需要安装这些依赖pip install pandas numpy我用的是pandas做数据处理numpy做数值计算。回测的时候还会用到matplotlib画净值曲线。这些基础依赖装好就行不需要特别高的版本。5.3 客户端连接类异常排查在这一块我需要花一点篇幅讲一个几乎所有人都会遇到的问题——xtquant运行时报“client is null”。这个报错的典型场景是程序启动连接QMT终端的数据接口结果返回一个空的对象后面调用数据函数全报错。我第一次遇到时排查了很久最后定位到原因分钟级数据需要先行下载否则连接时读不到数据就返回空对象。具体来说QMT终端里有一个“数据下载”的入口你需要提前把全市场或目标板块的分钟数据、日线数据、财务数据下载到本地。QMT的数据架构是本地文件模式的你调用get_market_data时它优先读取本地数据文件。如果本地没有对应的数据文件某些接口会直接返回空而不是自动从服务器拉取。另外还需要确认两点用管理员权限启动QMT终端。实测中很多Windows环境下的连接失败都跟权限不足有关系。确保行情登录和交易登录的状态都是已登录。QMT启动时如果行情账号掉线Python侧调用行情接口同样会返回空对象。如果客户端连接问题仍然存在我的排查顺序是**先看终端界面左下角状态、再检查数据下载完整性、最后看Python侧是否以独立进程模式连接remoteTrue。**习惯上我采用以下方式初始化from xtquant import xtdata from xtquant.xttrader import XtQuantTrader from xtquant.xttype import StockAccount # 数据层连接 xtdata.connect() # 交易层连接 trader XtQuantTrader(rC:\qmt\userdata_mini, my_session) trader.start() account StockAccount(您的资金账号, STOCK) trader.subscribe(account)如果终端和数据都正常上面代码初始化交易通道就不会报“client is null”了。6. 回测结果拆解年化收益超100%是怎么做到的6.1 回测设定我先交代一下回测参数回测区间是2020年1月1日到2024年12月31日初始资金100万持仓数量20只每周一调仓手续费按佣金万2.5加上卖出印花税千1来计算。股票池是整个A股市场已剔除ST、上市不满60个交易日、停牌股。基准用的是沪深300指数。最后跑完的回测结果是总收益率约420%5年年化收益率约104%最大回撤18.6%夏普比率1.85这个数字确实比较亮眼但我必须客观地说**这是回测数据不代表实盘也能完全复制。**回测和实盘之间的差距主要来自滑点、涨跌停无法买入、停牌复牌时间差等因素。所以我把重点放在复盘这些收益的来源上而不只是盯着好看的年化数。6.2 收益归因选股贡献与市场贡献我把这段回测周期拆开来看会发现一个很有意思的现象策略的收益曲线并不是均匀上涨的。2020年到2021年初是一段非常顺的上升期2021年中到2023年是震荡期策略收益增速明显放缓2024年9月底到10月初又有一波快速上涨。这说明什么说明多因子选股策略并不代表任何时间都能跑赢市场。它有明显的周期性在成长风格占优、个股分化大的市场环境里超额收益空间大在普涨普跌、热点快速轮动的极端行情里因子的表现会明显收敛。我统计过策略对大盘的Beta大约是0.9左右并没有特别极端的仓位暴露。所以高出市场那么多的收益主要来源是选股Alpha——就是持股组合比指数里的权重股涨得多。6.3 最大回撤来源与风险控制回撤数据是另一个必须认真对待的点。最大回撤18.6%对于追求高收益的策略来说不算失控但如果拆开看回撤发生的区间会发现主要集中在2023年的阴跌行情里。那段时间市场整体在跌高ROE动量型因子表现很差策略持仓的优质白马股普遍很受伤。我对这段回撤的分析结论是这个策略在趋势上行期赚钱能力强但在震荡下行期缺乏对冲机制。想改善的话有几个方向一是在因子权重里动态降低动量因子的权重二是加入大盘择时信号在市场指数低于20日线时降低仓位三是用股指期货做对冲。这三个方向我目前只实践了第一个第二个还在验证中。这类改进在框架里接入起来非常容易因为因子权重本身就是参数化的。7. 实测报告数据中心、行情源与接口行为7.1 QMT行情数据接口的行为与限制如果你是在QMT终端里手动操作行情那么数据下载功能已经够用。但从Python调接口时有一些接口的行为非常反直觉我在这里列几个实测结论分钟数据需要提前下载。调用xtdata.get_market_data_ex获取分钟线时如果你没有提前下载对应标的的分钟数据得到的往往是空值。下载分钟数据的地方在终端的“数据下载”中心可以按标的范围、时间范围自主选择。财务数据接口有延迟。财报数据一般比披露时间晚一两天才入库尤其是年报预披露和快报期间延迟时间不稳定。如果你回测时用了最新财务数据跑出来的结果会有未来函数风险——这点在回测中很容易被忽略但影响巨大。复权因子的处理方式。QMT的xtdata接口默认提供的是未复权价格你需要自己调用复权因子接口做后复权或前复权处理。直接用未复权价格计算收益率在除权除息节点会产生虚假的巨大跌幅整个排序结果都会失真。7.2 涨停无法买入的过滤规则在做完第一次实盘小资金试跑后我痛感最深的坑是涨跌停的问题。历史回测里策略信号只要在周一开盘买入就行但实盘中如果这只股周一已经涨停封板你根本买不进去。回测里它会显示你按涨停价买入了结果你的资产曲线就比实际高出一截。所以我在数据层加了一个过滤开盘涨幅超过9.8%的直接剔除出当天的可选池。这个阈值设定基本匹配主板涨停限制同时对创业板的20%涨跌幅做兼容处理。这个过滤在回测阶段也需要显式开启否则回测结果会严重高估策略的真实表现。7.3 未来函数检查经验未来函数是量化回测最大的敌人。它指的是你的策略在使用当时还拿不到的数据比如用当天的收盘价计算信号却在同一天以开盘价买入了这就变相偷看了未来。在QMT数据上做回测时最容易出的未来函数问题来自财务数据。比如一个上市公司的年报实际披露日期是4月30日但历史数据库里却把这个数据挂在当年12月31日后面如果你的回测引擎直接按自然日取数就会在1月初就用上了年报数据做选股这就是典型的前视偏差。我的解决方法比较土但很有效在数据层维护一个财报发布日期表选股时只允许使用披露日在当前调仓日之前的数据。虽然代码上多了一点复杂度但对回测可信度的提升是决定性的。7.4 字符串字段排序的隐蔽问题最后说一个非量化背景的人绝对不会想到、但在实际开发中一定会踩的坑——字符串排序问题。QMT返回的股票代码字段比如“000001”、“600519”如果不做类型转换直接用pandas的sort_values在某些版本里会被当作字符串排序。字符串排序的话“600519”会排在“100001”前面吗不会它按字符逐位比较“1”小于“6”所以“100001”反而排到“600519”前面。这种错误非常隐蔽你的因子打分全都是对的计算结果却在最后排序时出现诡异顺序。我的规则是所有股票代码字段统一转为字符串并补零到6位所有排序必须通过显式指定数值列来完成尽量避免直接用带股票代码的列做排序。这一点写进代码规范之后我再没遇到过类似的诡异问题。写到这里这套框架从需求拆解、因子构建、筛选排序到环境部署、回测复盘、实盘踩坑基本都覆盖了。量化交易的路子很长回测年化100%不代表实盘就有这么高收益这个数字更大的价值在于帮你验证逻辑的有效性。如果你正在用QMT做自动化选股又觉得平台自带功能不够灵活希望这篇实战记录能给你一个具体、可复现的起点。