hledger-stockquotes 0.1.2.1 → 0.1.3.0
raw patch · 8 files changed
+558/−388 lines, 8 filessetup-changedPVP: major bump suggested
API removals or changes: PVP suggests a major version bump
API changes (from Hackage documentation)
- Hledger.StockQuotes: Crypto :: CryptoPrices -> GenericPrice
- Hledger.StockQuotes: Stock :: Prices -> GenericPrice
- Hledger.StockQuotes: data GenericPrice
- Hledger.StockQuotes: getClosePrice :: GenericPrice -> Scientific
- Web.AlphaVantage: CryptoPrices :: Scientific -> Scientific -> Scientific -> Scientific -> Scientific -> Scientific -> CryptoPrices
- Web.AlphaVantage: [cpClose] :: CryptoPrices -> Scientific
- Web.AlphaVantage: [cpHigh] :: CryptoPrices -> Scientific
- Web.AlphaVantage: [cpLow] :: CryptoPrices -> Scientific
- Web.AlphaVantage: [cpMarketCap] :: CryptoPrices -> Scientific
- Web.AlphaVantage: [cpOpen] :: CryptoPrices -> Scientific
- Web.AlphaVantage: [cpVolume] :: CryptoPrices -> Scientific
- Web.AlphaVantage: data CryptoPrices
- Web.AlphaVantage: instance Data.Aeson.Types.FromJSON.FromJSON Web.AlphaVantage.CryptoPrices
- Web.AlphaVantage: instance GHC.Classes.Eq Web.AlphaVantage.CryptoPrices
- Web.AlphaVantage: instance GHC.Generics.Generic Web.AlphaVantage.CryptoPrices
- Web.AlphaVantage: instance GHC.Read.Read Web.AlphaVantage.CryptoPrices
- Web.AlphaVantage: instance GHC.Show.Show Web.AlphaVantage.CryptoPrices
+ Hledger.StockQuotes: reAliasCommodities :: [(CommoditySymbol, a)] -> [CommoditySymbol] -> Map Text Text -> [(CommoditySymbol, a)]
+ Hledger.StockQuotes: unaliasAndBucketCommodities :: [CommoditySymbol] -> [Text] -> Map Text Text -> ([CommoditySymbol], [CommoditySymbol])
- Hledger.StockQuotes: fetchPrices :: Config -> [CommoditySymbol] -> [Text] -> Day -> Day -> Bool -> IO [(CommoditySymbol, [(Day, GenericPrice)])]
+ Hledger.StockQuotes: fetchPrices :: Config -> [CommoditySymbol] -> [Text] -> Map Text Text -> Day -> Day -> Bool -> IO [(CommoditySymbol, [(Day, Prices)])]
- Hledger.StockQuotes: makePriceDirectives :: [(CommoditySymbol, [(Day, GenericPrice)])] -> ByteString
+ Hledger.StockQuotes: makePriceDirectives :: [(CommoditySymbol, [(Day, Prices)])] -> ByteString
- Web.AlphaVantage: Prices :: Scientific -> Scientific -> Scientific -> Scientific -> Integer -> Prices
+ Web.AlphaVantage: Prices :: Scientific -> Scientific -> Scientific -> Scientific -> Scientific -> Prices
- Web.AlphaVantage: [pVolume] :: Prices -> Integer
+ Web.AlphaVantage: [pVolume] :: Prices -> Scientific
- Web.AlphaVantage: getDailyCryptoPrices :: Config -> Text -> Text -> Day -> Day -> IO (AlphaVantageResponse [(Day, CryptoPrices)])
+ Web.AlphaVantage: getDailyCryptoPrices :: Config -> Text -> Text -> Day -> Day -> IO (AlphaVantageResponse [(Day, Prices)])
Files
- CHANGELOG.md +25/−0
- README.md +36/−6
- Setup.hs +2/−0
- app/Main.hs +196/−138
- hledger-stockquotes.cabal +5/−4
- src/Hledger/StockQuotes.hs +165/−109
- src/Web/AlphaVantage.hs +119/−122
- tests/Spec.hs +10/−9
CHANGELOG.md view
@@ -3,6 +3,31 @@ ## master ++## v0.1.3.0++* Change `Prices` volume field from `Integer` to `Scientific` to support+ decimal amounts returned by cryptocurrency routes.+* AlphaVantage changed the information message field from `Note` to+ `Information` so we now attempt to parse both and throw an `ApiError` if+ either exist. This usually occurs when you've run out of API calls for the+ day.+* AlphaVantage changed the `DIGITAL_CURRENCY_DAILY` endpoint to return the same+ price fields as the `TIME_SERIES_DAILY` endpoint, so we dropped the+ `CryptoPrices` type and return the `Prices` type from both the stock & crypto+ API calls.+* AlphaVantage has swapped premium-only endpoints on us again - now+ `TIME_SERIES_DAILY` is free and `TIME_SERIES_DAILY_ADJUSTED` is paid-only so+ we had to switch back.+++## v0.1.2.2++* Switch from the (now premium-only) `TIME_SERIES_DAILY` AlphaVantage endpoint+ to the free `TIME_SERIES_DAILY_ADJUSTED` endpoint.+* Bump package dependencies.++ ## v0.1.2.1 * Fix breaking changes in `hledger-lib` v1.26.
README.md view
@@ -44,9 +44,9 @@ ### Excluding Commodities By default, we query AlphaVantage for all non-USD commodities included in your-journal file. We do not currently support AlphaVantage's FOREX or Crypto API-routes, so if you have those commodities, `stockquotes` will print an error-when fetching them. You can exclude commodities by passing them as arguments to+journal file. We do not currently support AlphaVantage's FOREX API route, so if+you have those commodities, `stockquotes` will print an error when fetching+them. You can exclude commodities by passing them as arguments to `hledger-stockquotes`: ```sh@@ -88,21 +88,51 @@ `$XDG_CONFIG_HOME/hledger-stockquotes/config.yaml`(`$XDG_CONFIG_HOME` is usually `~/.config/`). -You can set the `api-key`, `rate-limit`, `cryptocurrencies`, & `exclude`-options via this file:+You can set the `api-key`, `rate-limit`, `cryptocurrencies`, `exclude`, &+`commodity-aliases` options via this file: ```yaml rate-limit: false api-key: DeAdBeEf9001-crypto-currencies:+cryptocurrencies: - BTC - XMR exclude: - USD - AUTO+commodity-aliases:+ MY_BTC_CURRENCY: BTC+ 401K_VTSAX: VTSAX ``` CLI flags & environmental variables will override config file settings.+++### Aliases++By specifying the `commedity-aliases` option in your configuration file,+you can rename the commodities used in your journal to the commodities+expected by AlphaVantage.++Keys in the map should be your journal commities while their values are the+AlphaVantage ticker symbols:++```yaml+commodity-aliases:+ MY_VTSAX: VTSAX+ MY_BTC_CURRENCY: BTC+```++Renaming is done after commodity exclusion, but before bucketing them into+equities & cryptocurrencies so the `exclude` list should use your symbols while+the `cryptocurrencies` list should use AlphaVantage's:++```code+journal -> exclude -> commodity-aliases -> cryptocurrencies+```++Specifying aliases via command line options or environmental variable+is not currently supported. ### Additional Documentation
Setup.hs view
@@ -1,2 +1,4 @@ import Distribution.Simple++ main = defaultMain
app/Main.hs view
@@ -1,95 +1,109 @@ {-# LANGUAGE DeriveDataTypeable #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-}-{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TupleSections #-} {-# 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 Control.Applicative ((<|>))+import Control.Exception.Safe (try)+import Control.Monad (forM_)+import Data.Aeson+ ( FromJSON (..)+ , withObject+ , (.:?)+ )+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 Hledger.StockQuotes+import Paths_hledger_stockquotes (version)+import Web.AlphaVantage -import qualified Data.ByteString.Lazy as LBS-import qualified Data.Text as T+import qualified Data.ByteString.Lazy as LBS+import qualified Data.Map as M+import qualified Data.Text as T main :: IO () main = do- cfgArgs <- cmdArgs argSpec- cfgFile <- loadConfigFile+ cfgArgs <- cmdArgs argSpec+ cfgFile <- loadConfigFile AppConfig {..} <- mergeArgsEnvCfg cfgFile cfgArgs let cfg = Config $ T.pack apiKey- (commodities, start, end) <- getCommoditiesAndDateRange- (T.pack <$> excludedCurrencies)- journalFile+ (commodities, start, end) <-+ getCommoditiesAndDateRange+ (T.pack <$> excludedCurrencies)+ journalFile if not dryRun then do- prices <- fetchPrices cfg- commodities- cryptoCurrencies- start- end- rateLimit+ prices <-+ fetchPrices+ cfg+ commodities+ cryptoCurrencies+ aliases+ 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+ putStrLn $+ "Querying from "+ <> showDate start+ <> " to "+ <> showDate end let (stocks, cryptos) =- partition (`notElem` cryptoCurrencies) commodities+ unaliasAndBucketCommodities commodities cryptoCurrencies aliases putStrLn "Querying Stocks:" forM_ stocks $ \commodity -> putStrLn $ "\t" <> T.unpack commodity putStrLn "Querying CryptoCurrencies:" forM_ cryptos $ \commodity -> putStrLn $ "\t" <> T.unpack commodity+ let reAliased = map fst $ reAliasCommodities (fmap (,()) $ stocks <> cryptos) commodities aliases+ putStrLn "Writing Prices for:"+ forM_ reAliased $ \commodity -> putStrLn $ "\t" <> T.unpack commodity where showDate :: Day -> String showDate = formatTime defaultTimeLocale "%Y-%m-%d"@@ -99,23 +113,25 @@ logError = hPutStrLn stderr . ("[ERROR] " <>) - -- CONFIGURATION data AppConfig = AppConfig- { apiKey :: String- , rateLimit :: Bool- , journalFile :: FilePath- , outputFile :: FilePath+ { apiKey :: String+ , rateLimit :: Bool+ , journalFile :: FilePath+ , outputFile :: FilePath , excludedCurrencies :: [String]- , cryptoCurrencies :: [T.Text]- , dryRun :: Bool+ , cryptoCurrencies :: [T.Text]+ , dryRun :: Bool+ , aliases :: M.Map T.Text T.Text } deriving (Show, Eq) + defaultExcludedCurrencies :: [String] defaultExcludedCurrencies = ["$", "USD"] + -- | Merge the Arguments, Environmental Variables, & Configuration File -- into an 'AppConfig. --@@ -124,12 +140,12 @@ mergeArgsEnvCfg :: ConfigFile -> Args -> IO AppConfig mergeArgsEnvCfg ConfigFile {..} Args {..} = do envJournalFile <- lookupEnv "LEDGER_FILE"- envApiKey <- lookupEnv "ALPHAVANTAGE_KEY"- apiKey <- case argApiKey <|> envApiKey <|> cfgApiKey of+ 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."+ "Pass an AlphaVantage API Key with `-a` or $ALPHAVANTAGE_KEY." >> exitFailure let journalFile = fromMaybe "~/.hledger.journal" $ argJournalFile <|> envJournalFile@@ -139,119 +155,134 @@ 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+ cryptoCurrencies =+ if null argCryptoCurrencies+ then maybe [] (map T.pack) cfgCryptoCurrencies+ else concatMap (T.splitOn "," . T.pack) argCryptoCurrencies outputFile = argOutputFile- dryRun = argDryRun- return AppConfig { .. }+ dryRun = argDryRun+ aliases = fromMaybe M.empty cfgAliases+ return AppConfig {..} data ConfigFile = ConfigFile- { cfgApiKey :: Maybe String- , cfgRateLimit :: Maybe Bool+ { cfgApiKey :: Maybe String+ , cfgRateLimit :: Maybe Bool , cfgExcludedCurrencies :: Maybe [String]- , cfgCryptoCurrencies :: Maybe [String]+ , cfgCryptoCurrencies :: Maybe [String]+ , cfgAliases :: Maybe (M.Map T.Text T.Text) } deriving (Show, Eq) + instance FromJSON ConfigFile where parseJSON = withObject "ConfigFile" $ \o -> do- cfgApiKey <- o .:? "api-key"- cfgRateLimit <- o .:? "rate-limit"+ cfgApiKey <- o .:? "api-key"+ cfgRateLimit <- o .:? "rate-limit" cfgExcludedCurrencies <- o .:? "exclude"- cfgCryptoCurrencies <- o .:? "cryptocurrencies"- return ConfigFile { .. }+ cfgCryptoCurrencies <- o .:? "cryptocurrencies"+ cfgAliases <- o .:? "commodity-aliases"+ return ConfigFile {..} + loadConfigFile :: IO ConfigFile loadConfigFile = do configFile <- getUserConfigFile "hledger-stockquotes" "config.yaml"- hasConfig <- doesFileExist configFile+ 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+ 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-+ defaultConfig = ConfigFile Nothing Nothing Nothing Nothing Nothing data Args = Args- { argApiKey :: Maybe String- , argRateLimit :: Either () Bool- , argJournalFile :: Maybe FilePath- , argOutputFile :: FilePath+ { argApiKey :: Maybe String+ , argRateLimit :: Either () Bool+ , argJournalFile :: Maybe FilePath+ , argOutputFile :: FilePath , argExcludedCurrencies :: [String]- , argCryptoCurrencies :: [String]- , argDryRun :: Bool+ , argCryptoCurrencies :: [String]+ , argDryRun :: Bool } deriving (Data, Typeable, Show, Eq) + argSpec :: Args argSpec = Args- { argApiKey =- Nothing+ { argApiKey =+ Nothing &= help "Your AlphaVantage API key. Default: $ALPHAVANTAGE_KEY" &= explicit &= name "api-key" &= name "a" &= typ "ALPHAVANTAGE_KEY"- , argRateLimit = enum+ , argRateLimit =+ enum [ Left ()- &= help "Fall back to the configuration file, or True."- &= ignore+ &= 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"+ &= 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"+ &= help "Disable rate-limiting for the API"+ &= explicit+ &= name "no-rate-limit"+ &= name "n" ]- , argJournalFile =- Nothing+ , argJournalFile =+ Nothing &= help- "Journal file to read commodities from. Default: $LEDGER_FILE or ~/.hledger.journal"+ "Journal file to read commodities from. Default: $LEDGER_FILE or ~/.hledger.journal" &= explicit &= name "journal-file" &= name "f" &= typ "FILE"- , argOutputFile =- "prices.journal"+ , argOutputFile =+ "prices.journal" &= help- "File to write prices into. Existing files will be overwritten. Default: prices.journal"+ "File to write prices into. Existing files will be overwritten. Default: prices.journal" &= explicit &= name "output-file" &= name "o" &= typ "FILE"- , argCryptoCurrencies =- []+ , argCryptoCurrencies =+ [] &= help- "Cryptocurrencies to fetch prices for. Flag can be passed multiple times."+ "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+ , 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"- )+ ( "hledger-stockquotes v"+ ++ showVersion version+ ++ ", Pavan Rikhi 2020"+ ) &= program "hledger-stockquotes" &= helpArg [name "h"] &= help "Generate HLedger Price Directives From Daily Stock Quotes."@@ -259,7 +290,9 @@ programDetails :: [String]-programDetails = lines [r|+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.@@ -326,13 +359,38 @@ 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+- `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+- `commodity-aliases`: (map of strings) Rename journal commodities before+ querying AlphaVantage Environmental variables will overide any config file options, and CLI flags will override both environmental variables & config file options.+++ALIASES++By specifying the `commedity-aliases` option in your configuration file,+you can rename the commodities used in your journal to the commodities+expected by AlphaVantage.++Keys in the map should be your journal commities while their values are the+AlphaVantage ticker symbols:++ commodity-aliases:+ MY_VTSAX: VTSAX+ MY_BTC_CURRENCY: BTC++Renaming is done after commodity exclusion, but before bucketing them into+equities & cryptocurrencies so the `exclude` list should use your symbols+while the `cryptocurrencies` list should use AlphaVantage's:++ journal -> exclude -> aliases -> cryptocurrencies++Specifying aliases via command line options or environmental variables is+not currently supported. USAGE EXAMPLES
hledger-stockquotes.cabal view
@@ -1,11 +1,11 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.34.4.+-- This file has been generated from package.yaml by hpack version 0.36.0. -- -- see: https://github.com/sol/hpack name: hledger-stockquotes-version: 0.1.2.1+version: 0.1.3.0 synopsis: Generate HLedger Price Directives From Daily Stock Quotes. description: @hledger-stockquotes@ is an addon for <https://hledger.org/ hledger> that reads your journal file, pulls the historical stock prices for commodities,@@ -56,7 +56,7 @@ , safe >=0.3.5 && <1 , scientific <1 , split <1- , text <2+ , text <3 , time <2 , unordered-containers <0.3 default-language: Haskell2010@@ -73,11 +73,12 @@ , base >=4.7 && <5 , bytestring <1 , cmdargs >=0.6 && <1+ , containers <1 , directory <2 , hledger-stockquotes , raw-strings-qq <2 , safe-exceptions- , text <2+ , text <3 , time <2 , xdg-basedir <1 , yaml <1
src/Hledger/StockQuotes.hs view
@@ -1,59 +1,67 @@-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-}-{- | Helper functions for the @hledger-stockquotes@ application. --}+-- | Helper functions for the @hledger-stockquotes@ application. module Hledger.StockQuotes ( getCommoditiesAndDateRange , fetchPrices , makePriceDirectives- , GenericPrice(..)- , getClosePrice+ , unaliasAndBucketCommodities+ , reAliasCommodities ) where -import Control.Concurrent ( threadDelay )-import Control.Exception ( SomeException- , displayException- , try- )-import Data.Bifunctor ( second )-import Data.List.Split ( chunksOf )-import Data.Maybe ( catMaybes )-import Data.Scientific ( Scientific )-import Data.Text.Encoding ( encodeUtf8 )-import Data.Time ( Day- , UTCTime(utctDay)- , defaultTimeLocale- , formatTime- , fromGregorian- , getCurrentTime- , toGregorian- )-import Hledger-import Safe.Foldable ( maximumMay- , minimumMay- )-import System.IO ( hPutStrLn- , stderr- )+import Control.Concurrent (threadDelay)+import Control.Exception+ ( SomeException+ , displayException+ , try+ )+import Data.List.Split (chunksOf)+import Data.Maybe (catMaybes, fromMaybe)+import Data.Text.Encoding (encodeUtf8)+import Data.Time+ ( Day+ , UTCTime (utctDay)+ , defaultTimeLocale+ , formatTime+ , fromGregorian+ , getCurrentTime+ , toGregorian+ )+import Hledger+ ( CommoditySymbol+ , Journal (..)+ , Transaction (..)+ , definputopts+ , readJournalFile+ , runExceptT+ )+import Safe.Foldable+ ( maximumMay+ , minimumMay+ )+import System.IO+ ( hPutStrLn+ , stderr+ ) -import Web.AlphaVantage ( AlphaVantageResponse(..)- , Config- , CryptoPrices(..)- , Prices(..)- , getDailyCryptoPrices- , getDailyPrices- )+import Web.AlphaVantage+ ( AlphaVantageResponse (..)+ , Config+ , Prices (..)+ , getDailyCryptoPrices+ , getDailyPrices+ ) -import qualified Data.ByteString.Lazy as LBS-import qualified Data.ByteString.Lazy.Char8 as LC-import qualified Data.List as L-import qualified Data.Map.Strict as M-import qualified Data.Text as T+import qualified Data.ByteString.Lazy as LBS+import qualified Data.ByteString.Lazy.Char8 as LC+import qualified Data.List as L+import qualified Data.List.NonEmpty as NE+import qualified Data.Map.Strict as M+import qualified Data.Set as S+import qualified Data.Text as T -- | Given a list of Commodities to exclude and a Journal File, return the@@ -62,21 +70,23 @@ getCommoditiesAndDateRange :: [T.Text] -> FilePath -> IO ([CommoditySymbol], Day, Day) getCommoditiesAndDateRange excluded journalPath = do- journal <- fmap (either error id) . runExceptT $ readJournalFile- definputopts- journalPath+ journal <-+ fmap (either error id) . runExceptT $+ readJournalFile+ definputopts+ journalPath currentTime <- getCurrentTime let commodities =- filter (`notElem` excluded)- $ M.keys (jcommodities journal)- <> M.keys (jinferredcommodities journal)- dates = map tdate $ jtxns journal+ filter (`notElem` excluded) $+ M.keys (jcommodities journal)+ <> M.keys (jinferredcommodities journal)+ dates = map tdate $ jtxns journal currentYear = (\(y, _, _) -> y) $ toGregorian $ utctDay currentTime- minDate = case minimumMay dates of- Just d -> d+ minDate = case minimumMay dates of+ Just d -> d Nothing -> fromGregorian currentYear 1 1 maxDate = case maximumMay dates of- Just d -> d+ Just d -> d Nothing -> utctDay currentTime return (L.sort $ L.nub commodities, minDate, maxDate) @@ -92,16 +102,18 @@ -- ^ Commodities to Fetch -> [T.Text] -- ^ Commodities to Classify as Cryptocurrencies+ -> M.Map T.Text T.Text+ -- ^ Map of aliases to transform journal commodities -> Day -- ^ Start of Price Range -> Day -- ^ End of Price Range -> Bool -- ^ Rate Limit Requests- -> IO [(CommoditySymbol, [(Day, GenericPrice)])]-fetchPrices cfg symbols cryptoCurrencies start end rateLimit = do+ -> IO [(CommoditySymbol, [(Day, Prices)])]+fetchPrices cfg symbols cryptoCurrencies aliases start end rateLimit = do let (stockSymbols, cryptoSymbols) =- L.partition (`notElem` cryptoCurrencies) symbols+ unaliasAndBucketCommodities symbols cryptoCurrencies aliases genericAction = map FetchStock stockSymbols <> map FetchCrypto cryptoSymbols if rateLimit@@ -109,74 +121,117 @@ else catMaybes <$> mapM fetch genericAction where fetch- :: AlphaRequest -> IO (Maybe (CommoditySymbol, [(Day, GenericPrice)]))+ :: AlphaRequest -> IO (Maybe (CommoditySymbol, [(Day, Prices)])) fetch req = do (symbol, label, resp) <- case req of FetchStock symbol ->- (symbol, "Stock", )+ (symbol,"Stock",)+ <$> try (getDailyPrices cfg symbol start end)+ FetchCrypto symbol ->+ (symbol,"Cryptocurrency",) <$> try- ( fmap (map (second Stock))- <$> getDailyPrices cfg symbol start end- )- FetchCrypto symbol -> (symbol, "Cryptocurrency", ) <$> try- ( fmap (map (second Crypto))- <$> getDailyCryptoPrices cfg symbol "USD" start end- )+ ( getDailyCryptoPrices cfg symbol "USD" start end+ ) case resp of Left (e :: SomeException) -> do- logError- $ "Error Fetching Prices for "- <> label- <> " `"- <> T.unpack symbol- <> "`:\n\t"- ++ displayException e- ++ "\n"+ logError $+ "Error Fetching Prices for "+ <> label+ <> " `"+ <> T.unpack symbol+ <> "`:\n\t"+ ++ displayException e+ ++ "\n" return Nothing- Right (ApiError note) -> do- logError- $ "Error Fetching Prices for "- <> label- <> " `"- <> T.unpack symbol- <> "`:\n\t"- <> T.unpack note- <> "\n"+ logError $+ "Error Fetching Prices for "+ <> label+ <> " `"+ <> T.unpack symbol+ <> "`:\n\t"+ <> T.unpack note+ <> "\n" return Nothing- Right (ApiResponse prices) -> return $ Just (symbol, prices) logError :: String -> IO () logError = hPutStrLn stderr +-- | Given a list of commodities from a journal, a list a cryptocurrencies,+-- and a map of aliases, return the a list of AlphaVantage equities+-- & cryptocurencies.+unaliasAndBucketCommodities+ :: [CommoditySymbol]+ -- ^ Journal symbols+ -> [T.Text]+ -- ^ Cryptocurrency symbols+ -> M.Map T.Text T.Text+ -- ^ Aliases+ -> ([CommoditySymbol], [CommoditySymbol])+unaliasAndBucketCommodities symbols cryptoCurrencies aliases =+ L.partition (`notElem` cryptoCurrencies) $+ S.toList $+ S.fromList $+ map transformAliases symbols+ where+ transformAliases :: T.Text -> T.Text+ transformAliases original =+ fromMaybe original $ M.lookup original aliases+++-- | Given a list of paired unaliased symbols, the original journal+-- commodities, and the map of aliases, generate a new list of paired+-- symbols that reflects the commodities in the original journal.+--+-- Pairs with symbols in the journal but not in the aliases will be+-- unaltered. Pairs with aliases only in the journal will return only alias+-- items. Pairs for multiple aliases with return a set of items for each+-- alias. Pairs with symbols and aliases in the journal will return both+-- sets of items.+reAliasCommodities+ :: [(CommoditySymbol, a)]+ -- ^ Unaliased pairs of symbols+ -> [CommoditySymbol]+ -- ^ Original symbols from the journal+ -> M.Map T.Text T.Text+ -- ^ Aliases+ -> [(CommoditySymbol, a)]+reAliasCommodities symbolPairs journalSymbols aliases =+ concatMap reAlias symbolPairs+ where+ reAlias :: (CommoditySymbol, a) -> [(CommoditySymbol, a)]+ reAlias s@(cs, a) = case M.lookup cs reverseAliases of+ Nothing ->+ [s]+ Just revAliases ->+ map (,a) $ filter (`elem` journalSymbols) $ NE.toList revAliases+ reverseAliases :: M.Map T.Text (NE.NonEmpty T.Text)+ reverseAliases =+ let journalSymbolPairs = map (\s -> (s, NE.singleton s)) journalSymbols+ in M.fromListWith (<>)+ . (<> journalSymbolPairs)+ . map (\(k, v) -> (v, NE.singleton k))+ $ M.assocs aliases++ -- | Types of AlphaVantage requests we make. Unified under one type so we -- write a generic fetching function that can be rate limited. data AlphaRequest = FetchStock CommoditySymbol | FetchCrypto CommoditySymbol --- | Union type for all the various prices we can return.-data GenericPrice- = Stock Prices- | Crypto CryptoPrices --- | Get the day's closing price.-getClosePrice :: GenericPrice -> Scientific-getClosePrice = \case- Stock Prices { pClose } -> pClose- Crypto CryptoPrices { cpClose } -> cpClose- -- | Perform the actions at a rate of 5 per minute, then return all the -- results. -- -- Note: Will log waiting times to stdout. rateLimitActions :: [IO a] -> IO [a] rateLimitActions a = case chunksOf 5 a of- [ first] -> sequence first- first : rest -> do- rest_ <- concat <$> mapM runAndDelay rest+ [first] -> sequence first+ first : rest -> do+ rest_ <- concat <$> mapM runAndDelay rest first_ <- sequence first return $ first_ ++ rest_ [] -> return []@@ -191,20 +246,21 @@ -- | Build the Price Directives for the Daily Prices of the given -- Commodities. makePriceDirectives- :: [(CommoditySymbol, [(Day, GenericPrice)])] -> LBS.ByteString+ :: [(CommoditySymbol, [(Day, Prices)])] -> LBS.ByteString makePriceDirectives = (<> "\n") . LBS.intercalate "\n\n" . map makeDirectives where makeDirectives- :: (CommoditySymbol, [(Day, GenericPrice)]) -> LBS.ByteString+ :: (CommoditySymbol, [(Day, Prices)]) -> LBS.ByteString makeDirectives (symbol, prices) =- LBS.intercalate "\n"- $ ("; " <> LBS.fromStrict (encodeUtf8 symbol))- : map (makeDirective symbol) prices- makeDirective :: CommoditySymbol -> (Day, GenericPrice) -> LBS.ByteString- makeDirective symbol (day, prices) = LBS.intercalate- " "- [ "P"- , LC.pack $ formatTime defaultTimeLocale "%F" day- , LBS.fromStrict $ encodeUtf8 symbol- , "$" <> LC.pack (show $ getClosePrice prices)- ]+ LBS.intercalate "\n" $+ ("; " <> LBS.fromStrict (encodeUtf8 symbol))+ : map (makeDirective symbol) prices+ makeDirective :: CommoditySymbol -> (Day, Prices) -> LBS.ByteString+ makeDirective symbol (day, prices) =+ LBS.intercalate+ " "+ [ "P"+ , LC.pack $ formatTime defaultTimeLocale "%F" day+ , LBS.fromStrict $ encodeUtf8 symbol+ , "$" <> LC.pack (show $ pClose prices)+ ]
src/Web/AlphaVantage.hs view
@@ -3,60 +3,66 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-}+ {- | A minimal client for the AlphaVantage API. Currently only supports the @TIME_SERIES_DAILY@ & @DIGITAL_CURRENCY_DAILY@ endpoints.- -} module Web.AlphaVantage- ( Config(..)- , AlphaVantageResponse(..)- , Prices(..)+ ( Config (..)+ , AlphaVantageResponse (..)+ , Prices (..) , getDailyPrices- , CryptoPrices(..) , getDailyCryptoPrices- )-where+ ) where -import Data.Aeson ( (.:)- , (.:?)- , FromJSON(..)- , Value(Object)- , withObject- )-import Data.Aeson.Types ( Parser )-import Data.Scientific ( Scientific )-import Data.Time ( Day- , defaultTimeLocale- , parseTimeM- )-import GHC.Generics ( Generic )-import Network.HTTP.Req ( (/~)- , (=:)- , GET(..)- , NoReqBody(..)- , defaultHttpConfig- , https- , jsonResponse- , req- , responseBody- , runReq- )-import Text.Read ( readMaybe )+import Control.Applicative+ ( (<|>)+ )+import Data.Aeson+ ( FromJSON (..)+ , Value (Object)+ , withObject+ , (.:)+ , (.:?)+ )+import Data.Aeson.Types (Parser)+import Data.Scientific (Scientific)+import Data.Time+ ( Day+ , defaultTimeLocale+ , parseTimeM+ )+import GHC.Generics (Generic)+import Network.HTTP.Req+ ( GET (..)+ , NoReqBody (..)+ , defaultHttpConfig+ , https+ , jsonResponse+ , req+ , responseBody+ , runReq+ , (/~)+ , (=:)+ )+import Text.Read (readMaybe) -import qualified Data.HashMap.Strict as HM-import qualified Data.List as L-import qualified Data.Text as T+import qualified Data.HashMap.Strict as HM+import qualified Data.List as L+import qualified Data.Text as T -- | Configuration for the AlphaVantage API Client.-newtype Config =- Config- { cApiKey :: T.Text- -- ^ Your API Key.- } deriving (Show, Read, Eq, Generic)+newtype Config+ = Config+ { cApiKey :: T.Text+ -- ^ Your API Key.+ }+ deriving (Show, Read, Eq, Generic) + -- | Wrapper type enumerating between successful responses and error -- responses with notes. data AlphaVantageResponse a@@ -64,107 +70,92 @@ | ApiError T.Text deriving (Show, Read, Eq, Generic, Functor) --- | Check for errors by attempting to parse a @Note@ field. If one does--- not exist, parse the inner type.-instance FromJSON a => FromJSON (AlphaVantageResponse a) where++-- | Check for errors by attempting to parse a @Note@ or @Information@+-- field. If one does not exist, parse the inner type.+instance (FromJSON a) => FromJSON (AlphaVantageResponse a) where parseJSON = withObject "AlphaVantageResponse" $ \v -> do mbErrorNote <- v .:? "Note"- case mbErrorNote of- Nothing -> ApiResponse <$> parseJSON (Object v)+ mbErrorInfo <- v .:? "Information"+ case mbErrorNote <|> mbErrorInfo of+ Nothing -> ApiResponse <$> parseJSON (Object v) Just note -> return $ ApiError note + -- | List of Daily Prices for a Stock.-newtype PriceList =- PriceList- { fromPriceList :: [(Day, Prices)]- } deriving (Show, Read, Eq, Generic)+newtype PriceList+ = PriceList+ { fromPriceList :: [(Day, Prices)]+ }+ deriving (Show, Read, Eq, Generic) + instance FromJSON PriceList where parseJSON = withObject "PriceList" $ \v -> do inner <- v .: "Time Series (Daily)" let daysAndPrices = HM.toList inner PriceList- <$> mapM (\(d, ps) -> (,) <$> parseDay d <*> parseJSON ps)- daysAndPrices- where parseDay = parseTimeM True defaultTimeLocale "%F"+ <$> mapM+ (\(d, ps) -> (,) <$> parseDay d <*> parseJSON ps)+ daysAndPrices+ where+ parseDay = parseTimeM True defaultTimeLocale "%F" + -- | The Single-Day Price Quotes & Volume for a Stock,. data Prices = Prices- { pOpen :: Scientific+ { pOpen :: Scientific -- ^ Day's Opening Price- , pHigh :: Scientific+ , pHigh :: Scientific -- ^ High Price of the Day- , pLow :: Scientific+ , pLow :: Scientific -- ^ Low Price of the Day- , pClose :: Scientific+ , pClose :: Scientific -- ^ Day's Closing Price- , pVolume :: Integer+ , pVolume :: Scientific -- ^ Trading Volume for the Day } deriving (Show, Read, Eq, Generic) + instance FromJSON Prices where parseJSON = withObject "Prices" $ \v -> do- pOpen <- parseScientific $ v .: "1. open"- pHigh <- parseScientific $ v .: "2. high"- pLow <- parseScientific $ v .: "3. low"- pClose <- parseScientific $ v .: "4. close"+ pOpen <- parseScientific $ v .: "1. open"+ pHigh <- parseScientific $ v .: "2. high"+ pLow <- parseScientific $ v .: "3. low"+ pClose <- parseScientific $ v .: "4. close" pVolume <- parseScientific $ v .: "5. volume"- return Prices { .. }+ return Prices {..} -- | List of Daily Prices for a Cryptocurrency.-newtype CryptoPriceList =- CryptoPriceList- { fromCryptoPriceList :: [(Day, CryptoPrices)]- } deriving (Show, Read, Eq, Generic)+newtype CryptoPriceList+ = CryptoPriceList+ { fromCryptoPriceList :: [(Day, Prices)]+ }+ deriving (Show, Read, Eq, Generic) + instance FromJSON CryptoPriceList where parseJSON = withObject "CryptoPriceList" $ \v -> do inner <- v .: "Time Series (Digital Currency Daily)" let daysAndPrices = HM.toList inner CryptoPriceList <$> mapM- (\(d, ps) -> (,) <$> parseAlphavantageDay d <*> parseJSON ps- )- daysAndPrices---- | The Single-Day Price Quotes, Volume, & Market Cap for--- a Cryptocurrency.-data CryptoPrices = CryptoPrices- { cpOpen :: Scientific- -- ^ Day's Opening Price- , cpHigh :: Scientific- -- ^ High Price of the Day- , cpLow :: Scientific- -- ^ Low Price of the Day- , cpClose :: Scientific- -- ^ Day's Closing Price- , cpVolume :: Scientific- -- ^ Trading Volume for the Day- , cpMarketCap :: Scientific- }- deriving (Show, Read, Eq, Generic)--instance FromJSON CryptoPrices where- parseJSON = withObject "CryptoPrices" $ \v -> do- cpOpen <- parseScientific $ v .: "1b. open (USD)"- cpHigh <- parseScientific $ v .: "2b. high (USD)"- cpLow <- parseScientific $ v .: "3b. low (USD)"- cpClose <- parseScientific $ v .: "4b. close (USD)"- cpVolume <- parseScientific $ v .: "5. volume"- cpMarketCap <- parseScientific $ v .: "6. market cap (USD)"- return CryptoPrices { .. }+ ( \(d, ps) -> (,) <$> parseAlphavantageDay d <*> parseJSON ps+ )+ daysAndPrices parseAlphavantageDay :: String -> Parser Day parseAlphavantageDay = parseTimeM True defaultTimeLocale "%F" -parseScientific :: (MonadFail m, Read a) => m String -> m a++parseScientific :: (MonadFail m) => m String -> m Scientific parseScientific parser = do val <- parser case readMaybe val of- Just x -> return x+ Just x -> return x Nothing -> fail $ "Could not parse number: " ++ val @@ -177,19 +168,22 @@ -> Day -> IO (AlphaVantageResponse [(Day, Prices)]) getDailyPrices cfg symbol startDay endDay = do- resp <- runReq defaultHttpConfig $ req- GET- (https "www.alphavantage.co" /~ ("query" :: T.Text))- NoReqBody- jsonResponse- ( ("function" =: ("TIME_SERIES_DAILY" :: T.Text))- <> ("symbol" =: symbol)- <> ("outputsize" =: ("full" :: T.Text))- <> ("datatype" =: ("json" :: T.Text))- <> ("apikey" =: cApiKey cfg)- )- return . fmap (filterByDate startDay endDay . fromPriceList) $ responseBody- resp+ resp <-+ runReq defaultHttpConfig $+ req+ GET+ (https "www.alphavantage.co" /~ ("query" :: T.Text))+ NoReqBody+ jsonResponse+ ( ("function" =: ("TIME_SERIES_DAILY" :: T.Text))+ <> ("symbol" =: symbol)+ <> ("outputsize" =: ("full" :: T.Text))+ <> ("datatype" =: ("json" :: T.Text))+ <> ("apikey" =: cApiKey cfg)+ )+ return . fmap (filterByDate startDay endDay . fromPriceList) $+ responseBody+ resp -- | Fetch the Daily Prices for a Cryptocurrency, returning only the prices@@ -200,21 +194,24 @@ -> T.Text -> Day -> Day- -> IO (AlphaVantageResponse [(Day, CryptoPrices)])+ -> IO (AlphaVantageResponse [(Day, Prices)]) getDailyCryptoPrices cfg symbol market startDay endDay = do- resp <- runReq defaultHttpConfig $ req- GET- (https "www.alphavantage.co" /~ ("query" :: T.Text))- NoReqBody- jsonResponse- ( ("function" =: ("DIGITAL_CURRENCY_DAILY" :: T.Text))- <> ("symbol" =: symbol)- <> ("market" =: market)- <> ("apikey" =: cApiKey cfg)- )+ resp <-+ runReq defaultHttpConfig $+ req+ GET+ (https "www.alphavantage.co" /~ ("query" :: T.Text))+ NoReqBody+ jsonResponse+ ( ("function" =: ("DIGITAL_CURRENCY_DAILY" :: T.Text))+ <> ("symbol" =: symbol)+ <> ("market" =: market)+ <> ("apikey" =: cApiKey cfg)+ ) return . fmap (filterByDate startDay endDay . fromCryptoPriceList) $ responseBody resp+ -- | Filter a list of prices to be within a range of two 'Day's. filterByDate :: Day -> Day -> [(Day, a)] -> [(Day, a)]
tests/Spec.hs view
@@ -1,10 +1,10 @@-import Hedgehog-import Test.Tasty-import Test.Tasty.HUnit-import Test.Tasty.Hedgehog+import Hedgehog+import Test.Tasty+import Test.Tasty.HUnit+import Test.Tasty.Hedgehog -import qualified Hedgehog.Gen as Gen-import qualified Hedgehog.Range as Range+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range main :: IO ()@@ -23,9 +23,10 @@ properties :: TestTree-properties = testGroup- "Properties"- [testProperty "Addition is Communative" testAdditionCommunative]+properties =+ testGroup+ "Properties"+ [testProperty "Addition is Communative" testAdditionCommunative] where testAdditionCommunative :: Property testAdditionCommunative = property $ do