Skip to main content

lib/rujira/fin/book.ex

defmodule Rujira.Fin.Book do
  @moduledoc """
  Order book for the FIN protocol.

  Struct, construction, and queries. Use `Rujira.Fin` as the public API.
  """

  alias Rujira.Contracts
  alias Rujira.Fin.Pair
  alias Rujira.Logger
  alias Rujira.Math

  use Memoize

  defmodule Price do
    @moduledoc """
    Represents a price level in the order book with associated order details.
    """
    alias Rujira.Amount
    alias Rujira.Math

    defstruct price: Decimal.new(0),
              total: 0,
              side: nil,
              value: 0

    @type side :: :bid | :ask
    @type t :: %__MODULE__{
            price: Decimal.t(),
            total: Amount.t(),
            side: side,
            value: Amount.t()
          }

    @spec new(side, map()) :: {:ok, t()} | {:error, term()}
    def new(side, %{"price" => price_str, "total" => total_str}) when side in [:bid, :ask] do
      with {:ok, price} <- Math.to_decimal(price_str),
           {:ok, total} <- Amount.new(total_str) do
        {:ok,
         %__MODULE__{
           side: side,
           total: total,
           price: price,
           value: value(side, price, total)
         }}
      end
    end

    def new(_, _), do: {:error, :invalid_attrs}

    @spec value(side, Decimal.t(), Amount.t()) :: Amount.t()
    defp value(:ask, price, total) do
      total
      |> Decimal.new()
      |> Decimal.mult(price)
      |> Decimal.round(0, :floor)
      |> Decimal.to_integer()
    end

    defp value(:bid, price, total) do
      total
      |> Decimal.new()
      |> Decimal.div(price)
      |> Decimal.round(0, :floor)
      |> Decimal.to_integer()
    end
  end

  defstruct id: nil,
            bids: [],
            asks: [],
            center: Decimal.new(0),
            spread: Decimal.new(0)

  @type t :: %__MODULE__{
          id: String.t(),
          bids: list(Price.t()),
          asks: list(Price.t()),
          center: Decimal.t(),
          spread: Decimal.t()
        }

  # --- Construction ---

  @spec new(String.t(), map()) :: {:ok, t()} | {:error, term()}
  def new(address, %{"base" => asks, "quote" => bids}) do
    with {:ok, asks} <- Rujira.Enum.reduce_while_ok(asks, &Price.new(:ask, &1)),
         {:ok, bids} <- Rujira.Enum.reduce_while_ok(bids, &Price.new(:bid, &1)) do
      {:ok, %__MODULE__{id: address, asks: asks, bids: bids} |> populate()}
    end
  end

  # --- Queries ---

  @spec load(Pair.t(), integer()) :: {:ok, Pair.t()} | {:error, term()}
  def load(pair, limit \\ 75)

  def load(pair, limit) do
    with {:ok, res} <- query(pair.address, limit),
         {:ok, book} <- new(pair.address, res) do
      {:ok, %{pair | book: book}}
    else
      {:error, err} ->
        Logger.error(__MODULE__, "load #{pair.address} #{inspect(err)}")
        {:ok, %{pair | book: %__MODULE__{id: pair.address}}}
    end
  end

  @spec from_id(String.t()) :: {:ok, t()} | {:error, term()}
  def from_id(id) do
    with {:ok, res} <- Pair.get(id),
         {:ok, %{book: book}} <- load(res, 100) do
      {:ok, book}
    end
  end

  @spec price(String.t()) :: {:ok, map()} | {:error, term()}
  def price(id) do
    with {:ok, book} <- from_id(id) do
      {:ok, %{price: book.center, change: 0}}
    end
  end

  # --- Calculations ---

  @spec populate(t()) :: t()
  defp populate(%__MODULE__{asks: [ask | _], bids: [bid | _]} = book) do
    center =
      ask.price
      |> Decimal.add(bid.price)
      |> Decimal.div(Decimal.new(2))

    %{
      book
      | center: center,
        spread: ask.price |> Decimal.sub(bid.price) |> Decimal.div(center)
    }
  end

  defp populate(book), do: book

  @spec depth(t(), :bid | :ask, number()) :: non_neg_integer()
  def depth(%__MODULE__{bids: []}, :bid, _), do: 0
  def depth(%__MODULE__{asks: []}, :ask, _), do: 0

  def depth(%__MODULE__{bids: [best | _] = bids}, :bid, deviation),
    do: sum_depth(bids, best.price, Decimal.sub(1, Decimal.from_float(deviation)), :lt, :total)

  def depth(%__MODULE__{asks: [best | _] = asks}, :ask, deviation),
    do: sum_depth(asks, best.price, Decimal.add(1, Decimal.from_float(deviation)), :gt, :value)

  defp sum_depth(prices, best, factor, exclude, field) do
    bound = Decimal.mult(best, factor)

    prices
    |> Enum.filter(&(Decimal.compare(&1.price, bound) != exclude))
    |> Enum.reduce(Decimal.new(0), fn p, acc -> Decimal.add(Map.get(p, field), acc) end)
    |> Decimal.round(0, :floor)
    |> Decimal.to_integer()
  end

  # --- Private ---

  defmemo query(contract, limit \\ 100) do
    Contracts.query_state_smart_with_retry(contract, %{book: %{limit: limit}})
  end
end