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 交易规则

策略按以下顺序完成一次选股和调仓:

  1. 在调仓信号日,先从样本池中排除非成员、ST、上市不足 90 个自然日以及成交量不大于 0 的股票。

  2. 排除 PE、PB 或 ROE 缺失的股票,并应用 0 < PE < 20、0 < PB < 3 和 ROE > 15% 三个严格筛选条件。

  3. 对合格股票计算 ROE / (PE × PB),按得分降序排列;得分相同时按股票代码升序排列,最多选择 10 只。

  4. 根据实际入选数量,将信号日账户权益等额分配给入选股票,并使用信号日收盘价计算每只股票的目标持仓数量。目标数量向下取整到 100 股的整数倍,并预留买入手续费。

  5. 下一交易日按开盘价执行调仓。策略先卖出未入选或超过目标数量的持仓,再买入当前持仓低于目标数量的股票。

  6. 买入数量同时受目标差额和可用现金限制;卖出数量同时受实际持仓和可卖数量限制。每轮调仓只执行一次,不会在后续交易日自动追单。

本例从第一个有效日线 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 加载至内存中。

行情表结构如下:

表 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.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 写入信号记录。其内部流程分为两部分:

  1. 如果存在更早交易日生成的待执行目标,先检查可卖数量和开盘交易条件,卖出超配持仓;再检查可用现金和涨停条件,买入低配股票。执行一次后清除待调仓标记。

  2. 调仓计数达到 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 位小数,实际排序与数量计算均使用原始精度:

表 3. 表 3-1 首个信号日的选股结果
模拟股票 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 单位及指标口径,并进一步评估持仓集中、成本、行业偏离和样本外表现等因素。