市值残差Alpha策略

市值残差α策略(Market-Cap-Residual Alpha Model, MCR-α)

核心思想:

  1. 每 10 个交易日调仓一次;
  2. 用财务因子+技术指标因子对全市场股票做横截面回归;
  3. 把预测市值低于真实市值最多的 10 只股票视为被低估,买入;
  4. 卖出不再被低估的持仓。

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 变换的意义可以拆成两部分说明:

  1. 重命名列把原始英文/数据库字段改成简洁、语义化的新名字,提高后续代码的可读性与可维护性 ,避免诸如 valuation.market_cap 这种长字段在矩阵运算或特征索引中带来的歧义。

  2. 对变量做 log 变换(np.log)

    1. 2.1 压缩量级差异
    2. 市值、净资产、净利润等财务指标右偏严重,跨股票差异可达十倍以上。取对数后,分布更接近正态,减少极端值对 SVR 等机器学习模型的杠杆效应。
    3. 2.2 经济解释直观
    4. 对数化后的变量可理解为"百分比"或"弹性"概念,例如 log(mcap) 的 0.01 变化 ≈ 1% 的市值变化,与因子系数可直接解释为"弹性"。
    5. 2.3 数值稳定性
    6. 降低变量尺度差异,避免优化过程中出现梯度爆炸或收敛过慢问题。
    7. 2.4 线性化潜在非线性关系
    8. 许多财务指标与市值呈幂律关系,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,简称"趋势指标")是一套衡量股价中期与短期趋势方向及力度的技术工具,由两条曲线组成:

  1. 黄线(CYEL):短期线,反映约一周的趋势强度;
  2. 白线(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)是一种把"支持向量机"思想借来做回归 的算法------它只关心预测误差在容忍带 ε 以内 的点,最大化"间隔"而非最小化平方误差,从而在高维空间获得稀疏、稳健、非线性的拟合。

核心要点

  1. 容忍带 ε:预测值与真实值之差只要落在 ±ε 内就视为 0 误差,减少过拟合。
  2. 核技巧:通过核函数(RBF、线性、多项式等)隐式地把数据映射到高维,轻松处理非线性关系。
  3. 稀疏解:最终模型只由少量"支持向量"决定,节省内存、加速预测。
  4. 对异常值不敏感:由于使用 ε-不敏感损失,离群点不会拉偏整个模型。

调参三件套

  • 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    
         
相关推荐
Java技术小馆2 小时前
InheritableThreadLoca90%开发者踩过的坑
后端·面试·github
诗和远方14939562327343 小时前
iOS 异常捕获原理详解
面试
我是哪吒4 小时前
分布式微服务系统架构第167集:从零到能跑kafka-redis实战
后端·面试·github
似水流年流不尽思念4 小时前
Spring 的声明式事务在多线程的场景当中会失效,该怎么解决呢?
后端·spring·面试
天天摸鱼的java工程师4 小时前
OpenFeign 首次调用卡 3 秒?八年老开发扒透 5 个坑,实战优化到 100ms
java·后端·面试
言兴4 小时前
前端工程化演进之路 —— 从 Webpack 到 Vite 的架构革命
前端·javascript·面试
tanxiaomi4 小时前
Spring面试宝典:Spring IOC的执行流程解析
java·spring·面试
南篱4 小时前
JavaScript 异步之巅:深入理解 ES6 Promise
javascript·面试
程序员小续5 小时前
React 源码解读流程:从入口到渲染的全链路揭秘
前端·javascript·面试