2020-05-12

過去の板を復元する

bitFlyerのAPIにより記録された板のスナップショット+差分データから過去の板を復元する

Article Image

目的

これまでリアルタイムに板を可視化するプログラムを制作したが今回はとある時点での板を復元するコードを制作することにする。

板を使ったバックテストを行う場合に必要と考え実装した。

ただ、残念ながらこのデータを正しく分析する手法を持ち合わせていない。

今回はとりあえず最初の入口としてbitFlyerの板を復元することだけを目的とする。

前提

板の記録はInfluxDBにて取得できるすべてのデータを24H記録している。

参考記事:【InfluxDB】bitFlyer Realtime APIの情報を全部保存したらどうなるか

板データのInfluxDBスキーマ

typesnapshot or update(差分)

{
    measurement: 'lightning_board_FX_BTC_JPY',
    fields: {
        price: Influx.FieldType.INTEGER,
        size: Influx.FieldType.FLOAT,
    },
    tags: [
        'type',
        'bid_or_ask',
    ]
},

InfluxDB Tips

データは一度に何件も配列で記録されているが、これらを一度にInfluxDBに放り込むとタイムスタンプ重複によりデータは上書きされてしまう。それぞれ特有のTagがつければタイムスタンプが同一でも別レコードとして扱われるのだが、板データからタグに変換できる情報がないため利用不可能。仮にpriceをタグにする場合はタグの種類が100万件単位となるためこれはInfluxDBの仕様で不可能(せいぜい数百程度)

そこでナノ秒ずらし(と勝手に命名した)テクニックを使ってタイムスタンプを微妙にずらしている。

特定の日時

ユーザー側が日時を指定してそのポイントの板を復元するというプログラムである。

では指定された時間の板を復元する場合どのようなデータが必要だろうか。

その時間直前のレコードを見ただけではそれが差分なのかスナップショットなのかわからず、仮に差分だった場合板の復元はほんの一部だけになってしまう。

そこで指定された時点から10秒前からデータを取得し、そこから順次板を組み上げる。

10秒あればスナップショットが最低1回は含まれると思われるため指値が入ったまま10秒以上動いてないレコードも保管できると考えられるためだ。

より厳密にする場合はこのあたりの処理を考える必要がありそうだ。例えばソケットが切れている・サーバーが落ちている・遅延が発生しているデータがそれにあたる。今回は考慮しない。

とりあえずテストとして現在時刻から1分前の板を復元する。

InfluxQL

DBに投げるクエリは次のWHERE句で絞る

'対象の日時' > time AND time > '対象の日時' - 10s

つまり 対象の日時 ~ 対象の日時-10秒 の間の10秒間のデータが出力される。

板:内部のデータ構造

リアルタイムで板を可視化する記事と同様データ構造を変更して処理する。

たとえば price = 1000000size = 0.1 の指値がある場合は

bitFlyerから送られてくるデータ{price: 1000000 , size: 0.1}

{ 1000000 : 0.1 } に変更する。

前者のコードでは特定の価格のデータが変更された場合

  • データの存在確認
  • 新規
  • 更新
  • 削除

が必要だが、後者であれば

  • 更新 (このコードが存在確認、新規作成も兼ねる)
  • 削除

の2つで完了する。

連想配列のソートは不可能!

このコードの正当性をチェックしていて「謎のチャート」のバグの理由が分かってきた。

まず、連想配列は前々から for ... in で取得した時の順番が保証されないというのはなんとなく意識していたが連想配列ソートのコードを出しているユーザーが多数おり実際に検証してみるといつもキーが昇順で帰ってくるため問題ないと考えていた。

どうやらこれは実行環境依存や時々昇順ではないパターンがある可能性がでてきた。謎のチャートのスプレッドが異常値、マイナスになる場合だ。

ともかく検証が十分ではないので一旦このソートが正しく動作するようにキー名のみをコピーした通常の一次元配列を生成しそちらをソートしたのち for ... of で読むことにより正当性を確保する。

for ... in ではなく for ... of であれば正しく順番に出力されるのはMDNに記載されていた(連想配列にはそもそも for ... of は使用不可能)

繰り返しの順序が実装依存なため、配列の繰り返しは要素を一貫した順番で参照することになるとは限りません。このため、アクセスの順番が大事となる配列を繰り返す時には、数値のインデックスでの for ループ (か Array.prototype.forEach() か for...of ループ) を使った方が良いです。

キー名のみをソートするコード

// 各板を昇順にソートしたキー名の配列を返す
const _sort = (message) => {
    const keys = Object.keys(message)
    keys.sort((a, b) => {
        // 昇順
        return a - b
    })

    //ソートされたキー名のみ返す
    return keys
}

実際に板を価格順に並べる場合はこのソートされた配列をキーにして連想配列から取り出していく。

アウトプット

今回は復元された板データのオブジェクトを作成するだけだ。

実際に生成されるのは

  • 板データのオブジェクト
  • 板データに入っている価格一覧の配列(昇順ソート済み)
  • おまけで)BestAsk, BestBid, Spread

今回可視化されるデータは無いが一応コンソールには次のように表示される。

2020-05-12T17:04:09+09:00
BestAsk: 934818
BestBid: 934779
Spread: 39

所感

今回は板のデータを復元するだけの非常に詰まらない回だった。

このコードを書きながら連想配列ソートの正当性を検証していたので必要以上に時間がかかったが、やはりバグとなる可能性が出てきたため回避コードの実装となった。

このデータを使って板読みの検証を進めるまえに謎のチャートへ実装しているコードの修正が急務だ。

コード

本日実行したコード全体を公開しておく。

import Config from '../utils/config.mjs' //自前コードのため無視してよい
import DB from './libs/classes.mjs' //自前コードのため無視してよい

import dayjs from 'dayjs'

// ------------------------------------------
// 板復元
// v0.0.1
// ------------------------------------------

// 取得する日時 ------------------------------
const targetTime = dayjs().subtract(60, 'second').format() //現在時刻より1分前の板を復元
// ------------------------------------------

const config = new Config('../config/config.yaml') //DB用設定の読み込み 自前コードのため無視してよい

// DBサーバ 自前コードのため無視してよい
const remoteDB = new DB({
    host: config.db_host_remote,
    username: config.db_username,
    password: config.db_password,
}).influx

const queryString = `SELECT * FROM "bitFlyer_db"."autogen"."lightning_board_FX_BTC_JPY" WHERE '${targetTime}' > time AND time > '${targetTime}' - 10s`

remoteDB.query(queryString)
    .then((res) => {
        if (res.length === 0) { console.log("No Data Selected."); process.exit(0); }
        //メイン処理
        boardMake(res)
    }).catch((err) => {
        console.log(err)
    })

const boardMake = (data) => {
    const boardData = { bids: {}, asks: {} }
    let prevTime //時刻の正当性チェック
    for (const d of data) {
        if (prevTime) {
            //時刻の正当性チェック
            dayjs(d.time).isBefore(dayjs(prevTime)) ? console.log("day error") : null
        }
        prevTime = d.time
        // 一旦{ price : size }の連想配列で格納
        if (d.bid_or_ask === 'bid') {
            if (d.size === 0) { delete boardData.bids[d.price] }
            else { boardData.bids[d.price] = d.size }
        } else if (d.bid_or_ask === 'ask') {
            if (d.size === 0) { delete boardData.asks[d.price] }
            else { boardData.asks[d.price] = d.size }
        } else {
            console.log(`Error: 'bid_or_ask' contains unknown data(${d.bid_or_ask})`)
            console.log(`Price:${d.price}, Size:${d.size}`)
        }
    }

    //結果データを生成
    const asksSortedKeys = _sort(boardData.asks)
    const bidsSortedKeys = _sort(boardData.bids)
    const bestAsk = asksSortedKeys[0]
    const bestBid = bidsSortedKeys[bidsSortedKeys.length - 1]
    const spread = bestAsk - bestBid

    //日付
    console.log(targetTime)
    //売り
    console.log(`BestAsk: ${bestAsk}`)
    //買い
    console.log(`BestBid: ${bestBid}`)
    //スプレッド
    console.log(`Spread: ${spread}`)

}

// 各板を昇順にソートしたキー名の配列を返す
const _sort = (message) => {
    const keys = Object.keys(message)
    keys.sort((a, b) => {
        // 昇順
        return a - b
    })

    //ソートされたキー名のみ返す
    return keys
}


この記事をシェア


謎の技術研究部 (謎技研)