期货分钟线与 Tick 趋势策略

本策略以逐笔快照为输入,将 tick 数据合成为 1 分钟 bar 后开展日内交易:用 tick 数据合成 1 分钟 bar,在每根 bar 上同时看相对强弱指标(Relative Strength Index,RSI) 的超买超卖与布林带通道的相对位置,顺势开多或开空,并随单挂出止损与止盈,属于典型的日内趋势跟随做法。

1. 策略介绍

本策略交易频繁、持仓时间短,采用‘顺势开仓、按固定盈亏比出场’的交易方式。入场依据为 RSI 与布林带的组合条件,出场由随单设置的止损止盈触发;策略不预测隔夜走势,也不持有隔夜仓位。

1.1 核心因子

两个指标都在合成 bar 上计算,只用当前及之前已完成的 bar:

RSI=100×平均涨幅平均涨幅+平均跌幅,布林带=(mid+2σ,mid,mid−2σ)

其中 RSI 取 11 期、平均涨跌幅用 Wilder 平滑;布林带以 20 期指数移动平均线(Exponential Moving Average,EMA)为中轨,并以中轨加减 2 倍标准差确定上下轨。RSI、stddev、bBands_ 三个函数都用 @state 声明,表示它们是有状态的流式计算函数,引擎按 bar 增量推送其计算结果。

开多、开空与价差过滤规则如下:

  • RSI 高于 70(超买)且收盘价高于布林上轨且收盘价高于开盘价,三者同时成立才考虑开多。

  • RSI 低于 30(超卖)且收盘价低于布林上轨且收盘价低于开盘价,三者同时成立才考虑开空。

  • 盘口价差大于 maxSpread(0.9)时整根 bar 跳过,用作异常行情的保护。

1.2 交易标的

  • EURUSD6:单一模拟外汇合约,快照里的 symbolSource 为 XDCE。

期货账户按保证金交易,可持有多仓与空仓,每笔委托 2 手。回测以逐笔快照为输入,引擎按 frequency = 60000(毫秒)将快照合成为 1 分钟 bar,再触发策略。因此,策略处理的是 bar 数据,而非单笔行情。合约是模拟品种,不代表任何真实期货或外汇合约,也未经真实换月、保证金调整与交割制度处理。

1.3 交易规则

  1. onBar 收到当根合成 bar,先取卖一价与买一价,算出盘口价差;价差大于 maxSpread 直接返回,不在该 bar 上做任何判断。

  2. 取订阅的 RSI 值,为空(预热不足)时返回。

  3. 若 RSI 高于 70、收盘价高于布林上轨、收盘价高于开盘价,且当前持有多空仓位均不大于 0,则以 卖一价 + 0.0002(滑点)开多 2 手。

  4. 若 RSI 低于 30、收盘价低于布林上轨、收盘价低于开盘价,且当前多空持仓均小于 1 手,则以 买一价 - 0.0002 开空 2 手。

  5. 多头和空头开仓委托均为算法单:多头单挂止损 卖一价 - 0.001、止盈 卖一价 + 0.01;空头单挂止损 买一价 + 0.001、止盈 买一价 - 0.01。委托在 barTime + 36000000 后失效,未成交部分自动撤销。

  6. 平仓只由止损或止盈触发:策略本身没有主动平仓分支,也没有按时间强平的逻辑;持仓未平时后续 bar 不再开新仓。

2. 策略回测

本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 与模拟撮合引擎插件 3.00.6.6 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前,代码会检查插件是否已安装,并加载这两个插件。

2.1 准备数据

下载附录中 futuresTick_tickTrend.csv 文件,作为回测的案例数据。导入数据前,请先将 loadText 调用中的路径 tickRaw = loadText("/.../futuresTick_tickTrend.csv") 替换为 CSV 文件的实际存储路径,再执行下方代码。

表 1. 表 2-1 模拟行情数据表结构
列名 类型 含义
symbol、symbolSource STRING 标的代码与来源标记,本文件为 “EURUSD6”、“XDCE”
timestamp、tradingDay TIMESTAMP、DATE 快照时间、所属交易日
lastPrice DOUBLE 最新价,合成 1 分钟 bar 时作为收盘价来源
bidPrice1-bidPrice5 DOUBLE 买盘 1 至 5 档价格
offerPrice1-offerPrice5 DOUBLE 卖盘 1 至 5 档价格
bidQty1-bidQty5 LONG 买盘 1 至 5 档数量
offerQty1-offerQty5 LONG 卖盘 1 至 5 档数量
totalBidQty、totalOfferQty LONG 买卖盘总委托量
highPrice、lowPrice DOUBLE 卖一价、买一价(盘口高低口径:卖一价 / 买一价)
signal1、signal2 DOUBLE 前一笔买一价、卖一价,对应引擎行情表中约定的 signal 数组列
prevClosePrice、settlementPrice、prevSettlementPrice DOUBLE 前收盘价、结算价、前结算价
open、close、low、high DOUBLE 该笔快照的开高低收
volume LONG 该笔快照的成交量

行情按固定的 5 档盘口排列,档位越高价格越差、数量越大,卖一价始终高于买一价,脚本读到的价差约为 0.0002。

脚本读取该文件后把列名与类型对齐到引擎要求,并把 5 档买卖价还原成数组列:

tickRaw = loadText("/.../futuresTick_tickTrend.csv")
tickData = select symbol$SYMBOL as symbol, symbolSource$SYMBOL as symbolSource,
        timestamp(timestamp) as timestamp, date(tradingDay) as tradingDay,
        double(lastPrice) as lastPrice, double(upLimitPrice) as upLimitPrice,
        double(downLimitPrice) as downLimitPrice, long(totalBidQty) as totalBidQty,
        long(totalOfferQty) as totalOfferQty,
        fixedLengthArrayVector(double(bidPrice1), double(bidPrice2), double(bidPrice3), double(bidPrice4), double(bidPrice5)) as bidPrice,
        fixedLengthArrayVector(long(bidQty1), long(bidQty2), long(bidQty3), long(bidQty4), long(bidQty5)) as bidQty,
        fixedLengthArrayVector(double(offerPrice1), double(offerPrice2), double(offerPrice3), double(offerPrice4), double(offerPrice5)) as offerPrice,
        fixedLengthArrayVector(long(offerQty1), long(offerQty2), long(offerQty3), long(offerQty4), long(offerQty5)) as offerQty,
        double(highPrice) as highPrice, double(lowPrice) as lowPrice,
        fixedLengthArrayVector(double(signal1), double(signal2)) as signal,
        double(prevClosePrice) as prevClosePrice, double(settlementPrice) as settlementPrice,
        double(prevSettlementPrice) as prevSettlementPrice,
        double(open) as open, double(close) as close, double(low) as low, double(high) as high,
        long(volume) as volume
    from tickRaw order by timestamp

2.2 编写策略逻辑

脚本由指标定义、策略回调和引擎调用三部分组成。指标通过 @state 声明为流式函数。在策略回调中,initialize 负责设置参数和订阅指标,onBar 负责交易判断,其余回调按接口要求保留。

指标函数

  • RSI(close, timePeriod=14):对收盘价差分后分别累计上涨与下跌,用 wilder 平滑得到平均涨跌幅,返回 0 至 100 的 RSI。以 defg 声明,配合 @state 保证跨 bar 增量计算。策略中调用的是 RSI(lastPrice, 11)。

  • stddev(close, timePeriod=5, nbdev=1):返回滚动标准差乘以倍数,供布林带使用。

  • bBands_(close, timePeriod=5, nbdevUp=2, nbdevDn=2, maType=0):中轨取 EMA,上下轨取中轨加减 nbdevUp/nbdevDn 倍标准差,返回三元组(上轨、中轨、下轨)。策略中调用的是 bBands_(lastPrice, 20, 2, 2, 0)。

@state
defg RSI(close, timePeriod=14) {
    deltaClose = deltas(close)
    up = iif(nullCompare(>, deltaClose, 0), deltaClose, 0)
    down = iif(nullCompare(>, deltaClose, 0), 0, -deltaClose)
    upAvg = wilder(up, timePeriod)
    downAvg = wilder(down, timePeriod)
    return 100.0 * upAvg / (upAvg + downAvg)
}
@state
def stddev(close, timePeriod=5, nbdev=1) {
    return sqrt(var(close, timePeriod, nbdev)) * nbdev
}
@state
def bBands_(close, timePeriod=5, nbdevUp=2, nbdevDn=2, maType=0) {
    mid = ema(close, timePeriod)
    md = stddev(close, timePeriod, 1)
    return ((mid + nbdevUp * md), mid, (mid - nbdevDn * md))
}

初始化回调:initialize(mutable contextDict)

写入四个策略参数,并把 RSI、布林带上中下轨四条流式指标注册到引擎的 snapshot_kline 数据流上。注册后引擎会在每根合成 bar 上推送指标值,策略在 onBar 里通过 indicator[0] 读取;除 RSI 与上轨外,中轨与下轨也已订阅,便于后续按需要扩展判断条件。

def initialize(mutable contextDict) {
    contextDict["Slippage"] = 0.0002          // 委托价相对盘口的滑点
    contextDict["tp"] = 0.01                  // 止盈距离
    contextDict["sl"] = 0.001                 // 止损距离
    contextDict["maxSpread"] = 0.9            // 允许的最大盘口价差
    // 订阅基于合成 bar 的指标:RSI(11)、布林带(20, 2) 的上轨 / 中轨 / 下轨
    d = dict(STRING, ANY)
    d["rsi"] = <RSI(lastPrice, 11)>
    d["bhigh"] = <bBands_(lastPrice, 20, 2, 2, 0)[0]>
    d["bmid"] = <bBands_(lastPrice, 20, 2, 2, 0)[1]>
    d["blow"] = <bBands_(lastPrice, 20, 2, 2, 0)[2]>
    Backtest::subscribeIndicator(contextDict["engine"], "snapshot_kline", d)
}

行情回调:onBar(mutable contextDict, msg, indicator=NULL)

msg 是当根合成 bar 的行情,msg[0] 取出本条记录;indicator 是同一根 bar 上的指标值,indicator[0] 取出对应的指标记录。回调按「价差检查 → 指标预热检查 → 开多判断 → 开空判断」的顺序执行,两个方向互斥,任一方向下单后立即返回。

def onBar(mutable contextDict, msg, indicator=NULL) {
    msg_ = msg[0]
    askPrice0 = msg_.offerPrice[0]            // 卖一价
    bidPrice0 = msg_.bidPrice[0]              // 买一价
    spread = askPrice0 - bidPrice0
    // step 1:价差检查,价差异常时跳过本根 bar
    if (spread > contextDict["maxSpread"]) { return }
    indicator_ = indicator[0]
    rsi_ = indicator_.rsi
    if (isNull(rsi_)) { return }              // 指标预热不足
    bhigh = indicator_.bhigh
    close = msg_.lastPrice
    open = msg_.open
    symbolSource = msg_.symbolSource
    istock = msg_.symbol
    position = Backtest::getPosition(contextDict["engine"], istock)[0]
    longpos = position.longPosition
    shortpos = position.shortPosition
    // step 2:超买且突破上轨、收盘高于开盘 → 开多
    if (rsi_ > 70. and close > bhigh and close > open) {
        if (longpos > 0 or shortpos > 0) { return }
        Backtest::submitOrder(contextDict["engine"], (istock, symbolSource,
            contextDict["barTime"], 5, askPrice0 + contextDict["Slippage"],
            askPrice0 - contextDict["sl"], askPrice0 + contextDict["tp"],
            2, 1, contextDict["Slippage"], 0, contextDict["barTime"] + 36000000), "openBuy", 5)
        return
    }
    // step 3:超卖且收盘低于开盘 → 开空
    if (rsi_ < 30. and close < bhigh and close < open) {
        if (longpos >= 1 or shortpos >= 1) { return }
        Backtest::submitOrder(contextDict["engine"], (istock, symbolSource,
            contextDict["barTime"], 5, bidPrice0 - contextDict["Slippage"],
            bidPrice0 + contextDict["sl"], bidPrice0 - contextDict["tp"],
            2, 2, contextDict["Slippage"], 0, contextDict["barTime"] + 36000000), "openSell", 5)
        return
    }
}

委托元组的字段依次为:标的、来源、时间、订单类型(5 表示算法单)、委托价、止损价、止盈价、数量、方向(1 开多、2 开空)、滑点、有效期参数、失效时间,最后两个实参依次是委托标签与订单类型。开多的委托价取卖一价加上滑点、开空取买一价减去滑点,保证挂单方向不会逆势;止损止盈按前述距离对称布置。

空头条件里用的是布林上轨、多头条件里也用的是上轨,两者并不对称:由于收盘价绝大多数时候都低于上轨,“收盘价低于上轨”这一条对空头几乎没有过滤作用,因此策略在模拟数据上明显偏空。本次回测保留这一取值不做修正,接入实盘前需要确认这是否为笔误,以及是否应改为下轨。

其余回调:beforeTrading、onSnapshot、onOrder、onTrade、afterTrading、finalize 均为空实现,由引擎接口要求保留;其中 onSnapshot 在 callbackForSnapshot = 2 时不会被调用,保留它是为了满足引擎对回调参数的校验。委托成交与撤单信息通过 Backtest::getTradeDetails 查询,不需要在回调里处理。

2.3 设置回测参数

userConfig = dict(STRING, ANY)
userConfig["startDate"] = 2019.01.02
userConfig["endDate"] = 2019.01.31
userConfig["strategyGroup"] = "futures"
userConfig["exchange"] = "universal"          // 通用交易所口径,快照按 frequency 合成 bar
userConfig["frequency"] = 60000               // 合成 1 分钟 bar
userConfig["dataType"] = 1                    // 快照(tick)行情
userConfig["depth"] = 5                       // 盘口档数,与行情文件中的 5 档买卖价一致
userConfig["enableAlgoOrder"] = true          // 需要算法单(委托带止损止盈)
userConfig["latency"] = 1                     // 撮合延迟(毫秒)
userConfig["cash"] = 10000000                 // 初始资金 1000 万
userConfig["commission"] = 0.0                // 手续费按合约信息表计收
userConfig["tax"] = 0.0                       // 期货不设印花税
userConfig["msgAsTable"] = true               // 回调收到的行情以表形式传递
userConfig["callbackForSnapshot"] = 2         // 2 表示只触发 onBar(不用 onSnapshot)
  • startDate / endDate:DATE 类型,2019.01.02 与 2019.01.31,与行情数据的时间范围一致。

  • strategyGroup:STRING 类型,"futures",按期货账户规则撮合与计费,可持多空双向仓位。

  • exchange:STRING 类型,"universal",以通用交易所口径把快照合成 bar。

  • frequency:INT 类型,60000,合成 bar 的周期(毫秒),即 1 分钟。

  • dataType:INT 类型,1,输入快照(tick)行情,行情表需要 5 档买卖价数组列。

  • depth:INT 类型,5,盘口档数,与行情文件中的 5 档买卖价一致;取值区间为 5 至 50。

  • enableAlgoOrder:BOOL 类型,true,本策略的委托带止损止盈、订单类型为 5,必须开启。

  • latency:INT 类型,1,从委托到撮合的延迟(毫秒)。

  • cash:INT 类型标量,10000000,初始资金 1000 万元。

  • commission / tax:DOUBLE 类型,均为 0.0;本策略的手续费由合约信息表的 commission 字段(每手 1.5)计收,期货不设印花税。

  • msgAsTable:BOOL 类型,true,回调收到的行情以表形式传入。

  • callbackForSnapshot:INT 类型,2,只触发 onBar;0 表示只触发 onSnapshot,1 表示两者都触发。

上下文中的四个策略参数由 initialize 写入:Slippage 0.0002 为委托价相对盘口的滑点,tp 0.01 与 sl 0.001 分别是止盈、止损距离,maxSpread 0.9 为允许的最大盘口价差。合约乘数、保证金率、最小变动单位与每手手续费不在 config 中,而是通过下面这张合约信息表传入引擎:

// 合约信息:乘数 / 保证金率 / 最小变动单位 / 手续费
symbolList = distinct(tickData.symbol)
basicInfo = table(symbolList as symbol,
        take(100., size(symbolList)) as multiplier,
        take(0.2, size(symbolList)) as marginRatio,
        take(0.01, size(symbolList)) as tradeUnit,
        take(0.02, size(symbolList)) as priceUnit,
        take(0.03, size(symbolList)) as priceTick,
        take(1.5, size(symbolList)) as commission,
        take(1, size(symbolList)) as deliveryCommissionMode)
  • multiplier:DOUBLE 类型,100.0,合约乘数,决定一手对应的标的数量。

  • marginRatio:DOUBLE 类型,0.2,保证金率。

  • tradeUnit、priceUnit、priceTick:DOUBLE 类型,分别取值 0.01、0.02、0.03,用于最小变动单位与报价精度相关校验。

  • commission:DOUBLE 类型,1.5,每手手续费。

  • deliveryCommissionMode:INT 类型,1,交割手续费模式。

2.4 创建回测引擎

engineName = "futuresTickTrendStrategy"
try { Backtest::dropBacktestEngine(engineName) } catch(ex) { }
engine = Backtest::createBacktestEngine(engineName, userConfig, basicInfo, initialize,
        beforeTrading, onBar, onSnapshot, onOrder, onTrade, afterTrading, finalize)
go

引擎名称在同一会话内不能重复,创建前先用 Backtest::dropBacktestEngine 清理同名引擎。

2.5 执行回测

Backtest::appendQuotationMsg(engine, tickData)
Backtest::appendEndMarker(engine)

appendQuotationMsg 把 44,000 笔快照按时间顺序推给引擎,引擎按 frequency 合成 1 分钟 bar 并在每根 bar 上触发 onBar;appendEndMarker 表示行情结束、引擎开始收尾,同时把仍然挂单的算法单作废。与逐笔追加不同,这里一次性传入整张表,由引擎自己完成按时间推进与 bar 合成。

2.6 获取回测结果

DailyPosition = Backtest::getDailyPosition(engine)
DailyTotalPortfolios = Backtest::getDailyTotalPortfolios(engine)
TradeDetails = Backtest::getTradeDetails(engine)
ReturnSummary = Backtest::getReturnSummary(engine)
  • TradeDetails:委托与成交明细,含orderId、symbol、direction、sendTime、orderPrice、orderQty、tradeTime、tradePrice、tradeQty、orderStatus、label。策略主动提交的委托带 openBuy/openSell 标签,而由止损、止盈触发的成交没有标签(label 为空),因此按标签统计时会出现一行空标签,它对应的正是算法单的平仓成交。

  • ReturnSummary:收益概述,含 totalReturn、annualReturn、annualVolatility、sharpeRatio、maxDrawdown、dailyWinningRate 等;期货账户还包含maxMarginRatio(区间内最高保证金占用比例,以比率而非百分数表示)。未设置基准时 beta、alpha、annualExcessReturn、benchmarkReturn 为 0。

  • DailyPosition:逐日持仓,共 22 行,可核对每个交易日结束后的多空持仓与持仓盈亏。

  • DailyTotalPortfolios:逐日组合权益、保证金占用与可用资金,共 22 行。

3. 回测结果分析

本节结果来自 DolphinDB Server 3.00.6(Linux x86_64)上的实际运行。行情为上述模拟快照数据(44,000 笔、22 个交易日、2019-01-02 至 2019-01-31),初始资金 1000 万元,每笔 2 手、合约乘数 100、保证金率 0.2、每手手续费 1.5,快照按 60,000 毫秒合成 1 分钟 bar,撮合延迟 1 毫秒,每次委托都随单挂出止损 0.001 与止盈 0.01。

表 2. 表 3-1 回测结果概览
指标 数值
区间总收益 -0.0018%
年化收益 -0.0203%
年化波动率 0.0060%
最大回撤 0.0018%
夏普比率 -3.3839
最高保证金占用 5.96e-08
换手率 0%
日胜率 54.55%

成交方面,委托与成交明细共 120 行,其中实际成交 60 笔:带 openSell 标签 29 笔(58 手)、带 openBuy 标签 1 笔(2 手),另有 30 笔(60 手)没有标签——这些是止损或止盈触发的平仓成交。逐日持仓与逐日权益各 22 行,与 22 个交易日对应。买卖笔数明显偏向空头,原因在第 2.2 节已经说明:空头条件里的“收盘价低于布林上轨”几乎不会构成约束,于是只要 RSI 低于 30 且收盘低于开盘就会开空,而多头要求同时突破上轨,触发机会少得多。

收益与风险指标的量级要结合合约规模阅读:每笔 2 手、乘数 100、价格约 1.1,单笔名义规模约 220 元,相对 1000 万元本金可以忽略,因此最高保证金占用只有 5.96e-08,区间总收益 -0.0018%、年化波动率 0.0060% 都极小,夏普比率为 -3.3839 只是“收益为负而波动更小”的结果。本次回测结果仅用于验证完整回测流程能否顺利执行,不能用于评价该策略在真实期货或外汇品种上的表现。

4. 总结

策略在逐笔快照合成的 1 分钟 bar 上做日内突破交易:用 RSI(11) 判断超买超卖、用布林带(20, 2) 的上轨判断通道位置,同时要求收盘价相对开盘价的方向一致,满足则市价开出 2 手多单或空单,并把止损(0.001)与止盈(0.01)随单挂出,委托 1 小时后失效;持仓未平时不再开新仓,平仓全部由止损止盈触发。

使用时需要注意三点限制。

  • 行情是模拟数据,价格路径由随机字段重建、日内波动远大于真实品种,本次收益与回撤只是流程验证的产物,不能外推。

  • 收益量级与合约规模强相关:每笔 2 手、乘数 100,相对 1000 万本金过小,若要评估成本与盈亏的敏感性,需要按真实合约规模重新设置手数与初始资金。

  • 开多与开空的条件不对称(空头判断里用的是布林上轨而非下轨),这会让策略先天偏空;本次回测保留原取值不做修正,接入实盘前需要确认是否为笔误。