黄金期货 RSI 吊灯止盈策略

基于 DolphinDB Backtest 插件,构建以相对强弱指标(RSI)穿越触发入场、移动平均(MA)过滤趋势、平均真实波幅(ATR)吊灯线控制退出的黄金期货策略。策略通过 Backtest::subscribeIndicator 订阅 RSI、MA、ATR 及吊灯候选线,由插件内部的指标计算引擎按实际合约独立维护历史窗口和递推状态,实现增量计算。每一分钟 K 线到达后,策略回调获取当前指标,处理换月、入场及持仓退出逻辑。持仓期间,吊灯线沿有利方向单向移动,收盘价越线时退出;换月时,先平旧合约,确认空仓后再按新合约的当前信号决定是否开仓。

1. 策略介绍

1.1 策略背景

黄金期货在上海期货交易所(SHFE)交易,具有连续交易时段、流动性较好、波动适中、合约月份连续等特点,适合作为日内趋势策略的标的。趋势策略的核心问题是:何时入场、何时退出。

本策略采用"RSI 穿越入场 + MA 过滤 + ATR 吊灯退出"的组合方案:

  • 入场:以 RSI 穿越阈值作为动能触发信号,以 MA 作为趋势方向过滤,两者同时满足才开仓;

  • 退出:以 ATR 构建的吊灯线作为移动止损,持仓期间单向收紧,收盘价越线即退出;

  • 换月:黄金期货按月份合约交易,主力合约随时间切换。策略按"先平旧、再开新"的顺序处理换月,避免新旧月份价差被误判为价格变化。

该方案的优势在于:RSI 提供及时的动能触发,MA 过滤掉逆势信号,ATR 吊灯线在锁定利润的同时保留趋势延续空间。三者组合能兼顾信号质量与退出效率。

1.2 核心指标

  1. RSI:RSI 用于衡量一段时间内价格上涨与下跌动能的相对强弱。令收盘价变化为 Δ t = C t − C t − 1 ,上涨量、下跌量分别为: G t = max ( Δ t , 0 ) , D t = max ( − Δ t , 0 )

    前 n 个价格变化的算术平均作为初值;此后使用 Wilder 平滑:

    G ― t = ( n − 1 ) G ― t − 1 + G t n , D ― t = ( n − 1 ) D ― t − 1 + D t n .

    R S I t = 100 − 100 1 + G ― t / D ― t .

    RSI 的边界处理:如果平均下跌量为零、平均上涨量大于零,RSI 为 100;两者都为零时定义为 50;只有下跌时为 0。首个 RSI 在第 n+1 根 K 线上产生。

  2. MA:MA 用于过滤趋势方向,取最近 m 根已完成 K 线收盘价的算术平均:

    M A t = 1 m ∑ i = 0 m − 1 C t − i .

    仅当收盘价位于 MA 上方时允许开多,位于 MA 下方时允许开空。MA 未满窗口时保持空值,不产生信号。

  3. ATR:ATR 用于衡量价格波动幅度,作为吊灯线的宽度基准。真实波幅定义为:

    T R t = max ( H t − L t , ∣ H t − C t − 1 ∣ , ∣ L t − C t − 1 ∣ ) .

    第一根没有前收盘价,取 T R 1 = H 1 − L 1 。前 n 个 TR 的算术平均为初始 ATR,此后使用 Wilder 平滑:

    A ⁢ T R t = ( n − 1 ) ⁢ A ⁢ T R t − 1 + T R t n .

  4. 吊灯线(Chandelier Exit):吊灯线是一种移动止损线,以最近 L 根 K 线的极值为基础,减去/加上 ATR 的倍数:

    B t L = max ( H t − L + 1 , … , H t ) − k ⋅ A ⁢ T R t

    B t S = min ( L t − L + 1 , … , L t ) + k ⋅ A ⁢ T R t .

    其中 k 为 ATR 倍数。开仓成交后,用对应方向候选线初始化本次持仓吊灯线。持仓期间:

    即多头吊灯线只升不降,空头吊灯线只降不升。当收盘价严格低于多头吊灯线时平多,严格高于空头吊灯线时平空。每根先更新吊灯线,再判断收盘价是否越线;入场当根只初始化吊灯线,从下一根开始检查退出。

1.3 交易规则

  1. 入场规则

    方向 触发条件

    多头入场

    前根 RSI ≤ 上阈值,当前 RSI > 上阈值,且当前收盘价 > MA

    空头入场

    前根 RSI ≥ 下阈值,当前 RSI < 下阈值,且当前收盘价 < MA

    边界处理:收盘价等于 MA、RSI 等于当前阈值、收盘价等于吊灯线,都不触发对应操作。价格持续位于 RSI 阈值一侧不构成新的穿越,不会连续重复开仓。

  2. 退出规则

    方向 触发条件

    多头退出

    多头吊灯线只升不降;收盘价严格低于该线

    空头退出

    空头吊灯线只降不升;收盘价严格高于该线

    "吊灯止盈"是移动退出规则,也可能在尚未盈利时退出。反向 RSI 信号不会直接平仓或反手,持仓由吊灯条件或换月退出。普通吊灯退出的当根 K 线不重新开仓。

  3. 仓位规则

    • 同时最多持有一个合约、一个方向;不加仓;

    • 开仓名义本金不超过当前权益的指定杠杆倍数。

  4. 换月规则

    主力选择由交易日—目标合约映射决定。同一交易日使用同一个目标合约。换月执行顺序:

    1. 如果仍持有旧合约,使用旧合约当前 K 线提交平仓。

    2. 查询引擎实际多空持仓,确认旧合约都为零。

    3. 清空旧仓吊灯状态,立即检查新合约当前根的 RSI 穿越及 MA 条件。

    4. 满足条件才开新仓,并使用新合约候选线初始化吊灯。

    换月不会照搬旧仓方向;新合约没有新穿越或资金不足时保持空仓。旧合约缺少当前时点行情、拒单或未平完时,不新开仓。后续有行情时继续尝试平旧,入场仍要求后续当前根存在有效信号,不补发过期信号。

2. 策略回测

运行前需要安装与 DolphinDB Server 匹配的 Backtest 和模拟撮合引擎(Matching Engine Simulator)插件。完整脚本会检查已加载的插件,并加载尚未加载的插件;插件安装由运行环境提前完成。

本案例已在 DolphinDB Server 3.00.6、Backtest 与 MatchingEngineSimulator 3.00.6.11 上运行。

回测流程依次为:读取合约信息、分钟行情和主力映射,定义指标计算及交易函数,设置回测参数,创建引擎并回放行情,最后获取结果。

2.1 准备数据

本例从 DolphinDB 服务器目录 dataDir 读取三个已准备好的 CSV 文件:

表 1. 表 2-1 样例数据
文件 用途
bars.csv

各月份合约已完成的 1 分钟 K 线、涨跌停价数据

mainContracts.csv

每个交易日对应的目标主力合约

securityReferences.csv

合约乘数、tick、保证金率、手续费和最后交易日

先将附件中的三个文件上传到新节点的同名子目录,或将 dataDir 修改为实际上传目录。loadText 读取的是 DolphinDB 服务器上的文件,路径不会指向本地编辑器所在电脑。脚本会先检查文件是否存在,再使用显式 schema 加载数据:

dataDir = "..."
// 路径属于 DolphinDB 服务器;
// CSV 含表头且字段顺序与 schema 一致;数据质量及时间顺序由文件准备阶段保证。
// tradeTime 为已完成分钟的结束时间;volume 为整数手,tradingDay 结算日期。
barsSchema = table(
["symbol","symbolSource","tradeTime","tradingDay","open","low","high","close","volume","upLimitPrice","downLimitPrice"] as name,
    ["STRING","STRING","TIMESTAMP","DATE","DOUBLE","DOUBLE","DOUBLE","DOUBLE","LONG","DOUBLE","DOUBLE"] as type)
bars = loadText(dataDir+"bars.csv",schema=barsSchema,containHeader=true)
// 主力合约在每个交易日之前确定;换月日 CSV 同时保留新旧合约的分钟行情。
mainContracts = loadText(dataDir+"mainContracts.csv",
    schema=table(["tradingDay","symbol"] as name,["DATE","STRING"] as type),containHeader=true)
// 合约信息直接交给引擎;CSV 的 multiplier 应与 contractMultiplier=1000 一致。
// 本例使用按手手续费(deliveryCommissionMode=1);其他合约参数均由 CSV 提供。
securityReference = loadText(dataDir+"securityReferences.csv",
    schema=table(["symbol","multiplier","marginRatio","tradeUnit",
    "priceUnit","priceTick","commission","deliveryCommissionMode",
      "intradayClosingFee","lastTradingDay"] as name,    ["STRING","DOUBLE","DOUBLE","DOUBLE","DOUBLE","DOUBLE","DOUBLE","INT","DOUBLE","DATE"] as type),containHeader=true)

行情字段说明如下:

表 2. 表 2-2 行情字段说明
字段 类型 含义

symbol

STRING

实际月份合约代码,如 au2406

symbolSource

STRING

本例为 SHFE

tradeTime

TIMESTAMP

已完成分钟 K 线的结束时间

tradingDay

DATE

交易日,保留夜盘归属

open / low / high / close

DOUBLE

该月份合约未复权 OHLC

volume

LONG

当前 K 线成交量,单位为手

upLimitPrice / downLimitPrice

DOUBLE

对应交易日的涨跌停价格

mainContracts.csv 每个交易日恰有一行,包含 tradingDay 和 symbol,并覆盖预热期。

数据准备的关键点:

  • CSV 具有表头,字段及顺序与 schema 一致;行情已在文件准备阶段完成清洗、校验,并按 tradeTime,symbol 排序,脚本读取后直接回放。

  • tradeTime 表示已完成的 1 分钟 K 线结束时间。RSI、ATR 和 MA 均按有效 K 线根数计算,跨休市时不等同于连续自然分钟。

  • 待换入合约在成为主力前需保留预热行情;换月时需同时提供新旧合约行情,供旧仓平仓及新仓判断使用。

  • 行情、映射和合约表使用一致的合约代码。每个实际合约提供唯一的静态合约信息。

2.2 编写策略逻辑

策略由指标计算函数、交易辅助函数和回调函数组成,职责如下:

表 3. 表 2-3 策略函数
函数 作用

rsiWilder

计算 Wilder 平滑的 RSI

atrWilder

计算真实波幅及 Wilder ATR

createIndicatorMetrics

将周期和倍数绑定为指标计算表达式

readIndicator

读取当前指标,判断预热及 RSI、MA 入场条件

initialize

订阅指标,初始化持仓状态及三张日志表

getPositionQty

查询实际多空持仓及昨仓数量

submitOrder

提交整数手期货限价委托

clearHeldPosition

实际平仓后清空持仓合约、吊灯线及退出标记

closePosition

区分今昨仓平仓,查询是否实际空仓

openPosition

根据资金约束开仓,成交后初始化吊灯线

onBar

处理当前指标、主力换月、吊灯退出和信号入场

执行顺序为:创建引擎时订阅指标并初始化状态 → 回放时读取各合约指标 → 按交易日确认目标合约 → 先处理旧仓和退出 → 空仓时判断新信号。下面按函数说明。

计算状态指标: rsiWilder 和 atrWilder 使用 @state 声明,在订阅指标引擎中按合约维护历史状态。RSI 第一根的价格变化保持空值;ATR 第一根以前收盘价等于本根收盘价处理,使 TR 等于 high-low。

@state
def rsiWilder(close,period){
    change = deltas(close)
    // 第一根没有前收盘价,保持 NULL,避免将其当作第一个零涨跌参与初值计算。
    gain = iif(isNull(change),double(NULL),iif(change>0,change,0.0))
    loss = iif(isNull(change),double(NULL),iif(change<0,-change,0.0))
    avgGain = wilder(gain,period)
    avgLoss = wilder(loss,period)
    // 平均涨跌均为零时定义 RSI=50;只有上涨时为 100,只有下跌时为 0。
    return iif(isNull(avgGain) or isNull(avgLoss),double(NULL),
        iif(avgLoss==0,iif(avgGain==0,50.0,100.0),100.0-100.0/(1.0+avgGain/avgLoss)))
}

@state
def atrWilder(high,low,close,period){
    // 第一根用自身收盘价补前收盘价,使该根 TR 等于 high-low。
    previousClose = nullFill(prev(close),close)
    tr = rowMax(high-low,abs(high-previousClose),abs(low-previousClose))
    // ATR 包含第一根 TR;默认 period=10 时,第 10 根得到初值。
    return wilder(tr,period)
}

生成订阅表达式: createIndicatorMetrics 把配置中的周期和 ATR 倍数写入元代码,订阅根数、当前及前根 RSI、MA、ATR 和两条候选线。完整窗口不足时,移动均值和极值保持空值。

def createIndicatorMetrics(cfg){
    rsiExpr = "rsiWilder(close,"+string(cfg.rsiPeriod)+")"
    atrExpr = "atrWilder(high,low,close,"+string(cfg.atrPeriod)+")"
    maWindow = string(cfg.maPeriod)
    lookback = string(cfg.chandelierLookback)
    metrics = dict(STRING,ANY)
    // barCount 按真实合约计数,不随交易日切换或主力换月而重置。
    metrics["barCount"] = <cumcount(close)>
    metrics["rsi"] = parseExpr(rsiExpr)
    metrics["prevRsi"] = parseExpr("prev("+rsiExpr+")")
    // 第三个参数要求完整 maPeriod 窗口;默认未满 30 根时 MA 为空。
    metrics["ma"] = parseExpr("mavg(close,"+maWindow+","+maWindow+")")
    metrics["atr"] = parseExpr(atrExpr)
    // 这里只订阅行情候选线;成交后实际持仓的单向吊灯线由 context.trail 维护。
    metrics["longCandidate"] = parseExpr("mmax(high,"+lookback+","+lookback+")-"+string(cfg.atrMultiple)+"*"+atrExpr)
    metrics["shortCandidate"] = parseExpr("mmin(low,"+lookback+","+lookback+")+"+string(cfg.atrMultiple)+"*"+atrExpr)
    return metrics
}

初始化回调: initialize 在回放前调用 Backtest::subscribeIndicator。heldSymbol 记录实际持仓合约,activeMain 记录映射中的目标合约,trail 保存本次持仓的吊灯线。退出尚未完成时,exitPending 使后续行情继续尝试平仓。

def initialize(mutable context){
    // 在行情回放之前订阅,指标引擎自动按各真实合约分组更新。
    metrics = createIndicatorMetrics(context)
    Backtest::subscribeIndicator(context.engine,"kline",metrics)
    // heldSymbol 记录本策略实际持仓合约;activeMain 记录映射要求的目标合约。
    context.heldSymbol = ""
    // trail 只属于当前这一次持仓,开仓成交后初始化,平仓完成后清空。
    context.trail = double(NULL)
    // 退出已触发但未成交完成时,后续 K 线继续平仓,不因价格恢复而放弃退出。
    context.exitPending = false
    context.activeMain = ""
}

订阅产生的候选线与持仓吊灯线分别维护:前者只依赖行情,后者在成交后初始化,并在本次持仓期间单向更新。指标订阅接口的详细说明见 subscribeIndicator。

读取入场信号: readIndicator 接收同一合约、同一根 K 线的行情及订阅结果,检查前数据预热、RSI 严格穿越和 MA 方向。它只处理当前订阅值,历史序列由内部的指标引擎管理。

def readIndicator(bar,values,cfg){
    s = {n:int(values.barCount),rsi:double(values.rsi),prevRsi:double(values.prevRsi),
        ma:double(values.ma),atr:double(values.atr),longCandidate:double(values.longCandidate),
        shortCandidate:double(values.shortCandidate),longSignal:false,shortSignal:false}
    // 根数严格大于 warmupBars;默认前 40 根禁入场,第 41 根起且指标齐备时判断。
    ready = s.n > cfg.warmupBars and not isNull(s.prevRsi) and not isNull(s.rsi) and
        not isNull(s.ma) and not isNull(s.longCandidate) and not isNull(s.shortCandidate)
    // RSI 穿越和 MA 方向必须在同一根同时满足;已在阈值一侧不会连续重复开仓。
    s.longSignal = ready and s.prevRsi <= cfg.longThreshold and s.rsi > cfg.longThreshold and bar.close > s.ma
    s.shortSignal = ready and s.prevRsi >= cfg.shortThreshold and s.rsi < cfg.shortThreshold and bar.close < s.ma
    return s
}

查询持仓与提交委托: getPositionQty 返回当前总多仓、当前总空仓、昨多仓及昨空仓。submitOrder 使用九字段期货限价单,以当前收盘价下单,手数转为 LONG。

def getPositionQty(engine, sym){
    p = Backtest::getPosition(engine,sym)
    return [long(nullFill(p.longPosition,0)),long(nullFill(p.shortPosition,0)),
        long(nullFill(p.lastDayLongPosition,0)),long(nullFill(p.lastDayShortPosition,0))]
}

def submitOrder(mutable context, bar, qty, direction, label){
    if (qty <= 0) return
    // 九字段期货限价单;0 延迟、当日有效;
    // 字段依次为:合约、交易所、时间、订单类型、价格、止损/止盈价、手数、方向、有效性。
    // 方向 1/2=买开/卖开,3/4=普通卖平/买平(本例用于昨仓),6/7=卖平今/买平今。
    return Backtest::submitOrder(context.engine,
        (string(bar.symbol),"SHFE",bar.tradeTime,5,double(bar.close),0.0,long(qty),int(direction),0),label)
}

订单字段依次为合约、交易所、时间、订单类型、价格、止损或止盈价、数量、方向和有效性。这里 5 表示限价单,最后的 0 表示当日有效。方向 1/2 表示买开和卖开,3/4 在本例用于卖平昨和买平昨,6/7 表示卖平今和买平今。

平仓操作: closePosition 先撤销旧委托,再按昨仓和今仓分别平多、平空。只有查询到当前总多仓和总空仓均为零,才清空持仓状态并允许后续入场。平仓未完成时,保留退出标记。

def closePosition(mutable context, bar, reason){
    sym = string(bar.symbol)
    // 先撤销该合约旧单,避免平仓时仍有遗留开仓委托成交。
    Backtest::cancelOrder(context.engine,sym)
    p = getPositionQty(context.engine,sym)
    // 当前总持仓减昨仓得到今仓;今昨仓拆单,以便引擎使用对应的手续费。
    longToday = max(0l,p[0]-p[2])
    shortToday = max(0l,p[1]-p[3])
    if (p[2] > 0) submitOrder(context,bar,p[2],3,reason+"_longYesterday")
    if (longToday > 0) submitOrder(context,bar,longToday,6,reason+"_longToday")
    if (p[3] > 0) submitOrder(context,bar,p[3],4,reason+"_shortYesterday")
    if (shortToday > 0) submitOrder(context,bar,shortToday,7,reason+"_shortToday")
    // 同步收盘撮合后立即查询实际持仓;拒单或部分成交不能视为已经平完。
    after = getPositionQty(context.engine,sym)
    flat = after[0]+after[1] == 0
    if (flat) clearHeldPosition(context)
    else context.exitPending = true
    return flat
}

开仓操作: openPosition 同时检查名义本金预算及可用资金,取较小的整数手数。提交委托后查询实际持仓,确认成交才记录持仓合约并初始化吊灯线。剩余开仓单在当前回调中撤销,避免后续无信号时补成交。

def openPosition(mutable context, bar, s, side){
    // 只在空仓和当前 K 线的穿越信号下开仓;不累计或补发过期信号。
    // side=1 开多,side=-1 开空;由 onBar 保证单合约、单方向持仓。
    equity = double(Backtest::getTotalPortfolios(context.engine).totalEquity)
    available = double(Backtest::getAvailableCash(context.engine))
    sym = string(bar.symbol)
    ref = context.references[sym]
    // 名义本金约束控制 1 倍杠杆;保证金率只参与可用资金约束。
    notionalPerLot = bar.close*context.contractMultiplier
    budget = max(0.0,equity*context.leverage*(1.0-context.cashReserve))
    // 每手名义本金或保证金加上开仓费后计算可开手数,取较小值并向下取整。
    qty = long(floor(min(budget/(notionalPerLot+ref.commission),
        available/(notionalPerLot*ref.marginRatio+ref.commission))))
    if (qty <= 0){return}
    Backtest::cancelOrder(context.engine,sym)
    submitOrder(context,bar,qty,iif(side==1,1,2),iif(side==1,"entry_long","entry_short"))
    p = getPositionQty(context.engine,sym)
    actual = p[0]+p[1]
    if (actual > 0){
        // 用真实成交确认持仓,初始化新仓吊灯,不能沿用上次持仓或旧合约的线。
        context.heldSymbol = sym
        context.trail = iif(side==1,s.longCandidate,s.shortCandidate)
    }
    // 在此例的同步收盘撮合下,剩余开仓单立即撤销,避免下一根无信号补成交。
    Backtest::cancelOrder(context.engine,sym)
}

clearHeldPosition 清空本次持仓状态。事件记录中的价格为触发时收盘价;实际成交量和成交价通过引擎成交明细读取。

分钟行情回调: onBar(mutable context,msg,indicator) 接收按合约索引的行情和指标字典。主回调分为五步:

  1. 读取各合约当前指标,记录指标与信号。

  2. 按行情交易日查询当日目标合约。

  3. 换月时先平旧仓,未平完则返回。

  4. 有仓位时更新吊灯线并判断退出。

  5. 空仓时按当前根信号开仓。

回调结构如下,五个步骤与完整脚本中的注释对应:

def onBar(mutable context,msg,indicator){
    // step 1:读取各合约当前指标,记录指标与信号。
    // step 2:按交易日读取目标合约。
    // step 3:换月时先平旧仓。
    // step 4:更新持仓吊灯线并判断退出。
    // step 5:空仓时按当前信号开仓。
}

以下代码是 onBar 函数的连续节选,需放在同一个回调函数中使用;完整函数见附件脚本。

Step 1:读取各真实合约的订阅结果。msg[sym] 与 indicator[sym] 一一对应,新旧合约的历史分别维护。缺少本根订阅结果时报告异常,避免用旧指标生成信号。

// step 1:读取各真实合约的当前订阅指标,记录指标与入场信号。
// msg、indicator 都按 symbol 索引;同一时点先读取所有合约,再做交易判断。
syms = string(msg.keys())
if (size(syms)==0) return
// 这个字典只保存当前回调的结果,历史窗口由订阅指标引擎负责。
currentIndicators = dict(STRING,ANY)
for (sym in syms){
    bar = msg[sym]
    if (not (sym in indicator.keys())) throw "Gold RSI: 未收到订阅指标:"+sym
    s = readIndicator(bar,indicator[sym],context)
    currentIndicators[sym] = s
}

Step 2:通过 tradingDay 查找当日主力合约。主力变化时记录事件,并更新目标合约;实际持仓仍通过 heldSymbol 和引擎持仓确认。

// step 2:根据 tradingDay 读取当日目标合约。
sampleBar = msg[syms[0]]
// 按行情交易日查目标合约,不能直接用自然日期替代夜盘所属交易日。
td = sampleBar.tradingDay
if (not (td in context.mainByDay.keys())) throw "Gold RSI: 缺少交易日主力映射:"+string(td)
main = context.mainByDay[td]
if (context.activeMain != main){
    // 更新目标合约不表示已换仓;实际旧仓仍由 heldSymbol 和引擎持仓确认。
    context.activeMain = main
}

Step 3:换月优先处理旧仓。缺少旧合约当前分钟行情时等待;平仓拒单或尚未全部成交时返回。旧仓实际归零后继续执行,因此同一次回调可以判断新主力信号。

// step 3:换月先平旧仓,实际空仓后继续检查新合约。
// 换月用旧合约当前分钟行情平仓;缺少该行情时记录等待并结束本次回调。
if (context.heldSymbol != "" and context.heldSymbol != main){
    old = context.heldSymbol
    if (not (old in syms)){return}
    if (not closePosition(context,msg[old],"roll_exit")) return
    // 此处没有 return:同一交易日、同一次回调继续判断新主力穿越信号。
}

Step 4:读取新主力的当前行情及指标,检查已有持仓。存在待退出状态时继续平仓并返回,即使价格恢复到吊灯线内,也不取消已触发的退出决定。然后根据实际持仓方向选择候选线,多头取前值与候选值的最大值,空头取最小值。先更新吊灯线,再用收盘价严格越线判断退出;已有持仓流程在此返回,避免普通退出当根重新开仓。

// step 4:取得目标合约行情,优先重试尚未完成的退出。单向更新持仓吊灯线,收盘价严格越线时平仓。
if (not (main in syms)){return}
bar = msg[main]
s = currentIndicators[main]
if (context.heldSymbol != ""){
    p = getPositionQty(context.engine,main)
    if (p[0]+p[1]==0) clearHeldPosition(context)
    else {
          // 待平仓状态持续重试,不能因价格恢复到线内而撤销退出决定。
          if (context.exitPending){closePosition(context,bar,"exit_retry");return}
          side = iif(p[0]>0,1,-1)
          previous = context.trail
          candidate = iif(side==1,s.longCandidate,s.shortCandidate)
      // 候选线可双向波动,但持仓多头线只能抬高,空头线只能降低。
      if (side==1) context.trail = max(previous,candidate)
      else context.trail = min(previous,candidate)
          // 先收紧线,再用收盘价严格越线判断;等于吊灯线不触发退出。
          stop = iif(side==1,bar.close<context.trail,bar.close>context.trail)

          if (stop) closePosition(context,bar,"chandelier_exit")
          // 已有仓位时,反向 RSI 不单独触发反手;退出当根也不重新开仓。
          return
      }
  }

Step 5:空仓时仅根据当前根的新穿越信号开仓。换月不强制复制旧仓方向,没有新信号、指标尚未预热或资金不足时保持空仓。

// step 5:空仓时按当前根 RSI 穿越和 MA 过滤信号开仓。
// 只有空仓流程能到这里;换月成功平旧后也可在当前根直接检查新合约信号。
if (s.longSignal) openPosition(context,bar,s,1)
else if (s.shortSignal) openPosition(context,bar,s,-1)

2.3 设置回测参数

securityReferences 提供合约乘数、保证金率、手续费及最后交易日。字段说明参考 Backtest 期货基本信息表。

表 4. 表 2-4 合约参数
参数 样例值 含义

multiplier

1000

每手价格变动 1 元对应的人民币损益

priceTick

0.02

合法价格步长

marginRatio

1.0

合成样例使用全额资金占用

commission

10 元/手

开仓及普通平仓手续费

intradayClosingFee

10 元/手

平今手续费

deliveryCommissionMode

1

按手收费

lastTradingDay

2024.12.31

合成样例的测试配置

历史回测需替换为各合约对应的实际参数,并将行情限定在其有效交易区间内。本例使用静态合约表;费用或保证金随日期变化时,需要相应扩展输入和参数配置。

策略默认参数如下:

表 5. 表 2-5 策略参数
参数 默认值 含义

initialCash

1,000,000

初始资金,单位元

leverage

1.0

开仓名义本金相对权益的上限

contractMultiplier

1000.0

与合约表中的乘数一致

cashReserve

0.01

名义预算保留 1% 余量

warmupBars

40

每个合约禁止入场的预热根数

rsiPeriod / maPeriod / atrPeriod

10 / 30 / 10

各项指标的 K 线周期

chandelierLookback / atrMultiple

10 / 2.0

吊灯极值窗口及 ATR 倍数

longThreshold / shortThreshold

60 / 15.7143

多头上穿及空头下穿阈值

barIntervalMs

60000

记录输入为 1 分钟,不执行聚合或频率校验

设当前权益为,可用资金为,价格为,合约乘数为,保证金率为,每手开仓费为。开仓手数为:

q t = ⌊ min ( E t ⋅ l ⁢ e ⁢ v ⁢ e ⁢ r ⁢ a ⁢ g ⁡ e ⋅ ( 1 − c ⁢ a ⁢ s ⁢ h ⁢ R ⁢ e ⁢ s ⁢ e ⁢ r ⁢ v ⁢ e ) P t M + f ⁡ , A t P t M ⁢ m + f ⁡ ) ⌋ .

配置分为两层:外层设置回测时间、行情类型和撮合方式,context 传入策略参数及查询字典mainByDay 由主力映射构造,references 由合约表按合约代码构造。

cfgContext = {mainByDay:mainByDay,references:references,contractMultiplier:contractMultiplier,
    leverage:leverage,cashReserve:cashReserve,warmupBars:warmupBars,rsiPeriod:rsiPeriod,
    maPeriod:maPeriod,atrPeriod:atrPeriod,chandelierLookback:chandelierLookback,
    atrMultiple:atrMultiple,longThreshold:longThreshold,shortThreshold:shortThreshold}
// dataType=3 使用输入的分钟 K 线;matchingMode=2 在当前根收盘价同步撮合。
// frequency=0 不再合成 K 线,实际 1 分钟周期由 bars.csv 保证。
// enableSettlementPrice=false:本例不另行输入结算价,使用 K 线价格估值。
config = {startDate:min(bars.tradingDay),endDate:max(bars.tradingDay),strategyGroup:"futures",
    cash:initialCash,dataType:3,matchingMode:2,frequency:0,latency:0,
    enableSettlementPrice:false,context:cfgContext}

dataType=3 使用分钟 K 线,matchingMode=2 在行情与订单时间相同时按本根收盘价撮合。enableSettlementPrice=false 使用行情价格计算净值。配置说明见 DolphinDB 期货回测配置。

2.4 创建回测引擎并执行回测

准备好数据、函数及配置后,使用 createBacktester 创建引擎,再提交分钟行情并调用 appendEndMarker 结束回放。

callbacks = {initialize:initialize,onBar:onBar}
strategyName = "Gold_RSI_Chandelier"
engine = Backtest::createBacktester(strategyName,config,callbacks,false,securityReference)
Backtest::appendQuotationMsg(engine,bars)
Backtest::appendEndMarker(engine)

第四个参数 false 表示关闭 JIT,securityReference 用于计算持仓、资金和费用。创建引擎时,initialize 完成指标订阅,随后才开始回放,包括新旧合约的预热行情。

2.5 获取回测结果

回放结束后,从成交、权益、持仓及策略日志几个维度检查结果:

finalContext = Backtest::getContextDict(engine)
tradeDetails = Backtest::getTradeDetails(engine)
// 委托明细含提交、撤单等生命周期行;
fills = select * from tradeDetails where orderStatus in [0,1],tradeQty>0
dailyEquity = Backtest::getDailyTotalPortfolios(engine)
dailyPosition = Backtest::getDailyPosition(engine)
finalPosition = Backtest::getPosition(engine)
returnSummary = Backtest::getReturnSummary(engine)

各结果表说明如下:

表 6. 表 2-6 结果表说明
结果表 内容

tradeDetails / fills

委托生命周期记录及筛选出的有效成交

dailyEquity / returnSummary

日度权益、累计费用及收益统计

dailyPosition / finalPosition

每日持仓及期末实际多空数量

tradeDetails 包含提交、撤单、成交等记录,不能用总行数作为成交笔数。

3. 总结

本案例实现了黄金期货 RSI 吊灯止盈策略的回测流程:加载已完成的分钟行情,按合约订阅 RSI、MA 和 ATR 指标,以 RSI 穿越和均线方向确定入场,持仓期间单向更新吊灯线,再读取成交、费用、持仓和账户权益。

策略实现涵盖以下要点:

  • 指标订阅: 各月份合约独立预热,回调读取当前订阅结果。

  • 信号判断: 使用严格穿越和均线过滤,避免阈值同侧重复入场。

  • 持仓管理: 吊灯线按单次持仓维护,退出未完成时持续重试。

  • 换月处理: 先确认旧仓归零,再评估新合约当前信号。

  • 交易约束: 根据权益和可用资金确定整数手,区分今昨仓平仓及手续费。

扩展时,可以调整 RSI 阈值、指标周期和 ATR 倍数,观察交易频率、持仓时间与回撤的变化;也可以替换为其他期货品种,同时更新合约乘数、保证金和手续费。