packages feed

gemini-exports-0.1.0.1: 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