问题描述
解决方案
这是一个非常经典的量化交易需求。配对交易(Pairs Trading)是一种市场中性策略,其核心思想是利用两只历史走势高度相关的股票,当它们的价格比率偏离历史均值时进行反向操作(买入低估的,卖出高估的),等待回归均值获利。
策略设计思路
- 标的选择:为了演示,我们选择银行板块中相关性较高的两只股票:浦发银行 (600000.SH) 和 兴业银行 (601166.SH)。在实际应用中,你需要通过协整检验(Cointegration Test)来筛选标的。
- 核心逻辑:
- 计算两只股票的价格比率:
Ratio = Price_A / Price_B。 - 计算比率的移动平均线(Mean)和标准差(Std)。
- 计算 Z-Score(标准分):
Z = (Current_Ratio - Mean) / Std。
- 计算两只股票的价格比率:
- 交易信号:
- 做空价差 (Short Ratio):当
Z > 2(比率过高),说明 A 贵 B 便宜。操作:卖出 A,买入 B。 - 做多价差 (Long Ratio):当
Z < -2(比率过低),说明 A 便宜 B 贵。操作:买入 A,卖出 B。 - 平仓 (Exit):当
abs(Z) < 0.5(回归均值),平掉所有仓位。
- 做空价差 (Short Ratio):当
QMT 策略代码实现
以下是完整的 Python 策略代码。请注意,A股做空(卖出)需要开通融资融券账户。在回测模式下,QMT 通常支持负持仓模拟;在实盘中,你需要确保账户有融券额度。
# -*- coding: gbk -*-
import pandas as pd
import numpy as np
def init(ContextInfo):
"""
策略初始化函数
"""
# 1. 设置两只配对股票
# Stock A (分子): 浦发银行
# Stock B (分母): 兴业银行
ContextInfo.stock_a = '600000.SH'
ContextInfo.stock_b = '601166.SH'
ContextInfo.pairs = [ContextInfo.stock_a, ContextInfo.stock_b]
# 2. 设置参数
ContextInfo.lookback_window = 20 # 均值回归的回看窗口(例如20天)
ContextInfo.entry_threshold = 2.0 # 开仓阈值(2倍标准差)
ContextInfo.exit_threshold = 0.5 # 平仓阈值(0.5倍标准差)
ContextInfo.trade_capital = 100000 # 单边交易金额(元)
# 3. 设置账号(实盘或回测需替换为真实账号)
# 请在'交易'界面查看您的资金账号
ContextInfo.account_id = 'YOUR_ACCOUNT_ID'
ContextInfo.account_type = 'STOCK' # 股票账号
ContextInfo.set_account(ContextInfo.account_id)
# 4. 设置股票池(用于回测数据下载)
ContextInfo.set_universe(ContextInfo.pairs)
def handlebar(ContextInfo):
"""
K线周期运行函数
"""
# 跳过历史K线,只在最后几根或实时行情运行,避免回测初期数据不足报错
if ContextInfo.barpos < ContextInfo.lookback_window + 2:
return
# 1. 获取历史行情数据
# 获取过去 N 天的收盘价数据
# 注意:count 要比 lookback_window 稍大一点以确保计算准确
data = ContextInfo.get_market_data_ex(
fields=['close'],
stock_code=ContextInfo.pairs,
period='1d',
count=ContextInfo.lookback_window + 5,
dividend_type='front' # 前复权
)
# 检查数据是否获取成功
if ContextInfo.stock_a not in data or ContextInfo.stock_b not in data:
return
df_a = data[ContextInfo.stock_a]
df_b = data[ContextInfo.stock_b]
# 确保数据长度一致且足够
common_index = df_a.index.intersection(df_b.index)
if len(common_index) < ContextInfo.lookback_window:
return
# 对齐数据
close_a = df_a.loc[common_index]['close']
close_b = df_b.loc[common_index]['close']
# 2. 计算统计指标
# 计算价格比率序列
ratio_series = close_a / close_b
# 获取切片(最近 lookback_window 天)
recent_ratios = ratio_series.iloc[-ContextInfo.lookback_window:]
# 计算均值和标准差
mean = recent_ratios.mean()
std = recent_ratios.std()
# 获取当前最新的比率
current_ratio = recent_ratios.iloc[-1]
# 计算 Z-Score
if std == 0:
return
z_score = (current_ratio - mean) / std
# 打印日志方便调试
# print(f"Date: {common_index[-1]}, Ratio: {current_ratio:.4f}, Z-Score: {z_score:.4f}")
# 3. 获取当前持仓状态
# 这里使用 get_trade_detail_data 获取持仓,判断是否持有
positions = ContextInfo.get_trade_detail_data(ContextInfo.account_id, ContextInfo.account_type, 'POSITION')
pos_dict = {obj.m_strInstrumentID + '.' + obj.m_strExchangeID: obj.m_nVolume for obj in positions}
# 简化持仓判断逻辑:正数为多头,负数为空头(回测中)
# 实盘中需要区分融券卖出
vol_a = pos_dict.get(ContextInfo.stock_a, 0)
vol_b = pos_dict.get(ContextInfo.stock_b, 0)
# 4. 交易逻辑
# --- 信号:做空价差 (A贵B便宜 -> 卖A买B) ---
if z_score > ContextInfo.entry_threshold:
# 如果没有持有该方向的仓位
if not (vol_a < 0 and vol_b > 0):
# 先平掉反向仓位(如果有)
if vol_a > 0: order_target_value(ContextInfo.stock_a, 0, ContextInfo, ContextInfo.account_id)
if vol_b < 0: order_target_value(ContextInfo.stock_b, 0, ContextInfo, ContextInfo.account_id)
# 开仓:卖空 A,买入 B
# 注意:order_target_value 负数在回测中代表做空,实盘需用 passorder 发融券单
order_target_value(ContextInfo.stock_a, -ContextInfo.trade_capital, ContextInfo, ContextInfo.account_id)
order_target_value(ContextInfo.stock_b, ContextInfo.trade_capital, ContextInfo, ContextInfo.account_id)
print(f"信号触发: Z-Score {z_score:.2f} > 2. 卖出 {ContextInfo.stock_a}, 买入 {ContextInfo.stock_b}")
# --- 信号:做多价差 (A便宜B贵 -> 买A卖B) ---
elif z_score < -ContextInfo.entry_threshold:
# 如果没有持有该方向的仓位
if not (vol_a > 0 and vol_b < 0):
# 先平掉反向仓位
if vol_a < 0: order_target_value(ContextInfo.stock_a, 0, ContextInfo, ContextInfo.account_id)
if vol_b > 0: order_target_value(ContextInfo.stock_b, 0, ContextInfo, ContextInfo.account_id)
# 开仓:买入 A,卖空 B
order_target_value(ContextInfo.stock_a, ContextInfo.trade_capital, ContextInfo, ContextInfo.account_id)
order_target_value(ContextInfo.stock_b, -ContextInfo.trade_capital, ContextInfo, ContextInfo.account_id)
print(f"信号触发: Z-Score {z_score:.2f} < -2. 买入 {ContextInfo.stock_a}, 卖出 {ContextInfo.stock_b}")
# --- 信号:回归均值 (平仓) ---
elif abs(z_score) < ContextInfo.exit_threshold:
# 如果有仓位,则平仓
if vol_a != 0 or vol_b != 0:
order_target_value(ContextInfo.stock_a, 0, ContextInfo, ContextInfo.account_id)
order_target_value(ContextInfo.stock_b, 0, ContextInfo, ContextInfo.account_id)
print(f"信号触发: Z-Score {z_score:.2f} 回归均值. 平仓所有头寸")
代码关键点解析
-
数据获取 (
get_market_data_ex):- 我们使用了
get_market_data_ex接口,这是 QMT 推荐的高效数据接口。 count参数设置为lookback_window + 5是为了防止停牌等原因导致数据不够计算均值。- 使用了 Pandas 的
intersection方法确保两只股票的日期是对齐的,这在配对交易中非常重要。
- 我们使用了
-
Z-Score 计算:
- 这是策略的核心。它衡量当前价差偏离历史均值的程度。
Ratio = Price A / Price B。- 当 Z-Score 大于 2 时,意味着 A 相对于 B 涨得太多了(或者跌得太少),统计学上认为这是一个小概率事件,大概率会回归。
-
交易执行 (
order_target_value):- 使用了
order_target_value函数。这个函数非常方便,它会自动计算需要买卖的数量以达到目标持仓市值。 - 关于做空:代码中使用了负数目标值(如
-ContextInfo.trade_capital)来表示做空。- 回测模式:QMT 的回测引擎通常能识别负数为做空。
- 实盘模式:
order_target_value在实盘中可能无法直接处理融券卖出(取决于券商接口实现)。如果实盘需要融券,建议使用passorder函数,并指定opType为 28 (融券卖出) 和 29 (买券还券)。
- 使用了
-
风险提示:
- 协整关系失效:如果两只股票的基本面发生重大变化(如一家暴雷),它们的价格关系可能会永久破裂,导致价差不回归,从而产生巨额亏损。
- 融券成本:做空需要支付利息,且券源不一定充足。
如何运行
- 打开 QMT 客户端,进入【策略研究/模型研究】。
- 新建一个 Python 策略,将上述代码粘贴进去。
- 修改
ContextInfo.account_id为你的实际账号(如果是回测则不需要改)。 - 点击【编译】,确保无误。
- 在右侧设置回测参数(如时间段、初始资金),点击【回测】查看效果。
Q&A
Q: 如何在实盘中真正进行融券卖出?
A: 在实盘中,简单的 order_target_value 可能默认走普通卖出逻辑(即卖出持仓)。若要融券,需使用 passorder。例如融券卖出 A 股票:
# opType=28 (融券卖出), orderType=1101 (单股单账号), prType=11 (市价/限价)
passorder(28, 1101, ContextInfo.account_id, '600000.SH', 5, -1, 1000, ContextInfo)
Q: 为什么选择浦发和兴业?
A: 它们同属股份制商业银行,业务结构相似,市值接近,历史上走势相关性较高。但请注意,相关性是动态变化的,实盘前请务必重新验证。
Q: 可以用 ETF 做配对吗?
A: 可以,例如 510050 (上证50ETF) 和 510300 (沪深300ETF)。ETF 配对交易通常不需要融券(可以通过期权合成空头或利用融资融券),且非系统性风险比个股小。