packages feed

binance-exports-0.1.2.0: src/Console/Binance/Exports/Main.hs

{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-}
{- | CLI application harness.
-}
module Console.Binance.Exports.Main
    ( run
    , getArgs
    , Args(..)
    , loadConfigFile
    , ConfigFile(..)
    ) where

import           Control.Applicative            ( (<|>) )
import           Control.Exception.Safe         ( try )
import           Control.Monad                  ( (<=<) )
import           Control.Monad.IO.Class         ( liftIO )
import           Data.Aeson                     ( (.:?)
                                                , FromJSON(..)
                                                , withObject
                                                )
import           Data.List                      ( sortOn )
import           Data.Maybe                     ( fromMaybe )
import           Data.Ord                       ( Down(..) )
import           Data.Time                      ( UTCTime(..)
                                                , toGregorian
                                                )
import           Data.Time.Clock.POSIX          ( posixSecondsToUTCTime )
import           Data.Version                   ( showVersion )
import           Data.Yaml                      ( prettyPrintParseException )
import           Data.Yaml.Config               ( ignoreEnv
                                                , loadYamlSettings
                                                )
import           System.Console.CmdArgs         ( (&=)
                                                , Data
                                                , Typeable
                                                , args
                                                , 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.Binance.Exports.Csv
import           Paths_binance_exports          ( version )
import           Web.Binance

import qualified Data.ByteString.Lazy.Char8    as LBS
import qualified Data.Text                     as T
import qualified Data.Text.IO                  as T


-- | Generate & print a trade export based on the executable arguments.
run :: ConfigFile -> Args -> IO ()
run cfg cfgArgs = do
    AppConfig {..} <- mergeCfgEnvArgs cfg cfgArgs
    results        <- runApi binanceCfg $ do
        symbolDetails <-
            fmap eiSymbols $ getExchangeInfo symbols >>= handleBinanceError
        rawExportData <- concat <$> mapM getTradesForSymbol symbolDetails
        return . filterYear year $ sortOn (Down . tTime . tedTrade)
                                          rawExportData
    -- Write CSV to file or stdout
    let outputFileOrStdout = fromMaybe "-" outputFile
    let output             = buildTradeExport results
    if outputFileOrStdout == "-"
        then LBS.putStr output
        else LBS.writeFile outputFileOrStdout output
  where
    -- | If an error is present, print the code & message to stderr, then
    -- exit with an error status code.
    handleBinanceError :: Either BinanceError a -> BinanceApiM a
    handleBinanceError = \case
        Left e ->
            liftIO
                $  exitWithErr
                $  "Binance API Error Code "
                <> T.pack (show $ beCode e)
                <> ": "
                <> beMsg e
        Right a -> return a
    -- | Get all trades for the given symbol & convert them into the export
    -- format.
    getTradesForSymbol :: SymbolDetails -> BinanceApiM [TradeExportData]
    getTradesForSymbol s =
        map (TradeExportData s) <$> getTradeHistory (sdSymbol s) Nothing Nothing
    -- | Filter the trades if a 'year' argument has been passed.
    filterYear :: Maybe Integer -> [TradeExportData] -> [TradeExportData]
    filterYear = \case
        Nothing -> id
        Just y ->
            filter
                $ (\(y_, _, _) -> y == y_)
                . toGregorian
                . utctDay
                . posixSecondsToUTCTime
                . tTime
                . tedTrade

-- | Print some error text to 'stderr', then exit with a failure code.
exitWithErr :: T.Text -> IO a
exitWithErr = const exitFailure <=< T.hPutStrLn stderr . ("[ERROR] " <>)


-- CONFIGURATION

data AppConfig = AppConfig
    { binanceCfg :: BinanceConfig
    , symbols    :: [T.Text]
    , year       :: Maybe Integer
    , outputFile :: Maybe FilePath
    }
    deriving (Show, Eq)

-- | Given a parsed configuration file & CLI arguments, check for
-- environmental variables and either build an AppConfig or log an error
-- & exit if no API credentials or symbols have been passed.
mergeCfgEnvArgs :: ConfigFile -> Args -> IO AppConfig
mergeCfgEnvArgs ConfigFile {..} Args {..} = do
    envApiKey    <- fmap T.pack <$> lookupEnv "BINANCE_API_KEY"
    envApiSecret <- fmap T.pack <$> lookupEnv "BINANCE_API_SECRET"
    apiKey       <-
        requiredValue "Pass a Binance API Key with `-k` or $BINANCE_API_KEY."
        $   argApiKey
        <|> envApiKey
        <|> cfgApiKey
    apiSecret <-
        requiredValue
            "Pass a Binance API Secret with `-s` or $BINANCE_API_SECRET."
        $   argApiSecret
        <|> envApiSecret
        <|> cfgApiSecret
    let binanceCfg =
            BinanceConfig { bcApiKey = apiKey, bcApiSecret = apiSecret }
    symbols <- case (argSymbols, fromMaybe [] cfgSymbols) of
        ([], []) -> exitWithErr "Pass at least one symbol."
        ([], s ) -> return s
        (s , _ ) -> return s
    return AppConfig { year = argYear, outputFile = argOutputFile, .. }
  where
    requiredValue :: T.Text -> Maybe a -> IO a
    requiredValue errMsg = maybe (exitWithErr errMsg) return


-- CONFIG FILE

-- | Optional configuration data parsed from the config file.
data ConfigFile = ConfigFile
    { cfgApiKey    :: Maybe T.Text
    , cfgApiSecret :: Maybe T.Text
    , cfgSymbols   :: Maybe [T.Text]
    }
    deriving (Show, Eq)

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

-- | Attempt to read a 'ConfigFile' from
-- @$XDG_CONFIG_HOME\/binance-exports\/config.yaml@. Print any parsing
-- errors to 'stderr'.
loadConfigFile :: IO ConfigFile
loadConfigFile = do
    configPath   <- getUserConfigFile "binance-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 T.Text
    , argApiSecret  :: Maybe T.Text
    , argSymbols    :: [T.Text]
    , argYear       :: Maybe Integer
    , argOutputFile :: Maybe FilePath
    }
    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
                              &= help "Binance API Key"
                              &= name "k"
                              &= name "api-key"
                              &= explicit
                              &= typ "KEY"
            , argApiSecret  = def
                              &= help "Binance API Secret"
                              &= name "s"
                              &= name "api-secret"
                              &= explicit
                              &= typ "SECRET"
            , argYear       = Nothing
                              &= help "Limit output to year"
                              &= name "y"
                              &= name "year"
                              &= explicit
                              &= typ "YYYY"
            , argOutputFile =
                Nothing
                &= help "File to write the export to. Default: stdout"
                &= name "o"
                &= name "output-file"
                &= explicit
                &= typ "FILE"
            , argSymbols    = def &= args &= typ "SYMBOL [SYMBOL ...]"
            }
        &= summary
               (  "binance-exports v"
               <> showVersion version
               <> ", Pavan Rikhi 2022"
               )
        &= program "binance-exports"
        &= helpArg [name "h"]
        &= help "Export Binance Trade History to a CSV"
        &= details programDetails


programDetails :: [String]
programDetails = lines [r|
binance-exports generates a CSV export of your Binances Trade History. It
is intended to replace Binance's (removed) Trade History export.


DESCRIPTION

By default, we will pull every single trade you have made for the passed
symbols & print them out in reverse-chronological order with the following
fields:

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

This closely matches Binance's Trade History export, except we've split the
`symbol` column into `base-asset` & `quote-asset` columns and include the
`trade-id`.


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 `$BINANCE_API_KEY` & `$BINANCE_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/binance-exports/config.yaml`. It
supports the following top-level keys:

    - `api-key`:        (string) Your Binance API key
    - `api-secret`:     (string) Your Binance API secret
    - `symbols`:        (list of strings) The trade symbols to fetch

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


USAGE EXAMPLES

Fetch all my BNB trades:
    binance-exports BNBUSD

Fetch my BTC trades from 2020:
    binance-exports -y 2020 BTCUSD

Fetch my BNB & BTC trades from 2022, write them to a file:
    binance-exports -y 2022 -o 2022-binance-trades.csv BNBUSD BTCUSD
|]