早盘强势跟随与尾盘退出策略
早盘强势股跟随 + 尾盘退出是典型的日内动量策略,策略在早盘观察股票相对当日开盘的涨幅与累计成交额,选择涨幅和累计成交额表现强势的股票,并在尾盘卖出已有持仓中的昨仓可卖部分。本文使用模拟分钟行情数据,通过 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 交易规则与使用限制
策略按以下顺序完成一次选股和调仓:
-
遍历当前消息中的股票,跳过成交量不大于 0 的行情。14:50 及以后,如果持仓和昨仓可卖数量都大于 0,以该分钟 open 提交卖出限价委托。
-
切换交易日时重置触发状态,只在时间恰好为 09:35:00.000 时尝试选股。缺少该时间的回调则当天不入场。
-
从当前行情消息中筛选有成交量且符合涨幅、累计成交额条件的股票,排序后最多取 5 只。
-
对入选但尚未持有的股票检查涨停条件,使用当前未复权 close 计算数量并提交买入限价委托。已持有的入选股票不加仓,也不按新权重调仓。
-
每提交一笔买单,就在本轮预留对应资金并占用一个持仓名额。达到 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)
| 列名 | 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 表结构如下:
| 字段 | 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。
回调函数作用包括更新当日触发标记、候选池、入选列表和权重,并提交买卖委托。先处理尾盘卖出,再判断是否到达早盘选股时点,主要逻辑如下:
-
选股只在每天 09:35 的分钟回调中进行。函数先检查
lastSignalDate,如果当天已经触发过选股就直接返回;否则先记录当天日期,再遍历当前行情消息中的股票。即使本次没有符合条件的标的,当天也不会再次选股。 -
对于每只股票,先排除当前分钟成交量不大于 0 的记录,再计算开盘以来的涨幅,只有涨幅有效、严格大于 2% 且小于 7%,同时截至当前分钟的累计成交额
stk_day_amount严格超过 5,000 万元,才进入候选集合。这里使用的是截至 09:35 的累计成交额,而不是全天成交额。 -
候选集合按涨幅从高到低排序,涨幅相同时按股票代码升序排列,取前 holdingCount 只,默认最多 5 只;不足 5 只时全部保留,没有候选股票则结束本次选股。计划权重按实际入选数量等分,例如选中 4 只时,每只按账户总权益的 25% 计算计划买入预算。
-
入选后,函数按排序结果依次检查买入条件:已有持仓的股票不再加仓,当前收盘价达到或超过有效涨停价的股票跳过。每笔新买单还受到可用现金和剩余持仓名额限制,提交后立即预留资金并占用一个名额。跳过的入选股票不会由排名靠后的候选股票递补,也不会将其计划预算重新分配给其他入选股票,因此实际买入数量可能少于入选数量。
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)
各对象的用途如下:
| 对象 | 类型 | 使用方式 |
|---|---|---|
| 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%。由于数据来自模拟数据,这一指标不能用于评价真实市场中的相对表现。
| 指标 | 本次结果 | 解读 |
|---|---|---|
| 区间总收益 | -5.1633% | 期末权益相对 100 万元初始资金的累计变化 |
| 年化收益 | -44.0575% | 引擎按回测日收益年化后的结果 |
| 年化波动率 | 11.1547% | 价格路径波动幅度 |
| 夏普比率 | -3.9497 | 去掉风险波动后的收益 |
| 最大回撤 | 7.3041% | 期内净值相对先前高点的最大下跌,风险暴露一般 |
| 回撤比率 | -6.0319 | 引擎返回的收益与回撤相关统计 |
| 日胜率 | 47.8260% | 每日收益为正的交易日占比 |
4. 总结
本策略将早盘相对开盘涨幅、累计成交额和历史交易资格结合起来,在固定分钟挑选最多 5 只强势股票,以计划等权方式尝试新开仓,并在尾盘卖出昨仓可卖部分。实际成交取决于后续行情、限价条件、资金和引擎处理,实际组合也不保证等权或最多 5 只。
使用时应重点核对隔夜风险、日内信号的可用时点、未成交订单处理以及行情和基准数据完整性。只有在这些条件明确并取得真实回测证据后,才能评价策略表现。
5. 附录
完整脚本包含策略函数、配置和引擎执行流程。
