packages feed

hledger-stockquotes-0.1.2.0: app/Main.hs

{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ViewPatterns #-}
module Main where

import           Control.Applicative            ( (<|>) )
import           Control.Exception.Safe         ( try )
import           Control.Monad                  ( forM_ )
import           Data.Aeson                     ( (.:?)
                                                , FromJSON(..)
                                                , withObject
                                                )
import           Data.List                      ( partition )
import           Data.Maybe                     ( fromMaybe )
import           Data.Time                      ( Day
                                                , defaultTimeLocale
                                                , formatTime
                                                )
import           Data.Version                   ( showVersion )
import           Data.Yaml                      ( prettyPrintParseException )
import           Data.Yaml.Config               ( ignoreEnv
                                                , loadYamlSettings
                                                )
import           System.Console.CmdArgs         ( (&=)
                                                , Data
                                                , Typeable
                                                , args
                                                , cmdArgs
                                                , details
                                                , enum
                                                , explicit
                                                , help
                                                , helpArg
                                                , ignore
                                                , 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           Hledger.StockQuotes
import           Paths_hledger_stockquotes      ( version )
import           Web.AlphaVantage

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


main :: IO ()
main = do
    cfgArgs        <- cmdArgs argSpec
    cfgFile        <- loadConfigFile
    AppConfig {..} <- mergeArgsEnvCfg cfgFile cfgArgs
    let cfg = Config $ T.pack apiKey
    (commodities, start, end) <- getCommoditiesAndDateRange
        (T.pack <$> excludedCurrencies)
        journalFile
    if not dryRun
        then do
            prices <- fetchPrices cfg
                                  commodities
                                  cryptoCurrencies
                                  start
                                  end
                                  rateLimit
            if null prices
                then logError "No price directives were able to be fetched."
                else LBS.writeFile outputFile $ makePriceDirectives prices
        else do
            putStrLn
                $  "Querying from "
                <> showDate start
                <> " to "
                <> showDate end
            let (stocks, cryptos) =
                    partition (`notElem` cryptoCurrencies) commodities
            putStrLn "Querying Stocks:"
            forM_ stocks $ \commodity -> putStrLn $ "\t" <> T.unpack commodity
            putStrLn "Querying CryptoCurrencies:"
            forM_ cryptos $ \commodity -> putStrLn $ "\t" <> T.unpack commodity
  where
    showDate :: Day -> String
    showDate = formatTime defaultTimeLocale "%Y-%m-%d"


logError :: String -> IO ()
logError = hPutStrLn stderr . ("[ERROR] " <>)



-- CONFIGURATION

data AppConfig = AppConfig
    { apiKey             :: String
    , rateLimit          :: Bool
    , journalFile        :: FilePath
    , outputFile         :: FilePath
    , excludedCurrencies :: [String]
    , cryptoCurrencies   :: [T.Text]
    , dryRun             :: Bool
    }
    deriving (Show, Eq)

defaultExcludedCurrencies :: [String]
defaultExcludedCurrencies = ["$", "USD"]

-- | Merge the Arguments, Environmental Variables, & Configuration File
-- into an 'AppConfig.
--
-- Arguments override environmental variables, which overrides the
-- configuration file.
mergeArgsEnvCfg :: ConfigFile -> Args -> IO AppConfig
mergeArgsEnvCfg ConfigFile {..} Args {..} = do
    envJournalFile <- lookupEnv "LEDGER_FILE"
    envApiKey      <- lookupEnv "ALPHAVANTAGE_KEY"
    apiKey         <- case argApiKey <|> envApiKey <|> cfgApiKey of
        Just k -> return k
        Nothing ->
            logError
                    "Pass an AlphaVantage API Key with `-a` or $ALPHAVANTAGE_KEY."
                >> exitFailure
    let journalFile =
            fromMaybe "~/.hledger.journal" $ argJournalFile <|> envJournalFile
        rateLimit =
            fromMaybe True $ either (const cfgRateLimit) Just argRateLimit
        excludedCurrencies =
            if argExcludedCurrencies == defaultExcludedCurrencies
                then fromMaybe defaultExcludedCurrencies cfgExcludedCurrencies
                else argExcludedCurrencies
        cryptoCurrencies = if null argCryptoCurrencies
            then maybe [] (map T.pack) cfgCryptoCurrencies
            else concatMap (T.splitOn "," . T.pack) argCryptoCurrencies
        outputFile = argOutputFile
        dryRun     = argDryRun
    return AppConfig { .. }


data ConfigFile = ConfigFile
    { cfgApiKey             :: Maybe String
    , cfgRateLimit          :: Maybe Bool
    , cfgExcludedCurrencies :: Maybe [String]
    , cfgCryptoCurrencies   :: Maybe [String]
    }
    deriving (Show, Eq)

instance FromJSON ConfigFile where
    parseJSON = withObject "ConfigFile" $ \o -> do
        cfgApiKey             <- o .:? "api-key"
        cfgRateLimit          <- o .:? "rate-limit"
        cfgExcludedCurrencies <- o .:? "exclude"
        cfgCryptoCurrencies   <- o .:? "cryptocurrencies"
        return ConfigFile { .. }

loadConfigFile :: IO ConfigFile
loadConfigFile = do
    configFile <- getUserConfigFile "hledger-stockquotes" "config.yaml"
    hasConfig  <- doesFileExist configFile
    if hasConfig
        then try (loadYamlSettings [configFile] [] ignoreEnv) >>= \case
            Left (lines . prettyPrintParseException -> errorMsg) ->
                hPutStrLn stderr "[WARN] Invalid Configuration File Format:"
                    >> mapM_ (hPutStrLn stderr . ("\t" <>)) errorMsg
                    >> return defaultConfig
            Right c -> return c
        else return defaultConfig
  where
    defaultConfig :: ConfigFile
    defaultConfig = ConfigFile Nothing Nothing Nothing Nothing



data Args = Args
    { argApiKey             :: Maybe String
    , argRateLimit          :: Either () Bool
    , argJournalFile        :: Maybe FilePath
    , argOutputFile         :: FilePath
    , argExcludedCurrencies :: [String]
    , argCryptoCurrencies   :: [String]
    , argDryRun             :: Bool
    }
    deriving (Data, Typeable, Show, Eq)

argSpec :: Args
argSpec =
    Args
            { argApiKey             =
                Nothing
                &= help "Your AlphaVantage API key. Default: $ALPHAVANTAGE_KEY"
                &= explicit
                &= name "api-key"
                &= name "a"
                &= typ "ALPHAVANTAGE_KEY"
            , argRateLimit          = enum
                [ Left ()
                &= help "Fall back to the configuration file, or True."
                &= ignore
                , Right True
                &= help "Apply rate-limting for the API"
                &= explicit
                &= name "rate-limit"
                &= name "r"
                , Right False
                &= help "Disable rate-limiting for the API"
                &= explicit
                &= name "no-rate-limit"
                &= name "n"
                ]
            , argJournalFile        =
                Nothing
                &= help
                       "Journal file to read commodities from. Default: $LEDGER_FILE or ~/.hledger.journal"
                &= explicit
                &= name "journal-file"
                &= name "f"
                &= typ "FILE"
            , argOutputFile         =
                "prices.journal"
                &= help
                       "File to write prices into. Existing files will be overwritten. Default: prices.journal"
                &= explicit
                &= name "output-file"
                &= name "o"
                &= typ "FILE"
            , argCryptoCurrencies   =
                []
                &= help
                       "Cryptocurrencies to fetch prices for. Flag can be passed multiple times."
                &= explicit
                &= name "c"
                &= name "crypto"
                &= typ "TICKER,..."
            , argExcludedCurrencies = defaultExcludedCurrencies &= args &= typ
                                          "EXCLUDED_CURRENCY ..."
            , argDryRun             =
                False &= explicit &= name "dry-run" &= name "d" &= help
                    "Print the commodities and dates that would be processed."
            }
        &= summary
               (  "hledger-stockquotes v"
               ++ showVersion version
               ++ ", Pavan Rikhi 2020"
               )
        &= program "hledger-stockquotes"
        &= helpArg [name "h"]
        &= help "Generate HLedger Price Directives From Daily Stock Quotes."
        &= details programDetails


programDetails :: [String]
programDetails = lines [r|
hledger-stockquotes reads a HLedger journal file, queries the AlphaVantage
stock quote API, and writes a new journal file containing price directives
for each commodity.


DESCRIPTION

By default, we find all non-USD commodities in your journal file and query
AlphaVantage for their stock prices over the date range used in the journal
file. Currently, we only support public U.S. equities & cryptocurrencies
& do not call out to AlphaVantage's FOREX API routes.

If you have commodities that are not supported by AlphaVantage,
hledger-stockquotes will output an error when attempting to processing
them. To avoid processing of unsupported currencies, you can pass in any
commodities to exclude as arguments. If you use the default commodity
directive in your journal file, hledger will include an `AUTO` commodity
when parsing your journal.


CRYPTOCURRENCIES

We support feching daily closing prices for all cryptocurrencies supported
by AlphaVantage. Use the `-c` flag to specify which commodities are
cryptocurrencies. You can pass the flag multiple times or specify them as
a comma-separated list. For the listed cryptocurrencies, we will hit
AlphaVantage's Daily Crypto Prices API route instead of the normal Stock
Prices route.


API LIMITS

AlphaVantage's API limits users to 5 requests per minute. We respect this
limit by waiting for 60 seconds after every 5 commities we process. You
can ignore the rate-limiting by using the `-n` flag, but requests are more
likely to fail. You can use the `-d` flag to print out the dates
& currencies that we will fetch to avoid any unecessary processing or API
requests.


OUTPUT FILE

You can use the `-o` flag to set the file we will write the generated price
directives into. By default, we write to `prices.journal`.

Warning: the output file will always be overwritten with the new price
directives. We currently do not support appending to the output file.


ENVIRONMENTAL VARIABLES

If no `-f` flag is passed and the LEDGER_FILE environmental variable is
set, the program will use that as the default HLedger file. Otherwise
~/.hledger.journal will be used.

Instead of passing the `-a` flag with your AlphaVantage API key, you can
set the ALPHAVANTAGE_KEY environmental variable instead.


CONFIGURATION FILE

If you have common options you constantly pass to the application, you can
specify them in a YAML configuration file. We attempt to parse
a configuration file in $XDG_CONFIG_HOME/hledger-stockquotes/config.yaml.
It currently supports the following top-level keys:

- `api-key`:          (string) Your AlphaVantage API Key
- `cryptocurrencies`: (list of string) Cryptocurrencies to Fetch
- `exclude`:          (list of strings) Currencies to Exclude
- `rate-limit`:       (bool) Obey AlphaVantage's Rate Limit

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


USAGE EXAMPLES

Fetch prices for all commodities in the default journal file:
    hledger-stockquotes -a <your-api-key>

Output prices into a custom journal file:
    hledger-stockquotes -a <your-api-key> -o prices/2021.journal

Fetch prices for all commodities, including Bitcoin:
    hledger-stockquotes -a <your-api-key> -c BTC

Ignore the default, foreign, & crypto commodities:
    hledger-stockquotes -a <your-api-key> AUTO BTC ETH EUR
|]