programmer Jiji

FXシステムトーレードツール jiji を Digital Oceanで動かす

目的

TensorFlow を使った為替(FX)のトレードシステムを作るチュートリアル ~システムのセットアップからトレードまで~ で公開されているサンプルコードを実行してみて、トレードの成績は良いものの、取引回数が減ってしまうので、複数通貨で取引できるようにしてみた。さらに今回は Digital Ocean VPS で実際に動かすところまでやってみる。

※ 実際の取引は自己責任でお願いしますね。

取引 Agent のコード

サンプルコードを参考に複数通貨対応していった感じ。Tensorflow 側のコードはいじってないです。 ちょっとログを出力する関係でイマイチな部分もある。 Currency クラスを作ってそのインスタンスを複数持つことで対応した。

# tensorflow_agent.rb
# coding: utf-8

require 'jiji/model/agents/agent'
require 'httpclient'
require 'json'

# ここで通貨ペアを複数指定。ただし、jiji上で同時にバックテストができるのは5通貨ペアまで。
TRADE_CURRENCIES = %i(USDJPY EURUSD USDJPY).freeze

# 通貨単位。最高で 通貨ペア×通貨単位 分保有することがあるので調整してください。
CURRENCY_UNIT = 10000

TENSORFLOW_API_URL = "http://tensorflow:5000/api/estimator".freeze

class TensorFlowAgent
  include Jiji::Model::Agents::Agent

  def self.description
    <<-STR
TensorFlowと連携してトレードするエージェントのサンプル
      STR
  end

  def self.property_infos
    [
      Property.new('exec_mode',
                   '動作モード("collect" or "trade" or "test")',
                   "collect")
    ]
  end

  def post_create
    @mode = create_mode(@exec_mode)
    @currencies = TRADE_CURRENCIES.map { |currency_pair| Currency.new(currency_pair, broker, @mode) }
  end

  # 次のレートを受け取る
  def next_tick(tick)
    timestamp = tick.timestamp
    return if already_check?(timestamp)
    @current_timestamp = timestamp
    logger.info "check for crossing at #{timestamp}"
    @currencies.each do |currency|
      currency.next_tick(tick)
      logger.info currency.do_trade
    end
  end

  # 1日に2回チェックを行う
  def already_check?(timestamp)
    return true if (timestamp.hour % 12).nonzero?
    !@current_timestamp.nil? && @current_timestamp.mday == timestamp.mday && @current_timestamp.hour == timestamp.hour
  end

  def create_mode(mode)
    case mode
    when 'trade' then
      TradeMode.new
    when 'collect' then
      CollectMode.new
    else
      TestMode.new
    end
  end

  # データ収集モード
  #
  # TensorFlowでの予測を使用せずに移動平均のシグナルのみでトレードを行い、
  # 結果をDBに保存する
  #
  class CollectMode
    def do_trade?(signal, sell_or_buy)
      true
    end

    # ポジションが閉じられたら、トレード結果とシグナルをDBに登録する
    def after_position_closed(signal, position)
      TradeAndSignals.create_from(signal, position).save
    end
  end

  # テストモード
  #
  # TensorFlowでの予測を使用せずに移動平均のシグナルのみでトレードする
  # トレード結果は収集しない
  #
  class TestMode
    def do_trade?(signal, sell_or_buy)
      true
    end

    def after_position_closed(signal, position)
      # do nothing.
    end
  end

  # 取引モード
  #
  # TensorFlowでの予測を使用してトレードする。
  # トレード結果は収集しない
  #
  class TradeMode
    def initialize
      @client = HTTPClient.new
    end

    # トレードを勝敗予測をtensorflowに問い合わせる
    def do_trade?(signal, sell_or_buy)
      body = { sell_or_buy: sell_or_buy }.merge(signal)
      body.delete(:ma5)
      body.delete(:ma10)

      result = @client.post(TENSORFLOW_API_URL, {
                              body: JSON.generate(body),
                              header: {
                                'Content-Type' => 'application/json'
                              }
                            })
      # up と予測された場合のみトレード
      JSON.parse(result.body)["result"] == "up"
    end

    def after_position_closed(signal, position)
      # do nothing.
    end
  end
end

# トレード結果とその時の各種指標。
# MongoDBに格納してTensorFlowの学習データにする
class TradeAndSignals
  include Mongoid::Document

  store_in collection: 'tensorflow_example_trade_and_signals'

  field :macd_difference,    type: Float # macd - macd_signal

  field :rsi,                type: Float

  field :slope_10,           type: Float # 10日移動平均線の傾き
  field :slope_25,           type: Float # 25日移動平均線の傾き
  field :slope_50,           type: Float # 50日移動平均線の傾き

  field :ma_10_estrangement, type: Float # 10日移動平均からの乖離率
  field :ma_25_estrangement, type: Float
  field :ma_50_estrangement, type: Float

  field :profit_or_loss,     type: Float
  field :sell_or_buy,        type: Symbol
  field :entered_at,         type: Time
  field :exited_at,          type: Time

  def self.create_from(signal_data, position)
    TradeAndSignals.new do |ts|
      signal_data.each do |pair|
        next if pair[0] == :ma5 || pair[0] == :ma10
        ts.send("#{pair[0]}=".to_sym, pair[1])
      end
      ts.profit_or_loss = position.profit_or_loss
      ts.sell_or_buy    = position.sell_or_buy
      ts.entered_at     = position.entered_at
      ts.exited_at      = position.exited_at
    end
  end
end

class Currency
  def initialize(currency_pair, broker, mode)
    @currency_pair = currency_pair
    @broker = broker
    @mode = mode
    @cross = Cross.new
  end

  def next_tick(tick)
    prepare_signals(tick) unless @macd
    @current_signals = calculate_signals(tick[@currency_pair])
    @cross.next_data(@current_signals[:ma5], @current_signals[:ma10])
  end

  def do_trade
    # 5日移動平均と10日移動平均のクロスでトレード
    if @cross.cross_up?
      log_text = buy
    elsif @cross.cross_down?
      log_text = sell
    end
    log_text || "#{@currency_pair} has no crossing"
  end

  def buy
    close_exist_positions
    return "#{@currency_pair} is cross up but tensorflow decided no-go" unless @mode.do_trade?(@current_signals, "buy")
    result = @broker.buy(@currency_pair, CURRENCY_UNIT)
    @current_position = @broker.positions[result.trade_opened.internal_id]
    @current_hold_signals = @current_signals
    "#{@currency_pair} is cross up and traded"
  end

  def sell
    close_exist_positions
    return "#{@currency_pair} is cross down but tensorflow decided no-go" unless @mode.do_trade?(@current_signals, "sell")
    result = @broker.sell(@currency_pair, CURRENCY_UNIT)
    @current_position = @broker.positions[result.trade_opened.internal_id]
    @current_hold_signals = @current_signals
    "#{@currency_pair} is cross down and traded"
  end

  def close_exist_positions
    return unless @current_position
    @current_position.close
    @mode.after_position_closed(@current_hold_signals, @current_position)
    @current_position = nil
    @current_hold_signals = nil
  end

  def calculate_signals(tick)
    price = tick.bid
    macd = @macd.next_data(price)
    ma10 = @ma10.next_data(price)
    ma25 = @ma25.next_data(price)
    ma50 = @ma50.next_data(price)
    {
      ma5:  @ma5.next_data(price),
      ma10: ma10,
      macd_difference: macd ? macd[:macd] - macd[:signal] : nil,
      rsi:  @rsi.next_data(price),
      slope_10: ma10 ? @ma10v.next_data(ma10) : nil,
      slope_25: ma25 ? @ma25v.next_data(ma25) : nil,
      slope_50: ma50 ? @ma50v.next_data(ma50) : nil,
      ma_10_estrangement: ma10 ? calculate_estrangement(price, ma10) : nil,
      ma_25_estrangement: ma25 ? calculate_estrangement(price, ma25) : nil,
      ma_50_estrangement: ma50 ? calculate_estrangement(price, ma50) : nil
    }
  end

  def prepare_signals(tick)
    create_signals
    retrieve_rates(tick.timestamp).each do |rate|
      calculate_signals(rate.close)
    end
  end

  def create_signals
    @macd  = Signals::MACD.new
    @ma5   = Signals::MovingAverage.new(5)
    @ma10  = Signals::MovingAverage.new(10)
    @ma25  = Signals::MovingAverage.new(25)
    @ma50  = Signals::MovingAverage.new(50)
    @ma5v  = Signals::Vector.new(5)
    @ma10v = Signals::Vector.new(10)
    @ma25v = Signals::Vector.new(25)
    @ma50v = Signals::Vector.new(50)
    @rsi   = Signals::RSI.new(9)
  end

  def retrieve_rates(time)
    @broker.retrieve_rates(@currency_pair, :one_day, time - 60 * 60 * 24 * 60, time)
  end

  def calculate_estrangement(price, ma)
    ((BigDecimal.new(price, 10) - ma) / ma * 100).to_f
  end
end

Digital Ocean へのデプロイ手順

Digital Ocean 上で Droplet を作成

サーバーを立ち上げる。Digital Ocean のデザインが全体的にシンプルで好き。 とりあえず公式だと CentOS で動作確認しているみたいなので、CentOS でサーバーの size は一番小さいやつにしておく。(運用中は基本的に 12 時間に 1 回しかアクション起こさないので、これで十分なはず)

CentOS にアクセスして環境構築

先に、独自ドメインの DNS の設定と、Digital Ocean 側でもメニューの Networks から設定しておく。

また、今回は ssh キーを登録して root にログインしているので、sudo コマンドは使っていないので、適宜必要なところには入れてください。

Digital Ocean の管理画面に書かれている IP アドレスに ssh root@{IPアドレス} でアクセス


# 必要なパッケージをインストール
$ yum update -y
$ yum install -y epel-release
$ yum install -y docker git certbot # Let's Encrypt を利用するので certbot を入れる
$ certbot certonly --standalone -d {独自ドメイン} # 持っているドメインを設定して証明書を発行

# docker の設定
$ service docker start
$ chkconfig docker on

# docker-compose の構築
$ curl -L https://github.com/docker/compose/releases/download/1.8.1/docker-compose-Linux-x86_64 > /tmp/docker-compose #バージョンは最新の入れれば問題ない?
$ mv /tmp/docker-compose /usr/local/bin/
$ chmod +x /usr/local/bin/docker-compose

# jiji の構築
$ git clone https://github.com/unageanu/jiji-with-tensorflow-example.git
$ mkdir -p jiji-with-tensorflow-example/cert
$ mv /etc/letsencrypt/archive/{独自ドメイン}/cert1.pem jiji-with-tensorflow-example/cert/server.crt # Let's Encrypt で作成した証明書を移動
$ mv /etc/letsencrypt/archive/{独自ドメイン}/privkey1.pem jiji-with-tensorflow-example/cert/server.key
$ cd jiji-with-tensorflow-example
$ chown root.root cert/server.key
$ chmod 600 cert/server.key
$ vi docker-compose.yml # USER_SECRET を更新
$ vi build/tensorflow/Dockerfile # 3行目に `RUN pip install --upgrade pip` を挿入(最新版のpipでないとエラーになっていたので追加)
$ docker-compose up -d

collect テストを実行

立ち上がったサーバー https://{独自ドメイン}:10443 にアクセスし、jiji 上でエージェントを登録して collect mode のテストを実行する。

学習させる

# mongo DBに値が記録されているかを確認
$ docker exec -it jiji_example__mongodb  mongo
> use jiji
> db.tensorflow_example_trade_and_signals.find().count()
> quit()

# 学習させる
$ docker exec -it jiji_example__tensorflow /bin/bash

# docker 上での作業
$ cd /scripts/
$ python train.py

# たまに server.py が落ちてしまうことがあったので、forever.js を使ってずっと立ち上げ状態にしておく(docker の中をいじるのはあまり行儀が良くないが。。。)
$ apt update
$ apt install -y nodejs npm
$ npm install forever -g
$ ln -s /usr/bin/nodejs /usr/bin/node
$ forever start -c /usr/bin/python server.py

trade テストを実行

今度は trade mode で実行して上手くいっているかを確認。

感想

今回やってみて、docker の知識とか色々ついた。こういう触れるオモチャ的なものがあると学習って進む感じ。 jiji にはかなり感謝してます。