logo

cmdarek

31-01-2021

Using LiveView and GenServers to track BTC price

My assumption was that price of Bitcoin varies from one cryptocurrecy exchange to another and that I could earn just by buying and selling BTC on different platforms. In order to check that I could just get prices from last few days from few exchanges and use Excel to do the comparison or I could use this idea to improve my knowledge on LiveView and GenServer. As you have already guessed I picked latter option.

Our solution consist of two application: ebisu and ebisu_web. First one is responsible for periodically check BTC price in exchanges, saving this data in db and notifying ebisu_web about new pices. Ebisu_web is simple web application that shows BTC price from few exchanges in real time. To not add unnecessary complexity I'm going to describe solution only for BitBay exchange.

Main components

To fetch data from exchanges we use HTTPoison which is simple yet powerfull HTTP client. Ebisu.Utils.Http is wrapper around this library. It is done that way to reuse it in multiple places and to mock it in tests. This component should be moved to separate application where we could test it with real api and in tests for ebisu app we should use mocks.

                        
defmodule Ebisu.Utils.Http do
  @timeout 5000
  @callback get(String.t()) :: term() | no_return() | {:error, term()}

  def get(url) do
    case HTTPoison.get(url, [{"content-type", "application/json"}], recv_timeout: @timeout) do
      {:ok, %HTTPoison.Response{body: body, status_code: 200}} ->
        Jason.decode!(body)

      {:ok, %HTTPoison.Response{body: body}} ->
        {:error, body}

      {:error, %HTTPoison.Error{reason: reason}} ->
        {:error, reason}
    end
  end

  def new do
    Application.get_env(:ebisu, :http_client)
  end
end
                        
                    

To simplify code we have yet another layer of abstraction for http client. For each exchange we have specific http client. In BitBay example we are going to fetch BTC price in PLN which we then convert to USD.

                        
defmodule Ebisu.Bitbay.Clients.Http do
  alias Ebisu.Utils.Http

  def get_ticker do
    Http.new().get("https://bitbay.net/API/Public/BTCPLN/ticker.json")
  end

  def get_rate do
    rate = Http.new().get("http://api.nbp.pl/api/exchangerates/rates/a/usd/?format=json")

    rate
    |> Map.get("rates")
    |> Enum.at(0)
    |> Map.get("mid")
  end
end
                         
                    

In heart of our application is exchange module which main responsibility is to get last price of BTC, converte it to USD and save it to db. Data is going to be stored in PostgreSQL using Ecto. For this to work we also have to define schema and migration.

                        
defmodule Ebisu.Bitbay do
  alias Ebisu.Bitbay.Clients.Http
  alias Ebisu.Bitbay.Ticker

  alias Ebisu.Repo

  import Ecto.Query

  def add_ticker do
    rate = Http.get_rate()

    Http.get_ticker()
    |> Map.put("rate", rate)
    |> Ticker.changeset()
    |> Repo.insert()
  end

  def tickers(limit \\ 20) do
    Ticker
    |> order_by(desc: :updated_at)
    |> limit(^limit)
    |> Repo.all()
  end
end
                        
                        
defmodule Ebisu.Bitbay.Ticker do
  use Ecto.Schema

  import Ecto.Changeset

  @fields [:max, :min, :last, :bid, :ask, :vwap, :average, :volume, :rate]

  schema "bitbay_tickers" do
    field(:max, :float)
    field(:min, :float)
    field(:last, :float)
    field(:bid, :float)
    field(:ask, :float)
    field(:vwap, :float)
    field(:average, :float)
    field(:volume, :float)
    field(:rate, :float)

    timestamps(type: :utc_datetime)
  end

  def changeset(params) do
    %__MODULE__{}
    |> cast(params, @fields)
    |> validate_required(@fields)
  end
end
                    
                    

Last part of our ebisu application is GenServer called Ticker which we use to periodically call exchange module (which gets and saves information about BTC price) and broadcasts this information to all subscribers using Phoenix.PubSub. Right now have only one subsciber which is LiveView that lives in ebisu_web app.

                        
defmodule Ebisu.Bitbay.Worker.Ticker do
  use GenServer

  alias Ebisu.Bitbay

  @interval Application.fetch_env!(:ebisu, :exchange_worker_interval)

  def start_link(state \\ []) do
    GenServer.start_link(__MODULE__, state)
  end

  def init(state) do
    schedule_ticker_add(state)

    {:ok, state}
  end

  def handle_info(:add_ticker, state) do
    schedule_ticker_add(state)

    {:ok, ticker} = Bitbay.add_ticker()

    Phoenix.PubSub.broadcast(Ebisu.PubSub, "bitbay_ticker", ticker)

    {:noreply, state}
  end

  defp schedule_ticker_add(state) do
    Process.send_after(self(), :add_ticker, Keyword.get(state, :interval, @interval))
  end
end
                        
                    

To test our GenServer we have to make it predictable. Right now when start our worker it call external api and this call can take different amount of time on each call. We use Mox library to always return the same data and to not wait for response. Next we have to start worker wait for 150ms and check if we have any tickers in db.

                        
defmodule Ebisu.Bitbay.Worker.TickerTest do
  use Ebisu.DataCase

  alias Ebisu.Bitbay.Worker.Ticker, as: TickerWorker
  alias Ebisu.Bitbay.Ticker

  import Mox

  setup [:verify_on_exit!, :set_mox_from_context]

  setup do
    http_client = Application.get_env(:ebisu, :http_client)

    Application.put_env(:ebisu, :http_client, Ebisu.Utils.MockHttp)

    on_exit(fn ->
      Application.put_env(:ebisu, :http_client, http_client)
    end)
  end

  test "inserts message" do
    expect(Ebisu.Utils.MockHttp, :get, 2, fn url ->
      if url == "https://bitbay.net/API/Public/BTCPLN/ticker.json" do
        %{
          "max" => 4500,
          "min" => 1465,
          "last" => 1533,
          "bid" => 1513,
          "ask" => 1542,
          "vwap" => 1524.42,
          "average" => 1545.67,
          "volume" => 4.54042857
        }
      else
        %{
          "table" => "A",
          "currency" => "dolar amerykaƄski",
          "code" => "USD",
          "rates" => [
            %{"no" => "003/A/NBP/2021", "effectiveDate" => "2021-01-07", "mid" => 3.6656}
          ]
        }
      end
    end)

    start_supervised(%{
      id: TickerWorker,
      start: {TickerWorker, :start_link, [[interval: 100]]}
    })

    Process.sleep(150)

    assert Repo.aggregate(Ticker, :count) > 0
  end
end
                        
                

Our web application consist of LiveView page which gets tickers at initial load and then passes tickers received from GenServer to web page. On client side js hook is invoked on new data and graph is updated.

                    
defmodule EbisuWeb.TickerLive do
  use EbisuWeb, :live_view

  alias Ebisu.Bitbay
  alias Ebisu.Bitbay.Ticker, as: BitbayTicker

  @impl true
  def mount(_params, _session, socket) do
    if connected?(socket) do
      Phoenix.PubSub.subscribe(Ebisu.PubSub, "bitbay_ticker")
    end

    {:ok,
     assign(socket,
       tickers: %{
         bitbay: format(Bitbay.tickers())
       }
     )}
  end

  @impl true
  def render(assigns) do
    Phoenix.View.render(EbisuWeb.TickerView, "index.html", assigns)
  end

  @impl true
  def handle_info(%BitbayTicker{} = ticker, socket) do
    handle_new_ticker(ticker, socket, :bitbay)
  end

  defp handle_new_ticker(ticker, socket, type) do
    tickers =
      socket.assigns.tickers
      |> Map.get(type)
      |> add(ticker)
      |> window()

    tickers = Map.put(socket.assigns.tickers, type, tickers)

    socket = assign(socket, tickers: tickers)

    {:noreply, push_event(socket, "tickers", %{tickers: tickers})}
  end

  defp add(tickers, ticker) do
    tickers ++ [format(ticker)]
  end

  defp format(tickers) when is_list(tickers) do
    Enum.map(tickers, fn ticker -> format(ticker) end)
  end

  defp format(%BitbayTicker{} = ticker) do
    %{x: DateTime.to_time(ticker.updated_at), y: ticker.last / ticker.rate}
  end

  defp format(ticker) do
    %{x: DateTime.to_time(ticker.updated_at), y: ticker.last}
  end

  defp window(tickers) do
    Enum.take(tickers, -20)
  end
end
                

To display exchange information we are going to use chart.js library. Our view consist of canvas definition where we store initial array of tickers.


<script src="https://cdn.jsdelivr.net/npm/chart.js@2.8.0"></script>

<canvas id="chart" phx-update="ignore" phx-hook="Chart" data-tickers="<%= Poison.encode!(@tickers) %>"></canvas>
                

We define our hook in app.js file. In mounted function we specify how chart should look like. We also define handleEven which is used to update chart with new tickers. This function is called everytime we recive new data from exchange.


let Hooks = {};
Hooks.Chart = {
    tickers() { return JSON.parse(this.el.dataset.tickers) },
    mounted() {
        let ctx = this.el.getContext('2d');
        let chart = new Chart(ctx, {
            type: 'line',
            data: {
                labels: this.tickers().bitbay.map(ticker => ticker.x),
                datasets: [{
                    label: 'Bitbay',
                    borderColor: 'rgb(255, 99, 132)',
                    data: this.tickers().bitbay.map(ticker => ticker.y)
                }]
            },
            options: {}
        });

        this.handleEvent("tickers", (data) => {
            chart.data.datasets[0].data = data.tickers.bitbay.map(ticker => ticker.y);
            chart.data.labels = data.tickers.bitbay.map(ticker => ticker.x);
            chart.update();
        });
    }
};

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}})
            

You can view full code at: https://github.com/elpikel/ebisu