packages feed

gemini-exports-0.1.0.3: src/Web/Gemini.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE RecordWildCards #-}

-- | Request functions & response types for the Gemini Exchange API.
module Web.Gemini
    ( GeminiApiM
    , runApi
    , GeminiConfig (..)
    , GeminiError (..)

      -- * Requests

      -- ** Symbol Details
    , getSymbolDetails
    , SymbolDetails (..)

      -- ** Trade History
    , getMyTrades
    , Trade (..)

      -- ** Transfer History
    , getMyTransfers
    , Transfer (..)

      -- ** Earn History
    , getMyEarnTransactions
    , EarnHistory (..)
    , EarnTransaction (..)

      -- * Helpers
    , protectedGeminiRequest
    , retryWithRateLimit
    , createSignature
    , makeNonce
    ) where

import Control.Concurrent (threadDelay)
import Control.Exception.Safe
    ( MonadCatch
    , MonadThrow
    , try
    )
import Control.Monad.Reader
    ( MonadIO (liftIO)
    , MonadReader (ask)
    , ReaderT (..)
    , lift
    )
import Crypto.Hash (SHA384)
import Crypto.MAC.HMAC
    ( hmac
    , hmacGetDigest
    )
import Data.Aeson
    ( FromJSON (..)
    , ToJSON (..)
    , Value (..)
    , eitherDecode
    , encode
    , withObject
    , (.:)
    , (.:?)
    )
import Data.Base64.Types (extractBase64)
import Data.ByteString.Base64 (encodeBase64')
import Data.Maybe
    ( fromMaybe
    , listToMaybe
    , mapMaybe
    )
import Data.Ratio ((%))
import Data.Scientific (Scientific)
import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8)
import Data.Time (UTCTime)
import Data.Time.Clock.POSIX
    ( POSIXTime
    , getPOSIXTime
    , utcTimeToPOSIXSeconds
    )
import Data.Version (showVersion)
import GHC.Generics (Generic)
import Network.HTTP.Client
    ( HttpException (..)
    , HttpExceptionContent (..)
    , responseStatus
    )
import Network.HTTP.Req
    ( GET (..)
    , HttpBodyAllowed
    , HttpException (..)
    , HttpMethod (..)
    , JsonResponse
    , MonadHttp (..)
    , NoReqBody (..)
    , Option
    , POST (..)
    , ProvidesBody
    , Req
    , Url
    , defaultHttpConfig
    , header
    , https
    , jsonResponse
    , req
    , responseBody
    , runReq
    , (/:)
    )
import Network.HTTP.Types (Status (..))
import Text.Read (readMaybe)

import Paths_gemini_exports (version)

import qualified Data.Aeson.KeyMap as KM
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


-- | Required configuration data for making requests to the Gemini API.
data GeminiConfig = GeminiConfig
    { gcApiKey :: Text
    , gcApiSecret :: Text
    }
    deriving (Show, Read, Eq, Ord)


-- | Monad in which Gemini API requests are run.
newtype GeminiApiM a = GeminiApiM
    { runGeminiApiM :: ReaderT GeminiConfig Req a
    }
    deriving (Functor, Applicative, Monad, MonadIO, MonadReader GeminiConfig, MonadThrow, MonadCatch)


-- | Run a series of API requests with the given Config.
runApi :: GeminiConfig -> GeminiApiM a -> IO a
runApi cfg = runReq defaultHttpConfig . flip runReaderT cfg . runGeminiApiM


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


-- | Potential error response body from the API.
data GeminiError = GeminiError
    { geReason :: Text
    , geMessage :: Text
    }
    deriving (Show, Read, Eq, Ord)


instance FromJSON GeminiError where
    parseJSON = withObject "GeminiError" $
        \o -> GeminiError <$> o .: "reason" <*> o .: "message"


-- SYMBOL DETAILS

-- | Fetch the details on a supported symbol.
getSymbolDetails :: (MonadHttp m) => Text -> m SymbolDetails
getSymbolDetails symbol =
    responseBody
        <$> req
            GET
            ( https "api.gemini.com"
                /: "v1"
                /: "symbols"
                /: "details"
                /: symbol
            )
            NoReqBody
            jsonResponse
            userAgentHeader


-- | Currency & Precision details for a 'Trade' Symbol.
data SymbolDetails = SymbolDetails
    { sdSymbol :: Text
    , sdBaseCurrency :: Text
    , sdBasePrecision :: Scientific
    , sdQuoteCurrency :: Text
    , sdQuotePrecision :: Scientific
    }
    deriving (Show, Read, Eq, Ord, Generic)


instance FromJSON SymbolDetails where
    parseJSON = withObject "SymbolDetails" $ \o -> do
        sdSymbol <- o .: "symbol"
        sdBaseCurrency <- o .: "base_currency"
        sdBasePrecision <- o .: "tick_size"
        sdQuoteCurrency <- o .: "quote_currency"
        sdQuotePrecision <- o .: "quote_increment"
        return SymbolDetails {..}


-- TRADE HISTORY

-- | Fetch all my Gemini Trades
getMyTrades
    :: Maybe (UTCTime, UTCTime)
    -- ^ Optional @(start, end)@ ranges for fetching.
    -> GeminiApiM [Trade]
getMyTrades = fetchAllPages getTradeBatch tTimestamp
  where
    getTradeBatch :: Integer -> GeminiApiM [Trade]
    getTradeBatch timestamp = do
        nonce <- makeNonce
        let parameters =
                KM.fromList
                    [ ("request", String "/v1/mytrades")
                    , ("nonce", toJSON nonce)
                    , ("timestamp", toJSON $ timestampToSeconds timestamp)
                    , ("limit_trades", Number 500)
                    ]
        responseBody
            <$> protectedGeminiRequest
                POST
                (https "api.gemini.com" /: "v1" /: "mytrades")
                parameters


-- | A single, completed Trade.
data Trade = Trade
    { tId :: Integer
    , tSymbol :: Text
    , tPrice :: Scientific
    , tAmount :: Scientific
    , tFeeCurrency :: Text
    , tFeeAmount :: Scientific
    , tIsBuy :: Bool
    , tIsAggressor :: Bool
    , tTimestamp :: POSIXTime
    , tOrderId :: Text
    }
    deriving (Show, Read, Eq, Ord, Generic)


instance FromJSON Trade where
    parseJSON = withObject "Trade" $ \o -> do
        tId <- o .: "tid"
        tSymbol <- o .: "symbol"
        tPrice <- read <$> o .: "price"
        tAmount <- read <$> o .: "amount"
        tFeeCurrency <- o .: "fee_currency"
        tFeeAmount <- read <$> o .: "fee_amount"
        tIsBuy <- (== ("Buy" :: String)) <$> o .: "type"
        tIsAggressor <- o .: "aggressor"
        tTimestamp <- (/ 1000.0) <$> o .: "timestampms"
        tOrderId <- o .: "order_id"
        return Trade {..}


-- TRANSFER HISTORY

-- | Fetch all my Gemini Transfers
getMyTransfers
    :: Maybe (UTCTime, UTCTime)
    -- ^ Optional @(start, end)@ ranges for fetching.
    -> GeminiApiM [Transfer]
getMyTransfers = fetchAllPages getTransferBatch trTimestamp
  where
    getTransferBatch :: Integer -> GeminiApiM [Transfer]
    getTransferBatch timestamp = do
        nonce <- makeNonce
        let parameters =
                KM.fromList
                    [ ("request", String "/v1/transfers")
                    , ("nonce", toJSON nonce)
                    , ("timestamp", toJSON $ timestampToSeconds timestamp)
                    , ("limit_transfers", Number 50)
                    ]
        responseBody
            <$> protectedGeminiRequest
                POST
                (https "api.gemini.com" /: "v1" /: "transfers")
                parameters


-- | A single fiat or cryptocurrency transfer, credit, deposit, or withdrawal.
data Transfer = Transfer
    { trId :: Integer
    , trType :: Text
    , trStatus :: Text
    , trCurrency :: Text
    , trAmount :: Scientific
    , trMethod :: Maybe Text
    , trPurpose :: Maybe Text
    , trTimestamp :: POSIXTime
    }
    deriving (Show, Read, Eq, Ord, Generic)


instance FromJSON Transfer where
    parseJSON = withObject "Transfer" $ \o -> do
        trId <- o .: "eid"
        trType <- o .: "type"
        trStatus <- o .: "status"
        trCurrency <- o .: "currency"
        trAmount <- read <$> o .: "amount"
        trMethod <- o .:? "method"
        trPurpose <- o .:? "purpose"
        trTimestamp <- (/ 1000) <$> o .: "timestampms"
        return Transfer {..}


-- EARN HISTORY

-- | Fetch all my Gemini Earn Transactions
getMyEarnTransactions
    :: Maybe (UTCTime, UTCTime) -> GeminiApiM [EarnTransaction]
getMyEarnTransactions = fetchAllPages getEarnBatch etTimestamp
  where
    getEarnBatch :: Integer -> GeminiApiM [EarnTransaction]
    getEarnBatch timestamp = do
        nonce <- makeNonce
        let parameters =
                KM.fromList
                    [ ("request", String "/v1/earn/history")
                    , ("nonce", toJSON nonce)
                    , ("since", toJSON timestamp)
                    , ("sortAsc", toJSON True)
                    , ("limit", Number 500)
                    ]
        concatMap @[] ehTransactions
            . responseBody
            <$> protectedGeminiRequest
                POST
                (https "api.gemini.com" /: "v1" /: "earn" /: "history")
                parameters


-- | Earn Transactions grouped by a Provider/Borrower.
data EarnHistory = EarnHistory
    { ehProviderId :: Text
    , ehTransactions :: [EarnTransaction]
    }


instance FromJSON EarnHistory where
    parseJSON = withObject "EarnHistory" $
        \o -> EarnHistory <$> o .: "providerId" <*> o .: "transactions"


-- | A single Earn transaction.
data EarnTransaction = EarnTransaction
    { etId :: Text
    , etType :: Text
    , etAmountCurrency :: Text
    , etAmount :: Scientific
    , etPriceCurrency :: Maybe Text
    , etPrice :: Maybe Scientific
    , etTimestamp :: POSIXTime
    }
    deriving (Show, Read, Eq, Ord, Generic)


instance FromJSON EarnTransaction where
    parseJSON = withObject "EarnTransaction" $ \o -> do
        etId <- o .: "earnTransactionId"
        etType <- o .: "transactionType"
        etAmountCurrency <- o .: "amountCurrency"
        etAmount <- o .: "amount"
        etPriceCurrency <- o .:? "priceCurrency"
        etPrice <- o .:? "priceAmount"
        etTimestamp <- (/ 1000.0) <$> o .: "dateTime"
        return EarnTransaction {..}


-- UTILS

-- | Run a request that requires authorization against the Gemini API.
protectedGeminiRequest
    :: ( MonadHttp m
       , HttpMethod method
       , HttpBodyAllowed (AllowsBody method) (ProvidesBody NoReqBody)
       , ToJSON body
       , FromJSON response
       , MonadReader GeminiConfig m
       )
    => method
    -> Url scheme
    -> body
    -> m (JsonResponse response)
protectedGeminiRequest method url body = do
    cfg <- ask
    let payload = extractBase64 . encodeBase64' . LBS.toStrict $ encode body
        signature = createSignature cfg payload
    let authorizedOptions =
            mconcat
                [ header "Content-Type" "text/plain"
                , header "X-GEMINI-APIKEY" (encodeUtf8 $ gcApiKey cfg)
                , header "X-GEMINI-PAYLOAD" payload
                , header "X-GEMINI-SIGNATURE" signature
                , header "Cache-Control" "no-cache"
                , userAgentHeader
                ]
    req method url NoReqBody jsonResponse authorizedOptions


-- | Attempt a request & retry if a @429@ @RateLimited@ error is returned.
-- We attempt to parse the retry wait time from the @message@ field but
-- fallback to one second.
retryWithRateLimit :: (MonadHttp m, MonadCatch m) => m a -> m a
retryWithRateLimit request =
    try request >>= \case
        Left e@(VanillaHttpException (HttpExceptionRequest _ (StatusCodeException (statusCode . responseStatus -> 429) body))) ->
            case eitherDecode $ LBS.fromStrict body of
                Left _ -> handleHttpException e
                Right r ->
                    if geReason r == "RateLimited"
                        then
                            let msToWait =
                                    fromMaybe 1000
                                        . listToMaybe
                                        . mapMaybe (readMaybe . T.unpack)
                                        . T.words
                                        $ geMessage r
                             in do
                                    liftIO . threadDelay $ msToWait * 1000
                                    retryWithRateLimit request
                        else handleHttpException e
        Left e -> handleHttpException e
        Right r -> return r


-- | Fetch all pages of a response by calling the API with increasing
-- timestamp fields until it returns an empty response. Takes an optional
-- start & end date to offset the initial fetch & stop fetching early.
fetchAllPages
    :: (Integer -> GeminiApiM [a])
    -- ^ Make a request for items above the given milliseconds @timestamp@
    -> (a -> POSIXTime)
    -- ^ Pull a timestamp from an item
    -> Maybe (UTCTime, UTCTime)
    -- ^ Optional @(start, end)@ range
    -> GeminiApiM [a]
fetchAllPages mkRequest getTimestamp mbRange = do
    let startTimestamp =
            maybe 0 (truncate . (1000 *) . utcTimeToPOSIXSeconds . fst) mbRange
    fetchAll [] startTimestamp
  where
    fetchAll prevResults timestamp = do
        newResults <- retryWithRateLimit $ mkRequest timestamp
        if null newResults
            then return prevResults
            else
                let
                    maxTimestamp = maximum $ map getTimestamp newResults
                    nextTimestamp = truncate $ 1000 * maxTimestamp + 1
                    continueFetching =
                        fetchAll (newResults <> prevResults) nextTimestamp
                    filteredResults end =
                        filter ((<= end) . getTimestamp) newResults
                 in
                    case mbRange of
                        Nothing -> continueFetching
                        Just (_, utcTimeToPOSIXSeconds -> end) ->
                            if maxTimestamp >= end
                                then return $ filteredResults end <> prevResults
                                else continueFetching


-- | Given a timestamp in ms, convert it to a timestamp param in seconds by
-- dividing & rounding up.
timestampToSeconds :: Integer -> Integer
timestampToSeconds = ceiling . (% 1000)


-- | Generate a 'Crypto.MAC.HMAC.HMAC' 'SHA384' signature for an authorized
-- API request.
createSignature
    :: GeminiConfig
    -- ^ API Credentials
    -> BS.ByteString
    -- ^ Base64-encoded request body.
    -> BS.ByteString
createSignature cfg body =
    let digest =
            hmacGetDigest @SHA384 $ hmac (encodeUtf8 $ gcApiSecret cfg) body
     in BC.pack $ show digest


-- | Generate a nonce for authorized requests from the current timestamp in
-- milliseconds.
makeNonce :: (MonadIO m) => m Integer
makeNonce = truncate . (1000 *) <$> liftIO getPOSIXTime


-- | Generate a @User-Agent@ header with the library's current version.
userAgentHeader :: Option scheme
userAgentHeader =
    header "User-Agent" . BC.pack $ "gemini-exports/v" <> showVersion version