packages feed

gemini-exports-0.1.0.0: 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         ( try )
import           Control.Monad                  ( forM )
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           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
    AppConfig {..} <- makeConfig cfg cfgArgs
    exportData     <- runApi geminiCfg $ do
        trades <- getMyTrades dateRange
        let symbols = L.nub $ map tSymbol trades
        symbolDetails <- fmap M.fromList . forM symbols $ \symbol -> do
            (symbol, ) <$> getSymbolDetails symbol
        tradeExport <- fmap catMaybes . forM trades $ \t -> do
            let mbTrade = TradeExport t <$> M.lookup (tSymbol t) symbolDetails
            mapM makeExportData mbTrade
        transfers        <- getMyTransfers dateRange
        transferExport   <- mapM (makeExportData . TransferExport) transfers
        earnTransactions <- 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

-- | 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)
    }
    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
    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
    }
    deriving (Show, Read, Eq, Ord)

instance FromJSON ConfigFile where
    parseJSON = withObject "ConfigFile" $ \o -> do
        cfgApiKey    <- o .:? "api-key"
        cfgApiSecret <- o .:? "api-secret"
        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


-- CLI ARGS

-- | CLI arguments supported by the executable.
data Args = Args
    { argApiKey     :: Maybe Text
    , argApiSecret  :: Maybe Text
    , argOutputFile :: Maybe FilePath
    , argYear       :: Maybe Integer
    }
    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"
            }
        &= summary
               (  "gemini-exports v"
               <> showVersion version
               <> ", Pavan Rikhi 2022"
               )
        &= 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.


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

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
|]