早盘强势跟随与尾盘退出策略

早盘强势股跟随 + 尾盘退出是典型的日内动量策略,策略在早盘观察股票相对当日开盘的涨幅与累计成交额,选择涨幅和累计成交额表现强势的股票,并在尾盘卖出已有持仓中的昨仓可卖部分。本文使用模拟分钟行情数据,通过 Backtest 引擎完成信号获取、股票筛选、等权配置、定期调仓和结果分析。

1. 策略介绍

本策略通过早盘价格动量寻找短期强势标的,再用成交额过滤交易不活跃的股票。选股时点、委托时点与成交时点分别由策略代码和回测撮合配置决定,下面分别介绍核心信号、交易标的和交易规则。

1.1 核心信号

价格动量表示当前价格相对一个参考价格的变化幅度。这里的参考价格是当日最早一条有效输入分钟行情的开盘价,分子和分母都乘对应的复权因子:

其中,Ci,t 是股票 i 在时刻 t 的分钟收盘价,Oi,d,first 是交易日 d 第一条输入行情的开盘价,单位均为元;Ai,d 是当日复权因子。若同一天因子一致且非零,因子在比值中抵消。这不是相对昨收的涨跌幅。早盘行情缺失时,分母会变成第一条可用行情的开盘价,信号含义随之改变。

累计成交额为当日截至当前分钟的 amount 之和,要求源字段单位为元。本文案例在 09:35 时进行信号判断,筛选涨幅在 2% - 7% 的股票。

候选股票按涨幅降序截取前 5 只,再按实际入选数量 N,设置每只股票的计划权重 1/N。入选 1 只时,计划权重可达 100%;入选 5 只时才是每只 20%。

1.2 交易标的

本例交易 30 只由生成的模拟普通股票,股票标的为带 .SZ 后缀的字符串,例如 000001.SZ,并按普通深市股票的 100 股整数倍处理委托数量。

策略采用普通股票现金账户,只建立多头持仓,不进行卖空、融资或借券。行情频率为日频,因子也按交易日提供;订单按下一交易日开盘价撮合。引擎启用当日买入持仓不可当日卖出的限制,因此策略不会依靠日内回转交易完成调仓。

接入真实数据时,候选股票应来自具有历史时点信息的股票池。除了分频数据外,还需要准备当时有效的 ST 状态、上市日期、停牌或可交易状态、复权和分红信息。不能使用当前成分股替代过去各交易日的股票池。

1.3 交易规则与使用限制

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

  1. 遍历当前消息中的股票,跳过成交量不大于 0 的行情。14:50 及以后,如果持仓和昨仓可卖数量都大于 0,以该分钟 open 提交卖出限价委托。

  2. 切换交易日时重置触发状态,只在时间恰好为 09:35:00.000 时尝试选股。缺少该时间的回调则当天不入场。

  3. 从当前行情消息中筛选有成交量且符合涨幅、累计成交额条件的股票,排序后最多取 5 只。

  4. 对入选但尚未持有的股票检查涨停条件,使用当前未复权 close 计算数量并提交买入限价委托。已持有的入选股票不加仓,也不按新权重调仓。

  5. 每提交一笔买单,就在本轮预留对应资金并占用一个持仓名额。达到 5 个名额时停止继续买入;拒单不会在当天释放名额后补单。

每日买入流程最多触发一次,但不保证每天都有成交。卖出条件为“时间大于等于 14:50”,因此剩余可卖持仓可能在后续分钟再次触发委托。策略未记录未成交订单、未扣除挂单冻结数量,也未主动撤单或注册订单、成交回调,不能将其描述为已经实现去重追单机制。

持仓上限检查使用进入买入循环前的 openCount,循环内没有增加计数。如果原有 4 只持仓,当日又选中多只未持有股票,代码不会在第一笔新买单后立刻停止,实际持仓可能超过 5 只。5 是每次入选数量上限,不是可靠的总持仓硬上限。

2. 策略回测

回测使用附件中的样例数据,区间配置为 2026-07-07 至 2026-08-06,初始资金为 1,000,000 元。运行环境需要预先安装并加载兼容的 Backtest 及其所需撮合插件,并提供下述项目数据函数和 XSHG 交易日历。本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.4 与 MatchingEngineSimulator 3.00.6.4 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前的代码会检查并加载插件。

2.1 准备数据

先将配套附件 EarlySessionFollowing.csv 保存到 DolphinDB 服务器可读取的目录,再将 path 改为该文件的服务器真实路径。

csvPath = "/<path>/EarlySessionFollowing.csv"
csvSchema = table(
    ["symbol", "tradeTime", "open", "low", "high", "close", "volume", "amount",
     "upLimitPrice", "downLimitPrice", "adj_factor", "vwap"] as name,
    ["STRING", "TIMESTAMP", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE", "LONG", "DOUBLE",
     "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE"] as type)
data = loadText(csvPath, ",", csvSchema, containHeader=true)
表 1. 表 2-1 csv字段说明
列名 DolphinDB 类型 含义及单位
symbol STRING 股票代码,例如 000001.SZ
tradeTime TIMESTAMP 分钟结束时间,保留日期和毫秒精度
open DOUBLE 分钟开盘价,元
low DOUBLE 分钟最低价,元
high DOUBLE 分钟最高价,元
close DOUBLE 分钟收盘价,元
volume LONG 分钟成交量,股
amount DOUBLE 分钟成交额,元
upLimitPrice DOUBLE 涨停价,元
downLimitPrice DOUBLE 跌停价,元
adj_factor DOUBLE 复权因子,无量纲
vwap DOUBLE 分钟成交均价,元;当前选股不读取此列

计算回测需要用到的相关因子:

data = select *, first(open * adj_factor) as stk_today_open,
    cumsum(amount) as stk_day_amount
    from data context by symbol, date(tradeTime) csort tradeTime
data = select * from data order by tradeTime, symbol
stockPool = exec distinct symbol from data

最终送入引擎的 data 表结构如下:

表 2. 表 2-2 引擎接收数据字段说明
字段 DolphinDB 类型 含义与单位
symbol STRING 或 SYMBOL,沿用源字段 股票代码,需与状态表、因子表一致
tradeTime TIMESTAMP,依赖源时间类型匹配 分钟行情时间,需明确起始或结束时间标记
open、low、high、close DOUBLE,接入要求 未复权分钟价格,元
volume LONG,显式转换 该分钟成交量,股票按股计;源 vol 若按手计须先换算
amount DOUBLE,接入要求 该分钟成交额,元
upLimitPrice、downLimitPrice DOUBLE,接入要求 当日涨跌停价,元
adj_factor DOUBLE,显式转换 当日复权因子,无量纲
stk_today_open DOUBLE,在价格类型满足要求时 当日首条分钟开盘价乘因子
stk_day_amount DOUBLE,在金额类型满足要求时 截至当前分钟的累计成交额,元

2.2 编写策略逻辑

策略由 roundLot、initialize 和 onBar 组成。roundLot 函数处理成交数量,initialize 初始化策略状态,onBar 执行延迟调仓、市场状态判断和横截面选股。

初始化函数: initialize

initialize(mutable context) 是回测引擎的初始化回调。每次创建新引擎时调用一次,用于建立本次回测需要的可变状态。

context: 可变 DICTIONARY,包含引擎句柄。初始化回调将 lastSignalDate 设置为 DATE 类型空值,供后续分钟回调记录已选股日期。函数没有显式返回值,而是更新 context。

def initialize(mutable context) {
    context["lastSignalDate"] = date(NULL)
}

回调函数 onBar

context 为可变 DICTIONARY,保存引擎句柄、当前委托时间和初始化状态。

msg 为按代码索引行情记录的 DICTIONARY。函数没有显式返回值,而是更新 context。

回调函数作用包括更新当日触发标记、候选池、入选列表和权重,并提交买卖委托。先处理尾盘卖出,再判断是否到达早盘选股时点,主要逻辑如下:

  1. 选股只在每天 09:35 的分钟回调中进行。函数先检查 lastSignalDate,如果当天已经触发过选股就直接返回;否则先记录当天日期,再遍历当前行情消息中的股票。即使本次没有符合条件的标的,当天也不会再次选股。

  2. 对于每只股票,先排除当前分钟成交量不大于 0 的记录,再计算开盘以来的涨幅,只有涨幅有效、严格大于 2% 且小于 7%,同时截至当前分钟的累计成交额 stk_day_amount 严格超过 5,000 万元,才进入候选集合。这里使用的是截至 09:35 的累计成交额,而不是全天成交额。

  3. 候选集合按涨幅从高到低排序,涨幅相同时按股票代码升序排列,取前 holdingCount 只,默认最多 5 只;不足 5 只时全部保留,没有候选股票则结束本次选股。计划权重按实际入选数量等分,例如选中 4 只时,每只按账户总权益的 25% 计算计划买入预算。

  4. 入选后,函数按排序结果依次检查买入条件:已有持仓的股票不再加仓,当前收盘价达到或超过有效涨停价的股票跳过。每笔新买单还受到可用现金和剩余持仓名额限制,提交后立即预留资金并占用一个名额。跳过的入选股票不会由排名靠后的候选股票递补,也不会将其计划预算重新分配给其他入选股票,因此实际买入数量可能少于入选数量。

def onBar(mutable context, msg, indicator) {
    // 日内指标已在CSV导入后计算,保留接口要求的indicator形参但不读取。
    if (size(msg) == 0) { return }
    curDate = date(context.tradeTime)
    curTime = time(context.tradeTime)

    // 保留原策略尾盘卖出逻辑:仅卖昨仓,订单使用当前分钟open作为限价。
    // 未成交时后续分钟仍可能重复提交;本简化示例没有撤单或挂单去重机制。
    if (curTime >= 14:50:00.000) {
        for (s in msg.keys()) {
            bar = msg[s]
            if (bar.volume <= 0) { continue }
            position = Backtest::getPosition(context.engine, s)
            if (size(position) == 0) { continue }
            sellQty = max(0, position.lastDayLongPosition - position.todaySellVolume)
            if (position.longPosition > 0 && sellQty > 0) {
                Backtest::submitOrder(context.engine,
                    (s, context.tradeTime, 5, bar.open, sellQty, 3), "sell")
            }
        }
    }

    // 每天只在09:35触发一次,缺少该分钟不补触发。
    if (curTime != 09:35:00.000) { return }
    if (!isNull(context["lastSignalDate"])) {
        if (curDate == context["lastSignalDate"]) { return }
    }
    context["lastSignalDate"] = curDate
    symbols = []$STRING
    returns = []$DOUBLE
    for (s in msg.keys()) {
        bar = msg[s]
        if (bar.volume <= 0) { continue }
        r = bar.close * bar.adj_factor / bar.stk_today_open - 1.0
        if (isValid(r) && r > 0.02 && r < 0.07 && bar.stk_day_amount > 50000000) {
            symbols.append!(s)
            returns.append!(r)
        }
    }
    candidates = table(symbols as symbol, returns as intradayReturn)
    if (size(candidates) == 0) { return }
    limitN = min(context["holdingCount"], size(candidates))
    selected = select * from candidates order by intradayReturn desc, symbol asc limit limitN

    equity = Backtest::getTotalPortfolios(context.engine).totalEquity[0]
    weight = 1.0 / size(selected)
    positions = Backtest::getPosition(context.engine)
    openCount = 0
    if (size(positions) > 0) {
        openCount = size(select * from positions where longPosition > 0)
    }
    remainingCash = Backtest::getAvailableCash(context.engine)
    for (s in selected.symbol) {
        if (openCount >= context["holdingCount"]) { break }
        bar = msg[s]
        if (!isNull(bar.upLimitPrice) && bar.close >= bar.upLimitPrice) { continue }
        position = Backtest::getPosition(context.engine, s)
        if (size(position) > 0) {
            if (position.longPosition > 0) { continue }
        }
        unitCost = bar.close * (1.0 + context["commission"])
        cash = max(0.0, min(remainingCash, Backtest::getAvailableCash(context.engine)))
        buyQty = min(roundLot(equity * weight / unitCost), roundLot(cash / unitCost))
        if (buyQty <= 0) { continue }
        Backtest::submitOrder(context.engine,
            (s, context.tradeTime, 5, bar.close, buyQty, 1), "buy")
        // 提交即预留资金和持仓名额,避免本轮重复使用预算;拒单不在当日补单。
        remainingCash -= buyQty * unitCost
        openCount += 1
    }
}

2.3 设置回测参数

定义日期、资金和成本参数,并将参数传入上下文及引擎配置;手续费率使用同一变量,保证买入预算与引擎计费口径一致。

actualStartDate = 2026.07.07
actualEndDate = 2026.08.06
initialCash = 1000000.0
commission = 0.00015
tax = 0.0005
holdingCount = 5
config = {
    startDate: actualStartDate,
    endDate: actualEndDate,
    strategyGroup: "stock",
    cash: initialCash,
    commission: commission,
    tax: tax,
    dataType: 3,
    outputOrderInfo: true,
    matchingMode: 1,
    enableMinimumPerTransactionFee: false,
    enableSellCloseRestrict: true
}
config["context"] = context

引擎及顶层参数含义如下:

  • startDate、endDate:DATE 标量,分别为 2026.07.07、2026.08.06。区间也写在分红查询和交易日历查询中,修改时需保持一致。

  • strategyGroup:STRING 标量,"stock",使用股票账户。

  • cash:DOUBLE 标量,1000000.0 元,初始资金,应为正数。

  • commission:DOUBLE 标量,0.00015,即 0.015%,用于交易手续费;上下文同名值用于买入预算,两处应保持一致。

  • tax:DOUBLE 标量,0.0005,即 0.05%,配置给股票交易税费;上下文同名值虽存在,但本策略没有读取它计算订单。

  • dataType:INT 标量,3,分钟行情模式。

  • outputOrderInfo:BOOL 标量,true,保留订单信息用于追溯委托和成交。

  • matchingMode:INT 标量,1;分钟模式下,在行情时间大于委托时间后进行撮合,不能把 09:35 的信号和委托认定为 09:35 收盘即成交。

  • enableMinimumPerTransactionFee:BOOL 标量,false,不启用每笔最低交易费;小额委托的费用可能低于真实账户最低收费。

  • enableSellCloseRestrict:本策略排除当日新买数量,是由回调中的卖出数量公式实现,不能写成已经在配置中启用了该开关。

2.4 创建回测引擎

callbacks 为 Dictionary<STRING, ANY>,本例只设置 initialize 和 onBar。engineName 为固定 STRING,engine 保存创建接口返回的引擎句柄。创建前会尝试删除同名引擎,因此同一节点上并行任务需要避免名称冲突。

创建接口第四个实参为 false,表示不启用 JIT。

callbacks = dict(STRING, ANY)
callbacks["initialize"] = initialize
callbacks["onBar"] = onBar
engineName = "earlySessionFollowing_test"
try { Backtest::dropBacktestEngine(engineName) } catch(ex) { }
engine = Backtest::createBacktester(engineName, config, callbacks, false)

2.5 执行回测

将排序后的 data 一次送入引擎,再调用Backtest::appendEndMarker(engine)通知回测结束。

try {
    Backtest::appendQuotationMsg(engine, data)
    Backtest::appendEndMarker(engine)
} catch(ex) {
    Backtest::dropBacktestEngine(engineName)
    throw ex
}

正常结束后保留引擎,继续下一节读取结果。发生异常时清理本次引擎并抛出错误。

2.6 获取回测结果

处理完结束消息后,分别查询收益概述、订单与成交明细、每日持仓和每日账户权益。用 totalEquity / initialCash 计算净值;本例没有外部资金流入或流出。

将返回表和选股日志保存到 resultDict 后,再销毁引擎。结果仍可在当前会话中查看,不需要再次连接已销毁的引擎。完整结果处理代码如下:

resultDict = dict(STRING, ANY)
resultDict["returnSummary"] = Backtest::getReturnSummary(engine)
resultDict["tradeDetails"] = Backtest::getTradeDetails(engine)
resultDict["dailyPosition"] = Backtest::getDailyPosition(engine)
resultDict["totalPortfolios"] = Backtest::getDailyTotalPortfolios(engine)

各对象的用途如下:

表 3. 表 2-3 回测结果字段说明
对象 类型 使用方式
returnSummary TABLE 查看收益与风险概述;benchmarkReturn 被脚本后处理覆盖
tradeDetails TABLE 核对 orderId、sendTime、orderPrice、orderQty、tradeTime、tradePrice、tradeQty、orderStatus,分别解释委托和成交
dailyPosition TABLE 按交易日和代码查看持仓及买卖数量,检查是否隔夜及是否仍有未卖出的仓位
totalPortfolios TABLE 按 tradeDate 查看 totalEquity 等权益数据及后接入的基准序列

脚本没有设置真实指数基准,收益概述中与基准有关的默认字段不能解释为沪深 300 表现或有效超额收益。

3. 回测结果分析

使用上述版本、非 JIT 模式、附件提供的数据,回测处理 165,600 条行情,初始权益为 1,000,000.00 元,区间总收益为 -5.1633%。由于数据来自模拟数据,这一指标不能用于评价真实市场中的相对表现。

表 4. 表 3-1 回测结果
指标 本次结果 解读
区间总收益 -5.1633% 期末权益相对 100 万元初始资金的累计变化
年化收益 -44.0575% 引擎按回测日收益年化后的结果
年化波动率 11.1547% 价格路径波动幅度
夏普比率 -3.9497 去掉风险波动后的收益
最大回撤 7.3041% 期内净值相对先前高点的最大下跌,风险暴露一般
回撤比率 -6.0319 引擎返回的收益与回撤相关统计
日胜率 47.8260% 每日收益为正的交易日占比

4. 总结

本策略将早盘相对开盘涨幅、累计成交额和历史交易资格结合起来,在固定分钟挑选最多 5 只强势股票,以计划等权方式尝试新开仓,并在尾盘卖出昨仓可卖部分。实际成交取决于后续行情、限价条件、资金和引擎处理,实际组合也不保证等权或最多 5 只。

使用时应重点核对隔夜风险、日内信号的可用时点、未成交订单处理以及行情和基准数据完整性。只有在这些条件明确并取得真实回测证据后,才能评价策略表现。