packages feed

gemini-exports-0.1.0.3: src/Console/Gemini/Exports/Main.hs

{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-}

-- | CLI application harness.
module Console.Gemini.Exports.Main
    ( run
    , getArgs
    , Args (..)
    , loadConfigFile
    , ConfigFile (..)
    ) where

import Control.Applicative ((<|>))
import Control.Exception.Safe (displayException, try)
import Control.Monad (forM)
import Control.Monad.IO.Class (liftIO)
import Data.Aeson
    ( FromJSON (..)
    , withObject
    , (.:?)
    )
import Data.Maybe
    ( catMaybes
    , fromMaybe
    )
import Data.Text (Text)
import Data.Time
    ( LocalTime (..)
    , UTCTime (..)
    , ZonedTime (..)
    , fromGregorian
    , getTimeZone
    , timeToTimeOfDay
    , zonedTimeToUTC
    )
import Data.Version (showVersion)
import Data.Yaml (prettyPrintParseException)
import Data.Yaml.Config
    ( ignoreEnv
    , loadYamlSettings
    )
import Network.HTTP.Req (handleHttpException)
import System.Console.CmdArgs
    ( Data
    , Typeable
    , cmdArgs
    , def
    , details
    , explicit
    , help
    , helpArg
    , name
    , program
    , summary
    , typ
    , (&=)
    )
import System.Directory (doesFileExist)
import System.Environment (lookupEnv)
import System.Environment.XDG.BaseDir (getUserConfigFile)
import System.Exit (exitFailure)
import System.IO
    ( hPutStrLn
    , stderr
    )
import Text.RawString.QQ (r)

import Console.Gemini.Exports.Csv
import Paths_gemini_exports (version)
import Web.Gemini

import qualified Data.ByteString.Lazy.Char8 as LBS
import qualified Data.List as L
import qualified Data.Map.Strict as M
import qualified Data.Text as T


-- | Run the executable.
run :: ConfigFile -> Args -> IO ()
run cfg cfgArgs = do
    c@AppConfig {..} <- makeConfig cfg cfgArgs
    exportData <- runApi geminiCfg $ do
        trades <- logOrThrow c [] $ getMyTrades dateRange
        let symbols = L.nub $ map tSymbol trades
        symbolDetails <- fmap (M.fromList . catMaybes) . forM symbols $ \symbol -> do
            logOrThrow c Nothing (Just . (symbol,) <$> getSymbolDetails symbol)
        tradeExport <- fmap catMaybes . forM trades $ \t -> do
            let mbTrade = TradeExport t <$> M.lookup (tSymbol t) symbolDetails
            mapM makeExportData mbTrade
        transfers <- logOrThrow c [] $ getMyTransfers dateRange
        transferExport <- mapM (makeExportData . TransferExport) transfers
        earnTransactions <- logOrThrow c [] $ getMyEarnTransactions dateRange
        earnExport <- mapM (makeExportData . EarnExport) earnTransactions
        return $ tradeExport <> transferExport <> earnExport
    let
        csvData =
            makeExportCsv $
                L.sortOn (getExportLineTimestamp . edLine) exportData
    if outputFile == "-"
        then LBS.putStrLn csvData
        else LBS.writeFile outputFile csvData


-- | When some API action results in an error, either log the error
-- & return a default value or call the the 'handleHttpException' method,
-- depending on the 'ignoreErrors' config value.
logOrThrow :: AppConfig -> a -> GeminiApiM a -> GeminiApiM a
logOrThrow c defVal action =
    try action >>= \case
        Left e ->
            if ignoreErrors c
                then do
                    liftIO $ hPutStrLn stderr ("[ERROR] " <> displayException e)
                    return defVal
                else
                    handleHttpException e
        Right result ->
            return result


-- | Print some text to stderr and then exit with an error.
exitWithError :: String -> IO a
exitWithError msg = hPutStrLn stderr ("[ERROR] " <> msg) >> exitFailure


-- CONFIGURATION

data AppConfig = AppConfig
    { geminiCfg :: GeminiConfig
    , outputFile :: FilePath
    , dateRange :: Maybe (UTCTime, UTCTime)
    , ignoreErrors :: Bool
    }
    deriving (Show, Read, Eq, Ord)


-- | Pull Environmental variables, then merge the config file, env vars,
-- and cli args into an AppConfig.
--
-- Exit with an error if we cannot construct a 'GeminiConfig'.
makeConfig :: ConfigFile -> Args -> IO AppConfig
makeConfig ConfigFile {..} Args {..} = do
    envApiKey <- fmap T.pack <$> lookupEnv "GEMINI_API_KEY"
    gcApiKey <-
        errorIfNothing "Pass a Gemini API Key with `-k` or $GEMINI_API_KEY." $
            argApiKey
                <|> envApiKey
                <|> cfgApiKey
    envApiSecret <- fmap T.pack <$> lookupEnv "GEMINI_API_SECRET"
    gcApiSecret <-
        errorIfNothing
            "Pass a Gemini API Secret with `-s` or $GEMINI_API_SECRET."
            $ argApiSecret
                <|> envApiSecret
                <|> cfgApiSecret
    let geminiCfg = GeminiConfig {..}
    dateRange <- mapM buildDateRange argYear
    envIgnoreErrors <- fmap (not . null) <$> lookupEnv "GEMINI_API_IGNORE_ERRORS"
    let ignoreErrors =
            fromMaybe False $
                argIgnoreErrors
                    <|> envIgnoreErrors
                    <|> cfgIgnoreErrors
    return AppConfig {outputFile = fromMaybe "-" argOutputFile, ..}
  where
    -- Exit with error message if value is 'Nothing'
    errorIfNothing :: String -> Maybe a -> IO a
    errorIfNothing msg = maybe (exitWithError msg) return
    -- Given a year, build a tuple representing the span of a year in the
    -- user's timezone.
    buildDateRange :: Integer -> IO (UTCTime, UTCTime)
    buildDateRange y = do
        let yearStart = UTCTime (fromGregorian y 1 1) 0
            yearEnd =
                UTCTime
                    (fromGregorian y 12 31)
                    ((23 * 60 * 60) + (59 * 60) + 59 + 0.9999)
        (,) <$> mkZonedTime yearStart <*> mkZonedTime yearEnd
    -- Shift a time by the user's timezone - coercing it into a ZonedTime
    -- and converting that back into UTC.
    mkZonedTime :: UTCTime -> IO UTCTime
    mkZonedTime t = do
        tz <- getTimeZone t
        let localTime = LocalTime (utctDay t) (timeToTimeOfDay $ utctDayTime t)
            zonedTime = ZonedTime localTime tz
        return $ zonedTimeToUTC zonedTime


-- CONFIG FILE

-- | Optional configuration data parsed from a yaml file.
data ConfigFile = ConfigFile
    { cfgApiKey :: Maybe Text
    , cfgApiSecret :: Maybe Text
    , cfgIgnoreErrors :: Maybe Bool
    }
    deriving (Show, Read, Eq, Ord)


instance FromJSON ConfigFile where
    parseJSON = withObject "ConfigFile" $ \o -> do
        cfgApiKey <- o .:? "api-key"
        cfgApiSecret <- o .:? "api-secret"
        cfgIgnoreErrors <- o .:? "ignore-errors"
        return ConfigFile {..}


-- | Attempt to read a 'ConfigFile' from
-- @$XDG_CONFIG_HOME\/gemini-exports\/config.yaml@. Print any parsing
-- errors to 'stderr'.
loadConfigFile :: IO ConfigFile
loadConfigFile = do
    configPath <- getUserConfigFile "gemini-exports" "config.yaml"
    configExists <- doesFileExist configPath
    if configExists
        then
            try (loadYamlSettings [configPath] [] ignoreEnv) >>= \case
                Left (lines . prettyPrintParseException -> errorMsgs) ->
                    hPutStrLn stderr "[WARN] Invalid Configuration Format:"
                        >> mapM_ (hPutStrLn stderr . ("\t" <>)) errorMsgs
                        >> return defaultConfig
                Right cfg -> return cfg
        else return defaultConfig
  where
    defaultConfig :: ConfigFile
    defaultConfig = ConfigFile Nothing Nothing Nothing


-- CLI ARGS

-- | CLI arguments supported by the executable.
data Args = Args
    { argApiKey :: Maybe Text
    , argApiSecret :: Maybe Text
    , argOutputFile :: Maybe FilePath
    , argYear :: Maybe Integer
    , argIgnoreErrors :: Maybe Bool
    }
    deriving (Show, Read, Eq, Data, Typeable)


-- | Parse the CLI arguments with 'System.Console.CmdArgs'.
getArgs :: IO Args
getArgs = cmdArgs argSpec


-- | Defines & documents the CLI arguments.
argSpec :: Args
argSpec =
    Args
        { argApiKey =
            def
                &= name "api-key"
                &= name "k"
                &= explicit
                &= help "Gemini API Key"
                &= typ "KEY"
        , argApiSecret =
            def
                &= name "api-secret"
                &= name "s"
                &= explicit
                &= help "Gemini API Secret"
                &= typ "SECRET"
        , argOutputFile =
            Nothing
                &= help "File to write export to. Default: stdout"
                &= name "o"
                &= name "output-file"
                &= explicit
                &= typ "FILE"
        , argYear =
            Nothing
                &= help "Limit transactions to given year."
                &= name "y"
                &= name "year"
                &= explicit
                &= typ "YYYY"
        , argIgnoreErrors =
            Nothing
                &= help "Log, but ignore any API errors."
                &= name "ignore-errors"
                &= explicit
        }
        &= summary
            ( "gemini-exports v"
                <> showVersion version
                <> ", Pavan Rikhi 2025"
            )
        &= program "gemini-exports"
        &= helpArg [name "h"]
        &= help "Generate CSV Exports of your Gemini Trades."
        &= details programDetails


programDetails :: [String]
programDetails =
    lines
        [r|
gemini-exports generates a CSV export of your Gemini Trades, Earn
Transactions, & Transfers.


DESCRIPTION

By default, we will pull every single trade, transfer, and income you have
made on Gemini & print them out in chronological order with the following
fields:

   time,base-asset,quote-asset,type,description,price,quantity,total,fee,fee-currency,trade-id

Trades have blank descriptions.

Transfers have blank quote-assets, prices, & fees and potential blank
descriptions.

Earn transactions have blank descriptions & fees and potentially blank
quote-assets, price, & totals.


OUTPUT FILE

You can use the `-o` flag to set the file we will write the CSV data into.
By default, the export is simply printed to stdout.

Warning: the export file will always be overwritten. We do not support
appending to an existing file.


ENVIRONMENTAL VARIABLES

Instead of passing in your API credentials via the `-k` & `-s` CLI flags,
you can set the `$GEMINI_API_KEY` & `$GEMINI_API_SECRET` environmental
variables.

Setting the `$GEMINI_API_IGNORE_ERRORS` environmental variable to
a non-empty string will log but ignore API errors and attempt to continue
processing.


CONFIGURATION FILE

You can also set some program options in a YAML file. We attempt to parse
a configuration file at `$XDG_CONFIG_HOME/gemini-exports/config.yaml`. It
supports the following top-level keys:

    - `api-key`:        (string) Your Gemini API key
    - `api-secret`:     (string) Your Gemini API secret
    - `ignore-errors`:  (bool)   Ignore API errors & process as much as possible

Environmental variables will override any configuration options, and CLI
flags will override both environmental variables & configuration file
options.


USAGE EXAMPLES

Fetch all my trades, deposits, withdrawals, & earn transactions:
    gemini-exports -k <API_KEY> -s <API_SECRET>

Fetch my Gemini history from 2020:
    gemini-exports -y 2020

Fetch my history from 2022, write them to a file:
    gemini-exports -y 2022 -o 2022-gemini-history.csv
|]