見出し画像

Bokehではじめるデータビジュアライゼーション(仮想通貨取引データ分析編) コード解説

今回はMarketTech Meetup #2 で発表させていただいたデモのコード解説をしたいと思います。プレゼンテーションに関する解説はこちらの記事をご覧ください。

先に断っておきますが、コードは全然綺麗じゃないです。あと今更白状しますがアニメーション部分が若干バグり気味です(汗)。

自分でも良くもまぁこんなバグったコードを大公開してしまったなぁと思いますが、自戒を込めて解説します。。。

パッケージ構成

実行するのはmain.pyでvisualization.pyがBokehによってplotする関数を集めたものです。dataフォルダには板情報(order_book)と約定情報(trade_feed)が格納されています。いずれもPublic APIで取得したものをCSVで保存しています。requirements.txtに依存関係のあるパッケージが記載されていますので、pip install -r requirements.txtでインストールしてください。pandasとbokehしかないですけど・・・

.
├── README.md
├── data
│   ├── order_book
│   │   ├── order_book_buy_2019-03-03_14-00-08.csv
│   │   └── order_book_sell_2019-03-03_14-00-08.csv
│   └── trade_feed
│       └── trade_feed_2019-03-03_14-00-08.csv
├── main.py
├── requirements.txt
└── visualization.py

 データ

板情報はこちらです。プライス、気配数量、時間はその瞬間の板情報という意味です。

              price  quantity                  created_at
0      426707.00000  0.010000  2019-03-03 14:00:09.628465
1      426637.14000  0.399848  2019-03-03 14:00:09.628465
2      426608.02000  0.179713  2019-03-03 14:00:09.628465
3      426603.00100  0.200000  2019-03-03 14:00:09.628465
4      426602.36000  0.200000  2019-03-03 14:00:09.628465

約定情報はこちらです。時間は約定時間、約定ID、約定価格、約定数量、taker_sideはMarket Takerつまり成行注文を入れてきた側の売買です。

       created_at        id         price  quantity taker_side
0      1551625212  95728764  425131.00000  0.020000        buy
1      1551625211  95728762  425168.00000  0.100000        buy
2      1551625211  95728761  425119.20000  0.045606       sell
3      1551625211  95728760  425129.00000  0.716000       sell
4      1551625211  95728759  425130.00000  1.000000       sell

main.py

ここでは、plotする部分以外の全体の処理をまとめています。大まかな流れは、1.データ取得、前処理、2.ColumnDataSourceへデータのセット、3.描画機能の呼び出し、4.コールバック処理の設定という流れになっています。ColumnDataSourceやコールバック処理についてはこちらの記事をご覧ください。

必要なライブラリをimportします。

import pandas as pd
from math import pi
from datetime import datetime, timedelta, timezone
import visualization as v
from bokeh.models import ColumnDataSource, Button, WidgetBox
from bokeh.layouts import Row, Column
from bokeh.io import curdoc

次にデータを取得してColumnDataSourceへ渡すまでの前処理をします。約定情報はデータ量がかなり多いので、デモ用には5秒ごとにならしてしまっています。また、板情報も株のように呼値が決まっているわけではなく小数点何桁という世界になってしまうので、ある程度の単位(ここでは20円刻み)でデータをならしています。get_df_histは約定情報のヒストグラム用のデータでここでは0.2BTC単位でX軸をならしています。get_df_trade_feed_sideはドーナツチャート向けのデータ前処理です。

# data source
df_trade_feed = pd.read_csv("./data/trade_feed/trade_feed_2019-03-03_14-00-08.csv").drop(["id"], axis=1)
df_trade_feed["created_at"] = (df_trade_feed["created_at"] // 5) * 5  # grouping by 5 seconds
df_trade_feed["created_at"] = pd.to_datetime(df_trade_feed["created_at"], unit="s")
df_trade_feed["notional"] = df_trade_feed["price"] * df_trade_feed["quantity"]
df_trade_feed = df_trade_feed.groupby(["created_at", "taker_side"]).sum()
df_trade_feed["price"] = df_trade_feed["notional"] / df_trade_feed["quantity"]
df_trade_feed.reset_index(inplace=True)

def get_df_trade_feed(side):
    return df_trade_feed.query("taker_side == '{}'".format(side)).copy(deep=True)

def get_df_order_book(side):
    df_order_book = pd.read_csv("./data/order_book/order_book_{}_2019-03-03_14-00-08.csv".format(side))
    df_order_book["created_at"] = pd.to_datetime(df_order_book["created_at"], unit="ns").dt.floor("S")
    df_order_book["price"] = (df_order_book["price"] // 20) * 20  # grouping by 20 JPY
    df_order_book = df_order_book.groupby(["created_at", "price"]).sum().reset_index()
    return df_order_book

def get_df_hist(df_trade_feed_buy, df_trade_feed_sell_neg):
    df_trade_feed_hist = df_trade_feed_buy.append(df_trade_feed_sell_neg)
    df_trade_feed_hist["quantity"] = (df_trade_feed_hist["quantity"] * 10 // 1) * 1 * 0.1  # bins = 0.2BTC
    df_trade_feed_hist_groupby = df_trade_feed_hist.groupby("quantity").count().reset_index()
    df_trade_feed_hist_groupby.drop(["taker_side", "price", "notional"], axis=1, inplace=True)
    df_trade_feed_hist_groupby.columns = ["quantity", "count"]
    return df_trade_feed_hist.merge(df_trade_feed_hist_groupby, how="left", on="quantity")

def get_df_trade_feed_side(df_trade_feed):
    df_trade_feed_side = df_trade_feed.groupby("taker_side").sum()
    df_trade_feed_side["rate"] = df_trade_feed_side["quantity"] / df_trade_feed_side["quantity"].sum()
    df_trade_feed_side.sort_index(inplace=True)
    return df_trade_feed_side

df_trade_feed_buy = get_df_trade_feed(side="buy")
df_trade_feed_sell = get_df_trade_feed(side="sell")
df_trade_feed_sell_neg = df_trade_feed_sell.copy(deep=True)
df_trade_feed_sell_neg["quantity"] = df_trade_feed_sell_neg["quantity"] * -1
df_trade_feed_hist = get_df_hist(df_trade_feed_buy, df_trade_feed_sell_neg)
df_order_book_buy = get_df_order_book(side="buy")
df_order_book_sell = get_df_order_book(side="sell")
df_order_book_sell["quantity"] = df_order_book_sell["quantity"] * -1
df_trade_feed_side = get_df_trade_feed_side(df_trade_feed)

ここからがColumnDataSourceにデータをセットしているところになります。BokehではこのColumnDataSourceにデータをセットすることで描画にデータを反映させていきます。コールバック処理を行う際にもこのColumnDataSourceを更新することでデータの更新を行います。

source_trade_feed_buy = ColumnDataSource(data=dict(
    created_at=df_trade_feed_buy["created_at"],
    price=df_trade_feed_buy["price"],
    quantity=df_trade_feed_buy["quantity"],
))

source_trade_feed_sell = ColumnDataSource(data=dict(
    created_at=df_trade_feed_sell["created_at"],
    price=df_trade_feed_sell["price"],
    quantity=df_trade_feed_sell["quantity"],
))

source_trade_feed_center = ColumnDataSource(data=dict(
    x0=[datetime(2019, 3, 3, 14, 30, 0, tzinfo=timezone.utc).timestamp() * 1000],
    y0=[df_trade_feed["price"].min()],
    x1=[datetime(2019, 3, 3, 14, 30, 0, tzinfo=timezone.utc).timestamp() * 1000],
    y1=[df_trade_feed["price"].max()],
))

source_trade_feed_distribution_buy = ColumnDataSource(data=dict(
    created_at=df_trade_feed_buy["created_at"],
    price=df_trade_feed_buy["price"],
    quantity=df_trade_feed_buy["quantity"],
))

source_trade_feed_distribution_sell = ColumnDataSource(data=dict(
    created_at=df_trade_feed_sell["created_at"],
    price=df_trade_feed_sell["price"],
    quantity=df_trade_feed_sell_neg["quantity"],
))

source_trade_feed_side = ColumnDataSource(data=dict(
    start_angle=[pi / 2, df_trade_feed_side.tail(1)["rate"].values[0] * 2 * pi + pi / 2],
    end_angle=[df_trade_feed_side.tail(1)["rate"].values[0] * 2 * pi + pi / 2, pi / 2],
    color=["red", "green"],
    side=["sell", "buy"],
))

source_trade_feed_hist_buy = ColumnDataSource(data=dict(
    quantity=df_trade_feed_hist.query("taker_side == 'buy'")["quantity"],
    count=df_trade_feed_hist.query("taker_side == 'buy'")["count"],
))

source_trade_feed_hist_sell = ColumnDataSource(data=dict(
    quantity=df_trade_feed_hist.query("taker_side == 'sell'")["quantity"],
    count=df_trade_feed_hist.query("taker_side == 'sell'")["count"],
))

source_order_book_buy = ColumnDataSource(data=dict(
    price=df_order_book_buy["price"],
    quantity=df_order_book_buy["quantity"],
))

source_order_book_sell = ColumnDataSource(data=dict(
    price=df_order_book_sell["price"],
    quantity=df_order_book_sell["quantity"],
))

その後にBokehによる描画機能を呼び出して描画していきます。visualization.pyの解説は後でしていきます。link plotsのところでは、それぞれの描画結果のX軸やY軸を連動させています。例えばX軸が時間のもの同士を連動させたり、Y軸がプライスになっているもの同士を連動させたりしています。Bokehでは描画結果の拡大縮小や移動などをインタラクティブに行うことができますので、これをしておく事で色々いじっているときに見やすくなります。

# plotting
p_trade_feed = v.plot_trade_feed(source_trade_feed_buy, source_trade_feed_sell, source_trade_feed_center)
p_candlestick = v.plot_candlestick()
p_order_book = v.plot_order_book(source_order_book_buy, source_order_book_sell)
p_trade_feed_distribution = v.plot_trade_feed_distribution(source_trade_feed_distribution_buy, source_trade_feed_distribution_sell)
p_trade_feed_hist = v.plot_trade_feed_histogram(source_trade_feed_hist_buy, source_trade_feed_hist_sell)
p_buy_sell_pie = v.plot_side_trade_feed(source_trade_feed_side)

# link plots
p_trade_feed.y_range = p_order_book.y_range
p_trade_feed_distribution.y_range = p_trade_feed.y_range
p_candlestick.x_range = p_trade_feed.x_range
p_candlestick.y_range = p_trade_feed_distribution.y_range

次にコールバック処理です。非常に長いですが、やっている事は単純で、約定情報の描画のX軸が動いたら、描画するデータをそれに従って更新していきます。下から3分の1ぐらいの箇所に「p_trade_feed.x_range.on_change('start', lambda attr, old, new: update_start(new))
p_trade_feed.x_range.on_change('end', lambda attr, old, new: update_end(new))」という箇所が出てきますが、このon_changeがミソで、X軸のstart(左端)が動いたらupdate_startが呼び出され、X軸のend(右端)が動いたらupdate_endが呼び出されます。update_endの中からupdateが呼び出され、このupdateメソッド内で再度DataFrameでデータを取り直してColumnDataSourceの更新を行なっています。最後方にアニメーションに関する実装がありますが、こちらはボタンを押したら(on_click)、animate関数が呼び出され、そこからanimate_updateが呼び出されて描画が更新されています。

start = 0
end = 0

def update_start(new):
    global start
    start = datetime.utcfromtimestamp(new * 0.001)

def update_end(new):
    global end
    end = datetime.utcfromtimestamp(new * 0.001)
    update()

def update():
    global start, end

    df_trade_feed_buy_new = df_trade_feed_buy.query("'{}' <= created_at <= '{}'".format(start, end))
    df_trade_feed_sell_new = df_trade_feed_sell.query("'{}' <= created_at <= '{}'".format(start, end))

    source_trade_feed_buy.data = dict(
        created_at=df_trade_feed_buy_new["created_at"],
        price=df_trade_feed_buy_new["price"],
        quantity=df_trade_feed_buy_new["quantity"],
    )

    source_trade_feed_sell.data = dict(
        created_at=df_trade_feed_sell_new["created_at"],
        price=df_trade_feed_sell_new["price"],
        quantity=df_trade_feed_sell_new["quantity"],
    )

    source_trade_feed_center.data = dict(
        x0=[start + (end - start) * 0.5],
        y0=[df_trade_feed["price"].min()],
        x1=[start + (end - start) * 0.5],
        y1=[df_trade_feed["price"].max()],
    )

    df_trade_feed_new = df_trade_feed.query("'{}' <= created_at <= '{}'".format(start, start + (end - start) * 0.5))
    df_trade_feed_buy_new = df_trade_feed_buy.query("'{}' <= created_at <= '{}'".format(start, start + (end - start) * 0.5))
    df_trade_feed_sell_new = df_trade_feed_sell.query("'{}' <= created_at <= '{}'".format(start, start + (end - start) * 0.5))
    df_trade_feed_side_new = get_df_trade_feed_side(df_trade_feed_new)
    df_trade_feed_sell_neg_new = df_trade_feed_sell_neg.query("'{}' <= created_at <= '{}'".format(start, start + (end - start) * 0.5))
    df_trade_feed_hist_new = get_df_hist(df_trade_feed_buy_new, df_trade_feed_sell_neg_new)

    source_trade_feed_distribution_buy.data = dict(
        created_at=df_trade_feed_buy_new["created_at"],
        price=df_trade_feed_buy_new["price"],
        quantity=df_trade_feed_buy_new["quantity"],
    )

    source_trade_feed_distribution_sell.data = dict(
        created_at=df_trade_feed_sell_new["created_at"],
        price=df_trade_feed_sell_new["price"],
        quantity=df_trade_feed_sell_neg_new["quantity"],
    )

    source_trade_feed_side.data = dict(
        start_angle=[pi / 2, df_trade_feed_side_new.tail(1)["rate"].values[0] * 2 * pi + pi / 2],
        end_angle=[df_trade_feed_side_new.tail(1)["rate"].values[0] * 2 * pi + pi / 2, pi / 2],
        color=["red", "green"],
        side=["sell", "buy"],
    )

    source_trade_feed_hist_buy.data = dict(
        quantity=df_trade_feed_hist_new.query("taker_side == 'buy'")["quantity"],
        count=df_trade_feed_hist_new.query("taker_side == 'buy'")["count"],
    )

    source_trade_feed_hist_sell.data = dict(
        quantity=df_trade_feed_hist_new.query("taker_side == 'sell'")["quantity"],
        count=df_trade_feed_hist_new.query("taker_side == 'sell'")["count"],
    )

    snapshot_start = start + (end - start) * 0.5 - timedelta(seconds=1)
    snapshot_end = start + (end - start) * 0.5 + timedelta(seconds=1)
    df_order_book_buy_new = df_order_book_buy.query("'{}' <= created_at <= '{}'".format(snapshot_start, snapshot_end))
    df_order_book_sell_new = df_order_book_sell.query("'{}' <= created_at <= '{}'".format(snapshot_start, snapshot_end))

    source_order_book_buy.data = dict(
        price=df_order_book_buy_new["price"],
        quantity=df_order_book_buy_new["quantity"],
    )

    source_order_book_sell.data = dict(
        price=df_order_book_sell_new["price"],
        quantity=df_order_book_sell_new["quantity"],
    )

p_trade_feed.x_range.on_change('start', lambda attr, old, new: update_start(new))
p_trade_feed.x_range.on_change('end', lambda attr, old, new: update_end(new))

animate_start = datetime(2019, 3, 3, 13, 59, 0, tzinfo=timezone.utc).timestamp() * 1000
animate_end = datetime(2019, 3, 3, 15, 0, 0, tzinfo=timezone.utc).timestamp() * 1000

def animate_update():
    global animate_start
    animate_start = animate_start + 2000
    p_trade_feed.x_range.start = animate_start
    p_trade_feed.x_range.end = animate_start + 600000

callback_id = None

def animate():
    global callback_id, start, end
    if button.label == '► Play':
        button.label = '❚❚ Pause'
        callback_id = curdoc().add_periodic_callback(animate_update, 1)
    else:
        button.label = '► Play'
        start = datetime(2019, 3, 3, 14, 0, 0, tzinfo=timezone.utc).timestamp() * 1000
        end = datetime(2019, 3, 3, 15, 0, 0, tzinfo=timezone.utc).timestamp() * 1000
        curdoc().remove_periodic_callback(callback_id)

button = Button(label='► Play', width=60)
button.on_click(animate)

最後に各種描画パーツを並べてcurdoc()というメソッドを読んでいます。ここでこれまで描画してきたものを全てセットします。Bokehにはドキュメントという概念があり、このメソッドでこのドキュメントの更新をしています。詳しくはこちらをご覧ください。

visualization.py

次にBokehで描画を行う部分について解説していきます。まずはimportですがこれだけです。

import pandas as pd
from bokeh.plotting import figure

次にローソク足の描画処理です。pandasのDataFrameにはresampleというメソッドがあり、ここで時間の単位を指定("1T" = 1分)してohlc()というメソッドを呼ぶとローソク足の元データであるOpen High Low Closeのデータに変換してくれます。便利ですね。

その後のincは陽線、decは陰線になります。ここからBokeh固有の実装が出てきますが、w (ローソク足の実体の幅)をミリ秒単位で設定しています。これは、ローソク足のX軸がdatetimeであり、Bokehではこの最小単位はミリ秒になっています。そのため、例えば1分の幅のbar chartを描画したければ、60s * 1000ms 分の幅になります。1時間の幅にしたければさらに60を掛けます。

TOOLSというのは、描画の外側にあるツールでpanがドラッグして移動、wheel_zoomはマウスホイールによるズーム、box_zoomは矩形に切り出した範囲をズームします。resetは元のオリジナルの描画に戻して、saveは現在の描画状態(ズームしていればズームした状態)を画像で保存します。

次のp = figure()で描画部分の処理が始まります。ここで時系列データを描画する際に最も重要なポイントですが、必ずx_axis_type="datetime"を指定します。そしてその後各描画を行うときに指定するX軸のデータは必ずdatetime型になっている必要があります。このデモコードの中ではdf["created_at"] = pd.to_datetime(df["created_at"], unit="s")で変換しています。x_axis_type="datetime"の指定とX軸のデータをdatetime型にすることのこのいずれも必須で、どちらかをやり忘れると、描画自体行われません。何も表示されないので、最初の頃は原因探るのに結構ハマりました。両方忘れると、恐らく文字列のカテゴリデータとして日付データが処理されて多分描画がめちゃくちゃ遅くなる上に、描画の幅も1カテゴリの幅が1になるので、w = 60 * 1000とかにしていると描画もめちゃくちゃになります。

segmentというメソッドでヒゲ部分、vbarのメソッドでローソク足の実体を描画しています。陽線陰線それぞれ別々に描画しています。

def plot_candlestick():

    df = pd.read_csv("./data/trade_feed/trade_feed_2019-03-03_14-00-08.csv").drop(["id", "quantity", "taker_side"], axis=1)
    df["created_at"] = pd.to_datetime(df["created_at"], unit="s")
    df.set_index("created_at", inplace=True)
    df["price"] = df["price"].astype(float)
    df = df.resample("1T").ohlc()
    df.columns = df.columns.droplevel(0)

    inc = df.close > df.open
    dec = df.open > df.close
    w = 1 * 60 * 1000  # half day in ms

    TOOLS = "pan,wheel_zoom,box_zoom,reset,save"

    p = figure(x_axis_type="datetime", tools=TOOLS, plot_height=250, plot_width=750, title="Candlestick")

    p.segment(df.index, df.high, df.index, df.low, color="black")
    p.vbar(df.index[inc], w, df.open[inc], df.close[inc], fill_color="#D5E1DD", line_color="black")
    p.vbar(df.index[dec], w, df.open[dec], df.close[dec], fill_color="#F2583E", line_color="black")

    p.grid.grid_line_alpha = 0.3
    p.left[0].formatter.use_scientific = False

    return p

次に約定情報を散布図で各描画のサイズを約定数量の大きさにしているものです。このメソッドを呼び出す時点でmain.pyの方でColumnDataSourceにデータがセットされているので、その各種ColumnDataSourceを描画にセットしていきます。その際ColumnDataSourceのデータは辞書型で格納されているため、その辞書のキーをx, y, sizeなどに指定していきます。そしてsourceにColumnDataSourceを指定してやればデータと描画が連携されます。

def plot_trade_feed(source_buy, source_sell, source_center):

    p = figure(title="Trade Feed", x_axis_type="datetime", plot_height=250, plot_width=750, output_backend="webgl")
    p.circle(x="created_at", y="price", size="quantity", color="green", source=source_buy,  alpha=0.5)
    p.circle(x="created_at", y="price", size="quantity", color="red", source=source_sell, alpha=0.5)
    p.segment(x0="x0", y0="y0", x1="x1", y1="y1", source=source_center, color="navy")

    p.grid.grid_line_alpha = 0.3
    p.left[0].formatter.use_scientific = False

    return p

残りの部分も同様に処理をしていっている感じです。ドーナツチャートとかはアングルを事前に計算しておいてそれを更新する感じだったりと結構原始的なことをしたりしています。

def plot_trade_feed_distribution(source_buy, source_sell):

    p = figure(title="Trade Feed", plot_height=250, plot_width=250)
    p.hbar(y="price", height=0.01, left=0, right="quantity", color="green", source=source_buy)
    p.hbar(y="price", height=0.01, left="quantity", right=0,  color="red", source=source_sell)

    p.yaxis.major_tick_line_color = None  # turn off y-axis major ticks
    p.yaxis.minor_tick_line_color = None  # turn off y-axis minor ticks
    p.yaxis.major_label_text_font_size = '0pt'  # turn off y-axis tick labels
    p.grid.grid_line_alpha = 0.3

    return p

def plot_side_trade_feed(source):

    p = figure(title="Buy Sell (Trade Feed)", plot_height=200, plot_width=250)
    p.annular_wedge(x=0, y=0, inner_radius=0.4, outer_radius=0.9, start_angle="start_angle", end_angle="end_angle",
                    fill_color="color", source=source, alpha=0.5, legend="side")

    p.xaxis.major_tick_line_color = None  # turn off x-axis major ticks
    p.xaxis.minor_tick_line_color = None  # turn off x-axis minor ticks
    p.yaxis.major_tick_line_color = None  # turn off y-axis major ticks
    p.yaxis.minor_tick_line_color = None  # turn off y-axis minor ticks
    p.xaxis.major_label_text_font_size = '0pt'  # turn off y-axis tick labels
    p.yaxis.major_label_text_font_size = '0pt'  # turn off y-axis tick labels
    p.grid.grid_line_alpha = 0.3

    return p

def plot_order_book(source_buy, source_sell):

    p = figure(title="Order Book", plot_height=250, plot_width=250)
    p.hbar(y="price", height=20, left=0, right="quantity",  color="green", source=source_buy)
    p.hbar(y="price", height=20, left="quantity", right=0,  color="red", source=source_sell)

    p.x_range.start = -5
    p.x_range.end = 5
    p.yaxis.major_tick_line_color = None  # turn off y-axis major ticks
    p.yaxis.minor_tick_line_color = None  # turn off y-axis minor ticks
    p.yaxis.major_label_text_font_size = '0pt'  # turn off y-axis tick labels
    p.grid.grid_line_alpha = 0.3

    return p

def plot_trade_feed_histogram(source_buy, source_sell):

    p = figure(title="Trade Feed Histogram", plot_height=200, plot_width=750)
    p.vbar(x="quantity", top="count", width=0.1, color="green", alpha=0.5, source=source_buy)
    p.vbar(x="quantity", top="count", width=0.1, color="red", alpha=0.5, source=source_sell)

    return p

こんな感じでこのような描画結果になります。

きっと読まれている方はMatplotlibなどで描画周りの知識はある程度お持ちかと思いますので、細かいところは大分端折ってしまいましたが(すいません)、ご参考になれば幸いです。

Bokehはちょっとトリッキーなところがあったり、思わぬところにハマりポイントがあったりしますが、可愛がってやってもらえればと思います。

この記事が気に入ったらサポートをしてみませんか?