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 交易规则

每个日频回调按照“先卖后买”的顺序执行:

  1. 更新当日可交易池,剔除不在候选范围、处于 ST 状态、上市不足 90 天或前一交易日成交量为零的股票。

  2. 检查已有持仓的 RSI 超买、短期死叉、固定持有期和价格止损条件。

  3. 对于止损触发的卖出,拟成交价取开盘价与止损价中的较小值。如果开盘已经跳空跌破止损线,策略不假设能够以更高的止损价成交。

  4. 卖出前检查停牌、成交量和跌停可交易性;无法卖出时保留仓位,等待后续交易日再次判断。

  5. 检查未持仓股票,仅当开盘可买、且 T-1 的 RSI 小于 50 并出现 MA10 上穿 MA60 金叉时提交买单。

  6. 买入数量同时受到现金、手续费和最小交易单位的限制,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。以下结果仅对应本次运行的模拟数据。

表 1. 表 3-1 回测结果
指标 本次运行结果 说明
累计收益 -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. 附录

完整策略脚本包括交易数量规整、可交易性判断、初始化、日频回调、指标计算、引擎创建、行情回放与结果读取。