lib/binance.ex

defmodule Binance do
  alias Binance.Rest.HTTPClient

  # Server

  @doc """
  Pings binance API. Returns `{:ok, %{}}` if successful, `{:error, reason}` otherwise
  """
  def ping() do
    HTTPClient.get_binance("/api/v3/ping")
  end

  @doc """
  Get binance server time in unix epoch.

  Returns `{:ok, time}` if successful, `{:error, reason}` otherwise

  ## Example
  ```
  {:ok, 1515390701097}
  ```

  """
  def get_server_time() do
    case HTTPClient.get_binance("/api/v3/time") do
      {:ok, %{"serverTime" => time}} -> {:ok, time}
      err -> err
    end
  end

  def get_exchange_info() do
    case HTTPClient.get_binance("/api/v3/exchangeInfo") do
      {:ok, data} -> {:ok, Binance.ExchangeInfo.new(data)}
      err -> err
    end
  end

  @doc """
  Get historical trades
  https://binance-docs.github.io/apidocs/spot/en/#old-trade-lookup

  Returns `{:ok, [%Binance.HistoricalTrade{}]}` or `{:error, reason}`.

  ## Example
  {:ok,
    [%Binance.HistoricalTrade{
     id: 192180149,
     is_best_match: true,
     is_buyer_maker: true,
     price: "1.79878000",
     qty: "55.50000000",
     quote_qty: "99.83229000",
     time: 1618341167715
   }]
  }
  """
  def get_historical_trades(symbol, limit, from_id)
      when is_binary(symbol) and is_integer(limit) do
    arguments =
      %{
        symbol: symbol,
        limit: limit
      }
      |> Map.merge(
        unless(
          is_nil(from_id),
          do: %{fromId: from_id},
          else: %{}
        )
      )

    case HTTPClient.unsigned_request_binance(
           "/api/v3/historicalTrades",
           arguments,
           :get
         ) do
      {:ok, data} ->
        {:ok, Enum.map(data, &Binance.HistoricalTrade.new(&1))}

      {:error, err} ->
        err
    end
  end

  # Ticker

  @doc """
  Get all symbols and current prices listed in binance

  Returns `{:ok, [%Binance.SymbolPrice{}]}` or `{:error, reason}`.

  ## Example
  ```
  {:ok,
    [%Binance.SymbolPrice{price: "0.07579300", symbol: "ETHBTC"},
     %Binance.SymbolPrice{price: "0.01670200", symbol: "LTCBTC"},
     %Binance.SymbolPrice{price: "0.00114550", symbol: "BNBBTC"},
     %Binance.SymbolPrice{price: "0.00640000", symbol: "NEOBTC"},
     %Binance.SymbolPrice{price: "0.00030000", symbol: "123456"},
     %Binance.SymbolPrice{price: "0.04895000", symbol: "QTUMETH"},
     ...]}
  ```
  """
  def get_all_prices() do
    case HTTPClient.get_binance("/api/v3/ticker/price") do
      {:ok, data} ->
        {:ok, Enum.map(data, &Binance.SymbolPrice.new(&1))}

      err ->
        err
    end
  end

  @doc """
  Retrieves the current ticker information for the given trade pair.

  Symbol can be a binance symbol in the form of `"ETHBTC"` or `%Binance.TradePair{}`.

  Returns `{:ok, %Binance.Ticker{}}` or `{:error, reason}`

  ## Example
  ```
  {:ok,
    %Binance.Ticker{ask_price: "0.07548800", bid_price: "0.07542100",
      close_time: 1515391124878, count: 661676, first_id: 16797673,
      high_price: "0.07948000", last_id: 17459348, last_price: "0.07542000",
      low_price: "0.06330000", open_price: "0.06593800", open_time: 1515304724878,
      prev_close_price: "0.06593800", price_change: "0.00948200",
      price_change_percent: "14.380", volume: "507770.18500000",
      weighted_avg_price: "0.06946930"}}
  ```
  """
  def get_ticker(%Binance.TradePair{} = symbol) do
    case find_symbol(symbol) do
      {:ok, binance_symbol} -> get_ticker(binance_symbol)
      e -> e
    end
  end

  def get_ticker(symbol) when is_binary(symbol) do
    case HTTPClient.get_binance("/api/v3/ticker/24hr?symbol=#{symbol}") do
      {:ok, data} -> {:ok, Binance.Ticker.new(data)}
      err -> err
    end
  end

  @doc """
  Retrieves klines for a symbol, provided a given interval, e.g. "1h".

  Function can also take in a 'limit' argument to reduce the number of intervals.

  Returns `{:ok, [%Binance.Kline{}]` or `{:error, reason}`

  ## Example
  ```
  {:ok,
  [
   %Binance.Kline{
     close: "0.16527000",
     close_time: 1617861599999,
     high: "0.17100000",
     ignore: "0",
     low: "0.16352000",
     number_of_trades: 16167,
     open: "0.17088000",
     open_time: 1617858000000,
     quote_asset_volume: "7713624.32966000",
     taker_buy_base_asset_volume: "22020677.70000000",
     taker_buy_quote_asset_volume: "3668705.43042700",
     volume: "46282422.20000000"
   },
   %Binance.Kline{
   ...
   ```
  """

  def get_klines(symbol, interval, limit \\ 500) when is_binary(symbol) do
    case HTTPClient.get_binance(
           "/api/v3/klines?symbol=#{symbol}&interval=#{interval}&limit=#{limit}"
         ) do
      {:ok, data} ->
        {:ok, Enum.map(data, &Binance.Kline.new(&1))}

      err ->
        err
    end
  end

  @doc """
  Retrieves the bids & asks of the order book up to the depth for the given symbol

  Returns `{:ok, %{bids: [...], asks: [...], lastUpdateId: 12345}}` or `{:error, reason}`

  ## Example
  ```
  {:ok,
    %Binance.OrderBook{
      asks: [
        ["8400.00000000", "2.04078100", []],
        ["8405.35000000", "0.50354700", []],
        ["8406.00000000", "0.32769800", []],
        ["8406.33000000", "0.00239000", []],
        ["8406.51000000", "0.03241000", []]
      ],
      bids: [
        ["8393.00000000", "0.20453200", []],
        ["8392.57000000", "0.02639000", []],
        ["8392.00000000", "1.40893300", []],
        ["8390.09000000", "0.07047100", []],
        ["8388.72000000", "0.04577400", []]
      ],
      last_update_id: 113634395
    }
  }
  ```
  """
  def get_depth(symbol, limit) do
    case HTTPClient.get_binance("/api/v3/depth?symbol=#{symbol}&limit=#{limit}") do
      {:ok, data} -> {:ok, Binance.OrderBook.new(data)}
      err -> err
    end
  end

  @doc """
  Fetches system status.

  Returns `{:ok, %Binance.SystemStatus{}}` or `{:error, reason}`.
  """
  def get_system_status() do
    case HTTPClient.get_binance("/sapi/v1/system/status") do
      {:ok, data} -> {:ok, Binance.SystemStatus.new(data)}
      error -> error
    end
  end

  # Account

  @doc """
  Fetches user account from binance

  Returns `{:ok, %Binance.Account{}}` or `{:error, reason}`.

  In the case of a error on binance, for example with invalid parameters, `{:error, {:binance_error, %{code: code, msg: msg}}}` will be returned.

  Please read https://github.com/binance-exchange/binance-official-api-docs/blob/master/rest-api.md#account-information-user_data to understand API
  """

  def get_account() do
    api_key = Application.get_env(:binance, :api_key)
    secret_key = Application.get_env(:binance, :secret_key)

    case HTTPClient.get_binance("/api/v3/account", %{}, secret_key, api_key) do
      {:ok, data} -> {:ok, Binance.Account.new(data)}
      error -> error
    end
  end

  # User data streams

  @doc """
  Creates a socket listen key that later can be used as parameter to listen for
  user related events.

  Returns `{:ok, %Binance.DataStream{}}` or `{:error, reason}`.

  ## Example
  ```
  {:ok,
    %Binance.DataStream{
      listen_key: "pqia91ma19a5s61cv6a81va65sdf19v8a65a1a5s61cv6a81va65sdf19v8a65a1"
    }
  }
  ```

  For more context please read https://github.com/binance/binance-spot-api-docs/blob/master/user-data-stream.md#create-a-listenkey

  """
  def create_listen_key() do
    case HTTPClient.unsigned_request_binance("/api/v3/userDataStream", "", :post) do
      {:ok, data} -> {:ok, Binance.DataStream.new(data)}
      error -> error
    end
  end

  @doc """
  Socket listen key expires after 30 minutes withouth a pong response, this
  allows keeping it alive.

  Returns `{:ok, %{}}` or `{:error, reason}`.

  For more context please read https://github.com/binance/binance-spot-api-docs/blob/master/user-data-stream.md#pingkeep-alive-a-listenkey

  """
  def keep_alive_listen_key(key) do
    case HTTPClient.unsigned_request_binance(
           "/api/v3/userDataStream",
           "listenKey=#{key}",
           :put
         ) do
      {:ok, data} -> {:ok, data}
      error -> error
    end
  end

  @doc """
  Closes/disables the listen key. To be used when you stop listening to the
  stream.

  Returns `{:ok, %{}}` or `{:error, reason}`.

  For more context please read https://github.com/binance/binance-spot-api-docs/blob/master/user-data-stream.md#close-a-listenkey

  """
  def close_listen_key(key) do
    case HTTPClient.unsigned_request_binance(
           "/api/v3/userDataStream?listenKey=#{key}",
           nil,
           :delete
         ) do
      {:ok, data} -> {:ok, data}
      error -> error
    end
  end

  # Order

  @doc """
  Creates a new order on binance

  Returns `{:ok, %{}}` or `{:error, reason}`.

  In the case of a error on binance, for example with invalid parameters, `{:error, {:binance_error, %{code: code, msg: msg}}}` will be returned.

  Please read https://www.binance.com/restapipub.html#user-content-account-endpoints to understand all the parameters
  """
  def create_order(
        symbol,
        side,
        type,
        quantity,
        price \\ nil,
        time_in_force \\ nil,
        new_client_order_id \\ nil,
        stop_price \\ nil,
        iceberg_quantity \\ nil,
        receiving_window \\ 1000,
        timestamp \\ nil
      ) do
    timestamp =
      case timestamp do
        # timestamp needs to be in milliseconds
        nil ->
          :os.system_time(:millisecond)

        t ->
          t
      end

    arguments =
      %{
        symbol: symbol,
        side: side,
        type: type,
        quantity: quantity,
        timestamp: timestamp,
        recvWindow: receiving_window
      }
      |> Map.merge(
        unless(
          is_nil(new_client_order_id),
          do: %{newClientOrderId: new_client_order_id},
          else: %{}
        )
      )
      |> Map.merge(
        unless(is_nil(stop_price), do: %{stopPrice: format_price(stop_price)}, else: %{})
      )
      |> Map.merge(
        unless(is_nil(iceberg_quantity), do: %{icebergQty: iceberg_quantity}, else: %{})
      )
      |> Map.merge(unless(is_nil(time_in_force), do: %{timeInForce: time_in_force}, else: %{}))
      |> Map.merge(unless(is_nil(price), do: %{price: format_price(price)}, else: %{}))

    case HTTPClient.signed_request_binance("/api/v3/order", arguments, :post) do
      {:ok, %{"code" => code, "msg" => msg}} ->
        {:error, {:binance_error, %{code: code, msg: msg}}}

      data ->
        data
    end
  end

  @doc """
  Creates a new **limit** **buy** order

  Symbol can be a binance symbol in the form of `"ETHBTC"` or `%Binance.TradePair{}`.

  Returns `{:ok, %{}}` or `{:error, reason}`
  """
  def order_limit_buy(symbol, quantity, price, time_in_force \\ "GTC")

  def order_limit_buy(
        %Binance.TradePair{from: from, to: to} = symbol,
        quantity,
        price,
        time_in_force
      )
      when is_number(quantity)
      when is_number(price)
      when is_binary(from)
      when is_binary(to) do
    case find_symbol(symbol) do
      {:ok, binance_symbol} -> order_limit_buy(binance_symbol, quantity, price, time_in_force)
      e -> e
    end
  end

  def order_limit_buy(symbol, quantity, price, time_in_force)
      when is_binary(symbol)
      when is_number(quantity)
      when is_number(price) do
    create_order(symbol, "BUY", "LIMIT", quantity, price, time_in_force)
    |> parse_order_response
  end

  @doc """
  Creates a new **limit** **sell** order

  Symbol can be a binance symbol in the form of `"ETHBTC"` or `%Binance.TradePair{}`.

  Returns `{:ok, %{}}` or `{:error, reason}`
  """
  def order_limit_sell(symbol, quantity, price, time_in_force \\ "GTC")

  def order_limit_sell(
        %Binance.TradePair{from: from, to: to} = symbol,
        quantity,
        price,
        time_in_force
      )
      when is_number(quantity)
      when is_number(price)
      when is_binary(from)
      when is_binary(to) do
    case find_symbol(symbol) do
      {:ok, binance_symbol} -> order_limit_sell(binance_symbol, quantity, price, time_in_force)
      e -> e
    end
  end

  def order_limit_sell(symbol, quantity, price, time_in_force)
      when is_binary(symbol)
      when is_number(quantity)
      when is_number(price) do
    create_order(symbol, "SELL", "LIMIT", quantity, price, time_in_force)
    |> parse_order_response
  end

  @doc """
  Creates a new **market** **buy** order

  Symbol can be a binance symbol in the form of `"ETHBTC"` or `%Binance.TradePair{}`.

  Returns `{:ok, %{}}` or `{:error, reason}`
  """
  def order_market_buy(%Binance.TradePair{from: from, to: to} = symbol, quantity)
      when is_number(quantity)
      when is_binary(from)
      when is_binary(to) do
    case find_symbol(symbol) do
      {:ok, binance_symbol} -> order_market_buy(binance_symbol, quantity)
      e -> e
    end
  end

  def order_market_buy(symbol, quantity)
      when is_binary(symbol)
      when is_number(quantity) do
    create_order(symbol, "BUY", "MARKET", quantity)
  end

  @doc """
  Creates a new **market** **sell** order

  Symbol can be a binance symbol in the form of `"ETHBTC"` or `%Binance.TradePair{}`.

  Returns `{:ok, %{}}` or `{:error, reason}`
  """
  def order_market_sell(%Binance.TradePair{from: from, to: to} = symbol, quantity)
      when is_number(quantity)
      when is_binary(from)
      when is_binary(to) do
    case find_symbol(symbol) do
      {:ok, binance_symbol} -> order_market_sell(binance_symbol, quantity)
      e -> e
    end
  end

  def order_market_sell(symbol, quantity)
      when is_binary(symbol)
      when is_number(quantity) do
    create_order(symbol, "SELL", "MARKET", quantity)
  end

  defp parse_order_response({:ok, response}) do
    {:ok, Binance.OrderResponse.new(response)}
  end

  defp parse_order_response({
         :error,
         {
           :binance_error,
           %{code: -2010, msg: "Account has insufficient balance for requested action."} = reason
         }
       }) do
    {:error, %Binance.InsufficientBalanceError{reason: reason}}
  end

  # Misc

  defp format_price(num) when is_float(num), do: :erlang.float_to_binary(num, [{:decimals, 8}])
  defp format_price(num) when is_integer(num), do: inspect(num)
  defp format_price(num) when is_binary(num), do: num

  @doc """
  Searches and normalizes the symbol as it is listed on binance.

  To retrieve this information, a request to the binance API is done. The result is then **cached** to ensure the request is done only once.

  Order of which symbol comes first, and case sensitivity does not matter.

  Returns `{:ok, "SYMBOL"}` if successfully, or `{:error, reason}` otherwise.

  ## Examples
  These 3 calls will result in the same result string:
  ```
  find_symbol(%Binance.TradePair{from: "ETH", to: "REQ"})
  ```
  ```
  find_symbol(%Binance.TradePair{from: "REQ", to: "ETH"})
  ```
  ```
  find_symbol(%Binance.TradePair{from: "rEq", to: "eTH"})
  ```

  Result: `{:ok, "REQETH"}`

  """
  def find_symbol(%Binance.TradePair{from: from, to: to} = tp)
      when is_binary(from)
      when is_binary(to) do
    case Binance.SymbolCache.get() do
      # cache hit
      {:ok, data} ->
        from = String.upcase(from)
        to = String.upcase(to)

        found = Enum.filter(data, &Enum.member?([from <> to, to <> from], &1))

        case Enum.count(found) do
          1 -> {:ok, found |> List.first()}
          0 -> {:error, :symbol_not_found}
        end

      # cache miss
      {:error, :not_initialized} ->
        case get_all_prices() do
          {:ok, price_data} ->
            price_data
            |> Enum.map(fn x -> x.symbol end)
            |> Binance.SymbolCache.store()

            find_symbol(tp)

          err ->
            err
        end

      err ->
        err
    end
  end

  # Open orders

  @doc """
  Get all open orders, alternatively open orders by symbol

  Returns `{:ok, [%Binance.Order{}]}` or `{:error, reason}`.

  Weight: 1 for a single symbol; 40 when the symbol parameter is omitted

  ## Example
  ```
  {:ok,
    [%Binance.Order{price: "0.1", origQty: "1.0", executedQty: "0.0", ...},
     %Binance.Order{...},
     %Binance.Order{...},
     %Binance.Order{...},
     %Binance.Order{...},
     %Binance.Order{...},
     ...]}
  ```
  """
  def get_open_orders() do
    api_key = Application.get_env(:binance, :api_key)
    secret_key = Application.get_env(:binance, :secret_key)

    case HTTPClient.get_binance("/api/v3/openOrders", %{}, secret_key, api_key) do
      {:ok, data} -> {:ok, Enum.map(data, &Binance.Order.new(&1))}
      err -> err
    end
  end

  def get_open_orders(%Binance.TradePair{} = symbol) do
    case find_symbol(symbol) do
      {:ok, binance_symbol} -> get_open_orders(binance_symbol)
      e -> e
    end
  end

  def get_open_orders(symbol) when is_binary(symbol) do
    api_key = Application.get_env(:binance, :api_key)
    secret_key = Application.get_env(:binance, :secret_key)

    case HTTPClient.get_binance("/api/v3/openOrders", %{:symbol => symbol}, secret_key, api_key) do
      {:ok, data} -> {:ok, Enum.map(data, &Binance.Order.new(&1))}
      err -> err
    end
  end

  # Order

  @doc """
  Get order by symbol, timestamp and either orderId or origClientOrderId are mandatory

  Returns `{:ok, [%Binance.Order{}]}` or `{:error, reason}`.

  Weight: 1

  ## Example
  ```
  {:ok, %Binance.Order{price: "0.1", origQty: "1.0", executedQty: "0.0", ...}}
  ```

  Info: https://github.com/binance-exchange/binance-official-api-docs/blob/master/rest-api.md#query-order-user_data
  """
  def get_order(
        symbol,
        timestamp,
        order_id \\ nil,
        orig_client_order_id \\ nil,
        recv_window \\ nil
      ) do
    case is_binary(symbol) do
      true ->
        fetch_order(symbol, timestamp, order_id, orig_client_order_id, recv_window)

      false ->
        case find_symbol(symbol) do
          {:ok, binance_symbol} ->
            fetch_order(binance_symbol, timestamp, order_id, orig_client_order_id, recv_window)

          e ->
            e
        end
    end
  end

  def fetch_order(symbol, timestamp, order_id, orig_client_order_id, recv_window)
      when is_binary(symbol)
      when is_integer(timestamp)
      when is_integer(order_id) or is_binary(orig_client_order_id) do
    api_key = Application.get_env(:binance, :api_key)
    secret_key = Application.get_env(:binance, :secret_key)

    arguments =
      %{
        symbol: symbol,
        timestamp: timestamp
      }
      |> Map.merge(unless(is_nil(order_id), do: %{orderId: order_id}, else: %{}))
      |> Map.merge(
        unless(
          is_nil(orig_client_order_id),
          do: %{origClientOrderId: orig_client_order_id},
          else: %{}
        )
      )
      |> Map.merge(unless(is_nil(recv_window), do: %{recvWindow: recv_window}, else: %{}))

    case HTTPClient.get_binance("/api/v3/order", arguments, secret_key, api_key) do
      {:ok, data} -> {:ok, Binance.Order.new(data)}
      err -> err
    end
  end

  @doc """
  Cancel an active order..

  Symbol and either orderId or origClientOrderId must be sent.

  Returns `{:ok, %Binance.Order{}}` or `{:error, reason}`.

  Weight: 1

  Info: https://github.com/binance-exchange/binance-official-api-docs/blob/master/rest-api.md#cancel-order-trade
  """
  def cancel_order(
        symbol,
        timestamp,
        order_id \\ nil,
        orig_client_order_id \\ nil,
        new_client_order_id \\ nil,
        recv_window \\ nil
      ) do
    case is_binary(symbol) do
      true ->
        cancel_order_(
          symbol,
          timestamp,
          order_id,
          orig_client_order_id,
          new_client_order_id,
          recv_window
        )

      false ->
        case find_symbol(symbol) do
          {:ok, binance_symbol} ->
            cancel_order_(
              binance_symbol,
              timestamp,
              order_id,
              orig_client_order_id,
              new_client_order_id,
              recv_window
            )

          e ->
            e
        end
    end
  end

  defp cancel_order_(
         symbol,
         timestamp,
         order_id,
         orig_client_order_id,
         new_client_order_id,
         recv_window
       )
       when is_binary(symbol)
       when is_integer(timestamp)
       when is_integer(order_id) or is_binary(orig_client_order_id) do
    api_key = Application.get_env(:binance, :api_key)
    secret_key = Application.get_env(:binance, :secret_key)

    arguments =
      %{
        symbol: symbol,
        timestamp: timestamp
      }
      |> Map.merge(unless(is_nil(order_id), do: %{orderId: order_id}, else: %{}))
      |> Map.merge(
        unless(
          is_nil(orig_client_order_id),
          do: %{origClientOrderId: orig_client_order_id},
          else: %{}
        )
      )
      |> Map.merge(
        unless(is_nil(new_client_order_id),
          do: %{newClientOrderId: new_client_order_id},
          else: %{}
        )
      )
      |> Map.merge(unless(is_nil(recv_window), do: %{recvWindow: recv_window}, else: %{}))

    case HTTPClient.delete_binance("/api/v3/order", arguments, secret_key, api_key) do
      {:ok, data} -> {:ok, Binance.Order.new(data)}
      err -> err
    end
  end
end