行业中性因子选股策略
市净率(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 交易规则
一次调仓按以下顺序进行:
-
在信号日确定可交易候选池,排除 ST、新股和无法交易的股票。
-
在每个一级行业内,对 PB、六个月动量和 ROE 标准化并计算综合得分。
-
每个行业保留综合得分最高的一只股票;再从行业代表中选择全市场得分最高的前 10 只。
-
按实际入选数量等权分配信号日账户权益,并用信号日收盘价锁定目标股数。
-
下一交易日先卖出落选股票和超配部分,再买入或补足低配股票。买入和卖出均受现金、可卖数量、成交量和涨跌停限制。
策略第一次收到日线时即产生初始信号,之后每 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,因此交易条件主要检查成交量和价格本身。
| 列名 | 类型 | 说明 |
|---|---|---|
| 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 | 用于过滤的上市自然日数 |
| 列名 | 类型 | 说明 |
|---|---|---|
| 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 环境中的一次成功运行快照。
| 指标 | 数值 |
|---|---|
| 回测开始日期 | 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 单位及指标口径,并进一步评估持仓集中、成本、行业偏离和样本外表现等因素。
5. 附录
以下为本策略的完整策略脚本(包含策略函数、回测配置和引擎执行流程)以及配套的模拟行情与分红数据文件:
