Add Historical Data (#157)

* Fetch all tokens

* Fetch and store currency history
pull/166/head
Alex Garibay 7 years ago committed by GitHub
parent ff81c21d44
commit bf45faa513
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      apps/explorer/config/config.exs
  2. 38
      apps/explorer/lib/explorer/application.ex
  3. 18
      apps/explorer/lib/explorer/chain/statistics/server.ex
  4. 52
      apps/explorer/lib/explorer/exchange_rates/exchange_rates.ex
  5. 6
      apps/explorer/lib/explorer/exchange_rates/source.ex
  6. 51
      apps/explorer/lib/explorer/exchange_rates/source/coin_market_cap.ex
  7. 14
      apps/explorer/lib/explorer/exchange_rates/token.ex
  8. 117
      apps/explorer/lib/explorer/market/history/cataloger.ex
  9. 19
      apps/explorer/lib/explorer/market/history/source.ex
  10. 75
      apps/explorer/lib/explorer/market/history/source/crypto_compare.ex
  11. 34
      apps/explorer/lib/explorer/market/market.ex
  12. 25
      apps/explorer/lib/explorer/market/market_history.ex
  13. 2
      apps/explorer/mix.exs
  14. 13
      apps/explorer/priv/repo/migrations/20180424203101_create_market_history.exs
  15. 6
      apps/explorer/test/explorer/chain/statistics/server_test.exs
  16. 41
      apps/explorer/test/explorer/exchange_rates/exchange_rates_test.exs
  17. 58
      apps/explorer/test/explorer/exchange_rates/source/coin_market_cap_test.exs
  18. 62
      apps/explorer/test/explorer/market/history/cataloger_test.exs
  19. 90
      apps/explorer/test/explorer/market/history/source/crypto_compare_test.exs
  20. 87
      apps/explorer/test/explorer/market/market_test.exs
  21. 1
      apps/explorer/test/test_helper.exs
  22. 2
      mix.lock

@ -8,7 +8,9 @@ use Mix.Config
config :ethereumex, url: "http://localhost:8545"
# General application configuration
config :explorer, ecto_repos: [Explorer.Repo]
config :explorer,
ecto_repos: [Explorer.Repo],
coin: "POA"
config :explorer, :ethereum, backend: Explorer.Ethereum.Live

@ -5,34 +5,38 @@ defmodule Explorer.Application do
use Application
import Supervisor.Spec
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
def start(_type, _args) do
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Explorer.Supervisor]
Supervisor.start_link(children(Mix.env()), opts)
end
defp children(:test), do: children()
# Children to start in all environments
base_children = [
Explorer.Repo,
{Task.Supervisor, name: Explorer.MarketTaskSupervisor}
]
defp children(_) do
exq_options = [] |> Keyword.put(:mode, :enqueuer)
children = base_children ++ secondary_children(Mix.env())
children() ++
[
supervisor(Exq, [exq_options]),
worker(Explorer.Chain.Statistics.Server, []),
Explorer.ExchangeRates
]
opts = [strategy: :one_for_one, name: Explorer.Supervisor]
Supervisor.start_link(children, opts)
end
defp children do
defp secondary_children(:test), do: []
# Children to start when not testing
defp secondary_children(_) do
[
supervisor(Explorer.Repo, []),
{Task.Supervisor, name: Explorer.ExchangeRateTaskSupervisor}
%{
id: Exq,
start: {Exq, :start_link, [[mode: :enqueuer]]},
type: :supervisor
},
Explorer.Chain.Statistics.Server,
Explorer.ExchangeRates,
Explorer.Market.History.Cataloger
]
end
end

@ -15,19 +15,19 @@ defmodule Explorer.Chain.Statistics.Server do
end
end
def start_link, do: start_link(true)
def start_link(refresh) do
GenServer.start_link(__MODULE__, refresh, name: __MODULE__)
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def init(true) do
{:noreply, chain} = handle_cast({:update, Statistics.fetch()}, %Statistics{})
{:ok, chain}
def init(opts) do
if Keyword.get(opts, :refresh, true) do
{:noreply, chain} = handle_cast({:update, Statistics.fetch()}, %Statistics{})
{:ok, chain}
else
{:ok, Statistics.fetch()}
end
end
def init(false), do: {:ok, Statistics.fetch()}
def handle_info(:refresh, %Statistics{} = statistics) do
Task.start_link(fn ->
GenServer.cast(__MODULE__, {:update, Statistics.fetch()})

@ -1,6 +1,6 @@
defmodule Explorer.ExchangeRates do
@moduledoc """
Local cache for relevant exchange rates.
Local cache for token exchange rates.
Exchange rate data is updated every 5 minutes.
"""
@ -9,9 +9,8 @@ defmodule Explorer.ExchangeRates do
require Logger
alias Explorer.ExchangeRates.Rate
alias Explorer.ExchangeRates.Token
@default_tickers ~w(poa-network)
@interval :timer.minutes(5)
@table_name :exchange_rates
@ -21,29 +20,30 @@ defmodule Explorer.ExchangeRates do
def handle_info(:update, state) do
Logger.debug(fn -> "Updating cached exchange rates" end)
for ticker <- @default_tickers do
fetch_ticker(ticker)
end
fetch_rates()
{:noreply, state}
end
# Callback for successful ticker fetch
# Callback for successful fetch
@impl GenServer
def handle_info({_ref, {ticker, {:ok, %Rate{} = rate}}}, state) do
:ets.insert(table_name(), {ticker, rate})
def handle_info({_ref, {:ok, tokens}}, state) do
records =
for %Token{symbol: symbol} = token <- tokens do
{symbol, token}
end
:ets.insert(table_name(), records)
{:noreply, state}
end
# Callback for errored ticker fetch
# Callback for errored fetch
@impl GenServer
def handle_info({_ref, {ticker, {:error, reason}}}, state) do
Logger.warn(fn ->
"Failed to get exchange rates for ticker '#{ticker}' with reason '#{reason}'."
end)
def handle_info({_ref, {:error, reason}}, state) do
Logger.warn(fn -> "Failed to get exchange rates with reason '#{reason}'." end)
fetch_ticker(ticker)
fetch_rates()
{:noreply, state}
end
@ -81,12 +81,12 @@ defmodule Explorer.ExchangeRates do
@doc """
Lists exchange rates for the tracked tickers.
"""
@spec all_tickers() :: [Rate.t()]
def all_tickers do
@spec list :: [Token.t()]
def list do
table_name()
|> :ets.tab2list()
|> Enum.map(fn {_, rate} -> rate end)
|> Enum.sort_by(fn %Rate{symbol: symbol} -> symbol end)
|> Enum.sort_by(fn %Token{symbol: symbol} -> symbol end)
end
## Undocumented public functions
@ -102,15 +102,15 @@ defmodule Explorer.ExchangeRates do
Application.get_env(:explorer, __MODULE__, [])[key]
end
@spec fetch_ticker(String.t()) :: Task.t()
defp fetch_ticker(ticker) do
Task.Supervisor.async_nolink(Explorer.ExchangeRateTaskSupervisor, fn ->
{ticker, ticker_source().fetch_exchange_rate(ticker)}
end)
@spec exchange_rates_source() :: module()
defp exchange_rates_source do
config(:source) || Explorer.ExchangeRates.Source.CoinMarketCap
end
@spec ticker_source() :: module()
defp ticker_source do
config(:source) || Explorer.ExchangeRates.Source.CoinMarketCap
@spec fetch_rates :: Task.t()
defp fetch_rates do
Task.Supervisor.async_nolink(Explorer.MarketTaskSupervisor, fn ->
exchange_rates_source().fetch_exchange_rates()
end)
end
end

@ -3,10 +3,10 @@ defmodule Explorer.ExchangeRates.Source do
Behaviour for fetching exchange rates from external sources.
"""
alias Explorer.ExchangeRates.Rate
alias Explorer.ExchangeRates.Token
@doc """
Callback for fetching an exchange rate for a given cryptocurrency.
Callback for fetching an exchange rates for currencies/tokens.
"""
@callback fetch_exchange_rate(ticker :: String.t()) :: {:ok, Rate.t()} | {:error, any}
@callback fetch_exchange_rates :: {:ok, [Token.t()]} | {:error, any}
end

@ -3,23 +3,19 @@ defmodule Explorer.ExchangeRates.Source.CoinMarketCap do
Adapter for fetching exchange rates from https://coinmarketcap.com.
"""
alias Explorer.ExchangeRates.{Rate, Source}
alias Explorer.ExchangeRates.{Source, Token}
alias HTTPoison.{Error, Response}
@behaviour Source
@impl Source
def fetch_exchange_rate(ticker) do
url = source_url(ticker)
def fetch_exchange_rates do
headers = [{"Content-Type", "application/json"}]
case HTTPoison.get(url, headers) do
case HTTPoison.get(source_url(), headers) do
{:ok, %Response{body: body, status_code: 200}} ->
{:ok, format_data(body)}
{:ok, %Response{status_code: 404}} ->
{:error, :not_found}
{:ok, %Response{body: body, status_code: status_code}} when status_code in 400..499 ->
{:error, decode_json(body)["error"]}
@ -30,19 +26,22 @@ defmodule Explorer.ExchangeRates.Source.CoinMarketCap do
@doc false
def format_data(data) do
[json] = decode_json(data)
{last_updated_as_unix, _} = Integer.parse(json["last_updated"])
last_updated = DateTime.from_unix!(last_updated_as_unix)
%Rate{
btc_value: Decimal.new(json["price_btc"]),
id: json["id"],
last_updated: last_updated,
market_cap_usd: Decimal.new(json["market_cap_usd"]),
name: json["name"],
symbol: json["symbol"],
usd_value: Decimal.new(json["price_usd"])
}
for item <- decode_json(data), not is_nil(item["last_updated"]) do
{last_updated_as_unix, _} = Integer.parse(item["last_updated"])
last_updated = DateTime.from_unix!(last_updated_as_unix)
%Token{
available_supply: to_decimal(item["available_supply"]),
btc_value: to_decimal(item["price_btc"]),
id: item["id"],
last_updated: last_updated,
market_cap_usd: to_decimal(item["market_cap_usd"]),
name: item["name"],
symbol: item["symbol"],
usd_value: to_decimal(item["price_usd"]),
volume_24h_usd: to_decimal(item["24h_volume_usd"])
}
end
end
defp base_url do
@ -51,10 +50,16 @@ defmodule Explorer.ExchangeRates.Source.CoinMarketCap do
end
defp decode_json(data) do
:jiffy.decode(data, [:return_maps])
Jason.decode!(data)
end
defp to_decimal(nil), do: nil
defp to_decimal(value) do
Decimal.new(value)
end
defp source_url(ticker) do
"#{base_url()}/v1/ticker/#{ticker}/"
defp source_url do
"#{base_url()}/v1/ticker/?limit=0"
end
end

@ -1,11 +1,12 @@
defmodule Explorer.ExchangeRates.Rate do
defmodule Explorer.ExchangeRates.Token do
@moduledoc """
Data container for modeling an exchange rate.
Data container for modeling an exchange rate for a currency/token.
"""
@typedoc """
Represents an exchange rate for a given currency.
Represents an exchange rate for a given token.
* `:available_supply` - Available supply of a token
* `:btc_value` - The Bitcoin value of the currency
* `:id` - ID of a currency
* `:last_updated` - Timestamp of when the value was last updated
@ -13,16 +14,19 @@ defmodule Explorer.ExchangeRates.Rate do
* `:name` - Human-readable name of a ticker
* `:symbol` - Trading symbol used to represent a currency
* `:usd_value` - The USD value of the currency
* `:volume_24h_usd` - The volume from the last 24 hours in USD
"""
@type t :: %__MODULE__{
available_supply: Decimal.t(),
btc_value: Decimal.t(),
id: String.t(),
last_updated: DateTime.t(),
market_cap_usd: Decimal.t(),
name: String.t(),
symbol: String.t(),
usd_value: Decimal.t()
usd_value: Decimal.t(),
volume_24h_usd: Decimal.t()
}
defstruct ~w(btc_value id last_updated market_cap_usd name symbol usd_value)a
defstruct ~w(available_supply btc_value id last_updated market_cap_usd name symbol usd_value volume_24h_usd)a
end

@ -0,0 +1,117 @@
defmodule Explorer.Market.History.Cataloger do
@moduledoc """
Fetches the daily market history.
Market grabs the last 365 day's worth of market history for the configured
coin in the explorer. Once that data is fectched, current day's values are
checked every 60 minutes. Additionally, failed requests to the history
source will follow exponential backoff `100ms * 2^(n+1)` where `n` is the
number of failed requests.
## Configuration
The following example shows the configurable values in a sample config.
config :explorer, Explorer.Market.History.Cataloger,
# fetch interval in milliseconds
history_fetch_interval: :timer.minutes(60),
# Base backoff in milliseconds for failed requets to history API
base_backoff: 100
"""
use GenServer
require Logger
alias Explorer.Market
@typep milliseconds :: non_neg_integer()
## GenServer callbacks
@impl GenServer
def init(:ok) do
send(self(), {:fetch_history, 365})
{:ok, %{}}
end
@impl GenServer
def handle_info({:fetch_history, day_count}, state) do
fetch_history(day_count)
{:noreply, state}
end
@impl GenServer
# Record fetch successful.
def handle_info({_ref, {_, _, {:ok, records}}}, state) do
Market.bulk_insert_history(records)
# Schedule next check for history
fetch_after = config_or_default(:history_fetch_interval, :timer.minutes(60))
Process.send_after(self(), {:fetch_history, 1}, fetch_after)
{:noreply, state}
end
# Failed to get records. Try again.
@impl GenServer
def handle_info({_ref, {day_count, failed_attempts, :error}}, state) do
Logger.warn(fn -> "Failed to fetch market history. Trying again." end)
fetch_history(day_count, failed_attempts + 1)
{:noreply, state}
end
# Callback that a monitored process has shutdown.
@impl GenServer
def handle_info({:DOWN, _, :process, _, _}, state) do
{:noreply, state}
end
@doc """
Starts a process to continually fetch market history.
"""
@spec start_link(term()) :: GenServer.on_start()
def start_link(_) do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end
## Private Functions
@spec base_backoff :: milliseconds()
defp base_backoff do
config_or_default(:base_backoff, 100)
end
@spec config_or_default(atom(), term()) :: term()
defp config_or_default(key, default) do
Application.get_env(:explorer, __MODULE__, [])[key] || default
end
@spec source() :: module()
defp source do
config_or_default(:source, Explorer.Market.History.Source.CryptoCompare)
end
@spec fetch_history(non_neg_integer(), non_neg_integer()) :: Task.t()
defp fetch_history(day_count, failed_attempts \\ 0) do
Task.Supervisor.async_nolink(Explorer.MarketTaskSupervisor, fn ->
Process.sleep(delay(failed_attempts))
{day_count, failed_attempts, source().fetch_history(day_count)}
end)
end
@spec delay(non_neg_integer()) :: milliseconds()
defp delay(0), do: 0
defp delay(1), do: base_backoff()
defp delay(failed_attempts) do
# Simulates 2^n
multiplier = Enum.reduce(2..failed_attempts, 1, fn _, acc -> 2 * acc end)
multiplier * base_backoff()
end
end

@ -0,0 +1,19 @@
defmodule Explorer.Market.History.Source do
@moduledoc """
Interface for a source that allows for fetching of market history.
"""
@typedoc """
Record of market values for a specific date.
"""
@type record :: %{
closing_price: Decimal.t(),
date: Date.t(),
opening_price: Decimal.t()
}
@doc """
Fetch history for a specified amount of days in the past.
"""
@callback fetch_history(previous_days :: non_neg_integer()) :: {:ok, [record()]} | :error
end

@ -0,0 +1,75 @@
defmodule Explorer.Market.History.Source.CryptoCompare do
@moduledoc """
Adapter for fetching market history from https://cryptocompare.com.
The history is fetched for the configured coin. You can specify a
different coin by changing the targeted coin.
# In config.exs
config :explorer, coin: "POA"
"""
alias Explorer.Market.History.Source
alias HTTPoison.Response
@behaviour Source
@typep unix_timestamp :: non_neg_integer()
@impl Source
def fetch_history(previous_days) do
url = history_url(previous_days)
headers = [{"Content-Type", "application/json"}]
case HTTPoison.get(url, headers) do
{:ok, %Response{body: body, status_code: 200}} ->
{:ok, format_data(body)}
_ ->
:error
end
end
@spec base_url :: String.t()
defp base_url do
configured_url = Application.get_env(:explorer, __MODULE__, [])[:base_url]
configured_url || "https://min-api.cryptocompare.com"
end
@spec configured_coin :: String.t()
defp configured_coin do
Application.get_env(:explorer, :coin)
end
@spec date(unix_timestamp()) :: Date.t()
defp date(unix_timestamp) do
unix_timestamp
|> DateTime.from_unix!()
|> DateTime.to_date()
end
@spec format_data(String.t()) :: [Source.record()]
defp format_data(data) do
json = Jason.decode!(data)
for item <- json["Data"] do
%{
closing_price: Decimal.new(item["close"]),
date: date(item["time"]),
opening_price: Decimal.new(item["open"])
}
end
end
@spec history_url(non_neg_integer()) :: String.t()
defp history_url(previous_days) do
query_params = %{
"fsym" => configured_coin(),
"limit" => previous_days,
"tsym" => "USD"
}
"#{base_url()}/data/histoday?#{URI.encode_query(query_params)}"
end
end

@ -0,0 +1,34 @@
defmodule Explorer.Market do
@moduledoc """
Context for data related to the cryptocurrency market.
"""
import Ecto.Query
alias Explorer.Market.MarketHistory
alias Explorer.Repo
@doc """
Retrieves the history for the recent specified amount of days.
Today's date is include as part of the day count
"""
@spec fetch_recent_history(non_neg_integer()) :: [MarketHistory.t()]
def fetch_recent_history(days) when days >= 1 do
day_diff = days * -1
query =
from(
mh in MarketHistory,
where: mh.date > date_add(^Date.utc_today(), ^day_diff, "day"),
order_by: [desc: mh.date]
)
Repo.all(query)
end
@doc false
def bulk_insert_history(records) do
Repo.insert_all(MarketHistory, records, on_conflict: :replace_all, conflict_target: [:date])
end
end

@ -0,0 +1,25 @@
defmodule Explorer.Market.MarketHistory do
@moduledoc """
Represents market history of configured coin to USD.
"""
use Ecto.Schema
schema "market_history" do
field(:closing_price, :decimal)
field(:date, :date)
field(:opening_price, :decimal)
end
@typedoc """
The recorded values of the configured coin to USD for a single day.
* `:closing_price` - Closing price in USD.
* `:date` - The date in UTC.
* `:opening_price` - Opening price in USD.
"""
@type t :: %__MODULE__{
closing_price: Decimal.t(),
date: Date.t(),
opening_price: Decimal.t()
}
end

@ -82,7 +82,7 @@ defmodule Explorer.Mixfile do
{:exvcr, "~> 0.10", only: :test},
{:flow, "~> 0.12"},
{:httpoison, "~> 1.0", override: true},
{:jiffy, "~> 0.15.1"},
{:jason, "~> 1.0"},
{:junit_formatter, ">= 0.0.0", only: [:test], runtime: false},
{:math, "~> 0.3.0"},
{:mock, "~> 0.3.0", only: [:test], runtime: false},

@ -0,0 +1,13 @@
defmodule Explorer.Repo.Migrations.CreateMarketHistory do
use Ecto.Migration
def change do
create table(:market_history) do
add :date, :date
add :closing_price, :decimal
add :opening_price, :decimal
end
create unique_index(:market_history, :date)
end
end

@ -6,19 +6,19 @@ defmodule Explorer.Chain.Statistics.ServerTest do
describe "init/1" do
test "returns a new chain when not told to refresh" do
{:ok, statistics} = Server.init(false)
{:ok, statistics} = Server.init(refresh: false)
assert statistics.number == Statistics.fetch().number
end
test "returns a new chain when told to refresh" do
{:ok, statistics} = Server.init(true)
{:ok, statistics} = Server.init(refresh: true)
assert statistics == Statistics.fetch()
end
test "refreshes when told to refresh" do
{:ok, _} = Server.init(true)
{:ok, _} = Server.init([])
assert_receive :refresh, 2_000
end

@ -4,7 +4,7 @@ defmodule Explorer.ExchangeRatesTest do
import Mox
alias Explorer.ExchangeRates
alias Explorer.ExchangeRates.Rate
alias Explorer.ExchangeRates.Token
alias Explorer.ExchangeRates.Source.TestSource
@moduletag :capture_log
@ -12,7 +12,7 @@ defmodule Explorer.ExchangeRatesTest do
setup :verify_on_exit!
test "start_link" do
stub(TestSource, :fetch_exchange_rate, fn _ -> {:ok, %Rate{}} end)
stub(TestSource, :fetch_exchange_rates, fn -> {:ok, [%Token{}]} end)
set_mox_global()
assert {:ok, _} = ExchangeRates.start_link([])
@ -34,14 +34,13 @@ defmodule Explorer.ExchangeRatesTest do
test "handle_info with :update" do
ExchangeRates.init([])
ticker = "poa-network"
state = %{}
expect(TestSource, :fetch_exchange_rate, fn ^ticker -> {:ok, %Rate{}} end)
expect(TestSource, :fetch_exchange_rates, fn -> {:ok, [%Token{}]} end)
set_mox_global()
assert {:noreply, ^state} = ExchangeRates.handle_info(:update, state)
assert_receive {_, {^ticker, _}}
assert_receive {_, {:ok, [%Token{}]}}
end
describe "ticker fetch task" do
@ -51,48 +50,50 @@ defmodule Explorer.ExchangeRatesTest do
end
test "with successful fetch" do
expected_rate = %Rate{
btc_value: "1.000",
expected_token = %Token{
available_supply: Decimal.new("1000000.0"),
btc_value: Decimal.new("1.000"),
id: "test",
last_updated: DateTime.utc_now(),
market_cap_usd: "123456789.0000000",
market_cap_usd: Decimal.new("1000000.0"),
name: "test",
symbol: "test",
usd_value: "9000.000001"
usd_value: Decimal.new("1.0"),
volume_24h_usd: Decimal.new("1000.0")
}
id = expected_rate.id
expected_id = expected_token.id
state = %{}
assert {:noreply, ^state} = ExchangeRates.handle_info({nil, {id, {:ok, expected_rate}}}, state)
assert {:noreply, ^state} = ExchangeRates.handle_info({nil, {:ok, [expected_token]}}, state)
assert [{^id, ^expected_rate}] = :ets.lookup(ExchangeRates.table_name(), id)
assert [{^expected_id, ^expected_token}] = :ets.lookup(ExchangeRates.table_name(), expected_id)
end
test "with failed fetch" do
ticker = "failed-ticker"
state = %{}
expect(TestSource, :fetch_exchange_rate, fn "failed-ticker" -> {:ok, %Rate{}} end)
expect(TestSource, :fetch_exchange_rates, fn -> {:ok, [%Token{}]} end)
set_mox_global()
assert {:noreply, ^state} = ExchangeRates.handle_info({nil, {ticker, {:error, "some error"}}}, state)
assert {:noreply, ^state} = ExchangeRates.handle_info({nil, {:error, "some error"}}, state)
assert_receive {_, {^ticker, {:ok, _}}}
assert_receive {_, {:ok, _}}
end
end
test "all_tickers/0" do
test "list/0" do
ExchangeRates.init([])
rates = [
%Rate{id: "z", symbol: "z"},
%Rate{id: "a", symbol: "a"}
%Token{id: "z", symbol: "z"},
%Token{id: "a", symbol: "a"}
]
expected_rates = Enum.reverse(rates)
for rate <- rates, do: :ets.insert(ExchangeRates.table_name(), {rate.id, rate})
assert expected_rates == ExchangeRates.all_tickers()
assert expected_rates == ExchangeRates.list()
end
end

@ -1,7 +1,7 @@
defmodule Explorer.ExchangeRates.Source.CoinMarketCapTest do
use ExUnit.Case
alias Explorer.ExchangeRates.Rate
alias Explorer.ExchangeRates.Token
alias Explorer.ExchangeRates.Source.CoinMarketCap
alias Plug.Conn
@ -27,7 +27,7 @@ defmodule Explorer.ExchangeRates.Source.CoinMarketCapTest do
]
"""
describe "fetch_exchange_rate" do
describe "fetch_exchange_rates" do
setup do
bypass = Bypass.open()
Application.put_env(:explorer, CoinMarketCap, base_url: "http://localhost:#{bypass.port}")
@ -39,44 +39,46 @@ defmodule Explorer.ExchangeRates.Source.CoinMarketCapTest do
expected_date = ~N[2018-04-11 19:00:00] |> DateTime.from_naive!("Etc/UTC")
expected = %Rate{
btc_value: Decimal.new("0.00007032"),
id: "poa-network",
last_updated: expected_date,
market_cap_usd: Decimal.new("98941986.0"),
name: "POA Network",
symbol: "POA",
usd_value: Decimal.new("0.485053")
}
assert {:ok, ^expected} = CoinMarketCap.fetch_exchange_rate("poa-network")
end
expected = [
%Token{
available_supply: Decimal.new("203981804.0"),
btc_value: Decimal.new("0.00007032"),
id: "poa-network",
last_updated: expected_date,
market_cap_usd: Decimal.new("98941986.0"),
name: "POA Network",
symbol: "POA",
usd_value: Decimal.new("0.485053"),
volume_24h_usd: Decimal.new("20185000.0")
}
]
test "with invalid ticker", %{bypass: bypass} do
error_text = ~S({"error": "id not found"})
Bypass.expect(bypass, fn conn -> Conn.resp(conn, 404, error_text) end)
assert {:error, :not_found} == CoinMarketCap.fetch_exchange_rate("poa-network")
assert {:ok, ^expected} = CoinMarketCap.fetch_exchange_rates()
end
test "with bad request response", %{bypass: bypass} do
error_text = ~S({"error": "bad request"})
Bypass.expect(bypass, fn conn -> Conn.resp(conn, 400, error_text) end)
assert {:error, "bad request"} == CoinMarketCap.fetch_exchange_rate("poa-network")
assert {:error, "bad request"} == CoinMarketCap.fetch_exchange_rates()
end
end
test "format_data/1" do
expected_date = ~N[2018-04-11 19:00:00] |> DateTime.from_naive!("Etc/UTC")
expected = %Rate{
btc_value: Decimal.new("0.00007032"),
id: "poa-network",
last_updated: expected_date,
market_cap_usd: Decimal.new("98941986.0"),
name: "POA Network",
symbol: "POA",
usd_value: Decimal.new("0.485053")
}
expected = [
%Token{
available_supply: Decimal.new("203981804.0"),
btc_value: Decimal.new("0.00007032"),
id: "poa-network",
last_updated: expected_date,
market_cap_usd: Decimal.new("98941986.0"),
name: "POA Network",
symbol: "POA",
usd_value: Decimal.new("0.485053"),
volume_24h_usd: Decimal.new("20185000.0")
}
]
assert expected == CoinMarketCap.format_data(@json)
end

@ -0,0 +1,62 @@
defmodule Explorer.Market.History.CatalogerTest do
use Explorer.DataCase, async: false
import Mox
alias Explorer.Market.MarketHistory
alias Explorer.Market.History.Cataloger
alias Explorer.Market.History.Source.TestSource
alias Explorer.Repo
setup do
Application.put_env(:explorer, Cataloger, source: TestSource)
:ok
end
test "init" do
assert {:ok, %{}} == Cataloger.init(:ok)
assert_received {:fetch_history, 365}
end
test "handle_info with `{:fetch_history, days}`" do
records = [%{date: ~D[2018-04-01], closing_price: Decimal.new(10), opening_price: Decimal.new(5)}]
expect(TestSource, :fetch_history, fn 1 -> {:ok, records} end)
set_mox_global()
state = %{}
assert {:noreply, state} == Cataloger.handle_info({:fetch_history, 1}, state)
assert_receive {_ref, {1, 0, {:ok, ^records}}}
end
test "handle_info with successful task" do
Application.put_env(:explorer, Cataloger, history_fetch_interval: 1)
record = %{date: ~D[2018-04-01], closing_price: Decimal.new(10), opening_price: Decimal.new(5)}
state = %{}
assert {:noreply, state} == Cataloger.handle_info({nil, {1, 0, {:ok, [record]}}}, state)
assert_receive {:fetch_history, 1}
assert Repo.get_by(MarketHistory, date: record.date)
end
@tag capture_log: true
test "handle_info with failed task" do
state = %{}
test_pid = self()
expect(TestSource, :fetch_history, fn 10 -> send(test_pid, :retry) end)
set_mox_global()
assert {:noreply, state} == Cataloger.handle_info({nil, {10, 0, :error}}, state)
# Back off check
refute_receive :retry, 100
assert_receive :retry, 300
end
test "handle info for DOWN message" do
assert {:noreply, %{}} == Cataloger.handle_info({:DOWN, nil, :process, nil, nil}, %{})
end
@tag capture_log: true
test "start_link" do
assert {:ok, _} = Cataloger.start_link([])
end
end

@ -0,0 +1,90 @@
defmodule Explorer.Market.History.Source.CryptoCompareTest do
use ExUnit.Case, async: false
alias Explorer.Market.History.Source.CryptoCompare
alias Plug.Conn
@json """
{
"Response": "Success",
"Type": 100,
"Aggregated": false,
"Data": [
{
"time": 1524528000,
"close": 9655.77,
"high": 9741.91,
"low": 8957.68,
"open": 8967.86,
"volumefrom": 136352.05,
"volumeto": 1276464750.74
},
{
"time": 1524614400,
"close": 8873.62,
"high": 9765.23,
"low": 8757.06,
"open": 9657.69,
"volumefrom": 192797.41,
"volumeto": 1779806222.98
},
{
"time": 1524700800,
"close": 8804.32,
"high": 8965.84,
"low": 8669.38,
"open": 8873.57,
"volumefrom": 74704.5,
"volumeto": 661168891
}
],
"TimeTo": 1524700800,
"TimeFrom": 1523836800,
"FirstValueInArray": true,
"ConversionType": {
"type": "direct",
"conversionSymbol": ""
}
}
"""
describe "fetch_history/1" do
setup do
bypass = Bypass.open()
Application.put_env(:explorer, CryptoCompare, base_url: "http://localhost:#{bypass.port}")
{:ok, bypass: bypass}
end
test "with successful request", %{bypass: bypass} do
Bypass.expect(bypass, fn conn -> Conn.resp(conn, 200, @json) end)
expected = [
%{
closing_price: Decimal.new(9655.77),
date: ~D[2018-04-24],
opening_price: Decimal.new(8967.86)
},
%{
closing_price: Decimal.new(8873.62),
date: ~D[2018-04-25],
opening_price: Decimal.new(9657.69)
},
%{
closing_price: Decimal.new(8804.32),
date: ~D[2018-04-26],
opening_price: Decimal.new(8873.57)
}
]
assert {:ok, expected} == CryptoCompare.fetch_history(3)
end
test "with errored request", %{bypass: bypass} do
error_text = ~S({"error": "server error"})
Bypass.expect(bypass, fn conn -> Conn.resp(conn, 500, error_text) end)
assert :error == CryptoCompare.fetch_history(3)
end
end
end

@ -0,0 +1,87 @@
defmodule Explorer.MarketTest do
use Explorer.DataCase
alias Explorer.Market
alias Explorer.Market.MarketHistory
alias Explorer.Repo
test "fetch_recent_history/1" do
today = Date.utc_today()
records =
for i <- 0..5 do
%{
date: Timex.shift(today, days: i * -1),
closing_price: Decimal.new(1),
opening_price: Decimal.new(1)
}
end
Market.bulk_insert_history(records)
history = Market.fetch_recent_history(1)
assert length(history) == 1
assert Enum.at(history, 0).date == Enum.at(records, 0).date
more_history = Market.fetch_recent_history(5)
assert length(more_history) == 5
for {history_record, index} <- Enum.with_index(more_history) do
assert history_record.date == Enum.at(records, index).date
end
end
describe "bulk_insert_history/1" do
test "inserts records" do
comparable_values = %{
~D[2018-04-01] => %{
date: ~D[2018-04-01],
closing_price: Decimal.new(1),
opening_price: Decimal.new(1)
},
~D[2018-04-02] => %{
date: ~D[2018-04-02],
closing_price: Decimal.new(1),
opening_price: Decimal.new(1)
},
~D[2018-04-03] => %{
date: ~D[2018-04-03],
closing_price: Decimal.new(1),
opening_price: Decimal.new(1)
}
}
insertable_records = Map.values(comparable_values)
Market.bulk_insert_history(insertable_records)
history = Repo.all(MarketHistory)
missing_records =
Enum.reduce(history, comparable_values, fn record, acc ->
initial_record = Map.get(acc, record.date)
assert record.date == initial_record.date
assert record.closing_price == initial_record.closing_price
assert record.opening_price == initial_record.opening_price
Map.delete(acc, record.date)
end)
assert missing_records == %{}
end
test "overrides existing records on date conflict" do
date = ~D[2018-04-01]
Repo.insert(%MarketHistory{date: date})
new_record = %{
date: date,
closing_price: Decimal.new(1),
opening_price: Decimal.new(1)
}
Market.bulk_insert_history([new_record])
fetched_record = Repo.get_by(MarketHistory, date: date)
assert fetched_record.closing_price == new_record.closing_price
assert fetched_record.opening_price == new_record.opening_price
end
end
end

@ -11,3 +11,4 @@ ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo, :manual)
Mox.defmock(Explorer.ExchangeRates.Source.TestSource, for: Explorer.ExchangeRates.Source)
Mox.defmock(Explorer.Market.History.Source.TestSource, for: Explorer.Market.History.Source)

@ -35,7 +35,7 @@
"html_entities": {:hex, :html_entities, "0.4.0", "f2fee876858cf6aaa9db608820a3209e45a087c5177332799592142b50e89a6b", [:mix], [], "hexpm"},
"httpoison": {:hex, :httpoison, "1.1.0", "497949fb62924432f64a45269d20e6f61ecf35084ffa270917afcdb7cd4d8061", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
"idna": {:hex, :idna, "5.1.1", "cbc3b2fa1645113267cc59c760bafa64b2ea0334635ef06dbac8801e42f7279c", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
"jiffy": {:hex, :jiffy, "0.15.1", "be83b09388da1a6c7e798207c9d6a1c4d71bb95fcc387d37d35861788f49ab97", [:rebar3], [], "hexpm"},
"jason": {:hex, :jason, "1.0.0", "0f7cfa9bdb23fed721ec05419bcee2b2c21a77e926bce0deda029b5adc716fe2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"},
"junit_formatter": {:hex, :junit_formatter, "2.1.0", "ff03d2bbe9a67041f2488d8e72180ddcf4dff9e9c8a39b79eac9828fcb9e9bbf", [:mix], [], "hexpm"},
"math": {:hex, :math, "0.3.0", "e14e7291115201cb155a3567e66d196bf5088a6f55b030d598107d7ae934a11c", [:mix], [], "hexpm"},

Loading…
Cancel
Save