RSI 与双均线复合信号策略
相对强弱指标(Relative Strength Index,简称 RSI)与双均线复合信号策略将动量、趋势、固定持有期和止损机制结合在同一个日频交易流程中:当短中期趋势开始改善且 RSI 尚未进入强势过热区间时尝试建仓;持仓后则通过趋势转弱、RSI 过热、固定持有期和价格止损四类条件控制退出。
本文使用 Backtest 与模拟撮合引擎(Matching Engine Simulator)回放行情、模拟订单撮合和读取回测结果。
1. 策略介绍
本策略不依赖单一技术指标,而是将“趋势刚开始改善”和“短期尚未明显过热”组合为入场条件。在出场端,则将趋势反动、动能超买、持仓超时和价格止损独立处理,兼顾了入场择时、获利止盈、持仓期限以及单笔风险控制。
1.1 核心因子
RSI 根据一定周期内上涨幅度与下跌幅度的相对关系描述近期价格强弱,通常取值范围为 0 至 100。本例使用 14 日 RSI:当 RSI 小于 50 时,表示尚未进入本策略定义的强势过热阶段;当 RSI 大于 70 时,将其作为获利或风险退出条件之一。RSI 用于描述强弱状态,并不代表价格必然向某一方向运行。
移动平均线(MA)通过平滑收盘价观察价格趋势。本例使用 5 日、10 日、20 日和 60 日均线:
-
MA10 上穿 MA60(金叉):表示 MA10 从下方穿越 MA60。短中期趋势开始改善,作为买入信号的一部分。
-
MA5 下穿 MA20(死叉):表示 MA5 从上方穿越 MA20。短期趋势转弱,作为卖出信号之一。
策略的买入条件为:RSI(14) < 50,且 MA10 上穿 MA60。
策略的卖出条件为满足以下任一条件:
-
RSI(14) > 70;
-
MA5 下穿 MA20;
-
持有满 10 个交易日;
-
当日最低价触及成本价的 92%,即成本价下跌 8%。
为避免前视偏差,RSI 和移动平均线等技术指标均基于前一交易日的收盘数据计算。也就是说,T 日开盘生成交易信号时,仅使用截至 T-1 日收盘时已经可获得的数据,不使用 T 日收盘数据计算的指标。价格止损则根据持仓期间的日内最低价判断。
上述 RSI 阈值和均线窗口均为本案例中的策略参数,用于演示策略实现,不代表普适的最优参数。
1.2 交易标的
本例采用 5 只深市普通股票的模拟数据,代码为 000001.SZ 至 000005.SZ。输入为日频 OHLCV 行情,并额外构造涨跌停价、成交额和复权因子字段。
策略只做多,不使用融资、融券或卖空。普通股票买入数量按 100 股的整数倍向下取整;卖出数量受实际持仓和 T+1 可卖数量约束,当日新买入的股票不可在当日卖出。案例同时提供了科创板、创业板、北交所及部分债券或 ETF 的数量规整逻辑,便于扩展到其他交易标的。
在真实研究中,股票池、ST 状态、上市日期和可交易状态必须使用对应历史时点可获得的数据。不能使用当前股票池或当前 ST 状态替代历史时点数据。
1.3 交易规则
每个日频回调按照“先卖后买”的顺序执行:
-
更新当日可交易池,剔除不在候选范围、处于 ST 状态、上市不足 90 天或前一交易日成交量为零的股票。
-
检查已有持仓的 RSI 超买、短期死叉、固定持有期和价格止损条件。
-
对于止损触发的卖出,拟成交价取开盘价与止损价中的较小值。如果开盘已经跳空跌破止损线,策略不假设能够以更高的止损价成交。
-
卖出前检查停牌、成交量和跌停可交易性;无法卖出时保留仓位,等待后续交易日再次判断。
-
检查未持仓股票,仅当开盘可买、且 T-1 的 RSI 小于 50 并出现 MA10 上穿 MA60 金叉时提交买单。
-
买入数量同时受到现金、手续费和最小交易单位的限制,
entryPct=1.0表示单次建仓最多使用当时可用现金的全部额度。
持有期从建仓日记为第 0 天,并按照交易日历递增;到第 10 个交易日即触发时间退出。费用参数为买卖手续费率 0.015%、卖出印花税率 0.05%,未启用最低单笔手续费。
orderBookMatchingRatio=0.2 表示订单可能部分成交。因此,订单状态记录数量、订单 ID 数量和实际完整成交笔数不能直接等同,应结合 tradePrice > 0 与 tradeQty > 0 判断实际成交情况。
2. 策略回测
下文按照准备数据、定义策略、设置参数、创建引擎、回放行情和读取结果的顺序展开。本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 与模拟撮合引擎插件 3.00.6.6 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前,代码会检查插件是否已安装,并加载这两个插件。
2.1 准备数据
下载附录中的 compositeSignal.csv 文件,作为回测的案例数据。回测区间为 2025-08-07 至 2026-08-06。为计算策略指标,在正式回测开始日前额外获取 70 个交易日数据作为指标预热数据,并据此生成 5 只模拟股票的日线行情。预热数据用于计算 60 日均线和 RSI,不参与正式回测。
日线数据至少包含 symbol、tradeTime、open、low、high、close、volume 和 amount 等字段。本例构造的日线模拟数据 compositeSignal.csv 依据收盘价构造 10% 涨跌停价,并设置 adj_factor=1.0,因此未模拟真实除权除息、复权调整和交易所涨跌停制度的全部细节。
将附件的 csv 数据下载后,导入 csv 文件,<path> 按照实际存放数据的路径填写。生成指标前,先按 symbol 分组并按 tradeTime 升序排序,再计算 RSI、均线和交叉信号,最后使用 move(..., 1) 将策略指标整体向后移动一个周期:
dailyBars = loadText("<path>/compositeSignal.csv")
// 计算策略技术因子
dailyBarsWithFactors = select *,
move(double(RSI((close * adj_factor), 14)), 1) as rsi_val,
move(double(CROSS_((mavg((close * adj_factor), 10)), (mavg((close * adj_factor), 60)))), 1) as golden_cross,
move(double(CROSS_((mavg((close * adj_factor), 20)), (mavg((close * adj_factor), 5)))), 1) as dead_cross
from dailyBars
context by symbol
csort tradeTime
2.2 编写策略逻辑
案例策略由职责明确的函数组成:
-
roundLot:将买入数量规整到适用的交易单位。 -
normalizeSellQty:将卖出数量限制在实际持仓和当日可卖数量以内。 -
isBuyableAt与isSellableAt:根据成交量和涨跌停价判断开盘是否可提交对应方向的订单。 -
initialize:订阅所需指标并在 context 中建立手续费、税率、成本、入场日期、上日持仓和候选池等状态。
def initialize(mutable context) {
ind = dict(STRING, ANY)
ind["rsi_val"] = <rsi_val>
ind["golden_cross"] = <golden_cross>
ind["dead_cross"] = <dead_cross>
Backtest::subscribeIndicator(context.engine, "ohlc", ind)
context["commission"] = 0.00015
context["tax"] = 0.0005
context["entryPct"] = 1.0
context["prevPos"] = dict(STRING, INT)
context["costBasis"] = dict(STRING, DOUBLE)
context["entryDates"] = dict(STRING, DATE)
context["stockPool"] = objByName("stockPool")
context["tpExplicit"] = objByName("tpExplicit_")
context["tpIdxSnaps"] = objByName("tpIdxSnaps_")
context["tpStHist"] = objByName("tpStHist_")
context["tpListInfo"] = objByName("tpListInfo_")
context["tradablePool"] = set(array(STRING, 0))
context["lastPoolUpdateDate"] = NULL
}
-
onBar:先维护股票池和已有持仓,再执行卖出判断,最后执行买入判断。
策略在回测系统中的完整处理流程按顺序可归纳为以下四个环节:
-
T-1收盘:形成 RSI、均线和交叉信号,并将指标传递给下一交易日。
-
T 日开盘:更新股票池,检查已有持仓的退出条件。
-
先卖后买:提交可成交的卖单并估算释放的现金,再检查新开仓信号。
-
撮合与记录:引擎记录订单状态、部分成交、持仓、现金和账户权益。
本策略中的止损是价格触发条件,而非成交保证:当日最低价下穿成本价 92% 时,策略尝试以开盘价和止损价中的较低值提交卖单。实际成交仍取决于行情和撮合条件;例如,停牌、跌停或部分成交都可能导致订单无法立即或完整成交。同样,固定持有 10 个交易日表示达到持有期限后触发平仓判断,并不意味着一定能够在当日完成平仓。实际卖出数量仍受 T+1 可卖数量以及交易可行性的约束。
本例使用 entryPct=1.0,即单次开仓信号可使用全部可用现金,因此信号循环会在可用现金不足时停止。单个新信号可能占用绝大部分资金,因此该案例更适合展示交易状态机和风控条件,并不代表分散化组合策略。真实研究中应考虑单标的预算、持仓数量上限和组合层面的集中度约束。
完整代码示例如下:
def onBar(mutable context, msg, indicator) {
curBarTs = msg.values()[0]["tradeTime"]
curDate = date(curBarTs)
// 1. 每日更新可交易股票池(剔除当日处于 ST、上市不足 90 天以及停牌的股票)
if (curDate != context["lastPoolUpdateDate"]) {
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))
tpExplicit_ = context["tpExplicit"]
stHist_ = context["tpStHist"]
stCodes_ = set(exec code from stHist_ where start_date <= curDate and (isNull(period_end) or period_end > curDate))
listInfo_ = context["tpListInfo"]
cutoffDate_ = date(curDate - 90)
eligibleCodes_ = set(exec code from listInfo_ where list_date <= cutoffDate_)
pool_ = array(STRING, 0)
for (s_ in context["stockPool"]) {
isNotSuspended = (s_ in msg.keys()) && (msg[s_]["volume"] > 0)
if (((s_ in idxMembers_) || (s_ in tpExplicit_)) && !(s_ in stCodes_) && (s_ in eligibleCodes_) && isNotSuspended) {
pool_.append!(s_)
}
}
context["tradablePool"] = set(pool_)
context["lastPoolUpdateDate"] = curDate
}
// 2. 状态维护与持仓成本(已去除分红派息计算)
heldStocks = array(STRING, 0)
sellableQtyMap = dict(STRING, INT)
totalEquity = Backtest::getTotalPortfolios(context.engine).totalEquity[0]
expectedCash = Backtest::getAvailableCash(context.engine)
for (istock in msg.keys()) {
bar = msg[istock]
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)
if (pos > 0) {
heldStocks.append!(istock)
sellableQtyMap[istock] = pos_sellable
prevPos_ = iif(istock in context["prevPos"], context["prevPos"][istock], 0)
cost_ = iif(istock in context["costBasis"], context["costBasis"][istock], 0.0)
if (pos > prevPos_) {
if (prevPos_ == 0) {
seedPx_ = posInfo["longPositionAvgPrice"]
cost_ = pos * iif(seedPx_ > 0, seedPx_, bar["open"])
} else {
cost_ = cost_ + (pos - prevPos_) * bar["open"]
}
} else if (pos < prevPos_ && prevPos_ > 0) {
cost_ = cost_ * (double(pos) / prevPos_)
}
context["prevPos"][istock] = pos
context["costBasis"][istock] = cost_
if (!(istock in context["entryDates"])) { context["entryDates"][istock] = curDate }
} else {
if (istock in context["prevPos"]) { context["prevPos"].erase!(istock) }
if (istock in context["costBasis"]) { context["costBasis"].erase!(istock) }
if (istock in context["entryDates"]) { context["entryDates"].erase!(istock) }
}
}
// 3. 【先卖】依次检查持仓卖出条件,满足则当天直接卖出
soldStocksToday = set(array(STRING, 0))
for (istock in heldStocks) {
bar = msg[istock]
pos_sellable = sellableQtyMap[istock]
if (pos_sellable <= 0) { continue } // T+1 限制:当日买入不可卖
// 持有交易日数:建仓当日为第 0 天
entryDate_ = context["entryDates"][istock]
hold_days = size(getMarketCalendar("XSHG", entryDate_, curDate)) - 1
// 持仓成本与止损价格计算
cost_ = context["costBasis"][istock]
pos_ = context["prevPos"][istock]
posPriceRaw_ = iif(pos_ > 0, cost_ / pos_, bar["open"])
stopLossPrice = posPriceRaw_ * 0.92 // 8% 止损线
// 四大卖出条件:
// 前三个条件基于 T-1 收盘指标驱动,在 T 日开盘即确立;第四个条件检查 T 日盘中是否击穿止损线
cond1_rsiOverbought = isValid(indicator[istock]["rsi_val"]) && (indicator[istock]["rsi_val"] > 70)
cond2_deadCross = isValid(indicator[istock]["dead_cross"]) && indicator[istock]["dead_cross"]
cond3_timeStop = isValid(hold_days) && (hold_days >= 10)
cond4_stopLoss = (bar["low"] <= stopLossPrice)
if (cond1_rsiOverbought || cond2_deadCross || cond3_timeStop || cond4_stopLoss) {
// 确定实际期望成交价:止损触发则以 min(open, stopLossPrice) 成交,其余按开盘价成交
sellPrice = bar["open"]
if (cond4_stopLoss && !cond1_rsiOverbought && !cond2_deadCross && !cond3_timeStop) {
sellPrice = min(bar["open"], stopLossPrice)
}
// 用拟成交价检查是否跌停/停牌(跌停、停牌则卖不出去,跳过留待次日)
if (!isSellableAt(sellPrice, bar)) { continue }
sellQty = normalizeSellQty(istock, pos_, pos_sellable)
if (sellQty > 0) {
// 涨停价防微小浮点误差越界兜底
sellLimit = iif(isNull(bar["upLimitPrice"]), sellPrice, min(sellPrice, bar["upLimitPrice"]))
Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, sellLimit, sellQty, 3), "sell")
// 卖出资金当天释放,即时计入可用现金池
releasedCash = sellQty * sellLimit * (1 - context["commission"] - context["tax"])
expectedCash = expectedCash + releasedCash
soldStocksToday.append!(istock)
if (sellQty >= pos_) {
context["entryDates"].erase!(istock)
context["prevPos"].erase!(istock)
context["costBasis"].erase!(istock)
}
}
}
}
// 4. 【后买】检查买入条件,当天直接买入,资金用完即停止
for (istock in msg.keys()) {
if (expectedCash <= 0) { break }
// 避免重复加仓(已持仓且今天未卖尽的跳过;今天已清仓的可重新买入)
posInfo = Backtest::getPosition(context.engine, istock)
currentPos = iif(size(posInfo) > 0, posInfo["longPosition"], 0)
if (currentPos > 0 && !(istock in soldStocksToday)) { continue }
bar = msg[istock]
buyPrice = bar["open"]
// 交易池过滤及开盘价可买性(非停牌、非一字涨停)
if (!(istock in context["tradablePool"])) { continue }
if (!isBuyableAt(buyPrice, bar)) { continue }
// 买入信号(基于已位移的 T-1 指标):RSI < 50 且 均线金叉
rsiVal = indicator[istock]["rsi_val"]
isGoldenCross = indicator[istock]["golden_cross"]
condBuy = isValid(rsiVal) && isValid(isGoldenCross) && (rsiVal < 50) && isGoldenCross
if (condBuy) {
affordableQty = roundLot(istock, expectedCash / (buyPrice * (1 + context["commission"])))
entryBudget = min(totalEquity * context["entryPct"], expectedCash) / (1 + context["commission"])
plannedQty = roundLot(istock, entryBudget / buyPrice)
buyQty = min(plannedQty, affordableQty)
if (buyQty > 0) {
Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, buyPrice, buyQty, 1), "buy")
expectedCash = expectedCash - buyQty * buyPrice * (1 + context["commission"])
context["entryDates"][istock] = curDate
}
}
}
}
2.3 设置回测参数
本案例的主要回测参数如下:
startDate = 2025.08.07
endDate = 2026.08.06
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
}
dataType=4 表示输入数据为股票日频行情,并通过 onBar 回调触发策略逻辑;matchingMode=2 表示按日频开盘价进行撮合;outputOrderInfo=true 表示在交易明细中保留订单状态和拒单信息。佣金和印花税同时写入引擎配置和策略上下文,用于资金预留和成交后的现金估算。
本例未启用 JIT。先以非 JIT 模式确认交易逻辑和结果,再单独验证回调、字典和表操作是否兼容 JIT。开启 JIT 时,将创建引擎的第四个参数设置为 true,并参考 DolphinDB 回测引擎使用和性能优化攻略。
2.4 创建回测引擎
创建引擎前,将 initialize 和 onBar 注册到回调字典。Backtest 负责资金、持仓、交易日推进和绩效统计;模拟撮合引擎负责按照给定规则模拟订单撮合。
callbacks = dict(STRING, ANY)
callbacks["initialize"] = initialize
callbacks["onBar"] = onBar
engineName = "CompositeSignalStrategyEngine"
engine = Backtest::createBacktester(engineName, config, callbacks, false)
单用户调试时可以使用固定引擎名称并在运行前清理同名实例,但共享服务器或并发会话中不应使用“固定名称加先删后建”的方式批量执行,否则可能误删除其他会话的引擎。更安全的做法是为每次运行生成唯一名称,并且只清理本次创建的实例。
2.5 执行回测
正式数据应按交易时间排序后写入引擎。回调按交易日处理股票池、持仓、交易信号和订单。行情发送完成后,选取第一行行情并将 symbol 改为 “END”,以通知引擎结束回放:
Backtest::appendQuotationMsg(engine, data)
endRow = select top 1 * from data
update endRow set symbol = "END"
Backtest::appendQuotationMsg(engine, endRow)
结束标记仅用于通知回放完成,不代表可交易证券。建议将行情回放和结束通知放在异常处理范围内;发生异常时仅清理本次创建的引擎。日线时间戳用于标识交易日,实际撮合价格取当日 open 字段。
2.6 获取回测结果
回放结束后读取收益汇总、交易明细、每日持仓和账户权益:
tradeDetails = Backtest::getTradeDetails(engine)
returnSummary = Backtest::getReturnSummary(engine)
dailyPosition = Backtest::getDailyPosition(engine)
totalPortfolios = Backtest::getDailyTotalPortfolios(engine)
-
returnSummary:累计收益、年化收益、波动率、夏普比率和最大回撤等汇总指标。
-
tradeDetails:委托、部分成交、撤单和拒单等状态记录,不保证每行都是完整成交。
-
dailyPosition:有持仓记录的股票、日期、昨日仓位、买卖数量、成本和收盘价。
-
totalPortfolios:每日现金、市值、总权益、净值、盈亏和费用。
本例未设置真实指数基准,因此 benchmarkReturn、alpha、beta 和超额收益相关字段为默认值,不应解释为相对沪深 300 或其他基准的表现。
3. 回测结果分析
本例中回测覆盖 242 个交易日,初始权益为 1,000,000.00 元,期末日期为 2026-08-06。以下结果仅对应本次运行的模拟数据。
| 指标 | 本次运行结果 | 说明 |
|---|---|---|
| 累计收益 | -99.5126% | 本次模拟路径下的区间结果 |
| 年化收益 | -99.6089% | 按回测区间年化后的结果 |
| 年化波动率 | 518.7904% | 模拟权益曲线波动较高 |
| 夏普比率 | -0.1920 | 基于本次模拟结果计算,不代表真实市场风险调整收益 |
| 最大回撤 | 99.7797% | 历史峰值到后续低点的最大权益跌幅 |
| 日胜率 | 47.2222% | 权益日度盈利的比例,不能单独用于评价策略表现 |
本次运行的最高总权益为 1,467,737.77 元,最低总权益为 3,234.05 元。权益曲线前期曾显著上涨,随后经历深度回撤。日胜率为 47.2222%,但需注意,日胜率与累计收益、最大回撤等指标衡量的维度不同,不能仅凭胜率判断策略的整体抗风险能力。
本次模拟价格没有植入真实市场的行业、公告、流动性、复权、停复牌和因子收益关系,且没有设定真实基准。它只能说明:在给定模拟输入与撮合约束下,信号、T+1、部分成交、止损、时间退出、费用和绩效读取被完整串联执行。
4. 总结
本案例将 RSI 与均线交叉信号用于入场和趋势退出,并使用 10 个交易日的固定持有期和成本价的 92% 的止损条件进行风险控制。通过将所有指标滞后一个交易日,避免使用当日收盘信息生成当日开盘决策;通过先卖后买、交易数量规整、T+1 可卖数量和涨跌停检查,展示了日频股票回测中常见的执行限制。
Backtest 适合用于学习从指标预热、回调交易到结果读取的完整流程。进入真实研究前,应使用具有历史可得时点信息的真实行情和股票池,并纳入 ST、上市状态和复权等实际交易数据处理。对于真实研究,还应设置合理的市场基准,并进一步测试参数敏感性、资金分配、流动性、滑点、停牌、涨跌停以及样本外表现。
5. 附录
完整策略脚本包括交易数量规整、可交易性判断、初始化、日频回调、指标计算、引擎创建、行情回放与结果读取。
