带你学习硬核策略 之 「C++ 版 OKEX合约对冲策略」

Author: 小小梦, Created: 2019-08-26 14:30:47, Updated: 2023-10-19 21:09:01

img

带你学习硬核策略 之 「C++ 版 OKEX合约对冲策略」

说起对冲策略,在各个市场中有多种多样的策略、多种多样的组合、多种多样的思路。我们从最经典的跨期对冲来初探对冲策略的设计思路、理念。如今,数字货币市场活跃度远比市场形成之初高了很多,也出现了很多合约交易所,提供了大量可套利对冲的机会。现货跨市场套利、期现对冲套利、期货跨期套利、期货跨市场套利等等,策略层出不穷。下面我们就一起 领略一个 由C++语言编写,交易市场 为OKEX合约交易所的「硬核」跨期对冲策略,策略基于「发明者量化交易平台」编写。

  • 策略原理

    为什么说策略有些硬核,原因在于 策略使用C++ 语言编写,策略阅读难度略高。但是并不妨碍读者学习到这个策略设计、思路方面的精华。策略通篇较为简洁,代码长度适中,只有500多行。行情数据获取方面,不同于以往策略使用 rest 接口,该策略使用了websocket接口,接受交易所行情推送。 设计方面,策略结构合理,代码耦合度很低,很方便扩展或者优化。逻辑思路清晰,这样的设计不仅使用、扩展很方便。作为教学策略,学习策略设计也是一个很好的范例。策略原理比较简单,即 远期合约 和近期合约 做正、反套对冲,基本原理上和商品期货的跨期对冲一致。 正套,做空远期合约,做多近期合约。 反套,做多远期合约,做空近期合约。 清楚了基本原理,剩下的就是策略如何触发对冲开仓,如何平仓,如何加仓。仓位控制方式,策略细节处理。 对冲策略主要关注的是标的物差价的波动,对差价做回归交易。然而,差价是有可能小幅震荡,或者大幅震荡,或者单边一个方向。 这就带来了对冲盈亏的不确定性,但是风险还是远小于单边趋势的。对于跨期策略的各种优化很多都是选择从仓位控制层面入手,从开仓平仓触发上入手。例如经典的用布林指标做为差价波动时,正套、反套的开仓、平仓点。本策略由于设计合理、耦合度很低,也可以很容易的修改成布林指标跨期对冲策略。

  • 策略代码剖析

    通篇大概看一下代码,可以总结出,代码大概主要分为四个部分。

    • 1.枚举值定义,定义一些状态值,用于标记状态。一些和策略思路无关的功能函数,例如 url 编码函数,时间转换函数等,这些函数和策略思路没有关系,仅仅是对于数据做处理使用。
    • 2.K线数据生成器类:策略由该生成器类对象生成的K线数据驱动。
    • 3.对冲类:该类的对象可以执行具体的交易逻辑,对冲操作、策略细节的处理机制等。
    • 4.策略主函数,也就是 main 函数。main 函数是策略的入口函数,主要循环在此函数内执行,此外这个函数内还执行一个重要的操作,即访问交易所的websocket 接口,获取推送来的tick行情数据,作为K线数据生成器的原料数据。

    通过对策略代码整体上的了解,我们下面就可以通过逐步剖析各个环节,从而完整的学习该策略的设计、思路、技巧。

    • 枚举值定义,其它功能函数

      1、枚举类型 State 声明

      enum State {                    // 枚举类型  定义一些 状态
          STATE_NA,                   // 非正常状态
          STATE_IDLE,                 // 空闲
          STATE_HOLD_LONG,            // 持多仓
          STATE_HOLD_SHORT,           // 持空仓
      };
      

      因为代码中有些函数返回某个状态,所以这些状态都定义在了枚举类型 State中。 看到代码中出现 STATE_NA 即为 非正常状态, STATE_IDLE 为空闲状态,即可以对冲操作的状态。 STATE_HOLD_LONG 为持有正套对冲仓位的状态。 STATE_HOLD_SHORT 为持有反套对冲仓位的状态。

      2、字符串替换,本策略中没有调用,算是一个备用的工具函数,主要处理字符串。

      string replace(string s, const string from, const string& to)   
      

      3、功能为转换为十六进制字符的函数 toHex

      inline unsigned char toHex(unsigned char x)
      

      4、处理url编码的函数

      std::string urlencode(const std::string& str)
      

      5、时间转换函数,把字符串格式的时间转换为时间戳。

      uint64_t _Time(string &s)
      
    • K线数据生成器类

      class BarFeeder {                                                                       // K线 数据生成器类
          public:
              BarFeeder(int period) : _period(period) {                                       // 构造函数,参数为 period 周期, 初始化列表中初始化
                  _rs.Valid = true;                                                           // 构造函数体中初始化 K线数据的 Valid属性。
              }    
      
              void feed(double price, Chart *c=nullptr, int chartIdx=0) {                     // 输入数据,nullptr 空指针类型,chartIdx 索引默认参数为 0
                  uint64_t epoch = uint64_t(Unix() / _period) * _period * 1000;               // 秒级时间戳祛除不完整时间周期(不完整的_period 秒数),转为 毫秒级时间戳。
                  bool newBar = false;                                                        // 标记 新K线Bar 的标记变量
                  if (_rs.size() == 0 || _rs[_rs.size()-1].Time < epoch) {                    // 如果 K线数据 长度为 0 。 或者 最后一bar 的时间戳小于 epoch(K线最后一bar 比当前最近的周期时间戳还要靠前)
                      Record r;                                                               // 声明一个 K线bar 结构
                      r.Time = epoch;                                                         // 构造当前周期的K线bar 
                      r.Open = r.High = r.Low = r.Close = price;                              // 初始化 属性
                      _rs.push_back(r);                                                       // K线bar 压入 K线数据结构
                      if (_rs.size() > 2000) {                                                // 如果K线数据结构长度超过 2000 , 就剔除最早的数据。
                          _rs.erase(_rs.begin());
                      }
                      newBar = true;                                                          // 标记
                  } else {                                                                    // 其它情况,不是出现新bar 的情况下的处理。
                      Record &r = _rs[_rs.size() - 1];                                        // 引用 数据中最后一bar 的数据。
                      r.High = max(r.High, price);                                            // 对引用数据的最高价更新操作。
                      r.Low = min(r.Low, price);                                              // 对引用数据的最低价更新操作。
                      r.Close = price;                                                        // 对引用数据的收盘价更新操作。
                  }
          
                  auto bar = _rs[_rs.size()-1];                                               // 取最后一柱数据 ,赋值给 bar 变量
                  json point = {bar.Time, bar.Open, bar.High, bar.Low, bar.Close};            // 构造一个 json 类型数据
                  if (c != nullptr) {                                                         // 图表对象指针不等于 空指针,执行以下。
                     if (newBar) {                                                            // 根据标记判断,如果出现新Bar 
                          c->add(chartIdx, point);                                            // 调用图表对象成员函数add,向图表对象中插入数据(新增K线bar)
                          c->reset(1000);                                                     // 只保留1000 bar的数据
                      } else {
                          c->add(chartIdx, point, -1);                                        // 否则就更新(不是新bar),这个点(更新这个bar)。
                      } 
                  }
              }
              Records & get() {                                                               // 成员函数,获取K线数据的方法。
                  return _rs;                                                                 // 返回对象的私有变量 _rs 。(即 生成的K线数据)
              }
          private:
              int _period;
              Records _rs;
      };
      

      这个类主要就是负责把获取到的 tick 数据加工成差价K线,用于驱动策略对冲逻辑。 可能有的读者有疑问,为什么要用 tick 数据呢?为什么要构造一个 这样的K线数据生成器呢?直接用K线数据不好么?这样的疑问三连发,在我当初写一些对冲策略的时候也有迸发出来过。在写过差价布林对冲策略时就找到了答案。由于单个合约的K线数据是这一个合约在一定周期内的价格变化统计。 而两个合约的差价 的K线数据则是在一定周期内的差价价格变化统计,因此不能简单的拿两个合约各自的K线数据做减法运算,计算每根K线Bar上各个数据的差值,当做差价。这样最明显的错误就例如,两个合约的最高价、最低价,并不一定是同一时刻的。所以相减出来的数值没有太大意义。 因此我们需要使用实时的tick数据,实时计算差价,实时统计成一定周期内的价格变动(即K线柱上的高开低收)。这样我们就需要一个K线数据生成器,单独作为一个类,很好的进行处理逻辑分离。

    • 对冲类

      class Hedge {                                                                           // 对冲类,策略主要逻辑。
        public:
          Hedge() {                                                                           // 构造函数
              ...
          };
          
          State getState(string &symbolA, Depth &depthA, string &symbolB, Depth &depthB) {        // 获取状态,参数: 合约A名称 、合约A深度数据, 合约B名称、 合约B深度数据
              
              ...
          }
          bool Loop(string &symbolA, Depth &depthA, string &symbolB, Depth &depthB, string extra="") {       // 开平仓 策略主要逻辑
              
              ...
          }    
      
        private:
          vector<double> _addArr;                                     // 对冲加仓列表
          string _state_desc[4] = {"NA", "IDLE", "LONG", "SHORT"};    // 状态值 描述信息
          int _countOpen = 0;                                 // 开仓次数
          int _countCover = 0;                                // 平仓次数
          int _lastCache = 0;                                 // 
          int _hedgeCount = 0;                                // 对冲次数
          int _loopCount = 0;                                 // 循环计数(循环累计次数)
          double _holdPrice = 0;                              // 持仓价格
          BarFeeder _feederA = BarFeeder(DPeriod);            // A合约 行情 K线生成器
          BarFeeder _feederB = BarFeeder(DPeriod);            // B合约 行情 K线生成器
          State _st = STATE_NA;                               // 对冲类型 对象的 对冲持仓状态
          string _cfgStr;                                     // 图表配置 字符串
          double _holdAmount = 0;                             // 持仓量
          bool _isCover = false;                              // 是否平仓 标记
          bool _needCheckOrder = true;                        // 设置是否 检查订单
          Chart _c = Chart("");                               // 图表对象,并初始化
      };
      
      
      

      由于代码比较长,省略了一部分,主要显示一下这个对冲类的结构,构造函数Hedge就不说了,主要是对象初始化。剩下,主要有2个功能函数。

      • getState

        这个函数主要处理订单检测,订单撤销,仓位检测,仓位平衡等工作。因为在对冲交易过程中,是避免不了单腿的情况(即一个合约成交了,一个合约没有成交),如果在下单逻辑中进行检测,然后处理追单或者平仓,策略逻辑会比较乱。所以设计这部分的时候采取了另一种思路。如果触发对冲操作,下单一次,不论是否出现单腿对冲的情况,默认是对冲成功了,然后在getState函数中检测仓位平衡,独立出来检测处理平衡的这个逻辑。

      • Loop

        策略的交易逻辑封装在这个函数中,其中调用 getState , 使用K线数据生成器对象生成 差价的K线数据,进行对冲开仓、平仓、加仓逻辑的判断。还有一些对于图表的数据更新操作。

    • 策略主函数

      void main() {  
      
          ...
          
          string realSymbolA = exchange.SetContractType(symbolA)["instrument"];    // 获取设置的A合约(this_week / next_week / quarter ) ,在 OKEX 合约 当周、次周、季度 对应的真实合约ID 。
          string realSymbolB = exchange.SetContractType(symbolB)["instrument"];    // ...
          
          string qs = urlencode(json({{"op", "subscribe"}, {"args", {"futures/depth5:" + realSymbolA, "futures/depth5:" + realSymbolB}}}).dump());    // 对 ws 接口的要传的参数进行 json 编码、 url 编码
          Log("try connect to websocket");                                                                                                            // 打印连接 WS接口的信息。
          auto ws = Dial("wss://real.okex.com:10442/ws/v3|compress=gzip_raw&mode=recv&reconnect=true&payload="+qs);     // 调用FMZ API Dial 函数 访问  OKEX 期货的 WS 接口
          Log("connect to websocket success");
          
          Depth depthA, depthB;                               // 声明两个 深度数据结构的变量 用于储存A合约和B合约 的深度数据
          auto fillDepth = [](json &data, Depth &d) {         // 用接口返回的json 数据,构造 Depth 数据的代码。
              d.Valid = true;
              d.Asks.clear();
              d.Asks.push_back({atof(string(data["asks"][0][0]).c_str()), atof(string(data["asks"][0][1]).c_str())});
              d.Bids.clear();
              d.Bids.push_back({atof(string(data["bids"][0][0]).c_str()), atof(string(data["bids"][0][1]).c_str())});
          };
          string timeA;   // 时间 字符串 A 
          string timeB;   // 时间 字符串 B 
          while (true) {
              auto buf = ws.read();                           // 读取 WS接口 推送来的数据
              
              ...
              
      }
      

      策略启动后是从main 函数开始执行,策略在main 函数的初始化工作中,订阅了websocket 接口的tick行情。main 函数最主要的工作就是构造了一个主循环,不停的接收交易所websocket 接口推送来的tick行情,然后调用对冲类对象的成员函数: Loop 函数。由行情数据 驱动 Loop 函数中的交易逻辑。 需要说明的一点,上文中说道的tick行情,实际是订阅的订单薄深度数据接口,获取的是每档的订单薄数据。但是策略中只是使用了第一档的数据,其实就和tick行情数据差不多了,策略中并没有用其它档的数据,也没有用第一档的订单量数值。 详细看下 策略是如何订阅 websocket 接口的数据,又是如何设置的。

      string qs = urlencode(json({{"op", "subscribe"}, {"args", {"futures/depth5:" + realSymbolA, "futures/depth5:" + realSymbolB}}}).dump());    
      Log("try connect to websocket");                                                                                                            
      auto ws = Dial("wss://real.okex.com:10442/ws/v3|compress=gzip_raw&mode=recv&reconnect=true&payload="+qs);     
      Log("connect to websocket success");
      

      首先要对订阅的接口所传的订阅消息 json 参数进行 url 编码,也就是 payload 参数的值。然后比较重要的一步就是调用 发明者量化交易平台的 API 接口函数 Dial 函数。Dial 函数可用于访问交易所 websocket 接口。我们这里进行一些设置,让即将创建的 websocket 连接控制对象 ws 具有断线自动重连(订阅消息依然使用 payload 参数的值 qs字符串),实现这个功能就需要在 Dial 函数的参数字符串中增加配置选项。

      Dial函数参数的开头部分如下:

      wss://real.okex.com:10442/ws/v3
      

      是需要访问的websocket接口地址,之后用 | 分隔。 compress=gzip_raw&mode=recv&reconnect=true&payload="+qs 都是配置参数。

      参数名 描述
      compress compress为压缩方式 ,OKEX websocket 接口使用 gzip_raw 此种方式,故设置为gzip_raw
      mode mode为模式,可选 dual , send , recv 三种。dual 为双向,发送压缩数据,接收压缩数据。send 为发送压缩数据。recv 为接收压缩数据,本地解压缩。
      reconnect reconnect为是否设置重连,reconnect=true 为启用重连,不设置默认不重连。
      payload payload为ws重连时需要发送的订阅消息。

      这样设置后,即使websocket 连接断开,发明者量化交易平台 托管者底层系统也会进行自动重连,及时地获取到最新的行情数据。 抓住每次差价波动,快速捕获到合适的对冲行情。

  • 仓位控制

    仓位控制采用类似 「波菲纳契」数列的对冲仓位比例,进行控制。

    for (int i = 0; i < AddMax + 1; i++) {                                          // 构造 控制加仓数量的数据结构,类似 波菲纳契数列 对冲数量 比例。
        if (_addArr.size() < 2) {                                                   // 前两次加仓量变化为: 加一倍对冲数量 递增
            _addArr.push_back((i+1)*OpenAmount);
        }
        _addArr.push_back(_addArr[_addArr.size()-1] + _addArr[_addArr.size()-2]);   // 最后 两个加仓数量相加,算出当前的加仓数量储存到 _addArr数据结构中。
    }
    

    可以看到每次增加的加仓仓位数量都是最近的上两个仓位的和。 这样的仓位控制可以实现差价越大,套利对冲数量相对增加,对仓位进行分散,从而把握住小差价波动小仓位,大差价波动仓位适当增大。

  • 平仓:止损止盈

    固定的止盈差价,止损差价。 持仓差价到达止盈位置、止损位置即进行止盈、止损。

  • 入市、离市 周期设计

    参数 NPeriod 控制的周期对策略的开仓平仓进行一定的动态控制。

  • 策略图表

    策略自动生成差价 K线图表,标记相关交易信息。

    C++策略自定义图表画图操作,也非常简便,可以看到在 对冲类的构造函数中,我们使用了写好的图表配置字符串_cfgStr配置给了图表对象_c , _c 是对冲类 私有成员初始化时调用发明者量化自定义图表API接口函数 Chart 函数构造的图表对象。

    _cfgStr = R"EOF(
    [{
    "extension": { "layout": "single", "col": 6, "height": "500px"},
    "rangeSelector": {"enabled": false},
    "tooltip": {"xDateFormat": "%Y-%m-%d %H:%M:%S, %A"},
    "plotOptions": {"candlestick": {"color": "#d75442", "upColor": "#6ba583"}},
    "chart":{"type":"line"},
    "title":{"text":"Spread Long"},
    "xAxis":{"title":{"text":"Date"}},
    "series":[
        {"type":"candlestick", "name":"Long Spread","data":[], "id":"dataseriesA"},
        {"type":"flags","data":[], "onSeries": "dataseriesA"}
        ]
    },
    {
    "extension": { "layout": "single", "col": 6, "height": "500px"},
    "rangeSelector": {"enabled": false},
    "tooltip": {"xDateFormat": "%Y-%m-%d %H:%M:%S, %A"},
    "plotOptions": {"candlestick": {"color": "#d75442", "upColor": "#6ba583"}},
    "chart":{"type":"line"},
    "title":{"text":"Spread Short"},
    "xAxis":{"title":{"text":"Date"}},
    "series":[
        {"type":"candlestick", "name":"Long Spread","data":[], "id":"dataseriesA"},
        {"type":"flags","data":[], "onSeries": "dataseriesA"}
        ]
    }
    ]
    )EOF";
    _c.update(_cfgStr);                 // 用图表配置 更新图表对象
    _c.reset();                         // 重置图表数据。
    
    • 调用 _c.update(_cfgStr); 使用 _cfgStr 配置到图表对象。
    • 调用 _c.reset(); 重置图表数据。

    在策略代码需要给图表插入数据时,也是通过 直接调用 _c 对象的成员函数,或者 把 _c 的引用作为参数传递,进而调用_c的对象成员函数(方法)去进行图表数据更新,插入操作。 例如:

    _c.add(chartIdx, {{"x", UnixNano()/1000000}, {"title", action},  {"text", format("diff: %f", opPrice)}, {"color", color}});
    

    下单交易后,在K线图表上打上标签记录。

    如下,绘制K线是通过 调用 BarFeeder 类的 成员函数 feed 时,把图表对象 _c 的引用 作为参数 传入。

    void feed(double price, Chart *c=nullptr, int chartIdx=0)
    

    feed函数的形参 c 。

    json point = {bar.Time, bar.Open, bar.High, bar.Low, bar.Close};            // 构造一个 json 类型数据
    if (c != nullptr) {                                                         // 图表对象指针不等于 空指针,执行以下。
       if (newBar) {                                                            // 根据标记判断,如果出现新Bar 
            c->add(chartIdx, point);                                            // 调用图表对象成员函数add,向图表对象中插入数据(新增K线bar)
            c->reset(1000);                                                     // 只保留1000 bar个数据
        } else {
            c->add(chartIdx, point, -1);                                        // 否则就更新(不是新bar),这个点(更新这个bar)。
        } 
    }
    

    通过调用 图表对象 _c 的add成员函数,向图表中插入新的K线Bar数据。 代码:c->add(chartIdx, point);

  • 回测

    img

    img

    img

本策略仅为学习交流使用,实盘使用时请根据实盘实际情况,自行修改优化。

策略地址:https://www.fmz.com/strategy/163447

更多有趣的策略尽在「发明者量化交易平台」 : https://www.fmz.com


Related

More