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