{-# 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 Output prices into a custom journal file: hledger-stockquotes -a -o prices/2021.journal Fetch prices for all commodities, including Bitcoin: hledger-stockquotes -a -c BTC Ignore the default, foreign, & crypto commodities: hledger-stockquotes -a AUTO BTC ETH EUR |]