All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.github.paoloboni.binance.spot.SpotApi.scala Maven / Gradle / Ivy

/*
 * Copyright (c) 2021 Paolo Boni
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy of
 * this software and associated documentation files (the "Software"), to deal in
 * the Software without restriction, including without limitation the rights to
 * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
 * the Software, and to permit persons to whom the Software is furnished to do so,
 * subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
 * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
 * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
 * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

package io.github.paoloboni.binance.spot

import cats.effect.Async
import cats.implicits._
import fs2.Stream
import io.circe.generic.auto._
import io.github.paoloboni.binance.common._
import io.github.paoloboni.binance.common.parameters.TimeParams
import io.github.paoloboni.binance.common.response._
import io.github.paoloboni.binance.fapi.response.AggregateTradeStream
import io.github.paoloboni.binance.spot.parameters._
import io.github.paoloboni.binance.spot.response._
import io.github.paoloboni.binance.{BinanceApi, common, spot}
import io.github.paoloboni.encryption.{HMAC, MkSignedUri}
import io.github.paoloboni.http.QueryParamsConverter._
import io.github.paoloboni.http.ratelimit.RateLimiters
import io.github.paoloboni.http.{HttpClient, UriOps}
import org.typelevel.log4cats.Logger
import sttp.client3.UriContext
import sttp.client3.circe.{asJson, _}

import java.time.Instant
import scala.util.Try

final case class SpotApi[F[_]: Logger](
    config: SpotConfig[F],
    client: HttpClient[F],
    exchangeInfo: spot.response.ExchangeInformation,
    rateLimiters: RateLimiters[F]
)(implicit F: Async[F])
    extends BinanceApi[F] {

  private val mkSignedUri = new MkSignedUri[F](
    recvWindow = config.recvWindow,
    apiSecret = config.apiSecret
  )

  private val baseUrl        = config.restBaseUrl.addPath("api", "v3")
  private val depthUri       = baseUrl.addPath("depth")
  private val kLinesUri      = baseUrl.addPath("klines")
  private val tickerPriceUri = baseUrl.addPath("ticker", "price")
  private val accountUri     = baseUrl.addPath("account")
  private val orderUri       = baseUrl.addPath("order")
  private val openOrdersUri  = baseUrl.addPath("openOrders")

  /** Returns the depth of the orderbook.
    *
    * @param query
    *   an `Depth` object containing the query parameters
    * @return
    *   the orderbook depth
    */
  def getDepth(query: common.parameters.Depth): F[Depth] = {
    val uri = depthUri.addParams(query.toQueryParams)
    for {
      depthOrError <- client.get[CirceResponse[Depth]](
        uri = uri,
        responseAs = asJson[Depth],
        limiters = rateLimiters.requestsOnly,
        weight = query.limit.weight
      )
      depth <- F.fromEither(depthOrError)
    } yield depth
  }

  /** Returns a stream of Kline objects. It recursively and lazily invokes the endpoint in case the result set doesn't
    * fit in a single page.
    *
    * @param query
    *   an `KLines` object containing the query parameters
    * @return
    *   the stream of Kline objects
    */
  def getKLines(query: common.parameters.KLines): Stream[F, KLine] = {
    val uri = kLinesUri.addParams(query.toQueryParams)
    for {
      response <- Stream.eval(
        client.get[CirceResponse[List[KLine]]](
          uri = uri,
          responseAs = asJson[List[KLine]],
          limiters = rateLimiters.requestsOnly
        )
      )
      rawKlines <- Stream.eval(F.fromEither(response))
      klines <- rawKlines match {
        //check if a lone element is enough to fulfill the query. Otherwise a limit of 1 leads
        //to a strange behaviour
        case loneElement :: Nil
            if (query.endTime.toEpochMilli - loneElement.openTime) > query.interval.duration.toMillis =>
          val newQuery = query.copy(startTime = Instant.ofEpochMilli(loneElement.closeTime))
          Stream.emit(loneElement) ++ getKLines(newQuery)

        case init :+ last if (query.endTime.toEpochMilli - last.openTime) > query.interval.duration.toMillis =>
          val newQuery = query.copy(startTime = Instant.ofEpochMilli(last.openTime))
          Stream.emits(init) ++ getKLines(newQuery)

        case list => Stream.emits(list)
      }
    } yield klines
  }

  /** Returns a snapshot of the prices at the time the query is executed.
    *
    * @return
    *   A sequence of prices (one for each symbol)
    */
  def getPrices(): F[Seq[Price]] = for {
    pricesOrError <- client.get[CirceResponse[List[Price]]](
      uri = tickerPriceUri,
      responseAs = asJson[List[Price]],
      limiters = rateLimiters.requestsOnly,
      weight = 2
    )
    prices <- F.fromEither(pricesOrError)
  } yield prices

  /** Returns the current balance, at the time the query is executed.
    *
    * @return
    *   The account information including the balance (free and locked) for each asset
    */
  def getBalance(): F[SpotAccountInfoResponse] =
    for {
      uri <- mkSignedUri(accountUri)
      responseOrError <- client.get[CirceResponse[SpotAccountInfoResponse]](
        uri = uri,
        responseAs = asJson[SpotAccountInfoResponse],
        limiters = rateLimiters.requestsOnly,
        headers = Map("X-MBX-APIKEY" -> config.apiKey),
        weight = 10
      )
      response <- F.fromEither(responseOrError)
    } yield response

  /** Creates an order.
    *
    * @param orderCreate
    *   the parameters required to define the order
    *
    * @return
    *   The id of the order created
    */
  def createOrder(orderCreate: SpotOrderCreateParams): F[SpotOrderCreateResponse] = {

    def url(currentMillis: Long) = {
      val timeParams = TimeParams(config.recvWindow, currentMillis).toQueryParams
      val query = orderCreate.toQueryParams
        .param(timeParams.toMap)
        .param("newOrderRespType", SpotOrderCreateResponseType.FULL.toString)
      for {
        uri <- Try(uri"${config.restBaseUrl}/api/v3/order").map(_.addParams(query)).toEither
        signature = HMAC.sha256(config.apiSecret, uri.queryString)
      } yield uri.addParam("signature", signature)
    }

    val params = orderCreate.toQueryParams.param("newOrderRespType", SpotOrderCreateResponseType.FULL.toString).toSeq
    for {
      uri <- mkSignedUri(
        uri = orderUri,
        params: _*
      )
      responseOrError <- client
        .post[String, CirceResponse[SpotOrderCreateResponse]](
          uri = uri,
          responseAs = asJson[SpotOrderCreateResponse],
          requestBody = None,
          limiters = rateLimiters.value,
          headers = Map("X-MBX-APIKEY" -> config.apiKey)
        )
      response <- F.fromEither(responseOrError)
    } yield response
  }

  /** Cancels an order.
    *
    * @param orderCancel
    *   the parameters required to cancel the order
    *
    * @return
    *   currently nothing
    */
  def cancelOrder(orderCancel: SpotOrderCancelParams): F[Unit] =
    for {
      uri <- mkSignedUri(
        uri = orderUri,
        orderCancel.toQueryParams.toSeq: _*
      )
      res <- client
        .delete[CirceResponse[io.circe.Json]](
          uri = uri,
          responseAs = asJson[io.circe.Json],
          limiters = rateLimiters.value,
          headers = Map("X-MBX-APIKEY" -> config.apiKey)
        )
      _ <- F.fromEither(res)
    } yield ()

  /** Cancels all orders of a symbol.
    *
    * @param orderCancel
    *   the parameters required to cancel all the orders
    *
    * @return
    *   currently nothing
    */
  def cancelAllOrders(orderCancel: SpotOrderCancelAllParams): F[Unit] =
    for {
      uri <- mkSignedUri(openOrdersUri, orderCancel.toQueryParams.toSeq: _*)
      res <- client
        .delete[CirceResponse[io.circe.Json]](
          uri = uri,
          responseAs = asJson[io.circe.Json],
          limiters = rateLimiters.value,
          headers = Map("X-MBX-APIKEY" -> config.apiKey)
        )
      _ <- F.fromEither(res)
    } yield ()

  /** The Trade Streams push raw trade information; each trade has a unique buyer and seller.
    *
    * @param symbol
    *   the symbol
    * @return
    *   a stream of trades
    */
  def tradeStreams(symbol: String): Stream[F, TradeStream] =
    for {
      uri <- Stream.eval(
        F.fromEither(Try(uri"${config.wsBaseUrl}/ws/${symbol.toLowerCase}@trade").toEither)
      )
      stream <- client.ws[TradeStream](uri)
    } yield stream

  /** The Kline/Candlestick Stream push updates to the current klines/candlestick every second.
    *
    * @param symbol
    *   the symbol
    * @param interval
    *   the interval
    * @return
    *   a stream of klines
    */
  def kLineStreams(symbol: String, interval: Interval): Stream[F, KLineStream] =
    for {
      uri <- Stream.eval(
        F.fromEither(Try(uri"${config.wsBaseUrl}/ws/${symbol.toLowerCase}@kline_${interval.toString}").toEither)
      )
      stream <- client.ws[KLineStream](uri)
    } yield stream

  /** Order book price and quantity depth updates used to locally manage an order book.
    *
    * @param symbol
    *   the symbol
    * @return
    *   a stream of order book price and quantity depth updates
    */
  def diffDepthStream(symbol: String): Stream[F, DiffDepthStream] =
    for {
      uri    <- Stream.eval(F.fromEither(Try(uri"${config.wsBaseUrl}/ws/${symbol.toLowerCase}@depth").toEither))
      stream <- client.ws[DiffDepthStream](uri)
    } yield stream

  /** Top bids and asks
    *
    * @param symbol
    *   the symbol
    * @param level
    *   the level
    * @return
    *   a stream of top bids and asks
    */
  def partialBookDepthStream(symbol: String, level: Level): Stream[F, PartialDepthStream] =
    for {
      uri <- Stream.eval(
        F.fromEither(Try(uri"${config.wsBaseUrl}/ws/${symbol.toLowerCase}@depth${level.toString}").toEither)
      )
      stream <- client.ws[PartialDepthStream](uri)
    } yield stream

  /** Pushes any update to the best bid or ask's price or quantity in real-time for all symbols.
    *
    * @return
    *   a stream of best bid or ask's price or quantity for all symbols
    */
  def allBookTickersStream(): Stream[F, BookTicker] =
    for {
      uri    <- Stream.eval(F.fromEither(Try(uri"${config.wsBaseUrl}/ws/!bookTicker").toEither))
      stream <- client.ws[BookTicker](uri)
    } yield stream

  /** The Aggregate Trade Streams push trade information that is aggregated for a single taker order every 100
    * milliseconds.
    *
    * @param symbol
    *   the symbol
    * @return
    *   a stream of aggregate trade events
    */
  def aggregateTradeStreams(symbol: String): Stream[F, AggregateTradeStream] =
    for {
      uri    <- Stream.eval(F.fromEither(Try(uri"${config.wsBaseUrl}/ws/${symbol.toLowerCase}@aggTrade").toEither))
      stream <- client.ws[AggregateTradeStream](uri)
    } yield stream
}

object SpotApi {
  implicit def factory[F[_]: Logger](implicit
      F: Async[F]
  ): BinanceApi.Factory[F, SpotApi[F], SpotConfig[F]] =
    (config: SpotConfig[F], client: HttpClient[F]) =>
      for {
        exchangeInfoEither <- client
          .get[CirceResponse[spot.response.ExchangeInformation]](
            uri = config.exchangeInfoUrl,
            responseAs = asJson[spot.response.ExchangeInformation],
            limiters = List.empty
          )
        exchangeInfo <- F.fromEither(exchangeInfoEither)
        rateLimiters <- exchangeInfo.createRateLimiters(config.rateLimiterBufferSize)
      } yield SpotApi.apply(config, client, exchangeInfo, rateLimiters)
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy