【Bitcoinを自動売買】AWSのDockerで運用してみた話、外出先のAppleWatchから1TAPでON/OFF&Line通知付き

# autocoin2

BitCoinのFX自動売買プログラム。
BitflyerのAPIを利用して、node.jsにて仮想通貨トレードを自動化しました。
寝てる時、トイレ中、24時間中、お金が勝手に増えてくれたら、どんなに素敵だろう。。:gem:
楽して自動的に儲かりたい！そんなダメ人間モチベーションで作ってみました。
![iOS の画像.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215645/6514fa28-891e-2083-527f-28b1f8690119.jpeg)![スクリーンショット 2020-09-20 10.05.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215645/c5eef4d1-3781-7a08-ac9e-ee8620d94b37.png)

**いきなり結論ですが、、、残念ながら儲かりません:scream:
むしろ、減っています。。**

ですが、チューニングしたら、ひょっとしたら儲かり出すかもしれません。
(*損害を受けても当方は一切責任はありません。*)
あくまで、自己責任でお願いします！

**[Githubにコード公開しています](https://github.com/akiraseto/autocoin2)**


## 特徴

- 売り・買いポジション両方対応
- 複数アルゴリズムによる重み付け売買判断
- MongoDBによる売買履歴の保存
- 取引開始をLine通知
- 損得金額の閾値を超えたら、Lineにて通知
- 一定の日数が経過したら、ポジションを自動で手放す機能
- 日付変更30分前には、新たなポジション取得を抑制する機能
- Apple Home連携で外出先でもiphoneから1タップでON/OFF
- プログラム稼働中でも、並行して通常の人的トレードも可能


## システム概要

![autocoin2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215645/cf587979-4d48-f063-f3a3-c075245f46e1.png)


### 使用技術

- Node.js
- Docker
- AWS
- MongoDB
- shell
- Raspberry Pi


### ディレクトリ構成

```bash
.
├── autocoin
│  ├── algo.js
│  ├── app.js
│  ├── config.js
│  ├── crypto.js
│  ├── line.js
│  ├── mongo.js
│  └── utils.js
├── container_data
├── homebridge_AWS
│  ├── startAWS.sh
│  └── stopAWS.sh
├── .env
├── Dockerfile
└── docker-compose.yml
```


## メイン:app.js

このプログラムのエントリーポイント。
ループ処理でコードを廻すことで売買を繰り返します。


```js

'use strict';
const config = require('./config');
const moment = require('moment');
const ccxt = require('ccxt');
const bitflyer = new ccxt.bitflyer(config);

const Crypto = require('./crypto')
const Mongo = require('./mongo');
const mongo = new Mongo();
const Line = require('./line');
const line = new Line(config.line_token)
const utils = require('./utils');
const Algo = require('./algo');

//取引間隔(秒)
const tradeInterval = 180;
//取引量
const orderSize = 0.01;
//swap日数
const swapDays = 3;
//通知用の価格差閾値
const infoThreshold = 100;

//psychoAlgoの設定値;陽線カウント
const psychoParam = {
  'range': 10,
  'ratio': 0.7,
};
//crossAlgoの設定値:移動平均幅
const crossParam = {
  'shortMA': 5,
  'longMA': 30,
};

//ボリンジャーバンド設定値
const BBOrder = {
  //注文
  'period': 10,
  'sigma': 1.7
};
const BBProfit = {
  //利確
  'period': 10,
  'sigma': 1
};
const BBLossCut = {
  //損切り:日足で判断
  'period': 10,
  'sigma': 2.5
};

// アルゴリズムの重み付け:未使用は0にする
const algoWeight = {
  // 'psychoAlgo': 0,
  // 'crossAlgo': 0,
  // 'bollingerAlgo': 1,
  'psychoAlgo': 0.1,
  'crossAlgo': 0.2,
  'bollingerAlgo': 0.7,
};
//取引判断の閾値
const algoThreshold = 0.3;
//ロスカットの閾値
const lossCutThreshold = 0.5;


(async function () {
  let sumProfit = 0;
  let beforeProfit = null;
  const nowTime = moment();
  const collateral = await bitflyer.fetch2('getcollateral', 'private', 'GET');

  //(分)レコード作成
  const crypto = new Crypto();
  const beforeHour = crossParam.longMA * tradeInterval;
  const timeStamp = nowTime.unix() - beforeHour;
  let records = await crypto.getOhlc(tradeInterval, timeStamp);

  const algo = new Algo(records);

  //Lineに自動売買スタートを通知
  const strTime = nowTime.format('YYYY/MM/DD HH:mm:ss');
  const message = `\n 自動売買スタート\n date: ${strTime}\n collateral: ${collateral.collateral}`;
  line.notify(message);


  while (true) {
    let flag = null;
    let label = "";
    let tradeLog = null;

    const nowTime = moment();
    const strTime = nowTime.format('YYYY/MM/DD HH:mm:ss');

    //取引所の稼働状況を確認
    let health = await bitflyer.fetch2('getboardstate');
    if (health.state !== 'RUNNING') {
      // 異常ならwhileの先頭に
      console.log('取引所の稼働状況:', health);
      await utils.sleep(tradeInterval * 1000);
      continue;
    }

    //現在価格を取得
    const ticker = await bitflyer.fetchTicker('FX_BTC_JPY');
    const nowPrice = ticker.close;

    //レコードを更新
    algo.records.push(nowPrice);
    algo.records.shift()

    //アルゴリズム用Paramを初期化
    let bbRes = null;
    let totalEva = 0;
    algo.initEva();
    //共通アルゴリズム
    let crossRes = algo.crossAlgo(crossParam.shortMA, crossParam.longMA);
    let psychoRes = algo.psychoAlgo(psychoParam.range, psychoParam.ratio)

    //建玉を調べる
    const jsonOpenI = await bitflyer.fetch2('getpositions', 'private', 'GET', {product_code: "FX_BTC_JPY"});
    const openI = utils.chkOpenI(jsonOpenI)

    //共通表示
    console.log('================');
    console.log('time:', strTime);
    console.log('nowPrice: ', nowPrice);


    // 建玉がある場合
    if (openI.side) {
      //建玉の共通表示
      console.log('');
      console.log('建玉内容');
      console.log(openI);

      let diffDays = nowTime.diff(openI.open_date, 'days');
      // swap日数を超えているなら
      if (diffDays >= swapDays) {
        // 建玉を0に戻す
        label = 'swap日数を超えているため建玉をリセット'

        if (openI.side === 'BUY') {
          await bitflyer.createMarketSellOrder('FX_BTC_JPY', openI.size);
          flag = 'SELL';

        } else {
          await bitflyer.createMarketBuyOrder('FX_BTC_JPY', openI.size);
          flag = 'BUY';
        }
        sumProfit += openI.pnl;

      } else {
        // 日数を超えてないなら
        //  利益が出ている場合
        if (openI.pnl > 0) {
          label = '利確'
          bbRes = algo.bollingerAlgo(BBProfit.period, BBProfit.sigma, openI.price);
          totalEva = algo.tradeAlgo(algoWeight)

          //買い建玉で、下降シグナルが出ている
          if (openI.side === 'BUY' && totalEva < -algoThreshold) {
            await bitflyer.createMarketSellOrder('FX_BTC_JPY', openI.size);
            sumProfit += openI.pnl;
            flag = 'SELL';

            //売り建玉で、上昇シグナルが出ている
          } else if (openI.side === 'SELL' && totalEva > algoThreshold) {
            await bitflyer.createMarketBuyOrder('FX_BTC_JPY', openI.size);
            sumProfit += openI.pnl;
            flag = 'BUY';

          }
        } else {
          //  損してる場合
          label = 'ロスカット';

          //日足でアルゴリズム判断
          const dayPeriods = 60 * 60 * 24;
          const lossTimeStamp = nowTime.unix() - dayPeriods * BBLossCut.period;
          let dayRecords = await crypto.getOhlc(dayPeriods, lossTimeStamp);

          crossRes = algo.crossAlgo(crossParam.shortMA, crossParam.longMA, dayRecords);
          psychoRes = algo.psychoAlgo(psychoParam.range, psychoParam.ratio, dayRecords);
          bbRes = algo.bollingerAlgo(BBLossCut.period, BBLossCut.sigma, openI.price, dayRecords);
          totalEva = algo.tradeAlgo(algoWeight)

          //損してるのに、買いを持ってて大きなトレンドが下がり兆候
          if (openI.side === 'BUY' && totalEva < -lossCutThreshold) {
            await bitflyer.createMarketSellOrder('FX_BTC_JPY', openI.size);
            sumProfit += openI.pnl;
            flag = 'SELL';

            //損してるのに、売りを持ってて大きなトレンドで上がり兆候
          } else if (openI.side === 'SELL' && totalEva > lossCutThreshold) {
            await bitflyer.createMarketBuyOrder('FX_BTC_JPY', openI.size);
            sumProfit += openI.pnl;
            flag = 'BUY';
          }
        }
      }

      //建玉を精算したなら、
      if (flag) {
        tradeLog = {
          flag: flag,
          label: label,
          sumProfit: sumProfit,
          profit: openI.pnl,
          nowPrice: nowPrice,
          openPrice: openI.price,
          strTime: strTime,
          created_at: nowTime._d,
          openI: openI,
          bollinger: bbRes,
          cross: crossRes,
          psycho: psychoRes,
          totalEva: totalEva,
        };
        mongo.insert(tradeLog);

        console.log('');
        console.log(label);
        console.log(tradeLog);
      }

      // Line通知(閾値を超えたら)
      if (beforeProfit !== null) {
        const profit = openI.pnl;
        const diff = Math.abs(sumProfit + profit - beforeProfit);
        if (diff >= infoThreshold) {
          const message = `\n date: ${strTime}\n sumProfit: ${sumProfit}\n profit: ${profit}\n collateral: ${collateral.collateral}`;
          line.notify(message);
          beforeProfit = sumProfit + profit;
        }
      } else {
        //アラート初期化
        beforeProfit = sumProfit;
      }


    } else {
      //建玉を持ってない場合

      //スワップポイント対応 23:30-0:00 注文しない
      const limitDay = moment().hours(23).minutes(30).seconds(0)
      if (nowTime.isSameOrAfter(limitDay)) {
        console.log(' ');
        console.log('スワップポイント対応中_23:30-0:00');
        //注文を受け付けない while先頭に移動
        await utils.sleep(tradeInterval * 1000);
        continue;
      }

      // 注文する ボリンジャーを使用
      bbRes = algo.bollingerAlgo(BBOrder.period, BBOrder.sigma);
      totalEva = algo.tradeAlgo(algoWeight)

      if (totalEva > algoThreshold) {
        //【買い】で建玉する
        await bitflyer.createMarketBuyOrder('FX_BTC_JPY', orderSize);
        flag = 'BUY';

      } else if (totalEva < -algoThreshold) {
        //【売り】で建玉する
        await bitflyer.createMarketSellOrder('FX_BTC_JPY', orderSize);
        flag = 'SELL';
      }

      //建玉を取得したなら、
      if (flag) {
        label = '建玉取得';

        tradeLog = {
          flag: flag,
          label: label,
          sumProfit: sumProfit,
          nowPrice: nowPrice,
          bollinger: bbRes,
          cross: crossRes,
          psycho: psychoRes,
          totalEva: totalEva,
          strTime: strTime,
          created_at: nowTime._d,
        };
        mongo.insert(tradeLog);

        console.log('');
        console.log(label);
        console.log(tradeLog);
      }
    }

    console.log('');
    console.log('★sumProfit: ', sumProfit);
    console.log('');
    await utils.sleep(tradeInterval * 1000);
  }
})();


```


### ハイパーパラメーターの説明

- tradeInterval: 取引間隔。最短は60秒。
- orderSize: 注文数
- swapDays: 建玉の保持したい日数。超過したら手放す。
- infoThreshold: Line告知用の金額幅。設定額を超えた損得をするとLine告知する。
- psychoParam: サイコロジカルラインのアルゴリズムに使用するパラメーター。
    - 期間
    - 比率
- crossParam: ゴールデンクロス・デッドクロスのアルゴリズムに使用するパラメーター。
    - 短期移動平均線の期間
    - 長期移動平均線の期間
- BBOrder / BBProfit / BBLossCut: ボリンジャーバンドのアルゴリズムに使用するパラメーター。
建玉取得 / 利益確定 / 損切りごとに判断材料が異なるため分けています。
    - 判断期間
    - 標準偏差
- algoWeight: 各アルゴリズムの重み(重要度)を設定。
重要度はただの比率なので合計1になるような調整をおすすめ。
- algoThreshold: 取引判断の閾値。
アルゴリズムの複合判断された値がこの値以上(以下)であれば取引。
- lossCutThreshold: ロスカット判断の閾値。
複合判断された値がこの値以上(以下)であればロスカット。


### 分岐、流れの紹介説明

大まかな処理の流れです。

- 売買スタート
判断材料とするため、cryptowatchからコード実行前の取引内容を取得する。
Lineで「自動売買スタート」したことを通知

- 取引ループを開始
設定した取引間隔でループを廻す。

- bitflyerの取引所の稼働状況を確認
異常であれば、ループの先頭に移動

- 現在のbitcoinの価格を取得

- 共通利用アルゴリズム
クロス関数、サイコロジカル関数の評価をする

- 保持している建玉(ポジション)の内容を取得する。

- 建玉を保持している場合、建玉の保持日数を調べる
保持日数が、指定日数より長ければ、建玉を手放す
(swap金と、塩漬けされるのを回避するため。)

- 保持日数が短い場合
- 利益が出ていれば、ポジションと、アルゴリズム判断により売買

- 損が出ていれば、日足材料でのアルゴリズム判断によりロスカット
ロスカットが日足利用なのは、分足だと指標が流動的過ぎ、大きなトレンドで判断が必要と考慮したため。
実際、昔は分足を使っていたのですが、かなり細かいブレに振り回され利得チャンスを失った上、小さな損を積み上げやすかったです。
分足、時間足に変更は可能なので調整してみるのもいいかもしれません。

- 一定額の損得が発生したら、Lineで通知。

- 建玉を持っていない場合、

- 日付変更直前の30分なら、取引せずにループ。
建玉取得して早々にswap金が発生するのを避けるため。

- 日付直前じゃなければ、アルゴリズム判断により建玉を取得する。


## アルゴリズム:algo.js

売買アルゴリズムをまとめています。

```js
const gauss = require('gauss');

module.exports = class Algo {

  constructor(records) {
    this.records = records;

    // 各アルゴリズムの評価ポイント
    //上昇シグナル:+  下降シグナル:-
    this.eva = {
      'psychoAlgo': 0,
      'crossAlgo': 0,
      'bollingerAlgo': 0
    };
  }

  psychoAlgo(range, ratio, list = this.records) {
    //  陽線の割合で売買を判断する

    let countHigh = 0
    //  任意期間の陽線回数をカウント
    for (let i = range; i > 0; i--) {
      const before = list[list.length - i - 1];
      const after = list[list.length - i];

      if (before <= after) {
        countHigh += 1;
      }
    }

    let psychoRatio = 0;
    psychoRatio = countHigh / range;
    if (psychoRatio >= ratio) {
      this.eva['psychoAlgo'] = 1;
    } else if (psychoRatio <= 1 - ratio) {
      this.eva['psychoAlgo'] = -1;
    }

    return psychoRatio;
  }


  crossAlgo(shortMA, longMA, list = this.records) {
    //ゴールデン・デッドクロスで売買を判断する

    //移動平均作成
    const prices = new gauss.Vector(list);
    const shortValue = prices.ema(shortMA).pop();
    const longValue = prices.ema(longMA).pop();

    if (shortValue >= longValue) {
      this.eva['crossAlgo'] = 1;
    } else if (shortValue < longValue) {
      this.eva['crossAlgo'] = -1;
    }

    return {'shortValue': shortValue, 'longValue': longValue};
  }


  bollingerAlgo(period, sigma, price = this.records.slice(-1)[0], list = this.records) {
    //  ボリンジャーバンド

    const prices = new gauss.Vector(list.slice(-period));
    //SMA使用
    const sma = prices.sma(period).pop();
    const stdev = prices.stdev()

    const upper = Math.round(sma + stdev * sigma);
    const lower = Math.round(sma - stdev * sigma);

    if (price <= lower) {
      this.eva['bollingerAlgo'] = 1;
    } else if (price >= upper) {
      this.eva['bollingerAlgo'] = -1;
    }

    return {'upper': upper, 'lower': lower}
  }


  tradeAlgo(weight) {
    //  重み付けして総合的な取引判断

    let totalEva = 0
    //評価ポイントにそれぞれの重みを掛けて足し合わせる
    for (const [key, value] of Object.entries(this.eva)) {
      totalEva += value * weight[key];
    }

    totalEva = Math.round(totalEva * 100) / 100

    return totalEva
  }


  initEva() {
    //全評価ポイントを初期化
    Object.keys(this.eva).forEach(key => {
      this.eva[key] = 0;
    });
  }

}

```


### 複合評価

tradeAlgo()

取引判断は複数のアルゴリズムによる複合判断です。
アルゴリズムごとにそれぞれ評価ポイントを保持。
各アルゴリズムは材料データと設定パラメータにより、-1か、+1どちらかの評価をつける。
正数(+1)は上昇トレンド
負数(-1)は下降トレンド
各アルゴリズムごとに評価ポイントとその重みで掛け算し、最後に全て足し合わせて総合評価ポイントを算出します。

app.js内で閾値より、総合評価ポイントの絶対値が上回っていれば取引を実行。
買/売どちらのポジションで取引するかは、状況に応じてapp.jsで判断。


### アルゴリズムの追加について

今後、新たなアルゴリズムを追加したい場合は以下の手順を参考。

- Algoクラス
    - this.eva(メソッド名と同じ評価ポイントの追加)
    - methodとしてアルゴリズムの追加

- app.js
    - 重み付けの追加
    - 評価させたい箇所でメソッドを追加  
(恐らく共通アルゴリズムとしてcrossAlgo()などと同じ箇所が多いと思います。)


### ボリンジャーバンド

bollingerAlgo()

ボリンジャーバンドは、移動平均線と標準偏差を使った判断アルゴリズム。
ざっくり、標準偏差の絶対値が大きければ大きいほど平均に回帰する力が強くなるっていう考え方。
細かく触れないですが、こちらの説明が分かりやすいです。
[マネックス証券解説](https://info.monex.co.jp/technical-analysis/indicators/003.html)

4つの変数を使用。

- 判断期間
- 閾値にする標準偏差
- 判断したい値段
- 値動きリスト

値動きリストから判断したい期間を取り出す。
次に、取り出したリストをもとに指定した標準偏差のupper値とlower値を算出する。
最後に、算出した上下の標準偏差帯より、価格がはみ出していれば評価ポイントをつける。

lower値より価格が低い場合
その価格はトレンドより低めに付けられているので、上昇に転じやすい。
上昇トレンドとして、+1をつける

upper値より価格が高い場合
その価格はトレンドより高めに付けられているので、下降に転じやすい。
下降トレンドとして、 -1をつける


### サイコロジカルライン

psychoAlgo()

投資家心理を利用したアルゴリズム判断。
売り買いのどちらかが連続して偏った場合、更にその傾向は続く、またはそろそろ逆が出ると判断して多くの売買が行われることで価格変動を予想するアルゴリズム。
このページが分かりやすいです。
[マネックス証券解説](https://info.monex.co.jp/technical-analysis/indicators/007.html)

3つの変数を使用。

- 判断範囲
- 判断する比率
- 値段リスト

設定期間の値段リストに絞り込み、
一つ前の値段より上昇している値段の数と全体の値段の割合を調べる。
割合値と判断する比率を利用比較して、上昇トレンド、下降トレンドと評価ポイントを付ける。


### ゴールデンクロス、デッドクロス

crossAlgo()

長期移動平均線が、短期移動平均線を下から上に突き抜けることをゴールデンクロス。その逆がデッドクロス。
ゴールデンは上昇トレンドになるサインで、デッドクロスは下降トレンドのサイン。

3つの変数を使用。

- 短期期間
- 長期間
- 値段リスト

上記説明の通り、短期移動平均が長期移動平均より上なら上昇トレンドとして評価ポイント+1をつける。
その逆ならデッドクロスとして評価ポイントに-1をつける。
なお、よりトレンドの勢いを加味したかったので、直近値動きをより重視する指数平滑移動平均線(Exponential Moving Average)を使用しました。


## OHLCの取得:crypto.js

プログラム実行直後のOHLC取得にcryptowatchAPIを使用。
OHLCはopen/high/low/closeとローソク足データのことです。

## Line通知:line.js

取引開始と、一定額以上の損得が発生する毎にLineから通知をします。

Line Nnotifyに関してはこちらのページが分かりやすいです。
[LINE notify を使ってみる](https://qiita.com/iitenkida7/items/576a8226ba6584864d95)


## 取引内容の保存:mongo.js

MongoDBで、売買取引を記録。
取引内容はbitflyerAPIからのjson取得で定形データでは無いので、NoSQLのMongoDBを選択しました。

Dockerコンテナによる稼働ですが、volumesでデータを永続化しています。
最初のDockerコンテナ立ち上げで、volumeディレクトリ:container_dataが作成されます。


## その他:utils.js

その他のユーティリティ関数をまとめています。


## Apple Home連動

iphone,AppleWatchから1タップで、AWS上のプログラムのON/OFFできるようにしました。

1. AWS EC2インスタンスにDockerを展開
2. 自宅のRaspberry Piにhomebridge展開し、実行のshellファイルを紐付ける

詳しくは以下を参考ください。
[公式homebridge](https://www.npmjs.com/package/homebridge)

[Philips_HueをAPI連動！〜ラズパイをHomeKit化する](https://qiita.com/akinko/items/58c650f99f25fc7e3cb5#%E3%83%A9%E3%82%BA%E3%83%91%E3%82%A4%E3%82%92homekit%E5%8C%96%E3%81%99%E3%82%8B)


## IntelliJで取引DBを見る

MongoDBに保存した取引内容ですが、閲覧はIDEの利用をおすすめします。
直接MongoDBからの閲覧は、json形式のためかなり辛いです。
IntelliJなどのIDEでしたら、いい感じに見やすくしてくれます。
![スクリーンショット 2020-09-20 10.18.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215645/23f647b6-ad49-a004-dfef-827c828edd72.png)
![スクリーンショット 2020-09-20 10.11.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215645/ba06c058-e799-33a5-e08a-d55231b0578c.png)
IntelliJの設定方法は、過去記事を参照ください。
[IntelliJからAWSを操作する設定方法まとめ](https://qiita.com/akinko/items/d7001a8fe3ac87e1790c#db%E3%81%A8%E3%81%AE%E7%B0%A1%E5%8D%98%E6%8E%A5%E7%B6%9A)


## 注意点

### 最初のDocker立ち上げ
MongoDBが書き込み可能になるのに時間かかります。
volumeディレクトリのcontainer_dataを作成するためです。
余裕持って初回起動して、しばらくしても書き込みされなければ、再度Dockerを立ち上げ直してください。
私の場合ですが、2回目以降は問題なく稼働しています。


### Docker Volumeのcontainer_dataはroot権限
作成されるvolumeディレクトリのcontainer_dataはroot権限で作られます。削除したい際はsudoを付けてください。
私のうっかり経験ですが、Dockerを再ビルドする際に、このディレクトリ権限に気付かずエラーになって少しハマりました。

## 終わりに

楽して不労所得の夢は実現しません:skull::skull:
しつこいですが、必ず儲かるわけではないですし、自己責任でお願いします。:point_up_tone3:

ひょっとしたら、
私のアルゴリズムや、パラメーターがイマイチナだけで、誰かが追加したアルゴリズムにより儲かりだすかもしれません。。。

そんな時は、こっそりと教えて下さいね:musical_note:
それでは、よろしければホビーとしてほどほどに楽しんでみてください。