市值残差α策略(Market-Cap-Residual Alpha Model, MCR-α)
核心思想:
- 每 10 个交易日调仓一次;
- 用财务因子+技术指标因子对全市场股票做横截面回归;
- 把预测市值低于真实市值最多的 10 只股票视为被低估,买入;
- 卖出不再被低估的持仓。
1 头文件(import)
python
import pandas as pd
import numpy as np
import math
from sklearn.svm import SVR # 支持向量回归
from sklearn.model_selection import GridSearchCV # 网格搜索(本例未真正使用)
from sklearn.model_selection import learning_curve
from sklearn.linear_model import LinearRegression # 备用,实际没用
from sklearn.ensemble import RandomForestRegressor # 备用,实际没用
import jqdata # 聚宽数据 API
#from jqlib.alpha191 import * # 191 Alpha 因子库,注释掉
from jqlib.technical_analysis import * # 聚宽技术指标库
from pandas import DataFrame, Series # 方便写 DataFrame/Series
作用:导入计算、机器学习及聚宽平台所需的各种库。
2 初始化函数(initialize)
聚宽回测框架规定的入口函数,平台会在策略启动时自动调用一次。
python
def initialize(context):
set_params() # 设置全局参数
set_backtest() # 设置回测环境
run_daily(trade, '14:50') # 每天 14:50 执行 trade()
语法:run_daily(func, time)
是聚宽 API,用于定时运行函数。
3 设置全局参数(set_params)
python
def set_params():
g.days = 0 # 计数器,记录回测天数
g.refresh_rate = 10 # 每 10 天调仓一次
g.stocknum = 10 # 每次持有 10 只股票
g.index = '000985.XSHG' # 中证全指,作为股票池
g
是聚宽提供的全局变量容器,跨函数共享数据。
4 设置回测环境(set_backtest)
python
def set_backtest():
set_benchmark(g.index) # 设置业绩比较基准
set_option('use_real_price', True) # 用真实价格撮合
log.set_level('order', 'error') # 只把下单错误写入日志
#set_commission(...) # 注释掉的佣金设置
5 交易逻辑(trade)
核心函数,每天 14:50 被调用,但只有 g.days % 10 == 0
时真正执行调仓。
5.1 判断调仓日
python
if g.days % g.refresh_rate == 0:
5.2 获取股票池
python
sample = get_index_stocks(g.index, date=None)
get_index_stocks
是聚宽 API,返回指定指数当日的成分股列表。
5.3 查询财务数据
python
q = query(valuation.code, valuation.market_cap, balance.total_assets - balance.total_liability,
balance.total_assets / balance.total_liability, income.net_profit,
income.net_profit + 1, indicator.inc_revenue_year_on_year,
balance.development_expenditure).filter(valuation.code.in_(sample))
df = get_fundamentals(q, date=None)
query 语句用聚宽 ORM 语法,一次性拉取:
- 股票代码
- 市值(market_cap)
- 净资产(资产-负债)
- 资产负债比(资产/负债)
- 净利润
- 净利润+1(防止 0)
- 营收同比增长率
- 研发支出
语法点:query(...).filter(...)
与 SQL 类似。
5.4 重命名列并做 log 变换
python
df.columns = ['code', 'log_mcap', 'log_NC', 'LEV', 'NI_p', 'NI_n', 'g', 'log_RD']
df['log_mcap'] = np.log(df['log_mcap'])
df['log_NC'] = np.log(df['log_NC'])
df['NI_p'] = np.log(np.abs(df['NI_p']))
df['NI_n'] = np.log(np.abs(df['NI_n'][df['NI_n'] < 0]))
df['log_RD'] = np.log(df['log_RD'])
df.index = df.code.values
- 将所有规模类、金额类变量做对数化(降低异方差);
NI_n
只保留负净利润的绝对值对数;- 把 code 设为索引,后面方便对齐。
重命名列并做 log 变换的意义可以拆成两部分说明:
重命名列把原始英文/数据库字段改成简洁、语义化的新名字,提高后续代码的可读性与可维护性 ,避免诸如
valuation.market_cap
这种长字段在矩阵运算或特征索引中带来的歧义。对变量做 log 变换(np.log)
- 2.1 压缩量级差异
- 市值、净资产、净利润等财务指标右偏严重,跨股票差异可达十倍以上。取对数后,分布更接近正态,减少极端值对 SVR 等机器学习模型的杠杆效应。
- 2.2 经济解释直观
- 对数化后的变量可理解为"百分比"或"弹性"概念,例如 log(mcap) 的 0.01 变化 ≈ 1% 的市值变化,与因子系数可直接解释为"弹性"。
- 2.3 数值稳定性
- 降低变量尺度差异,避免优化过程中出现梯度爆炸或收敛过慢问题。
- 2.4 线性化潜在非线性关系
- 许多财务指标与市值呈幂律关系,log-log 转换后往往呈近似线性,使线性或核方法更容易捕捉结构。
5.5 计算技术指标 CYE(趋势指标)
python
CYEL, CYES = CYE(df.code.tolist(), check_date=pre_day)
df['CYEL'] = Series(CYEL)
df['CYES'] = Series(CYES)
CYE
来自 jqlib.technical_analysis
,返回两条曲线值,作为技术面因子。
CYE(Cycle Yield Estimate,简称"趋势指标")是一套衡量股价中期与短期趋势方向及力度的技术工具,由两条曲线组成:
- 黄线(CYEL):短期线,反映约一周的趋势强度;
- 白线(CYES):中期线,反映约一个月的趋势强度。
功能要点
1、方向判定:
• 指标值 > 0 表示上升趋势,数值越大,上涨力度越强;
• 指标值 < 0 表示下降趋势,负值越大,下跌力度越猛;
• 围绕 0 轴小幅震荡视为平衡市。
2、强度量化:
• 黄线通常在 ±1 之间波动,极端拉升可达 +4~+5;
• 白线通常在 ±2 之间波动,高位走平掉头向下常被视为中线见顶信号。
3、交易提示:
• 顺势:中线持仓选择 CYE>0 且持续上行的个股;
• 逆势 :短线抄底可关注 CYE 短期线快速跌至极负后的反转拐点。
4、算法本质:以当日 K 线数据做一次数值拟合,判断整体方向,简单直观地"让计算机模拟人对图形的趋势感"。
因此,在策略中引入 CYEL/CYES 作为因子,相当于把中期和短期的趋势强度量化进模型,帮助识别"趋势继续"或"拐点临近"的概率。
5.6 数据清洗
python
del df['code'] # 索引已经是 code
df = df.fillna(0) # 缺失值填 0
df[df > 10000] = 10000 # 极端值截断
df[df < -10000] = -10000
5.7 行业哑变量
python
industry_set = [...] # 申万一级行业 28 个代码
for i in range(len(industry_set)):
industry = get_industry_stocks(industry_set[i], date=None)
s = pd.Series([0]*len(df), index=df.index)
s[set(industry) & set(df.index)] = 1
df[industry_set[i]] = s
循环给每只股票打上行业哑变量(属于该行业=1,否则=0)。
行业哑变量(industry dummy variables)就是"把股票所在的行业类别变成 0/1 数字"的一种处理方法。
行业属性是离散的类别(农林牧渔、电子、银行等),而机器学习或回归模型只能处理数值。通过哑变量编码,把"属于/不属于"转化为 0 或 1,模型就能直接利用。
意义: • 控制行业差异:不同行业在估值、杠杆、盈利结构上差异巨大;不控制就会把"行业效应"误当成"个股错误定价"。 • 提高信号纯度:让模型在同一行业内比较个股,残差更能反映真正的低估/高估。 • 避免过拟合:防止模型把行业整体涨跌当成选股 α,提升策略稳定性。
5.8 构造 X、Y
python
X = df[['log_NC', 'LEV', 'NI_p', 'NI_n', 'g', 'log_RD',
'CYEL', 'CYES', ...所有行业哑变量...]]
Y = df[['log_mcap']]
- X:所有因子;
- Y:真实对数市值。
5.9 训练 SVR 回归
python
svr = SVR(kernel='rbf', gamma=0.1)
model = svr.fit(X, Y)
用支持向量回归对市值建模,拟合出「真实市值」与「因子」之间的非线性关系。
SVR(Support Vector Regression)是一种把"支持向量机"思想借来做回归 的算法------它只关心预测误差在容忍带 ε 以内 的点,最大化"间隔"而非最小化平方误差,从而在高维空间获得稀疏、稳健、非线性的拟合。
核心要点
- 容忍带 ε:预测值与真实值之差只要落在 ±ε 内就视为 0 误差,减少过拟合。
- 核技巧:通过核函数(RBF、线性、多项式等)隐式地把数据映射到高维,轻松处理非线性关系。
- 稀疏解:最终模型只由少量"支持向量"决定,节省内存、加速预测。
- 对异常值不敏感:由于使用 ε-不敏感损失,离群点不会拉偏整个模型。
调参三件套
- C:对超出 ε 区间的误差的惩罚力度(越大越不能犯错)。
- ε:容忍带宽度(越大越宽松)。
- γ(RBF 核):控制映射后曲率(越大越容易过拟合)。
5.10 计算因子(残差)
python
factor = Y - pd.DataFrame(svr.predict(X), index=Y.index, columns=['log_mcap'])
factor = factor.sort_index(by='log_mcap') # 升序,负得越多越低估
残差 = 真实市值 − 预测市值。残差越大(正)代表高估,越小(负)代表低估。
因此把残差从小到大排序,最小的 10 只为最被低估。
5.11 卖出逻辑
python
stockset = list(factor.index[:10]) # 最新最看好的 10 只
sell_list = list(context.portfolio.positions.keys())
for stock in sell_list:
if stock not in stockset[:g.stocknum]:
order_target_value(stock, 0) # 清仓
order_target_value(code, value)
是聚宽下单函数,value=0 即全部卖出。
5.12 买入逻辑
python
if len(context.portfolio.positions) < g.stocknum:
num = g.stocknum - len(context.portfolio.positions)
cash = context.portfolio.cash / num # 每只股票等额现金
else:
cash = 0
num = 0
for stock in stockset[:g.stocknum]:
if stock in sell_list: # 已持仓就跳过
pass
else:
order_target_value(stock, cash)
num -= 1
if num == 0:
break
- 计算剩余需要买入的只数
num
; - 按可用现金平均分配;
- 逐只下单,直到买够 10 只。
5.13 更新计数器
python
g.days += 1
非调仓日也会执行这句,保证天数累加。
6 小结
-
数据:中证全指成分股 + 28 个行业哑变量 + 8 个财务/技术因子;
-
模型:RBF 核 SVR;
-
信号:市值回归残差最小(最被低估)的 10 只股票;
-
调仓:每 10 个交易日等权再平衡;
-
回测框架:聚宽
initialize
/trade
/run_daily
体系。
完整代码
python
import pandas as pd
import numpy as np
import math
from sklearn.svm import SVR
from sklearn.model_selection import GridSearchCV
from sklearn.model_selection import learning_curve
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
import jqdata
#from jqlib.alpha191 import *
from jqlib.technical_analysis import *
from pandas import DataFrame,Series
def initialize(context):
set_params()
set_backtest()
run_daily(trade, '14:50')
def set_params():
g.days = 0
g.refresh_rate = 10
g.stocknum = 10
g.index = '000985.XSHG'
def set_backtest():
set_benchmark(g.index)
set_option('use_real_price', True)
log.set_level('order', 'error')
# 股票类每笔交易时的手续费是:买入时佣金万分之三,卖出时佣金万分之三加千分之一印花税, 每笔交易佣金最低扣5块钱
#set_commission(PerTrade(buy_cost=0.0003, sell_cost=0.0013, min_cost=5))
def trade(context):
if g.days % g.refresh_rate == 0:
cur_day = context.current_dt
pre_day = context.previous_date
sample = get_index_stocks(g.index, date = None)
q = query(valuation.code, valuation.market_cap, balance.total_assets - balance.total_liability,
balance.total_assets / balance.total_liability, income.net_profit, income.net_profit + 1,
indicator.inc_revenue_year_on_year, balance.development_expenditure).filter(valuation.code.in_(sample))
df = get_fundamentals(q, date = None)
df.columns = ['code', 'log_mcap', 'log_NC', 'LEV', 'NI_p', 'NI_n', 'g', 'log_RD']
df['log_mcap'] = np.log(df['log_mcap'])
df['log_NC'] = np.log(df['log_NC'])
df['NI_p'] = np.log(np.abs(df['NI_p']))
df['NI_n'] = np.log(np.abs(df['NI_n'][df['NI_n']<0]))
df['log_RD'] = np.log(df['log_RD'])
df.index = df.code.values
CYEL,CYES = CYE(df.code.tolist(),check_date=pre_day)
df['CYEL'] = Series(CYEL)
df['CYES'] = Series(CYES)
#log.info(df['CYEL'])
#log.info(df['CYES'])
del df['code']
df = df.fillna(0)
df[df>10000] = 10000
df[df<-10000] = -10000
industry_set = ['801010', '801020', '801030', '801040', '801050', '801080', '801110', '801120', '801130',
'801140', '801150', '801160', '801170', '801180', '801200', '801210', '801230', '801710',
'801720', '801730', '801740', '801750', '801760', '801770', '801780', '801790', '801880','801890']
for i in range(len(industry_set)):
industry = get_industry_stocks(industry_set[i], date = None)
s = pd.Series([0]*len(df), index=df.index)
s[set(industry) & set(df.index)]=1
df[industry_set[i]] = s
X = df[['log_NC', 'LEV', 'NI_p', 'NI_n', 'g', 'log_RD','CYEL','CYES','801010', '801020', '801030', '801040', '801050',
'801080', '801110', '801120', '801130', '801140', '801150', '801160', '801170', '801180', '801200',
'801210', '801230', '801710', '801720', '801730', '801740', '801750', '801760', '801770', '801780',
'801790', '801880', '801890']]
Y = df[['log_mcap']]
X = X.fillna(0)
Y = Y.fillna(0)
svr = SVR(kernel='rbf', gamma=0.1)
model = svr.fit(X, Y)
factor = Y - pd.DataFrame(svr.predict(X), index = Y.index, columns = ['log_mcap'])
factor = factor.sort_index(by = 'log_mcap')
stockset = list(factor.index[:10])
sell_list = list(context.portfolio.positions.keys())
for stock in sell_list:
if stock not in stockset[:g.stocknum]:
stock_sell = stock
order_target_value(stock_sell, 0)
if len(context.portfolio.positions) < g.stocknum:
num = g.stocknum - len(context.portfolio.positions)
cash = context.portfolio.cash/num
else:
cash = 0
num = 0
for stock in stockset[:g.stocknum]:
if stock in sell_list:
pass
else:
stock_buy = stock
order_target_value(stock_buy, cash)
num = num - 1
if num == 0:
break
g.days += 1
else:
g.days = g.days + 1