问题描述
如何编写一个行业轮动策略,定期计算各行业的财务指标,并买入表现最好的行业的股票?
解决方案
这是一个非常经典的量化策略需求。行业轮动策略的核心在于:自上而下地选股,先选出基本面最好的行业,再从该行业中选出代表性股票。
策略设计思路
- 行业划分:使用申万一级行业(或证监会行业)作为轮动池。
- 评价指标:使用 ROE(净资产收益率) 作为行业景气度的评价标准。
- 计算方法:行业ROE = $\frac{\sum(行业内成分股净利润)}{\sum(行业内成分股净资产)}$
- 这种整体法计算可以避免小市值股票极端值对行业整体的影响。
- 选股逻辑:
- 每个调仓周期(如每月),计算所有行业的 ROE。
- 选取 ROE 最高的 1 个行业。
- 在该行业内,选取 净利润最大 的 5 只股票(代表行业龙头)进行买入。
- 交易执行:
- 卖出不在目标持仓列表中的股票。
- 买入目标股票,等权重分配资金。
QMT 策略代码实现
以下是完整的 Python 策略代码。请将其复制到 QMT 的策略编辑器中。
# -*- coding: gbk -*-
import pandas as pd
import numpy as np
def init(ContextInfo):
"""
策略初始化函数
"""
# 1. 设置基本参数
ContextInfo.holding_num = 5 # 每个行业买入的股票数量
ContextInfo.rebalance_period = 20 # 调仓周期(交易日),约一个月
ContextInfo.industry_top_n = 1 # 选取排名前几的行业
# 2. 设置资金账号 (请替换为您自己的资金账号)
# ContextInfo.set_account('您的资金账号')
# 3. 定义行业列表 (这里以申万一级行业为例,需确保客户端有板块数据)
# 如果本地没有申万数据,可以使用 'CSRC金融业' 等证监会行业名称
ContextInfo.industry_list = [
'申万银行', '申万房地产', '申万医药生物', '申万食品饮料',
'申万电子', '申万计算机', '申万非银金融', '申万有色金属',
'申万化工', '申万采掘', '申万钢铁', '申万公用事业'
]
# 4. 设定回测相关参数 (仅回测有效)
ContextInfo.set_commission(0, [0.0001, 0.001, 0.0003, 0.0003, 0.0003, 5]) # 设置费率
ContextInfo.capital = 1000000 # 初始资金
def handlebar(ContextInfo):
"""
K线周期运行函数
"""
# 跳过历史K线,只在最后根K线或回测模式下运行
# 如果是实盘,建议结合定时器使用,这里演示简单的bar驱动
# 获取当前K线索引
bar_index = ContextInfo.barpos
# 检查是否达到调仓周期
if bar_index % ContextInfo.rebalance_period != 0:
return
print(f"=== 开始进行行业轮动分析 (Bar: {bar_index}) ===")
# 获取当前时间
current_date = ContextInfo.get_bar_timetag(bar_index)
current_date_str = timetag_to_datetime(current_date, '%Y%m%d')
# 1. 计算各行业 ROE
industry_scores = []
for industry_name in ContextInfo.industry_list:
# 获取行业成分股
stock_list = ContextInfo.get_stock_list_in_sector(industry_name)
if not stock_list:
continue
# 计算该行业的整体ROE
roe = calculate_industry_roe(ContextInfo, stock_list, current_date_str)
if roe is not None:
industry_scores.append((industry_name, roe))
# 2. 对行业按 ROE 从高到低排序
industry_scores.sort(key=lambda x: x[1], reverse=True)
if not industry_scores:
print("未计算出有效行业数据")
return
# 选取排名靠前的行业
target_industries = [x[0] for x in industry_scores[:ContextInfo.industry_top_n]]
print(f"本期选中行业: {target_industries}, ROE: {industry_scores[0][1]:.4f}")
# 3. 在选中行业中选股 (选净利润最大的龙头股)
target_stocks = []
for ind in target_industries:
stocks = ContextInfo.get_stock_list_in_sector(ind)
top_stocks = select_top_stocks(ContextInfo, stocks, current_date_str, ContextInfo.holding_num)
target_stocks.extend(top_stocks)
print(f"本期目标持仓: {target_stocks}")
# 4. 执行交易
rebalance_portfolio(ContextInfo, target_stocks)
def calculate_industry_roe(ContextInfo, stock_list, date_str):
"""
计算行业整体ROE = 行业总净利润 / 行业总净资产
"""
# 财务字段: 净利润(归属母公司), 股东权益(归属母公司)
fields = ['ASHAREINCOME.net_profit_incl_min_int_inc', 'ASHAREBALANCESHEET.total_shrhldr_eqy_excl_min_int']
# 获取财务数据 (注意:get_financial_data 返回数据结构较为复杂,需处理)
# 这里取最近的一个报告期数据
df = ContextInfo.get_financial_data(fields, stock_list, date_str, date_str)
if df is None or df.empty:
return None
# 数据清洗
# get_financial_data 在多股单时间点下返回 DataFrame,index为股票代码,columns为字段
try:
# 确保数据类型为数值
df = df.apply(pd.to_numeric, errors='coerce').fillna(0)
total_net_profit = df['ASHAREINCOME.net_profit_incl_min_int_inc'].sum()
total_equity = df['ASHAREBALANCESHEET.total_shrhldr_eqy_excl_min_int'].sum()
if total_equity == 0:
return 0
return total_net_profit / total_equity
except Exception as e:
# print(f"计算行业ROE出错: {e}")
return None
def select_top_stocks(ContextInfo, stock_list, date_str, top_n):
"""
在指定股票列表中,选取净利润最大的前N只
"""
field = ['ASHAREINCOME.net_profit_incl_min_int_inc']
df = ContextInfo.get_financial_data(field, stock_list, date_str, date_str)
if df is None or df.empty:
return []
try:
df = df.apply(pd.to_numeric, errors='coerce').fillna(0)
# 按净利润降序排列
df_sorted = df.sort_values(by='ASHAREINCOME.net_profit_incl_min_int_inc', ascending=False)
# 取前N只股票代码
return df_sorted.head(top_n).index.tolist()
except:
return []
def rebalance_portfolio(ContextInfo, target_stocks):
"""
调仓执行函数
"""
# 获取当前持仓
positions = ContextInfo.get_trade_detail_data(ContextInfo.accid, 'stock', 'position')
current_holdings = [pos.m_strInstrumentID for pos in positions if pos.m_nVolume > 0]
# 1. 卖出不在目标列表中的股票
for stock in current_holdings:
if stock not in target_stocks:
order_target_value(stock, 0, ContextInfo, ContextInfo.accid)
print(f"卖出: {stock}")
# 2. 买入目标股票
if not target_stocks:
return
# 获取账户总资产 (回测用 ContextInfo.capital 估算,实盘应用 get_trade_detail_data 获取 'account')
# 这里简单演示,假设满仓操作,每只股票等权重
# 获取账户净资产
acc_info = ContextInfo.get_trade_detail_data(ContextInfo.accid, 'stock', 'account')
total_asset = acc_info[0].m_dBalance if acc_info else ContextInfo.capital
# 每只股票的目标市值
target_value_per_stock = total_asset / len(target_stocks)
for stock in target_stocks:
order_target_value(stock, target_value_per_stock, ContextInfo, ContextInfo.accid)
print(f"买入/调仓: {stock}, 目标市值: {target_value_per_stock}")
代码关键点解析
-
数据获取 (
get_financial_data):- 我们使用了
ASHAREINCOME.net_profit_incl_min_int_inc(净利润) 和ASHAREBALANCESHEET.total_shrhldr_eqy_excl_min_int(归属母公司股东权益) 这两个核心字段。 - QMT 的财务数据接口会自动匹配离
date_str最近的已公告财报数据,避免了未来函数(即不会在财报发布前就用到数据)。
- 我们使用了
-
行业整体法计算:
- 代码中
calculate_industry_roe函数并没有简单地对每只股票的 ROE 求平均,而是将行业内所有公司的净利润加总,除以所有公司的净资产加总。这在量化中被称为“整体法”,能更真实地反映行业龙头的盈利能力,避免小市值亏损股扭曲行业数据。
- 代码中
-
板块成分股 (
get_stock_list_in_sector):- 代码中使用了
'申万银行'等名称。注意:在实际运行前,请务必在 QMT 客户端左侧的“板块”树中确认您已下载了对应的板块数据,且板块名称与代码中的字符串一致。如果您的客户端中叫 "SW银行",请相应修改代码。
- 代码中使用了
-
交易执行 (
order_target_value):- 使用
order_target_value是最方便的调仓方式。它会自动计算需要买入或卖出的差额。例如,如果目标市值是 10 万,当前持仓市值 8 万,它会自动买入 2 万。
- 使用
运行前准备
- 补充数据:
- 在 QMT 界面上方点击【数据管理】->【补充数据】。
- 勾选【财务数据】和【板块数据】(特别是申万行业或证监会行业板块)。
- 下载对应回测区间的历史日线数据。
- 设置账号:
- 在
init函数中,找到ContextInfo.set_account('您的资金账号'),取消注释并填入真实的资金账号(回测模式下通常不需要这一步,但实盘必须设置)。
- 在
常见问题排查
- 报错
NoneType object is not subscriptable:通常是因为get_financial_data没有取到数据。请检查数据管理中是否已下载了财务数据。 - 行业列表为空:请检查
ContextInfo.industry_list中的名称是否与 QMT 客户端左侧板块栏中的名称完全一致。