packages feed

binance-exports-0.1.0.0: src/Web/Binance.hs

{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-| Request functions & response types for the Binance.US API
-}
module Web.Binance
    (
    -- * API
      BinanceApiM
    , runApi
    , BinanceConfig(..)
    , BinanceError(..)
    -- * Requests
    -- ** Exchange Info
    , getExchangeInfo
    , ExchangeInfo(..)
    , SymbolDetails(..)
    -- ** Trade History
    , getTradeHistory
    , Trade(..)
    -- * Helpers
    , runSignedRequest
    , mkSignature
    ) where

import           Control.Exception.Safe         ( MonadCatch
                                                , MonadThrow
                                                , throw
                                                , try
                                                )
import           Control.Monad.Reader           ( (<=<)
                                                , MonadIO
                                                , MonadReader
                                                , ReaderT
                                                , ask
                                                , lift
                                                , liftIO
                                                , runReaderT
                                                )
import           Crypto.Hash.SHA256             ( hmac )
import           Data.Aeson                     ( (.:)
                                                , FromJSON(..)
                                                , eitherDecodeStrict'
                                                , withObject
                                                )
import           Data.Function                  ( on )
import           Data.List                      ( minimumBy )
import           Data.Proxy                     ( Proxy )
import           Data.Scientific                ( Scientific )
import           Data.Text.Encoding             ( encodeUtf8 )
import           Data.Time                      ( UTCTime
                                                , getCurrentTime
                                                )
import           Data.Time.Clock.POSIX          ( POSIXTime
                                                , posixSecondsToUTCTime
                                                )
import           Data.Time.Format               ( defaultTimeLocale
                                                , formatTime
                                                )
import           Network.HTTP.Client            ( HttpException(..)
                                                , HttpExceptionContent(..)
                                                , RequestBody(..)
                                                , queryString
                                                , requestBody
                                                , responseStatus
                                                )
import           Network.HTTP.Req              as Req
                                                ( (/:)
                                                , (=:)
                                                , AllowsBody
                                                , GET(..)
                                                , HttpBody
                                                , HttpBodyAllowed
                                                , HttpException(..)
                                                , HttpMethod
                                                , HttpResponse
                                                , JsonResponse
                                                , MonadHttp(..)
                                                , NoReqBody(..)
                                                , Option
                                                , ProvidesBody
                                                , Req
                                                , Url
                                                , defaultHttpConfig
                                                , header
                                                , https
                                                , jsonResponse
                                                , req
                                                , reqCb
                                                , responseBody
                                                , runReq
                                                )
import           Network.HTTP.Types             ( statusCode )
import           Text.Bytedump                  ( hexString )

import qualified Data.ByteString               as BS
import qualified Data.ByteString.Char8         as BC
import qualified Data.ByteString.Lazy          as LBS
import qualified Data.Text                     as T


-- | Necessary configuration data for making requests to the Binance API.
data BinanceConfig = BinanceConfig
    { bcApiKey    :: T.Text
    -- ^ Your API Key
    , bcApiSecret :: T.Text
    -- ^ Your API Key's Secret
    }
    deriving (Show, Read, Eq, Ord)

-- | The monad in which Binance API requests are run.
newtype BinanceApiM a =  BinanceApiM
    { runBinanceApiM :: ReaderT BinanceConfig Req a
    } deriving (Functor, Applicative, Monad, MonadIO, MonadReader BinanceConfig, MonadThrow, MonadCatch)

-- | Run a series of API requests with the given Config.
runApi :: BinanceConfig -> BinanceApiM a -> IO a
runApi cfg = runReq defaultHttpConfig . flip runReaderT cfg . runBinanceApiM

-- | Use 'MonadHttp' from the 'Req' instance.
instance  MonadHttp BinanceApiM where
    handleHttpException = BinanceApiM . lift . handleHttpException

-- | Error responses from the API.
data BinanceError = BinanceError
    { beCode :: Int
    , beMsg  :: T.Text
    }
    deriving (Show, Read, Eq, Ord)

instance FromJSON BinanceError where
    parseJSON = withObject "BinanceError"
        $ \o -> BinanceError <$> o .: "code" <*> o .: "msg"

-- | Decode a 'BinanceError' from a 400-error response, re-throwing all
-- other exception types.
catchErrorResponse
    :: (MonadThrow m, FromJSON a)
    => Either Req.HttpException (JsonResponse a)
    -> m (Either BinanceError a)
catchErrorResponse = \case
    Right r -> return . Right $ responseBody r
    Left e@(VanillaHttpException (HttpExceptionRequest _ (StatusCodeException (statusCode . responseStatus -> 400) errBody)))
        -> either (const $ throw e) (return . Left)
            $ eitherDecodeStrict' errBody
    Left e -> throw e


-- EXCHANGE INFO

-- | Get Exchange Information for the Given Symbol. Right now, just returns
-- the requested symbol information.
--
-- Returns Left if a passed symbol is invalid.
getExchangeInfo
    :: (MonadHttp m, MonadCatch m)
    => [T.Text]
    -> m (Either BinanceError ExchangeInfo)
getExchangeInfo symbols = do
    let symbolsParam =
            mconcat
                [ "["
                , T.intercalate "," (map (\s -> "\"" <> s <> "\"") symbols)
                , "]"
                ]
    catchErrorResponse <=< try $ req
        GET
        (https "api.binance.us" /: "api" /: "v3" /: "exchangeInfo")
        NoReqBody
        jsonResponse
        ("symbols" =: symbolsParam)

-- | General information about the exchange. Currently we only parse out
-- the details of requested symbols.
newtype ExchangeInfo = ExchangeInfo
    { eiSymbols :: [SymbolDetails]
    } deriving (Show, Read, Eq, Ord)

instance FromJSON ExchangeInfo where
    parseJSON =
        withObject "ExchangeInfo" $ \o -> ExchangeInfo <$> o .: "symbols"

-- | The asset pairs for a trade symbol, along with Binance's precisions
-- for each asset.
data SymbolDetails = SymbolDetails
    { sdSymbol              :: T.Text
    , sdBaseAsset           :: T.Text
    , sdBaseAssetPrecision  :: Int
    , sdQuoteAsset          :: T.Text
    , sdQuoteAssetPrecision :: Int
    }
    deriving (Show, Read, Eq, Ord)

instance FromJSON SymbolDetails where
    parseJSON = withObject "SymbolDetails" $ \o -> do
        sdSymbol              <- o .: "symbol"
        sdBaseAsset           <- o .: "baseAsset"
        sdBaseAssetPrecision  <- o .: "baseAssetPrecision"
        sdQuoteAsset          <- o .: "quoteAsset"
        sdQuoteAssetPrecision <- o .: "quoteAssetPrecision"
        return SymbolDetails { .. }


-- TRADE HISTORY

-- | Get Trade History for the Given Symbol.
getTradeHistory
    :: (MonadHttp m, MonadReader BinanceConfig m)
    => T.Text
    -- ^ Full symbol/pair of trades to fetch, e.g. @BNBUSD@.
    -> Maybe UTCTime
    -- ^ Start of time range
    -> Maybe UTCTime
    -- ^ End of time range
    -> m [Trade]
getTradeHistory symbol mbStart mbEnd = do
    cfg       <- ask
    timestamp <- utcToMs <$> liftIO getCurrentTime
    let limit = (1000 :: Int)
    resp <- runSignedRequest
        GET
        (https "api.binance.us" /: "api" /: "v3" /: "myTrades")
        NoReqBody
        jsonResponse
        (mconcat
            [ "symbol" =: symbol
            , "timestamp" =: timestamp
            , "limit" =: limit
            , maybe mempty (("startTime" =:) . utcToMs) mbStart
            , maybe mempty (("endTime" =:) . utcToMs)   mbEnd
            , header "X-MBX-APIKEY" (encodeUtf8 $ bcApiKey cfg)
            ]
        )
    let results = responseBody resp
    if length results /= limit
        then return results
        else do
            let minTime = minimumBy (compare `on` tTime) results
            (results <>) <$> getTradeHistory
                symbol
                mbStart
                (Just . posixSecondsToUTCTime $ tTime minTime)

-- | A single trade made on Binance.
data Trade = Trade
    { tSymbol          :: T.Text
    -- ^ Full symbol of the trade - base asset & quote asset
    , tId              :: Integer
    -- ^ Trade's ID number
    , tOrderId         :: Integer
    -- ^ Order ID number from which the Trade was made
    , tPrice           :: Scientific
    , tQuantity        :: Scientific
    , tQuoteQuantity   :: Scientific
    -- ^ The total amount spent/received during the trade. Note that we do
    -- not use this value in our exports, as Binance truncates it & loses
    -- a fraction of the amount. You probably want to do @'tQuantity'
    -- * 'tPrice'@ instead.
    , tCommission      :: Scientific
    , tCommissionAsset :: T.Text
    , tTime            :: POSIXTime
    , tIsBuyer         :: Bool
    , tIsMaker         :: Bool
    , tIsBestMatch     :: Bool
    }
    deriving (Show, Read, Eq, Ord)

instance FromJSON Trade where
    parseJSON = withObject "Trade" $ \o -> do
        tSymbol          <- o .: "symbol"
        tId              <- o .: "id"
        tOrderId         <- o .: "orderId"
        tPrice           <- read <$> o .: "price"
        tQuantity        <- read <$> o .: "qty"
        tQuoteQuantity   <- read <$> o .: "quoteQty"
        tCommission      <- read <$> o .: "commission"
        tCommissionAsset <- o .: "commissionAsset"
        -- Binance API returns milliseconds, POSIXTime is seconds
        tTime            <- (/ 1000.0) <$> o .: "time"
        tIsBuyer         <- o .: "isBuyer"
        tIsMaker         <- o .: "isMaker"
        tIsBestMatch     <- o .: "isBestMatch"
        return Trade { .. }


-- UTILS

-- | Run a request for a SIGNED endpoint by inserting the signature before
-- making the request.
runSignedRequest
    :: ( MonadHttp m
       , HttpMethod method
       , HttpBody body
       , HttpResponse response
       , HttpBodyAllowed (AllowsBody method) (ProvidesBody body)
       , MonadReader BinanceConfig m
       )
    => method
    -> Url scheme
    -> body
    -> Proxy response
    -> Option scheme
    -> m response
runSignedRequest m u b p s = do
    cfg <- ask
    reqCb m u b p s $ \req_ -> do
        let qs   = BS.drop 1 $ queryString req_
            body = getBodyBS $ requestBody req_
            sig  = mkSignature cfg qs body
            qs_  = if BS.length qs == 0
                then "?signature=" <> sig
                else qs <> "&signature=" <> sig
        return $ req_ { queryString = qs_ }
  where
    getBodyBS = \case
        RequestBodyLBS lbs -> LBS.toStrict lbs
        RequestBodyBS  bs  -> bs
        _                  -> ""

-- | Generate a HMAC SHA256 signature for a SIGNED api request.
mkSignature :: BinanceConfig -> BS.ByteString -> BS.ByteString -> BS.ByteString
mkSignature cfg queryParams reqBody =
    let totalParams = queryParams <> reqBody
        key         = encodeUtf8 $ bcApiSecret cfg
    in  BC.pack . concatMap hexString . BS.unpack $ hmac key totalParams

-- | Convert UTC into posix milliseconds for the Binance API.
utcToMs :: UTCTime -> String
utcToMs = formatTime defaultTimeLocale "%s000"