PE、PB 与 ROE 选股策略
股票市盈率(Price-to-Earnings Ratio,PE)、市净率(Price-to-Book Ratio,PB)与净资产收益率(Return on Equity,ROE)三因子选股策略结合估值与盈利能力,从候选股票中寻找市盈率较低、市净率较低且净资产收益率较高的标的。本文基于行情与因子数据,通过 DolphinDB Backtest 引擎完成多条件筛选、综合得分排序、等权配置、定期调仓和结果分析。
1. 策略介绍
本策略使用 PE、PB 和 ROE 同时衡量股票的估值与盈利能力。首先排除不满足基本条件的股票,再对符合条件的股票进行评分,并据此构建等权组合。下面分别介绍所使用的因子、交易标的和交易规则。
1.1 核心因子
PE 表示股价与每股收益的比值;PB 表示股价与每股净资产的比值。两者越低,分别表示单位盈利和账面净资产支付的价格越低。ROE 衡量公司利用净资产产生利润的能力,通常按净利润除以平均净资产计算,以百分比表示。
策略先检查三项指标是否完整,并同时要求 0 < PE < 20、0 < PB < 3、ROE > 15%。代码中
ROE 使用百分数数值,例如 roe=18.0 表示 18%,因此阈值写为 15.0,而不是
0.15。通过筛选后,策略根据以下综合得分对股票进行降序排列:score = ROE / (PE × PB)。
例如,PE 为 10、PB 为 1.5、ROE 为 20% 的股票,得分为 20 / (10 × 1.5) =
1.3333。在其他条件相同的情况下,综合得分越高意味着 ROE 越高、PE 和 PB 越低。该得分仅用于股票排序,不代表预期收益率。
策略最多选取综合得分最高的 10 只股票,若多只股票的得分相同,则按股票代码升序排列;实际入选 N 只股票时,每只目标权重均为 1/N。例如,只有 4 只股票通过筛选时,每只股票的目标权重为 25%,不会使用未通过筛选的股票补足到 10 只。
1.2 交易标的
本策略以 A 股市场的普通股票(如带有 .SZ 后缀的深市股票)为交易标的,并严格遵守 A 股交易规则,将买卖单量设置为 100 股的整数倍(一手)。
策略采用普通股票现金账户,仅建立多头持仓,不进行卖空、融资或借券。行情频率为日频,因子数据也按交易日提供;订单按下一交易日开盘价撮合。引擎启用当日买入持仓不可当日卖出的限制,因此策略不会依靠日内回转交易完成调仓。
接入真实数据时,候选股票应来自具有历史时点信息的股票池。除日线行情和 PE、PB、ROE 因子外,还需要准备当时有效的 ST 状态、上市日期、停牌或可交易状态、复权和分红信息。不能使用当前成分股替代过去各交易日的股票池。
1.3 交易规则
策略按以下顺序完成一次选股和调仓:
-
在调仓信号日,先从样本池中排除非成员、ST、上市不足 90 个自然日以及成交量不大于 0 的股票。
-
排除 PE、PB 或 ROE 缺失的股票,并应用
0 < PE < 20、0 < PB < 3和ROE > 15%三个严格筛选条件。 -
对合格股票计算
ROE / (PE × PB),按得分降序排列;得分相同时按股票代码升序排列,最多选择 10 只。 -
根据实际入选数量,将信号日账户权益等额分配给入选股票,并使用信号日收盘价计算每只股票的目标持仓数量。目标数量向下取整到 100 股的整数倍,并预留买入手续费。
-
下一交易日按开盘价执行调仓。策略先卖出未入选或超过目标数量的持仓,再买入当前持仓低于目标数量的股票。
-
买入数量同时受目标差额和可用现金限制;卖出数量同时受实际持仓和可卖数量限制。每轮调仓只执行一次,不会在后续交易日自动追单。
本例从第一个有效日线 onBar 回调开始产生信号,之后每 20
个交易日(回调)重新选股。若没有股票满足筛选条件,策略生成空目标,并在下一交易日尝试清空可卖持仓。
2. 策略回测
下文按照准备数据、定义策略、设置参数、创建引擎、回放行情和读取结果的顺序展开。本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 与模拟撮合引擎插件(Matching Engine Simulator) 3.00.6.6 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前,代码会检查插件是否已安装,并加载这两个插件。
2.1 准备数据
数据准备环节使用本地 CSV 文件加载模拟的股票数据。为还原真实的交易机制,回测引擎基于未复权价格与独立的分红表执行订单撮合。运行脚本前,需将脚本中的数据加载路径修改为本地实际路径。
数据覆盖 2024 年全年 30 只模拟标的,主要包含两部分:一是日频行情与因子宽表,记录了未复权日线价格(开、高、低、收)、成交量、涨跌停价,以及对应交易日的
PE、PB、ROE 因子与股票状态(如 ST 标记、上市天数);二是配套的模拟分红表,用于触发回测引擎的现金入账与持仓核算。数据通过
loadText 加载至内存中。
行情表结构如下:
| 列名 | 类型 | 说明 |
|---|---|---|
| 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.SZ) |
| endDate | DATE | 报告期(分红所属财务周期的最后一天) |
| annDate | DATE | 董事会/股东大会分红决议公告日 |
| recordDate | DATE | 股权登记日 |
| exDate | DATE | 除权除息日 |
| payDate | DATE | 派息日(现金红利发放到账日) |
| divListDate | DATE | 送转增红股上市交易日 |
| bonusRatio | DOUBLE | 每股送股比例 |
| capitalConversion | DOUBLE | 每股转增比例(资本公积转增股本) |
| afterTaxCashDiv | DOUBLE | 每股税后现金红利金额 |
| allotPrice | DOUBLE | 配股价(如有配股) |
| allotRatio | DOUBLE | 每股配股比例(如有配股) |
所有股票的 isMember 为 1;第一只股票在起始日之后的前 30 个自然日标记为 ST;最后一只股票的模拟上市日设为起始日前 30 天,其他股票的上市天数固定为 1,000。这些虚构编号与属性不代表真实公司的行情、财务信息或沪深 300 成分关系。
2.2 编写策略逻辑
策略由 4 个函数组成。threeFactorRoundLot
负责对股票数量取整;threeFactorSelect
负责多条件筛选、综合评分和排序;initialize 初始化交易状态;onBar
处理每天的调仓执行和新信号。
在 onBar 中,msg
是以股票代码为键、以该股票当日行情为值的字典。每次回调先执行上一个信号日已经确定的目标,再更新计数器。计数器初始为 -1,因此第 1、21、41
个有效回调产生选股信号;本例每日行情完整,因而对应每隔 20 个交易日重新选股。
选中 N 只股票后,用信号日账户权益、1/N 的权重和收盘价计算目标股数。目标数量确定后,次日开盘价的变化不会重新计算目标数量,但实际买入数量仍受可用现金限制。
取整函数:threeFactorRoundLot
threeFactorRoundLot(rawQty) 将计算得到的原始下单数量 rawQty 按 100
股的整数倍向下取整,并确保结果不小于 0。
def threeFactorRoundLot(rawQty) {
// 本例仅模拟普通深市股票,买卖数量按 100 股取整。
return long(max(0, floor(rawQty / 100.0) * 100))
}
选股函数:threeFactorSelect
threeFactorSelect(candidates, peUpper, pbUpper, roeLower,
countLimit) 接收候选股票表,对 PE、PB 和 ROE
进行有效性检查和条件筛选,然后计算综合得分并排序,最终截取指定数量的股票。它是选股规则的集中实现,不读取账户、持仓或行情引擎状态。
-
candidates:TABLE,至少包含 symbol、pe、pb 和 roe 四列,分别表示股票代码、市盈率、市净率和净资产收益率百分数值。
-
peUpper:数值标量,表示 PE 的严格上限。只有
0 < pe < peUpper的记录可以入选。 -
pbUpper:数值标量,表示 PB 的严格上限。只有
0 < pb < pbUpper的记录可以入选。 -
roeLower:数值标量,表示 ROE 的严格下限。只有
roe > roeLower的记录可以入选;本例使用百分数数值,因此 15 表示 15%。 -
countLimit:正整数标量,表示最多返回多少只股票,不会用不合格股票补足数量。
函数返回筛选后的 TABLE,并增加 score 列。结果先按 score 降序排列,再按 symbol 升序排列,最多返回 countLimit 行。没有股票满足条件时返回空表。
def threeFactorSelect(candidates, peUpper, pbUpper, roeLower, countLimit) {
eligible = select * from candidates
where isValid(pe), isValid(pb), isValid(roe),
pe > 0, pe < peUpper, pb > 0, pb < pbUpper, roe > roeLower
scored = select *, roe / (pe * pb) as score from eligible
validScores = select * from scored where isValid(score)
n = min(countLimit, size(validScores))
return select * from validScores order by score desc, symbol asc limit n
}
初始化函数:initialize
initialize(mutable context)
是回测引擎的初始化回调。每次创建新引擎时调用一次,用于初始化本次回测需要的可变状态。
context:可变 DICTIONARY,由创建引擎时传入。函数在其中写入调仓计数和待执行标记。
函数没有显式返回值,而是更新 context 中的状态。dayCount = -1 使第一次
onBar 回调时计数器变为 0,从而立即产生首次信号;pendingRebalance =
false 表示初始化时没有待执行调仓。信号日期、入选股票和目标数量会在首次产生选股信号时写入 context。
def initialize(mutable context) {
context["dayCount"] = -1
context["pendingRebalance"] = false
}
回调函数:onBar
onBar(mutable context, msg, indicator)
是日线行情回调,也是策略的主执行函数。在本例中,每个交易日调用一次。函数首先执行前一信号日确定的调仓目标,再判断当日是否需要生成新的选股信号。这样,当日收盘数据确定的目标不会使用同一交易日的开盘价执行。
-
context:可变 DICTIONARY,包含引擎注入的当前日期、时间和引擎句柄,也包含初始化状态、筛选参数、成本参数及日志表。
-
msg:DICTIONARY,以股票代码为键,以当日该股票的日线及因子记录为值。函数从中读取开盘价、收盘价、涨跌停价、成交量、PE、PB、ROE、样本池状态、ST 状态和上市天数。
-
indicator:引擎为指标结果预留的参数。本例没有配置独立指标,因此函数签名保留该参数,但策略逻辑没有读取它。
该函数没有显式返回值,主要副作用包括提交买卖订单、更新待调仓状态,以及向 selectionLog 和 rebalanceLog 写入信号记录。其内部流程分为两部分:
-
如果存在更早交易日生成的待执行目标,先检查可卖数量和开盘交易条件,卖出超配持仓;再检查可用现金和涨停条件,买入低配股票。执行一次后清除待调仓标记。
-
调仓计数达到 rebalanceDays 的整数倍时,构造候选表,调用
threeFactorSelect,按信号日账户权益和收盘价计算目标股数,并保存下一交易日要执行的目标。
def onBar(mutable context, msg, indicator) {
curDate = date(context.tradeDate)
// 执行昨日已确定的目标股数;不会读取今日因子或今日收盘价决定订单。
if (context["pendingRebalance"] && curDate > context["signalDate"]) {
targets = context["targetQtys"]
selected = context["selectedStocks"]
estimatedCash = Backtest::getAvailableCash(context.engine)
for (s in sort(msg.keys())) {
bar = msg[s]
position = Backtest::getPosition(context.engine, s)
held = long(0)
sellable = long(0)
if (size(position) > 0) {
held = long(position.longPosition)
sellable = long(max(0, position.lastDayLongPosition - position.todaySellVolume))
}
target = iif(s in targets, targets[s], long(0))
sellQty = threeFactorRoundLot(min(sellable, max(0, held - target)))
if (sellQty > 0 && bar.volume > 0 && bar.open > bar.downLimitPrice) {
Backtest::submitOrder(context.engine,
(s, context.tradeTime, 5, bar.open, sellQty, 3), "three_factor_sell")
estimatedCash += sellQty * bar.open * (1.0 - context["commission"] - context["tax"])
}
}
for (s in selected) {
if (!(s in msg)) { continue }
bar = msg[s]
if (bar.volume <= 0 || bar.open >= bar.upLimitPrice) { continue }
position = Backtest::getPosition(context.engine, s)
held = iif(size(position) > 0, long(position.longPosition), long(0))
requiredQty = max(0, targets[s] - held)
cash = max(0.0, min(estimatedCash, Backtest::getAvailableCash(context.engine)))
affordableQty = threeFactorRoundLot(cash / (bar.open * (1.0 + context["commission"])))
buyQty = threeFactorRoundLot(min(requiredQty, affordableQty))
if (buyQty > 0) {
Backtest::submitOrder(context.engine,
(s, context.tradeTime, 5, bar.open, buyQty, 1), "three_factor_buy")
estimatedCash -= buyQty * bar.open * (1.0 + context["commission"])
}
}
// 一次执行后清除意向,不自动追单补足目标。
context["pendingRebalance"] = false
}
context["dayCount"] += 1
if (context["dayCount"] % context["rebalanceDays"] != 0) { return }
symbols = array(STRING, 0)
peValues = array(DOUBLE, 0)
pbValues = array(DOUBLE, 0)
roeValues = array(DOUBLE, 0)
for (s in sort(msg.keys())) {
bar = msg[s]
if (bar.isMember != 1 || bar.isST == 1 || bar.listedDays < 90 || bar.volume <= 0) { continue }
symbols.append!(s)
peValues.append!(bar.pe)
pbValues.append!(bar.pb)
roeValues.append!(bar.roe)
}
candidates = table(symbols as symbol, peValues as pe, pbValues as pb, roeValues as roe)
ranked = threeFactorSelect(candidates, context["maxPE"], context["maxPB"],
context["minROE"], context["holdingCount"])
n = size(ranked)
selected = exec symbol from ranked
targets = dict(STRING, LONG)
equity = Backtest::getTotalPortfolios(context.engine).totalEquity[0]
if (n > 0) {
weight = 1.0 / n
for (s in selected) {
qty = threeFactorRoundLot(equity * weight / (msg[s].close * (1.0 + context["commission"])))
targets[s] = qty
tableInsert(context["selectionLog"], curDate, s, double(msg[s].pe), double(msg[s].pb),
double(msg[s].roe), double(msg[s].roe / (msg[s].pe * msg[s].pb)),
weight, equity, long(qty))
}
}
tableInsert(context["rebalanceLog"], curDate, int(n), equity)
context["selectedStocks"] = selected
context["targetQtys"] = targets
context["signalDate"] = curDate
context["pendingRebalance"] = true
}
2.3 设置回测参数
这里将回测引擎参数配置在 config,并将策略运行所需的参数、数据源配置和日志表存储在 context 中。完整配置如下:
selectionLog = table(1000:0, `signalDate`symbol`pe`pb`roe`score`weight`signalEquity`targetQty,
[DATE, STRING, DOUBLE, DOUBLE, DOUBLE, DOUBLE, DOUBLE, DOUBLE, LONG])
rebalanceLog = table(100:0, `signalDate`selectedCount`signalEquity, [DATE, INT, DOUBLE])
config = {
startDate: 2024.01.01,
endDate: 2024.12.31,
strategyGroup: "stock",
cash: 1000000.0,
commission: 0.00015,
tax: 0.0005,
dataType: 4,
matchingMode: 2,
outputOrderInfo: true,
enableMinimumPerTransactionFee: false,
enableSellCloseRestrict: true,
context: {
"commission": 0.00015,
"tax": 0.0005,
"maxPE": 20.0,
"maxPB": 3.0,
"minROE": 15.0,
"holdingCount": 10,
"rebalanceDays": 20,
"selectionLog": selectionLog,
"rebalanceLog": rebalanceLog
}
}
本例将 createBacktester 的第四个参数固定为 false,以非 JIT 模式执行。设置为 true 可启用
JIT,但启用前需要检查所有回调是否兼容,包括上下文变量预声明、变量类型和表操作等。本例回调包含 SQL 表处理,尚未验证开启 JIT
后的运行结果。具体检查方法参阅DolphinDB
回测引擎使用和性能优化攻略。
2.4 创建回测引擎
模拟撮合引擎负责订单撮合,Backtest 负责事件回调、持仓、资金与绩效管理。下面先检查并加载已经安装的插件,再注册初始化和日线回调。加载命令不会自动安装缺少的插件,需先在服务器端安装插件,参考:MatchingEngineSimulator、Backtest。
引擎通过 config 获取配置与上下文,通过 callbacks 找到策略函数。代码先尝试清理同名引擎实例,再创建新的引擎实例,避免重复使用包含历史状态的引擎。
callbacks = dict(STRING, ANY)
callbacks["initialize"] = initialize
callbacks["onBar"] = onBar
engineName = "ThreeFactorStockSelection"
// 首次运行时不存在同名引擎属于正常情况;静默忽略清理阶段的该异常。
try { Backtest::dropBacktestEngine(engineName) } catch(ex) { }
engine = Backtest::createBacktester(engineName, config, callbacks, false)
重复运行完整案例时,从 2.1 开始重新生成数据,再依次执行各节。不要在已有状态的引擎上重复回放同一批行情。
2.5 执行回测
将排好时间顺序的行情表写入引擎,回调函数按交易日推进,依次执行信号计算和后续交易。最后通过 appendEndMarker
发送结束标记,并保留当前引擎实例,以便在下一环节提取回测指标与交易明细。
Backtest::appendQuotationMsg(engine, csvtb)
Backtest::appendEndMarker(engine);
2.6 获取回测结果
处理结束消息后,分别查询收益概述、订单与成交明细、每日持仓和每日账户权益。用 totalEquity /
config["cash"] 计算净值;本例未设置外部资金流入或流出。
returnSummary = Backtest::getReturnSummary(engine)
tradeDetails = Backtest::getTradeDetails(engine)
dailyPosition = Backtest::getDailyPosition(engine)
totalPortfolios = Backtest::getDailyTotalPortfolios(engine)
update totalPortfolios set strategyNetValue = totalEquity / config["cash"]
各结果对象的主要用途如下:
-
returnSummary:查看引擎计算的收益、风险和交易统计。
-
tradeDetails:查看订单状态与成交;仅
tradeQty > 0的记录表示发生了成交。 -
dailyPosition:查看各日持仓、买卖数量和收盘估值。
-
totalPortfolios:查看每日账户权益和策略净值(Strategy Net Value)。
-
selectionLog:查看三项指标、综合得分、目标权重和股数,追溯选股依据。
-
rebalanceLog:查看选股日期及每次实际入选数量。
脚本未设置真实指数基准,因此收益概述中与基准有关的默认字段不能解释为沪深 300 表现或有效超额收益。
3. 回测结果分析
在上述 Server 和插件版本下,以非 JIT 模式、2024 年全年样本,回测处理 7,260 条行情,产生 13 次选股信号和 70 条入选股票记录。每次入选 2 至 9 只,均未达到 10 只的持仓数量上限。由于目标权重按实际入选数量等权分配,入选 2 只时,每只股票的目标权重达到 50%。这说明多条件筛选可能造成较高的持仓集中度,持仓数量上限为 10 只不等于单只股票权重上限为 10%。
首个信号日为 2024-01-02,共入选 4 只股票,各自目标权重为 25%。结果按综合得分从高到低排序,结果如下。表中数值保留 4 位小数,实际排序与数量计算均使用原始精度:
| 模拟股票 | PE | PB | ROE(%) | 综合得分 | 目标股数 |
|---|---|---|---|---|---|
| 000025.SZ | 13.0718 | 0.7434 | 31.1054 | 3.2008 | 5,400 |
| 000006.SZ | 18.9134 | 0.8678 | 21.2722 | 1.2960 | 9,500 |
| 000015.SZ | 11.8919 | 1.8982 | 26.3924 | 1.1692 | 7,100 |
| 000013.SZ | 19.0349 | 2.6676 | 30.7937 | 0.6065 | 7,500 |
首笔成交发生在 2024-01-03,以开盘价 46.25 元买入 000025.SZ 共 5,400 股。交易明细 tradeDetails 共包含 228 条记录,其中包括已上报 114 条和全部成交 114 条状态记录,全期共产生 114 个订单。
初始权益为 1,000,000 元,期末权益为 890,339.59 元,期末净值为 0.890340,区间总收益为 -10.9660%。按每日净值相对此前最高净值的跌幅计算,最大回撤为 13.9937%。收益包含手续费和印花税的影响,期末权益包含剩余持仓的收盘估值,但未额外扣除期末强制清仓产生的交易成本。
这些结果仅反映一组模拟价格和随机因子上的资金变化。模拟输入没有建立“低估值、高 ROE 应对应更高未来收益”的因果或统计关系,也没有模拟真实财报的公告点及数据修订。因此,本例的回测结果主要用于验证策略实现和回测流程,不能根据本次回测结果判断该策略在真实市场中的有效性,也不能仅凭一组模拟结果得出其优于其他策略的结论。
4. 总结
PE、PB 与 ROE 三因子选股策略先通过估值和盈利能力阈值筛选股票,再按 ROE / (PE × PB)
计算综合得分并排序,最后根据实际入选数量等权分配资金。通过 DolphinDB
Backtest,可以完整运行从数据生成、联合筛选、目标数量计算到次日开盘成交和结果获取的流程。
模拟数据适合用于理解策略实现与回测接口。用于真实投资研究时,应将输入替换为具有明确历史可用时点的行情和财务数据,统一 ROE 单位及指标口径,并进一步评估持仓集中、成本、行业偏离和样本外表现等因素。
