业绩快报利润增长选股策略

本案例以“业绩快报净利润同比增长率”作为排序因子,从可交易候选股票中筛选出该指标为正、且增速最高的标的构建等权组合,并通过回测引擎完成定期调仓与绩效统计。

需要说明的是,本案例旨在演示量化回测的工程实现。所用到的行情数据、因子明细及分红数据均由随机模拟生成。本例实现的是经典的基本面盈利动量逻辑,回测结果仅供技术验证,不代表真实市场的收益表现。

1. 策略介绍

本策略在本地预设的股票池中确定候选范围,剔除停牌或无成交量的证券;然后按业绩快报净利润同比增长率排序,持有前 10 只股票并每 20 个交易日回调一次。下面说明因子、交易标的和交易规则。

1.1 核心因子

业绩快报净利润同比增长率(express_yoy_net_profit)衡量了公司在最新报告期内净利润相较于去年同期的增长幅度。该指标基于公司先于正式财报披露的业绩快报,能够最为及时地反映公司短期的盈利增长动能。数值越高,代表公司的业绩增速越快、景气度越高。

策略在选股时,首先会检查该项指标数据是否完整有效,并同时严格要求业绩快报净利润同比增长率 > 0,即只保留实现业绩正增长的公司,剔除利润下滑或停滞的标的。通过筛选后,策略直接按以下得分进行降序排列:score = 业绩快报净利润同比增长率。

评分在其他条件不变时偏好增速更快的标的。策略最多选取前 10 只股票;实际入选 N 只时,每只目标权重为 1/N。例如若选满 10 只,每只权重为 10%;若只有 4 只通过正增长筛选,每只目标权重为 25%,策略不会用不合格的股票补足 10 只。需要注意的是,在真实的业绩超预期投研实践中,除了考察绝对增速外,往往还需接入公告前可获得的分析师一致预期数据,以此计算真实的预期差。

1.2 交易标的

本次回测的交易标的及股票池直接由本地加载的模拟数据集提取生成。该预处理文件包含了 50 只随机模拟的股票(代码为 000001.SH 至 000050.SH)。

本次实际运行共读取约 12,100 条股票日线数据(即 242 个交易日、每天 50 只股票),其中包含了基础量价行情与对应的业绩快报因子。在实际交易环节,策略会逐日检查标的的当日成交量和涨跌停报价,以确保筛选出的股票处于正常可交易状态。

1.3 交易规则

  • 首个有效日线回调立即产生选股信号,之后每 20 个有效日线回调重新选股;242 个交易日对应 13 次信号。
  • 在可交易候选中,因子有效且大于 0 的标的按业绩快报净利润同比增长率降序排序,最多选择 10 只;实际入选数量为 N 时,每只目标权重为 1/N。
  • 信号日使用收盘价和当日账户权益锁定目标股数;订单在下一交易日按开盘价尝试撮合,避免用同一根日线的收盘价成交。
  • 调仓先卖出未入选股票和超配部分,再按排名顺序买入低配或新入选股票。买入数量受可用现金约束;未成交的目标不会自动在后续日追单。
  • 买入要求有成交量且开盘价未涨停,卖出要求有成交量且开盘价未跌停;订单价格会被限制在涨跌停价之间。
  • A 股普通股票买入和部分卖出按 100 股整数倍规整。
  • 回测按买卖手续费率 0.015% 计费,卖出另按 0.05% 计印花税。组合目标仓位比例为 100%。

2. 策略回测

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

2.1 准备数据

回测区间为 2024 年 1 月 1 日至 2024 年 12 月 31 日,首个有行情的交易日为 2024 年 1 月 2 日。

本次回测直接通过 loadText 函数读取本地预先生成的模拟日线与分红 CSV 文件。在加载数据后,直接从行情数据中提取去重后的股票代码作为全局股票池:

// CSV 读取必须使用类型名称字符串,才能恢复 SYMBOL、DATE 与 DOUBLE 类型。
def getExpressDividendCsvSchema() {
    return table(`symbol`endDate`annDate`recordDate`exDate`payDate`divListDate`bonusRatio`capitalConversion`afterTaxCashDiv`allotPrice`allotRatio as name,
        ["SYMBOL", "DATE", "DATE", "DATE", "DATE", "DATE", "DATE", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE"] as type)
}

// 请根据实际情况修改路径
path = "/.../"

//读取分红数据
stocksDividend = loadText(path + "expressGrowth_stocksDividend.csv", ',', getExpressDividendCsvSchema())

//读取行情数据
data = loadText(path + "expressGrowth.csv", ',')

data.replaceColumn!("volume", long(data.volume))

stockPool = set(exec distinct string(symbol) from data)

行情中包括开高低收、成交量、成交额、涨跌停价、复权因子和业绩快报因子。此外,数据集中还包含了配套的股票分红明细。接入真实数据前,应增加主键唯一性、价格关系、涨跌停边界、停牌、因子缺失以及财报可得时点的校验。

2.2 编写策略逻辑

策略由 roundLot、normalizeSellQty、isBuyableAt、isSellableAt、getValidOrderPrice、initialize 和 onBar 组成。其中,前五个函数处理交易数量与价格约束;initialize 写入手续费、调仓状态、基础信息表及待执行目标;onBar 是日线回调和策略主体。

约束与规整函数:roundLot 与 normalizeSellQty

roundLot(code, rawQty) 返回符合各板块市场规则的买入数量。普通股票默认向下取整至 100 股。normalizeSellQty(code, positionQty, requestedQty) 先限制卖出数量不超过当前持仓,并确保整仓退出时允许卖出不足一个申报单位的余量。

初始化函数:initialize

initialize(mutable context) 是回测引擎的初始化回调,用于建立本次回测需要的可变状态。该函数没有显式返回值,而是将手续费、税率、调仓计数、选中股票、待执行目标数量和全局股票池保存到可变 context 中。其中,dayCount = -1 使第一根有效日线在计数后立即成为首次调仓信号日。

def initialize(mutable context) {
    context["commission"] = 0.00015
    context["tax"] = 0.0005
    context["dayCount"] = -1
    context["initialRebalancePending"] = true
    // 跨日调仓状态:保留有序列表以保证资金不足时按因子降序顺延
    context["selectedStocks"] = []$STRING
    context["pendingTargetQtys"] = dict(STRING, LONG)
    context["pendingRebalance"] = false
    context["rebalanceDate"] = NULL
    context["stockPool"] = objByName("stockPool")
}

回调函数:onBar

onBar 是日线行情回调,也是策略的主执行函数。msg 接收当日股票代码到行情记录字典。为了兼顾交易可行性和回测真实性,该函数在每次回调集中处理以下两个核心业务模块:

  • 延迟调仓:若存在待执行目标且已到执行日,以开盘价先卖出不再入选或超配的持仓,估算可回收现金,再按选中顺序买入不足部分。买入受可用现金、手续费、整手规则和可买状态约束,体现“先卖后买、现金兜底。
  • 调仓日选股:每隔固定交易日构建可交易池,在全局股票池中剔除当日无成交量的股票;再按业绩快报净利润同比增速筛选有效且正增长的股票,降序取前 10 只等权。若无合格股票,则形成空组合,后续卖出未入选持仓。最后按总权益和收盘价估算目标股数,留待下一交易日开盘执行。

这种设计的关键点在于:信号日只负责生成目标,执行日才负责成交;卖出优先于买入;现金不足时按实际可用资金缩减买入;空组合也被视为有效信号。因此,策略在保持低频、规则清晰的同时,兼顾了交易可行性和回测真实性。

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

    // === 1. 调仓执行阶段(次日开盘:先卖后买,资金不足按排名顺延)===
    if (context["pendingRebalance"] && curDate != context["rebalanceDate"]) {
        tgtQtys_ = context["pendingTargetQtys"]
        expectedCash_ = Backtest::getAvailableCash(context.engine)
        sellCashMult_ = 1.0 - context["commission"] - context["tax"]

        // 第一轮:卖出非入选股票(清仓;选股为空时此处将清空所有持仓)及超配股票(减仓)
        for (istock in msg.keys()) {
            bar = msg[istock]
            posInfo = Backtest::getPosition(context.engine, istock)
            pos = 0
            pos_sellable = 0
            if (size(posInfo) > 0) {
                pos = posInfo["longPosition"]
                pos_sellable = max(0, posInfo["lastDayLongPosition"] - posInfo["todaySellVolume"])
            }
            if (!(pos > 0 && pos_sellable > 0 && isSellableAt(bar["open"], bar))) { continue }
            
            orderPrice = getValidOrderPrice(bar["open"], bar)
            if (!(orderPrice > 0)) { continue }

            if (!(istock in context["selectedStocks"])) {
                // 不在最新入选名单中:全卖清仓
                Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, orderPrice, pos_sellable, 3), "rebalance_sell")
                expectedCash_ = expectedCash_ + pos_sellable * orderPrice * sellCashMult_
            } else {
                // 在名单中但持仓超过目标数:减仓
                targetQty = iif(istock in tgtQtys_, tgtQtys_[istock], 0)
                if (pos > targetQty) {
                    sellQty_ = normalizeSellQty(pos, min(pos_sellable, pos - targetQty))
                    if (sellQty_ > 0) {
                        Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, orderPrice, sellQty_, 3), "rebalance_sell")
                        expectedCash_ = expectedCash_ + sellQty_ * orderPrice * sellCashMult_
                    }
                }
            }
        }

        // 第二轮:按因子降序排名顺序买入/加仓
        for (istock in context["selectedStocks"]) {
            if (!(istock in msg)) { continue }
            bar = msg[istock]
            if (!isBuyableAt(bar["open"], bar)) { continue }

            orderPrice = getValidOrderPrice(bar["open"], bar)
            if (!(orderPrice > 0)) { continue }

            posInfo = Backtest::getPosition(context.engine, istock)
            pos = iif(size(posInfo) > 0, posInfo["longPosition"], 0)
            targetQty = iif(istock in tgtQtys_, tgtQtys_[istock], 0)

            if (targetQty > pos) {
                plannedQty = targetQty - pos
                affordableCash_ = min(expectedCash_, Backtest::getAvailableCash(context.engine))
                affordableQty = roundLot(affordableCash_ / (orderPrice * (1 + context["commission"])))
                buyQty = min(plannedQty, affordableQty)
                if (buyQty > 0) {
                    Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, orderPrice, buyQty, 1), "rebalance_buy")
                    expectedCash_ = expectedCash_ - buyQty * orderPrice * (1 + context["commission"])
                }
            }
        }
        context["pendingRebalance"] = false
    }

    // === 2. 选股与目标锁定阶段(信号日收盘后选股并按收盘价计算目标股数)===
    context["dayCount"] = context["dayCount"] + 1
    isRebalanceDay = ((context["dayCount"] % 20 == 0) || context["initialRebalancePending"])
    context["initialRebalancePending"] = false

    if (isRebalanceDay) {
        symbols = []$STRING
        yoyProfits = []$DOUBLE

        // 收集当日股票池中有效交易数据
        for (istock in msg.keys()) {
            if (!(istock in context["stockPool"])) { continue }
            bar = msg[istock]
            if (bar["volume"] <= 0) { continue }
            symbols.append!(istock)
            yoyProfits.append!(double(bar["express_yoy_net_profit"]))
        }

        poolT = table(symbols as symbol, yoyProfits as express_yoy_net_profit)
        selectedStocks = []$STRING

        if (poolT.size() > 0) {
            // 选股条件:因子有效且大于0,按因子降序取前10只
            poolT = select * from poolT where isValid(express_yoy_net_profit) && express_yoy_net_profit > 0 order by express_yoy_net_profit desc limit 10
            selectedStocks = exec symbol from poolT
        }

        // 计算目标持仓股数(解耦选股数量:若选股为空,tgtQtys_ 为空,次日开盘自动全卖空仓)
        tgtQtys_ = dict(STRING, LONG)
        if (selectedStocks.size() > 0) {
            // 取信号日当天的最新总资产(使用 .last() 修复 [0] 导致的期初本金偏离)
            signalEquity_ = Backtest::getTotalPortfolios(context.engine).totalEquity.last()
            targetPerStockAmt_ = signalEquity_ / selectedStocks.size()

            for (s_ in selectedStocks) {
                tgtQtys_[s_] = roundLot(targetPerStockAmt_ / (msg[s_]["close"] * (1 + context["commission"])))
            }
        }

        context["selectedStocks"] = selectedStocks
        context["pendingTargetQtys"] = tgtQtys_
        context["pendingRebalance"] = true
        context["rebalanceDate"] = curDate
    }
}

2.3 设置回测参数

脚本的主要配置如下:

startDate = 2024.01.01
endDate = 2024.12.31
benchmarkCode = "000300.SH"

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
}

初始资金为 1,000,000 元;dataType = 4 表示输入股票日线数据并触发 onBar;matchingMode = 2 表示用日线开盘价进行撮合;outputOrderInfo = true 会使交易明细保留订单状态记录;每笔最低手续费关闭,费用按成交金额和费率直接计算。脚本未显式启用 JIT,创建引擎时第四个参数传入 false。

本次模拟数据不依赖真实除权信息,分红数据仅通过配置项 stockDividend 传给引擎。

2.4 创建回测引擎

脚本将 initialize 与 onBar 放入回调字典,以固定名称 "express_growth_strategy_engine" 创建回测引擎:

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

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

运行环境需要已加载回测和模拟撮合引擎插件。脚本会先尝试删除同名旧引擎,再创建新实例。若在共享 DolphinDB 会话内并发运行,应改用唯一的引擎名称,避免误删除其他运行中的同名引擎。

2.5 执行回测

将按 tradeTime、symbol 排序的行情发送给引擎;随后通过 appendEndMarker 函数注入结束标记,以指示行情回放结束:

Backtest::appendQuotationMsg(engine, data)
Backtest::appendEndMarker(engine);

本案例已在指定的 DolphinDB 环境完成一次完整运行。脚本只使用当前会话内的本地数据集、内存表与回测引擎;运行结束后引擎被删除,没有把模拟行情或回测结果写入 DFS 。

2.6 获取回测结果

回测完成后,脚本取得收益概述、交易明细、每日持仓和每日账户权益:

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

回测完成后,脚本直接通过引擎内置接口提取策略的收益概述、交易明细、每日持仓和每日账户权益等核心结果,以此作为分析策略绝对收益与各项风险指标的基础。

各结果对象的用途如下:

  • returnSummary:查看总收益、年化收益、波动率、夏普比率、最大回撤、换手率和基准收益等汇总指标。
  • tradeDetails:查看订单状态、报单数量、成交时间、成交价格和成交数量;tradeQty > 0 的行并不都是成交,其中还有 tradePrice 为 0 的状态更新行,需结合 tradePrice 与仓位变动判断真实成交。
  • dailyPosition:查看每日各证券的期初持仓、买卖数量、期末持仓和收盘估值。
  • totalPortfolios:查看每日现金、市值、总权益、费用、策略净值,以及累计收益、浮动盈亏与已实现盈亏。
  • data:查看实际输入引擎的模拟日线与业绩快报因子。

3. 回测结果分析

本次回测处理 12,100 条股票行情,覆盖 242 个交易日。每个调仓日的 50 个候选因子均为正,因而每次均选中 10 只股票并按 10% 目标权重分配;共形成 13 次选股信号。首个信号日为 2024 年 1 月 2 日,按因子从高到低选出的 10 只股票及按信号日收盘价锁定的目标数量如下:

表 1. 表 3-1 首个信号日(2024-01-02)选股与目标持仓明细
股票代码 增长率 目标股数
000013.SH 200.32% 1,000 股
000025.SH 199.71% 1,100 股
000029.SH 199.19% 2,300 股
000006.SH 197.82% 2,100 股
000009.SH 191.37% 1,800 股
000007.SH 183.09% 1,700 股
000039.SH 182.41% 2,500 股
000034.SH 175.76% 3,800 股
000033.SH 175.63% 1,700 股
000046.SH 172.08% 4,000 股

首个执行日为 2024 年 1 月 3 日:信号日锁定的 10 只标的中,4 只(000006.SH、000009.SH、000034.SH、000039.SH)因开盘价高于涨停价被跳过,其余 6 只按目标数量提交买单;这 6 笔中有 5 笔的开盘价低于当日跌停价,报价被脚本抬至跌停价后未能撮合,最终只有 000046.SH 以开盘价 22.334 元成交 4,000 股,当日 dailyPosition 也只记录了这一只持仓。tradeDetails 一共有 153 行状态与成交记录,对应 76 个不同订单号;其中 tradeQty 大于 0 的 77 行里 tradePrice 不为 0 的只有 14 行,其余为订单状态更新。成交记录按标签分为 63 行 rebalance_buy 和 14 行 rebalance_sell。因此既不能把 153 行全部,也不能把 tradeQty 大于 0 的 77 行当作成交笔数。

表 2. 表 3-2 回测核心收益与风险指标汇总
指标 数值
账户初始权益 1,000,000.00 元
期末权益 1,182,113.58 元
期末净值 1.182114
本次区间总收益 18.2114%
年化收益 19.0314%
模拟基准区间收益 0.0000%
年化波动率 321.0695%
夏普比率 0.0593
最大回撤 50.8903%
换手率 1.4588%
每日胜率 49.3776%
累计费用 379.03 元

本次结果显示,策略在回测区间内取得了 18.2114% 的总收益(年化收益 19.0314%),但收益同时伴随着极高的波动(年化波动率 321.0695%)和高达 50.8903% 的最大回撤,风险调整后的夏普比率仅为 0.0593。

这表明仅依靠单一利润增速因子进行满仓轮动,虽然能捕捉到一定的基本面盈利动能,但无法有效抵御极端的下行风险。若要投入真实市场,仍需进一步结合行业中性化处理、仓位管理以及更严格的回撤控制机制,不宜直接据此作出投资结论。

4. 总结

本策略以业绩快报净利润同比增长率为唯一排序因子,在全局股票池中选取增长率最高的 10 只股票,按实际入选数量等权分配资金,并在信号日之后的下一交易日开盘完成调仓。脚本内部实现了常见证券的数量规整、涨跌停与停牌可交易性检查、现金兜底约束以及交易费用的扣除。