多事件叠加触发策略

多事件叠加触发策略将业绩预告、事件时效、北向持股和价格突破四类信号联合使用,只在四项条件同时成立时才将股票纳入候选范围。这样做的目的不是让某一个信号单独决定交易,而是要求基本面事件、资金持仓和价格行为在同一时点相互验证。本文使用 DolphinDB Backtest 引擎回放模拟数据,完成月末选股、等权建仓、持有期退出和结果分析。

1. 策略介绍

本策略把四个独立事件视为同一笔买入信号的必要条件:正向的业绩预告类型、足够新的预告事件、超过阈值的北向持股比例,以及复权收盘价突破此前 20 日高点。先以沪深 300 成分股、ST 状态、上市时间和可交易性确定候选范围,再以复权收盘价排序并构建等权组合。下面分别介绍所使用的因子、交易标的和交易规则。

1.1 核心因子

  1. 预告类型因子(事件属性)

    • 业务含义:代表上市公司业绩预告的类别编码。

    • 取值说明:本例中提取数值为 1 的记录,代表预先约定的正向利好预告(如业绩预增)。注:实际接入真实市场数据时,需查阅数据供应商的字典,确认编码“1”对应的准确业务含义。

  2. 事件新鲜度因子(时效性)

    • 业务含义:衡量该业绩预告发布距今的自然日天数。

    • 取值说明:数值越小,代表事件越新、市场反应空间越大。本例要求该因子 <= 5,旨在捕捉事件发生最初 5 天内的发酵期。

  3. 北向持股占比因子(资金面)

    • 业务含义:代表北向资金(外资/聪明资金)在该股票上的持股比例。

    • 取值说明:本例要求该数值严格 > 1%,以此来过滤掉完全没有主流机构关注的冷门股。

  4. 价格突破因子(技术面)

    • 业务含义:这是一个通过比对“当日复权收盘价”与“过去 20 个交易日最高价”得出的衍生因子。

    • 取值说明:当日收盘价大于前 20 日高点时,说明该股在技术形态上完成了向上突破,具备较强的多头动能。

  5. 排序与权重因子

    • 排序因子:当多只股票同时满足上述四个条件时,直接使用复权收盘价作为单一排序因子,由高到低降序排列,最多截取前 5 只。(若价格相同,则以底层引擎输出顺序为准)。

    • 权重因子:入选标的采用动态等权重(1/N)分配。因子评价秉持“宁缺毋滥”原则:若当期仅有 2 只股票达标,则它们的权重因子各为 50%,不会使用未达标的股票进行名额补足。

1.2 交易标的

本策略以行情表中出现、且在当前日属于沪深 300 成分股快照的 A 股普通股票为交易标的。脚本示例使用带 .SH 或 .SZ 后缀的股票代码,并将买入数量取整为 100 股的整数倍(一手)。

策略采用普通股票现金账户,仅建立多头持仓,不进行卖空、融资或借券。行情频率为日频,订单按下一交易日开盘价撮合。策略在卖出时读取可卖数量,因此不会依靠当日买入后立即卖出的日内回转交易完成退出。

接入真实数据时,股票池、指数成分股快照、ST 历史、上市日期、业绩预告、北向持股和复权数据都应具有明确的历史时点。

1.3 交易规则

策略按以下顺序完成一次选股、建仓和退出:

  • 步骤一,基础股票池过滤(择时与清洗):策略在回测首日及此后每个月的最后一个交易日触发选股。策略首先会划定基础范围:仅选取当日属于沪深300成分股的标的,并严格剔除 ST 股、上市不足 90 天的次新股,以及当日停牌(成交量为0)的股票。

  • 步骤二,多因子共振触发(核心信号):对上述初筛后的股票进行四维因子校验,要求候选股必须同时满足以下四项条件:

    1. 发布了正向业绩预告;

    2. 预告发布距今不超过 5 个自然日;

    3. 北向资金持股比例大于 1%;

    4. 当日复权收盘价向上突破过去 20 个交易日的最高点。

  • 步骤三,优选排序与名额截取:将达标的股票按复权收盘价由高到低排列,最多截取前 5 只。若当期市场环境较差,没有任何股票同时满足上述四项条件,则本期选股结果为空。

  • 步骤四,锁定目标仓位(信号日静态结算):根据实际入选的股票数量(N),将信号当天的账户总权益按 1/N 均等分配。策略会基于“信号日的收盘价”并扣除预估佣金后,算出目标持仓股数,并向下取整到 100 股的倍数。 注:目标数量在信号日当天生成后即被死锁,后续执行日不会再受新价格波动影响而重新算仓。

  • 步骤五,次日开盘执行(买卖与风控)

    • 买入建仓:在信号产生的下一交易日,系统按开盘价执行买入。策略仅对当前空仓的标的进行建仓;若某只股票已在持仓中,即使本月再次入选也不会追加买入。实际买入股数受目标差额与账户剩余现金双重限制。

    • 平仓退出:单只股票建仓满 20 个交易日后生成卖出意图,并在下一交易日按开盘价卖出所有可卖额度(遵循 T+1)。

    • 异常处理:若执行日遇到股票停牌或涨跌停导致无法按开盘价撮合,订单将被放弃,系统不会自动追单重试。

首次 onBar 回调会产生初始选股信号,之后使用 monthEnd 和交易日历判断每月最后一个交易日。信号日锁定目标数量,执行日不会使用新的因子或新的收盘价重新计算目标数量。

2. 策略回测

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

2.1 准备数据

本例的数据准备环节从服务器路径加载行情表。行情表覆盖 2025-08-13 至 2026-08-12,共 24,200 条日频记录、100 个模拟标的。运行脚本前,需将脚本中的行情表加载路径修改为服务器上的实际路径。

行情表同时提供未复权交易价格、复权收盘价、涨跌停价、成交量和四类信号字段。示例中的股票池从行情表提取;指数成分股快照、ST 历史和上市信息表也在内存中构造,仅用于演示筛选流程。

行情表结构如下:

表 1. 表 2-1 行情表结构
列名 类型 说明
symbol STRING 股票代码,带 .SH 或 .SZ 后缀
tradeTime TIMESTAMP 交易日对应的时间戳
open DOUBLE 开盘价,元/股,用于下一交易日撮合
close DOUBLE 收盘价,元/股,用于信号日目标数量计算
adjClose DOUBLE 复权收盘价,用于突破判断和入选排序
volume LONG 成交股数;不大于 0 时视为不可交易
upLimitPrice DOUBLE 涨停价,用于限制买入
downLimitPrice DOUBLE 跌停价,用于限制卖出
forecast_type_code DOUBLE 预告类型编码;本例要求等于 1
days_since_forecast_type_code DOUBLE 预告类型距当前日的天数;本例要求不大于 5
north_hold_ratio DOUBLE 北向持股比例;本例要求大于 0.01
prev_high_20 DOUBLE 前 20 个交易日高点参考值

辅助状态表结构如下:

表 2. 表 2-2 辅助状态表结构
表名 列名 类型 说明
指数成分股快照 index_code、snap_date、con_code STRING、DATE、STRING 每个交易日取不晚于当日的最新沪深 300 成分股快照
ST 历史表 code、start_date、period_end STRING、DATE、DATE 用于排除当日处于 ST 期间的股票
上市信息表 code、list_date STRING、DATE 用于排除上市不足 90 个自然日的新股

示例中指数成分股快照将行情表中的全部股票设置为 000300.SH 的成分股,ST 历史表为空,上市日期固定为 2000-01-01。这些模拟状态只用于演示数据准备和筛选接口,不代表真实沪深 300 成分、ST 记录或上市资料。

2.2 编写策略逻辑

取整函数:roundLot

roundLot(rawQty) 将原始买入数量按 100 股的整数倍向下取整。普通股票不足一手的买入量不会提交。

def roundLot(rawQty) {
    return floor(rawQty / 100) * 100
}

卖出数量函数:normalizeSellQty

normalizeSellQty(positionQty, requestedQty) 先将请求数量限制在实际持仓以内。若策略要退出全部持仓,则允许卖出不足一手的剩余零股;若仅退出一部分,则继续按 100 股向下取整。这样可避免因零股导致整仓退出无法完成。

def normalizeSellQty(positionQty, requestedQty) {
    positionQty_ = floor(positionQty)
    qty_ = floor(min(positionQty_, requestedQty))
    if (qty_ <= 0) { return 0 }
    if (qty_ >= positionQty_) { return positionQty_ }
    return roundLot(qty_)
}

可交易性函数:isBuyableAt 与 isSellableAt

isBuyableAt(price, bar) 要求当日有成交量,且开盘价未到涨停价;isSellableAt(price, bar) 同样要求有成交量,且开盘价未到跌停价。涨跌停价为空时,函数不额外阻止交易。

def isBuyableAt(price, bar) {
    return bar.volume > 0 && (isNull(bar.upLimitPrice) || price < bar.upLimitPrice)
}

def isSellableAt(price, bar) {
    return bar.volume > 0 && (isNull(bar.downLimitPrice) || price > bar.downLimitPrice)
}

初始化函数:initialize

initialize(mutable context) 是回测引擎的初始化回调。每次创建新引擎时调用一次,用于写入手续费、初始选股标记、持仓入场日期、待执行买入目标和待执行卖出意图,并从会话对象中读取股票池与三张辅助状态表。

context:可变 DICTIONARY,由创建引擎时传入。函数在其中写入跨交易日需要保留的状态。

函数没有显式返回值,而是更新 context。initialRebalancePending = true 使第一次 onBar 回调生成初始信号;pendingRebalance = false 表示初始化时不存在等待下一交易日执行的买入目标。

def initialize(mutable context) {
    context["commission"] = 0.00015
    context["initialRebalancePending"] = true
    context["selectedStocks"] = []$STRING
    context["entryDates"] = dict(STRING, DATE)
    context["pendingRebalance"] = false
    context["pendingTargetQtys"] = dict(STRING, LONG)
    context["pendingSells"] = dict(STRING, INT)
}

回调函数:onBar

onBar(mutable context, msg, indicator) 是日线行情回调,也是策略的主执行函数。在本例中,每个交易日调用一次。它先用当日开盘价执行上一个信号日保存的买入或卖出意图,再检查持仓是否已满 20 个交易日,最后判断是否应在初始日或月末生成新的选股信号。

  • context:可变 DICTIONARY,包含引擎注入的当前交易时间和引擎句柄,也保存交易成本、持仓入场日期、选中股票和待执行订单。

  • msg:DICTIONARY,以股票代码为键,以当日行情表记录为值。函数从中读取开盘价、收盘价、复权收盘价、成交量、涨跌停价和四类信号字段。

  • indicator:引擎为指标结果预留的参数。

回调函数主要作用包括提交买卖订单,以及更新待执行状态。其内部流程分为两部分:

  1. 如果存在更早交易日产生的待执行买入或卖出意图,先按当日开盘价和可交易条件提交订单。买入仅对空仓的入选股票执行;卖出量同时受实际持仓与可卖数量限制。

  2. 在初始日或月末,构造合格股票池,应用四项信号并按复权收盘价选出前 5 只,再按信号日权益锁定下一交易日的目标买入数量;持仓满 20 个交易日时则保存下一交易日的卖出意图。

完整策略流程如下:

def onBar(mutable context, msg, indicator) {
    curDate = date(msg.values()[0]["tradeTime"])
    // === 次日开盘:信号日锁定目标下单数量,执行日 open 撮合 + cash 兜底===
    if (context["pendingRebalance"]) {
        if (curDate != context["rebalanceDate"]) {
            tgtQtys_ = context["pendingTargetQtys"]   // 读信号日锁定的 target_qty
            expectedCash_ = Backtest::getAvailableCash(context.engine)
            // 只为当前空仓的入选股票建仓;不因月度调仓追加既有持仓。
            for (istock in context["selectedStocks"]) {
                if (!(istock in msg)) { continue }
                bar = msg[istock]
                if (!isBuyableAt(bar["open"], bar)) { continue }
                posInfo = Backtest::getPosition(context.engine, istock)
                pos = 0
                if (size(posInfo) > 0) { pos = posInfo["longPosition"] }
                if (pos != 0) { continue }
                targetQty = iif(istock in tgtQtys_, tgtQtys_[istock], 0)
                if (targetQty > 0) {
                    affordableCash_ = min(expectedCash_, Backtest::getAvailableCash(context.engine))
                    affordableQty = roundLot(affordableCash_ / (bar["open"] * (1 + context["commission"])))
                    buyQty = min(targetQty, affordableQty)
                    if (buyQty > 0) {
                        Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, bar["open"], buyQty, 1), "buy")
                        expectedCash_ = expectedCash_ - buyQty * bar["open"] * (1 + context["commission"])
                    }
                }
            }
            context["pendingRebalance"] = false
        }
    }
    // === 下根 bar 入口取出意图,用 bar.open 价提单 ===
    if (context["pendingSellsDate"] != NULL && curDate != context["pendingSellsDate"]) {
        for (istock in msg.keys()) {
            if (!(istock in context["pendingSells"])) { continue }
            bar = msg[istock]
            sellQty_pend = context["pendingSells"][istock]
            labelPend_ = context["pendingSellsLabel"][istock]
            posInfo_s = Backtest::getPosition(context.engine, istock)
            pos_total_s = 0
            pos_sellable_s = 0
            if (size(posInfo_s) > 0) {
                pos_total_s = posInfo_s["longPosition"]
                pos_sellable_s = max(0, posInfo_s["lastDayLongPosition"] - posInfo_s["todaySellVolume"])
            }
            sellQty_actual = normalizeSellQty(pos_total_s, min(sellQty_pend, pos_sellable_s))
            if (sellQty_actual > 0 && isSellableAt(bar["open"], bar)) {
                sellLimit_ = iif(isNull(bar["upLimitPrice"]), bar["open"], min(bar["open"], bar["upLimitPrice"]))
                Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, sellLimit_, sellQty_actual, 3), labelPend_)
            }
        }
        context["pendingSells"] = dict(STRING, INT)
        context["pendingSellsLabel"] = dict(STRING, STRING)
        context["pendingSellsDate"] = NULL
    }
    // === 逐股 onBar 循环:只产生卖出信号(买入在开头的延迟调仓执行块中统一处理)===
    for (istock in msg.keys()) {
        bar = msg[istock]
        if (bar["volume"] <= 0) { continue }  // 跳过停牌股
        posInfo = Backtest::getPosition(context.engine, istock)
        pos = iif(size(posInfo) > 0, posInfo["longPosition"], 0)
        pos_sellable = iif(size(posInfo) > 0, max(0, posInfo["lastDayLongPosition"] - posInfo["todaySellVolume"]), 0)
        // 已持仓交易日数(建仓当日=0,次日=1,清仓重置)
        if (pos > 0) {
            if (!(istock in context["entryDates"])) { context["entryDates"][istock] = curDate }
            hold_days = size(getMarketCalendar("XSHG", context["entryDates"][istock], curDate)) - 1
        } else {
            hold_days = 0
            if (istock in context["entryDates"]) {
                context["entryDates"].erase!(istock)
            }
        }

        // 卖出条件:持有满 20 个交易日后清仓可卖部分(T+1 下当日买的留到次日卖)
        if (pos > 0 && pos_sellable > 0 && hold_days >= 20) {
            context["pendingSells"][istock] = normalizeSellQty(pos, pos_sellable); context["pendingSellsLabel"][istock] = "sell"; context["pendingSellsDate"] = curDate
            continue
        }

    }

    // === 选股逻辑(信号日选股,下根 bar 开盘下单)===
    isRebalanceDay = (transFreq(monthEnd(curDate), "XSHG") == curDate)
    initialRebalance_ = context["initialRebalancePending"]
    isRebalanceDay = (isRebalanceDay || initialRebalance_)
    context["initialRebalancePending"] = false

    if (isRebalanceDay) {
        // 取当日最新一期指数成分股快照
        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))
        // 剔除当日处于 ST 状态的股票
        stHist_ = context["tpStHist"]
        stCodes_ = set(exec code from stHist_ where start_date <= curDate and (isNull(period_end) or period_end > curDate))
        // 剔除上市不足 N 天的新股
        listInfo_ = context["tpListInfo"]
        cutoffDate_ = curDate - 90  // curDate 已是 DATE,DATE - INT 仍为 DATE,无需再包一层 date()
        eligibleCodes_ = set(exec code from listInfo_ where list_date <= cutoffDate_)
        pool_ = array(STRING, 0)
        for (s_ in context["stockPool"]) { if ((s_ in idxMembers_) && !(s_ in stCodes_) && (s_ in eligibleCodes_)) { pool_.append!(s_) } }
        tradablePool_ = set(pool_)
        // 四个独立信号同时成立,按复权收盘价取前五并等权。
        symbols = []$STRING
        m_forecast_type_code_list = []$DOUBLE
        m_days_since_forecast_type_code_list = []$DOUBLE
        m_north_hold_ratio_list = []$DOUBLE
        m_close_list = []$DOUBLE
        m_prev_high_20_list = []$DOUBLE
        for (istock in msg.keys()) {
            if (!(istock in tradablePool_)) { continue }
            bar = msg[istock]
            if (bar["volume"] <= 0) { continue }  // 跳过停牌股

            symbols.append!(istock)
            m_forecast_type_code_list.append!(double(bar["forecast_type_code"]))
            m_days_since_forecast_type_code_list.append!(double(bar["days_since_forecast_type_code"]))
            m_north_hold_ratio_list.append!(double(bar["north_hold_ratio"]))
            m_close_list.append!(double(bar["adjClose"]))
            m_prev_high_20_list.append!(double(bar["prev_high_20"]))
        }

        poolT = table(symbols as symbol, m_forecast_type_code_list as `forecast_type_code, m_days_since_forecast_type_code_list as `days_since_forecast_type_code, m_north_hold_ratio_list as `north_hold_ratio, m_close_list as `close, m_prev_high_20_list as `prev_high_20)
        poolT = select * from poolT where forecast_type_code == 1
            and days_since_forecast_type_code <= 5
            and north_hold_ratio > 0.01
            and close > prev_high_20
        poolT = select top 5 * from poolT order by close desc
        selectedStocks = poolT.symbol
        context["selectedStocks"] = selectedStocks

        // 信号日:用信号日 close 价锁定目标下单数量
        signalEquity_ = Backtest::getTotalPortfolios(context.engine).totalEquity[0]
        tgtQtys_ = dict(STRING, LONG)
        // 等权:权重 = 1 / 入选只数,为常量,直接内联
        weight_ = 1.0 / max(1, selectedStocks.size())
        for (s_ in selectedStocks) {
            tgtQtys_[s_] = roundLot(signalEquity_ * weight_ / (msg[s_]["close"] * (1 + context["commission"])))
        }
        context["pendingTargetQtys"] = tgtQtys_
        context["pendingRebalance"] = true
        context["rebalanceDate"] = curDate
    }
}

2.3 设置回测参数

这里将回测引擎参数配置在 config,将策略运行所需的手续费、初始日期和待执行状态配置在 initialize 写入的 context 中。完整回测配置如下:

actualStartDate = 2025.08.13
backtestEndDate = 2026.08.12

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

本例将 createBacktester 的第四个参数固定为 false,以非 JIT 模式执行。设置为 true 可启用 JIT,但启用前需要检查所有回调是否兼容,包括上下文变量预声明、字典操作和表操作等。本例尚未验证开启 JIT 后的运行结果。具体检查方法参阅 DolphinDB 回测引擎使用和性能优化攻略。

2.4 创建回测引擎

模拟撮合引擎负责订单撮合,Backtest 负责事件回调、持仓、资金与绩效管理。下面先加载已安装的插件,再注册初始化和日线回调。加载命令不会自动安装缺少的插件,需先在服务器端安装插件,参考:MatchingEngineSimulator 、Backtest 。

引擎通过 config 获取基础配置,通过 callbacks 找到策略函数。代码先尝试清理同名引擎实例,再创建新的引擎实例,避免重复使用包含历史状态的引擎。

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

engineName = "multiEventBacktest"

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

2.5 执行回测

将按时间顺序排列的行情表写入引擎,回调函数按交易日推进,依次执行信号计算和后续交易。最后通过 appendEndMarker 发送结束标记,并保留当前引擎实例,以便在下一环节提取回测指标与交易明细。

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

2.6 获取回测结果

处理结束消息后,分别查询收益概述、订单与成交明细、每日持仓和每日账户权益。totalPortfolios 内的 netValue 已由引擎根据初始资金计算;totalReturn 为相对初始资金的累计收益。

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

各结果对象的主要用途如下:

  • returnSummary:查看引擎计算的收益、风险和交易统计。

  • tradeDetails:查看订单状态与成交;仅 tradeQty > 0 的记录表示发生了成交。

  • dailyPosition:查看各日持仓、买卖数量和收盘估值。

  • totalPortfolios:查看每日账户权益、净值、现金和持仓市值

3. 回测结果分析

本次在 DolphinDB Server 3.00.6.1 版本下,模拟的行情表覆盖 2025-08-13 至 2026-08-12,共处理 24,200 条记录。回测共产生 15 个订单编号、35 条交易明细记录。

首笔成交发生在 2025-08-14:以开盘价 21.67 元买入 000042.SH 共 10,100 股。该笔订单在交易明细中同时产生上报记录和成交记录,因此统计成交时应以正成交量记录为准。

初始权益为 1,000,000.00 元,期末权益为 1,888,238.18 元,期末净值为 1.888238,区间总收益为 88.8238%。期末持仓市值为 0,权益以现金形式保留。引擎返回的绩效指标如下;表中百分比指标按返回值转换为百分数,其他指标保留原始口径。

表 3. 表 3-1 回测结果汇总
指标 数值
行情表记录数 24,200
回测区间 2025-08-13 至 2026-08-12
初始权益 1,000,000.00 元
期末权益 1,888,238.18 元
期末净值 1.888238
区间总收益 88.8238%
年化收益 93.8492%
年化波动率 19.8355
夏普比率 0.0473
最大回撤 96.3452%
换手率 1.8868%
订单编号数 15

这些结果只反映本例行情表、模拟指数成分和模拟状态下的资金变化。数据没有证明业绩预告、北向持股和价格突破之间存在稳定的未来收益因果关系,也没有覆盖真实事件发布日期、数据修订、指数调样和交易冲击。因此,本例的回测结果主要用于验证策略实现和回测流程,不能据此判断该策略在真实市场中的有效性,也不能仅凭这一组结果得出其优于其他策略的结论。

4. 总结

多事件叠加触发策略先使用沪深 300 成分、ST、上市天数与交易状态过滤股票,再要求业绩预告类型、事件新鲜度、北向持股和 20 日突破四项信号同时成立,最后按复权收盘价选择前 5 只并按实际入选数量等权配置。通过 DolphinDB Backtest,可以完整运行从行情表加载、联合事件筛选、信号日锁定目标数量到下一交易日开盘成交、20 个交易日后退出和结果获取的流程。

模拟数据适合用于理解策略实现与回测接口。用于真实投资研究时,应将输入替换为具有明确历史可用时点的行情表和事件数据,确认预告类型编码与北向持股口径,并进一步评估持仓集中、成本、滑点、指数成分变化和样本外表现等因素。