行业中性因子选股策略

市净率(Price-to-Book Ratio,PB)、净资产收益率(Return on Equity,ROE)与 6 个月动量(Momentum_6m)分别从估值、盈利质量和价格趋势三个维度刻画股票。PB 越低,通常意味着股价相对净资产越便宜;ROE 越高,通常意味着股东权益的盈利效率越强;6 个月动量越高,通常意味着过去半年的价格趋势越强。将三者结合,目标是在候选股票中寻找市净率较低、净资产收益率较高且动量较强的标的。行业中性处理则先在行业内部做标准化比较,避免行业间估值与盈利水平差异干扰排序,再从各行业的优胜者中构建等权组合。

本文使用 DolphinDB Backtest 引擎回放模拟数据,演示从准备数据、生成信号、次日调仓到读取回测结果的完整流程。

1. 策略介绍

策略在模拟的“沪深 300 候选池”中,使用 PB、ROE 和过去六个月动量三个因子选股。每个调仓日先排除不满足交易条件的股票,再在一级行业内计算标准分,得到行业可比的综合得分;随后每个行业保留排名靠前的股票,最后从所有保留股票中取前 10 只等权配置。

该策略体现了多因子研究中三个常见层次:先定义可交易股票池,再进行横截面因子比较,最后把分数转换成可执行的目标持仓。与直接全市场排序相比,行业内标准化是本策略的关键差异。

1.1 核心因子

PB 表示价格相对账面净资产的估值倍数。其他条件相同时,较低 PB 常被解释为更偏价值;但 PB 并不等于“便宜且一定会上涨”,它仍会受资产质量、行业特征和会计口径影响。

ROE 衡量企业以净资产创造利润的能力,是常见的质量因子。本文模拟数据中的 ROE 由随机数生成,取值约在 0.05 到 0.30 之间,按小数比例理解,例如 0.15 表示 15%,而不是 15.0。

过去六个月动量以约 126 个交易日前的复权收盘价为基准计算:

momentum_6m = close × adj_factor / move(close × adj_factor, 126) - 1

动量用于描述相对趋势强弱,而不是保证未来收益。为了让回测首日就具备足够的历史长度计算六个月动量,脚本在正式开始日前额外准备了 144 个上交所交易日的预热数据。

对每只股票,脚本在其一级行业内分别计算三个因子的 z-score,并形成综合分数:

score = -z_pb + z_momentum_6m + z_roe

PB 前的负号表示行业内 PB 越低得分越高;较高的动量和较高的 ROE 则提高得分。z-score 表示某股票相对同行均值偏离了多少个标准差,因此这里比较的是行业内相对位置,而不是直接把银行和软件公司的 PB 原值放在一起相加。若某行业股票数极少、分布异常或标准差接近零,真实研究中应额外处理标准化稳定性问题。

1.2 交易标的

本例使用模拟的 300 只带 .SZ 后缀的股票,回测区间为 2024-01-01 至 2024-12-31。上交所交易日历在该区间产生 242 个交易日,因此送入回测引擎的主行情数据共有 72,600 条日线记录。

每只模拟股票被循环分配到 10 个虚构一级行业:Industry01 至 Industry10。脚本还构造了名为 000300.SH 的单个指数成分快照,但快照中实际包含这 300 只模拟代码;它只是案例中的候选池标签,并不代表真实沪深 300 历史成分。

策略使用普通股票现金账户,只做多,不使用融资、融券或卖空。股票下单数量通常按 100 股取整;代码也保留了可转债、科创板、创业板、北交所和 ETF/LOF 的数量规则,但本例模拟标的均为普通 .SZ 股票。订单以次交易日开盘价作为提交价格,并受可用现金、持仓可卖数量、成交量和涨跌停条件约束。

将策略迁移到真实市场时,至少应替换为历史时点一致的成分股、行业分类、ST 状态、上市日期、停复牌状态、复权数据、财务因子和真实基准。不能用当前成分股或后来修订的数据替代过去某日可获得的信息。

1.3 交易规则

一次调仓按以下顺序进行:

  1. 在信号日确定可交易候选池,排除 ST、新股和无法交易的股票。

  2. 在每个一级行业内,对 PB、六个月动量和 ROE 标准化并计算综合得分。

  3. 每个行业保留综合得分最高的一只股票;再从行业代表中选择全市场得分最高的前 10 只。

  4. 按实际入选数量等权分配信号日账户权益,并用信号日收盘价锁定目标股数。

  5. 下一交易日先卖出落选股票和超配部分,再买入或补足低配股票。买入和卖出均受现金、可卖数量、成交量和涨跌停限制。

策略第一次收到日线时即产生初始信号,之后每 20 个有效回调日再次选股。信号日与执行日分开,使当日收盘信息不会在同一日开盘价成交,从而避免明显的前视交易。

2. 策略回测

以下按照准备数据、定义策略、设置参数、创建引擎、回放行情和读取结果的顺序展开。本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 与模拟撮合引擎插件(Matching Engine Simulator) 3.00.6.6 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前,代码会检查插件是否已安装,并加载这两个插件。

2.1 准备数据

下载附录中的 industryNeutralBarData.csv 和 industryNeutralBarStocksDividend.csv 文件,作为回测的案例数据。运行脚本前需将 path 变量改为实际存放 csv 文件的路径。

path = "/.../"

stocksDividend = loadText(path + "industryNeutralBarStocksDividend.csv", ',', getIndustryNeutralDividendCsvSchema())

data = loadText(path + "industryNeutralBarData.csv", ',')

模拟的数据源主要包括以下几类:

  • 模拟日线:按上交所交易日历生成股票代码与交易日期的笛卡尔积,再生成 open、high、low、close、volume 和 amount。

  • 因子字段:模拟数据内已预先生成所需的因子及过滤标签。PB 在约 0.5 至 5.0 之间随机生成;ROE 在约 5% 至 30% 之间随机生成(如 18.0 表示 18%);同时包含市盈率(pe)、动量因子(如适用),以及是否为候选样本池(isMember)、ST 状态(isST)和上市天数(listedDays)。

  • 行业历史:每只模拟股票具有从预热期开始、结束日为空的一级至三级虚构行业标签,本策略只使用一级行业。

  • 分红除权:额外提供分红数据表,包含除权除息日、派息日、税后现金分红及送转股比例等,用于在回测中准确处理权益变动。

最终送入引擎的行情字段主要包含股票代码与日线时间戳、OHLC、成交量与成交额、涨跌停价、估值/质量因子(PB、PE、ROE)以及过滤状态标记。涨跌停字段(upLimitPrice、downLimitPrice)在本模拟数据中为 NULL,因此交易条件主要检查成交量和价格本身。

表 1. 表 2-1 行情表结构
列名 类型 说明
symbol STRING 模拟股票编号加 .SZ 后缀
tradeTime TIMESTAMP 交易日转为零点时间戳
open DOUBLE 模拟开盘价,元/股
low DOUBLE 模拟最低价,元/股
high DOUBLE 模拟最高价,元/股
close DOUBLE 模拟收盘价,元/股
volume LONG 模拟成交股数
amount DOUBLE close × volume,单位为元
upLimitPrice DOUBLE 前收盘价 × 1.1,保留两位小数
downLimitPrice DOUBLE 前收盘价 × 0.9,保留两位小数
prevClosePrice DOUBLE 前一交易日收盘价;首日使用模拟初始价格
pe DOUBLE 模拟市盈率,倍数
pb DOUBLE 模拟市净率,倍数
roe DOUBLE 模拟净资产收益率,18.0 表示 18%
isMember INT 是否属于模拟样本池,1 表示属于
isST INT 模拟 ST 状态,1 表示 ST
listedDays INT 用于过滤的上市自然日数
表 2. 表 2-2 分红表结构
列名 类型 说明
symbol SYMBOL 股票代码(带后缀,例如 000001.SH)
endDate DATE 报告期(分红所属财务周期的最后一天)
annDate DATE 董事会/股东大会分红决议公告日
recordDate DATE 股权登记日
exDate DATE 除权除息日
payDate DATE 派息日(现金红利发放到账日)
divListDate DATE 送转增红股上市交易日
bonusRatio DOUBLE 每股送股比例
capitalConversion DOUBLE 每股转增比例(资本公积转增股本)
afterTaxCashDiv DOUBLE 每股税后现金红利金额
allotPrice DOUBLE 配股价(如有配股)
allotRatio DOUBLE 每股配股比例(如有配股)

2.2 编写策略逻辑

initialize 是回测引擎的初始化回调函数。每次创建新引擎时调用一次,用于初始化本次回测所需的全局参数、状态机变量以及基础数据引用。

context:由创建回测引擎时传入,并由引擎在整个生命周期内维护。

该函数主要完成了以下几类状态的初始化:

  • 调仓触发控制:dayCount = -1 结合 initialRebalancePending = true,确保回测首日计数器归零,立即触发首次选股信号。

  • 异步交易状态管理:初始化 pendingRebalance = false 及各类空置的目标仓位变量。它们将在 T 日产生信号时被赋值,于 T+1 日被消耗并执行交易。

  • 费率与阈值配置:写入交易佣金(commission)、印花税(tax)及调仓偏差阈值(rebalanceThreshold)等全局常量供后续调用。

  • 外部数据挂载:通过 objByName 将行业分类、指数成分股快照、ST 历史等基础信息表挂载入 context,用于策略运行中的动态选股过滤。

def initialize(mutable context) {
    context["commission"] = 0.00015
    context["tax"] = 0.0005
    context["dayCount"] = -1
    context["initialRebalancePending"] = true
    context["selectedStocks"] = []$STRING
    context["pendingRebalance"] = false
    context["rebalanceDate"] = NULL
    context["pendingTargetQtys"] = dict(STRING, LONG)
    context["industryT"] = objByName("industryNeutralIndustryHistory")
    context["stockPool"] = objByName("industryNeutralStockPool")
    context["tpExplicit"] = objByName("industryNeutralExplicitSymbols")
    context["tpIdxSnaps"] = objByName("industryNeutralIndexSnapshots")
    context["tpStHist"] = objByName("industryNeutralStHistory")
    context["tpListInfo"] = objByName("industryNeutralListInfo")
}

策略函数 onBar 可分为四个职责:

  • 数量与交易有效性处理:买入使用对应市场的最小申报单位;卖出时允许在清仓场景卖出不足一个标准单位的余量;同时检查成交量与涨跌停约束。

  • 初始化:订阅 momentum_6m 指标,保存手续费、印花税、调仓计数、待执行目标、候选池与行业信息。

  • 信号生成:在调仓日做股票池过滤、行业内标准化、行业内初筛和全局前 10 排名,并生成等权目标股数。

  • 延迟执行:下一根日线先处理卖出、再处理买入,按开盘价提交订单并以预估现金限制买量。

信号函数不直接以当天开盘价重新估算目标数量。它在信号日用收盘价锁定数量,再在下一交易日使用开盘价执行,从而清楚区分“已知信息形成决策”与“可成交价格完成执行”。

def onBar(mutable context, msg, indicator) {
    curBarTs = msg.values()[0]["tradeTime"]
    curDate = date(curBarTs)

    // 调仓执行:信号日的下一个交易日开盘,先卖后买。
    if (context["pendingRebalance"] && curDate != context["rebalanceDate"]) {
        selectedStocks_ = context["selectedStocks"]
        targetQtys_ = context["pendingTargetQtys"]
        commission_ = context["commission"]
        expectedCash_ = Backtest::getAvailableCash(context.engine)
        sellCashMult_ = 1.0 - commission_ - context["tax"]

        for (istock in msg.keys()) {
            bar = msg[istock]
            posInfo = Backtest::getPosition(context.engine, istock)
            if (size(posInfo) == 0) continue
            pos = posInfo["longPosition"]
            sellable = max(0, posInfo["lastDayLongPosition"] - posInfo["todaySellVolume"])
            if (pos <= 0 || sellable <= 0 || !isSellableAt(bar["open"], bar)) continue

            if (!(istock in selectedStocks_)) {
                Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, bar["open"], sellable, 3), "rebalance")
                expectedCash_ += sellable * bar["open"] * sellCashMult_
                continue
            }

            targetQty = iif(istock in targetQtys_, targetQtys_[istock], 0)
            if (pos <= targetQty) continue
            sellQty_ = normalizeSellQty(istock, pos, min(sellable, pos - targetQty))
            if (sellQty_ > 0) {
                Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, bar["open"], sellQty_, 3), "rebalance")
                expectedCash_ += sellQty_ * bar["open"] * sellCashMult_
            }
        }

        for (istock in selectedStocks_) {
            if (!(istock in msg)) continue
            bar = msg[istock]
            if (!isBuyableAt(bar["open"], bar)) continue
            posInfo = Backtest::getPosition(context.engine, istock)
            pos = iif(size(posInfo) > 0, posInfo["longPosition"], 0)
            targetQty = iif(istock in targetQtys_, targetQtys_[istock], 0)
            if (targetQty <= pos) continue
            plannedQty = targetQty - pos
            affordableCash_ = min(expectedCash_, Backtest::getAvailableCash(context.engine))
            affordableQty = roundLot(istock, affordableCash_ / (bar["open"] * (1 + commission_)))
            buyQty = roundLot(istock, min(plannedQty, affordableQty))
            if (buyQty > 0) {
                Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, bar["open"], buyQty, 1), "buy")
                expectedCash_ -= buyQty * bar["open"] * (1 + commission_)
            }
        }
        context["pendingRebalance"] = false
    }

    // 信号生成:每 20 个交易日,在各行业内计算三个因子的 z-score。
    context["dayCount"] += 1
    isRebalanceDay = context["dayCount"] % 20 == 0 || context["initialRebalancePending"]
    context["initialRebalancePending"] = false
    if (!isRebalanceDay) return

    snapsT_ = context["tpIdxSnaps"]
    latestPerIdx_ = select index_code, max(snap_date) as latest_date from snapsT_ where snap_date <= curDate group by index_code
    idxMembers_ = set(exec con_code from ej(snapsT_, latestPerIdx_, `index_code`snap_date, `index_code`latest_date))
    stHist_ = context["tpStHist"]
    stCodes_ = set(exec code from stHist_ where start_date <= curDate and (isNull(period_end) or period_end > curDate))
    listInfo_ = context["tpListInfo"]
    eligibleCodes_ = set(exec code from listInfo_ where list_date <= date(curDate - 90))
    pool_ = array(STRING, 0)
    for (s_ in context["stockPool"]) {
        if (((s_ in idxMembers_) || (s_ in context["tpExplicit"])) && !(s_ in stCodes_) && s_ in eligibleCodes_) pool_.append!(s_)
    }
    tradablePool_ = set(pool_)

    pipelineDataValid_ = true
    selectedStocks_ = []$STRING
    symbols_ = []$STRING
    pb_ = []$DOUBLE
    roe_ = []$DOUBLE
    momentum_ = []$DOUBLE
    for (istock in msg.keys()) {
        if (!(istock in tradablePool_) || msg[istock]["volume"] <= 0) continue
        symbols_.append!(istock)
        pb_.append!(double(msg[istock]["pb"]))
        roe_.append!(double(msg[istock]["roe"]))
        momentum_.append!(double(msg[istock]["momentum_6m"]))
    }

    poolT_ = table(symbols_ as symbol, pb_ as pb, roe_ as roe, momentum_ as momentum_6m)
    if (poolT_.size() > 0) {
        validFactorCount_ = exec sum(iif(isValid(pb) && isValid(roe), 1, 0)) from poolT_
        if (validFactorCount_ == 0) pipelineDataValid_ = false
        poolT_ = select * from poolT_ where isValid(pb), pb > 0, isValid(roe), roe > 0, isValid(momentum_6m)
        industryT_ = select distinct symbol, l1_name from context["industryT"] where start_date <= curDate and (isNull(end_date) or curDate < end_date)
        poolT_ = select p.*, i.l1_name from ej(poolT_ p, industryT_ i, `symbol)
        scoredT_ = select *, -zscore(pb) + zscore(momentum_6m) + zscore(roe) as score from poolT_ context by l1_name
        groupValidT_ = select * from scoredT_ where isValid(score)
        if (scoredT_.size() > 0 && groupValidT_.size() == 0) pipelineDataValid_ = false
        groupRankedT_ = select *, cumcount(symbol) as groupRank from (select * from groupValidT_ context by l1_name csort score desc) context by l1_name
        groupWinners_ = exec distinct symbol from groupRankedT_ where groupRank == 1
        rankInputT_ = select * from scoredT_ where symbol in groupWinners_
        rankValidT_ = select * from rankInputT_ where isValid(score)
        if (rankInputT_.size() > 0 && rankValidT_.size() == 0) pipelineDataValid_ = false
        limitN_ = iif(10 < rankValidT_.size(), 10, rankValidT_.size())
        selectedStocks_ = exec distinct symbol from (select * from rankValidT_ order by score desc limit limitN_)
    }

    if (!pipelineDataValid_) return
    context["selectedStocks"] = selectedStocks_
    signalEquity_ = Backtest::getTotalPortfolios(context.engine).totalEquity[0]
    targetQtys_ = dict(STRING, LONG)
    if (selectedStocks_.size() > 0) {
        targetWeight_ = 1.0 / selectedStocks_.size()
        for (s_ in selectedStocks_) targetQtys_[s_] = roundLot(s_, signalEquity_ * targetWeight_ / (msg[s_]["close"] * (1 + context["commission"])))
    }
    context["pendingTargetQtys"] = targetQtys_
    context["pendingRebalance"] = true
    context["rebalanceDate"] = curDate
}

本例对综合得分采用等权组合。真实研究可以根据因子 IC、风险暴露或稳健性检验调整权重,但应先固定训练期、验证期与样本外期,避免在全样本回测结果上反复调参。

2.3 设置回测参数

本次配置的核心参数如下:

config = {
    startDate: startDate,
    endDate: endDate,
    strategyGroup: "stock",
    cash: 1000000.0,
    commission: 0.00015,
    tax: 0.0005,
    dataType: 4,
    outputOrderInfo: true,
    matchingMode: 2,
    orderBookMatchingRatio: 0.2,
    enableMinimumPerTransactionFee: false
}

代码中手续费和税率也在初始化上下文中重复设置。案例读者应把配置对象视为回测假设的一部分:改变费率、撮合模型、交易单位或调仓周期,结果都可能明显变化。

2.4 创建回测引擎

创建引擎时会将初始化和日线逻辑注册为事件回调。其中,initialize 负责准备指标和状态,onBar 负责执行前一日目标并在需要时生成新的目标。

引擎通过 config 获取基础配置,通过 callbacks 找到策略函数。引擎创建前会先尝试删除同名的旧引擎以避免名称冲突;运行结束后再次清理该 engineName 对应的实例,避免重复使用包含历史状态的引擎。

callbacks = dict(STRING, ANY)
callbacks["initialize"] = initialize
callbacks["onBar"] = onBar

engineName = "industryNeutralMultiFactorBacktest"
try { Backtest::dropBacktestEngine(engineName) } catch(ex) { }
engine = Backtest::createBacktester(engineName, config, callbacks, false)

2.5 执行回测

将按时间排序的行情送入引擎后,回调会按交易日推进。首日形成初始目标,后续交易日才尝试以开盘价成交;每隔 20 个交易日重复这一过程。

Backtest::appendQuotationMsg(engine, msg)

行情全部发送后,脚本直接调用 Backtest::appendEndMarker(engine) 向引擎追加结束标记,告知引擎数据回放完毕并触发最终的结算。

本案例中的完整运行顺序是:创建引擎、追加全部行情、追加结束消息、读取结果对象、把内部构造的模拟基准净值并入账户权益表、组装结果字典、删除回测引擎。

2.6 获取回测结果

脚本返回的结果字典主要包括:

  • returnSummary:总收益、年化收益、波动率、夏普比率、最大回撤、换手率和胜率等概览。

  • tradeDetails:订单 ID、方向、提交与成交时间、委托/成交价格、委托/成交数量、状态与标签。只有 tradeQty > 0 的行是实际成交。

  • dailyPosition:每日各股票的持仓及相关状态,用于追踪组合构成。

  • totalPortfolios:每日现金、市值、总权益、净值、收益和费用;脚本额外拼接了模拟基准的收盘价与净值。

tradeDetails = Backtest::getTradeDetails(engine)
returnSummary = Backtest::getReturnSummary(engine)
dailyPosition = Backtest::getDailyPosition(engine)
totalPortfolios = Backtest::getDailyTotalPortfolios(engine)

3. 回测结果分析

以下回测指标来自策略脚本在 DolphinDB Server 3.00.6.1 环境中的一次成功运行快照。

表 3. 表 3-1 回测结果
指标 数值
回测开始日期 2024-01-02
初始权益 1,000,000.00 元
回测结束日期 2024-12-31
期末总权益 2,075,708.90 元
期末净值 2.075709
区间总收益 107.5709%
年化收益 113.9304%
年化波动率 499.7312%
夏普比率 0.2280
最大回撤 64.6715%
期末现金 22,454.24 元
持仓市值 2,053,254.66 元
累计费用 2,705.42 元

回测共形成 242 个账户日、194 条订单记录和 103 条 tradeQty > 0 的实际成交记录;每日持仓结果有 2,391 行。每日收益为正的比例为 51.0373%,引擎报告的换手率为 0.0463%。这些统计能够证明回测流程完成了“选股—提交订单—成交—估值”的闭环,但不能证明因子策略本身有效。

这组结果出现大幅亏损和异常高波动,首要原因应从输入假设理解,而不是归因于 PB、ROE 或动量因子的经济含义。

本案例的正确解读是:行业内标准化、分行业初筛、全局前 10、次日开盘执行和结果接口均已实际跑通;本次负收益并不是“行业中性多因子策略在真实 A 股必然亏损”的证据。进入真实研究前,应以合规历史数据重建样本,并分别检验因子方向、行业暴露、持仓集中度、交易成本、停牌涨跌停影响、样本外表现与不同市场阶段的稳健性。

4. 总结

行业中性多因子选股策略把低 PB、较高 ROE 与较强六个月动量统一为行业内相对分数,再通过每个行业保留综合得分最高的一只股票和全局得分前 10 两层筛选构建等权组合。信号日锁定数量、下一交易日开盘执行的安排,使案例避免了用当日收盘信息在同日开盘成交的明显前视偏差。

本次实际运行验证了 DolphinDB Backtest 的数据回放、订单提交和结果汇总流程。模拟数据适合用于理解策略实现与回测接口。用于真实投资研究时,应将输入替换为具有明确历史可用时点的行情和财务数据,统一 ROE 单位及指标口径,并进一步评估持仓集中、成本、行业偏离和样本外表现等因素。