├── .gitignore ├── Setup.hs ├── stack.yaml ├── fourmolu.yaml ├── hie.yaml ├── stack.yaml.lock ├── src ├── Hledger │ ├── StockQuotes │ │ └── Compat.hs │ └── StockQuotes.hs └── Web │ └── AlphaVantage.hs ├── tests └── Spec.hs ├── LICENSE ├── CHANGELOG.md ├── package.yaml ├── .github └── workflows │ └── main.yml ├── hledger-stockquotes.cabal ├── README.md └── app └── Main.hs /.gitignore: -------------------------------------------------------------------------------- 1 | .stack-work/ 2 | -------------------------------------------------------------------------------- /Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | 3 | 4 | main = defaultMain 5 | -------------------------------------------------------------------------------- /stack.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | resolver: lts-23.0 4 | 5 | packages: 6 | - . 7 | 8 | extra-deps: 9 | [] 10 | -------------------------------------------------------------------------------- /fourmolu.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | indentation: 4 4 | function-arrows: leading 5 | comma-style: leading 6 | import-export-style: leading 7 | record-brace-space: true 8 | newlines-between-decls: 2 9 | haddock-style: single-line 10 | haddock-style-module: multi-line 11 | -------------------------------------------------------------------------------- /hie.yaml: -------------------------------------------------------------------------------- 1 | cradle: 2 | stack: 3 | - path: "./src" 4 | component: "hledger-stockquotes:lib" 5 | 6 | - path: "./app/Main.hs" 7 | component: "hledger-stockquotes:exe:hledger-stockquotes" 8 | 9 | - path: "./app/Paths_hledger_stockquotes.hs" 10 | component: "hledger-stockquotes:exe:hledger-stockquotes" 11 | 12 | - path: "./tests" 13 | component: "hledger-stockquotes:test:hledger-stockquotes-test" 14 | -------------------------------------------------------------------------------- /stack.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: [] 7 | snapshots: 8 | - completed: 9 | sha256: 9444fadfa30b67a93080254d53872478c087592ad64443e47c546cdcd13149ae 10 | size: 678857 11 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/23/0.yaml 12 | original: lts-23.0 13 | -------------------------------------------------------------------------------- /src/Hledger/StockQuotes/Compat.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE CPP #-} 2 | -- | Hledger-related functions that use CPP macros for multiple 3 | module Hledger.StockQuotes.Compat 4 | ( allJournalCommodities 5 | ) where 6 | 7 | import Hledger (CommoditySymbol, Journal (..)) 8 | 9 | import qualified Data.Map.Strict as M 10 | 11 | -- | Get all declared & inferred commodities of a Journal. 12 | allJournalCommodities :: Journal -> [CommoditySymbol] 13 | allJournalCommodities journal = 14 | -- TODO: remove < 1.41 support after we drop GHC 9.8 15 | #if MIN_VERSION_hledger_lib(1, 41, 0) 16 | M.keys (jdeclaredcommodities journal) 17 | <> M.keys (jinferredcommoditystyles journal) 18 | #else 19 | M.keys (jcommodities journal) 20 | <> M.keys (jinferredcommodities journal) 21 | #endif 22 | -------------------------------------------------------------------------------- /tests/Spec.hs: -------------------------------------------------------------------------------- 1 | import Hedgehog 2 | import Test.Tasty 3 | import Test.Tasty.HUnit 4 | import Test.Tasty.Hedgehog 5 | 6 | import qualified Hedgehog.Gen as Gen 7 | import qualified Hedgehog.Range as Range 8 | 9 | 10 | main :: IO () 11 | main = defaultMain tests 12 | 13 | 14 | tests :: TestTree 15 | tests = testGroup "Tests" [unitTests, properties] 16 | 17 | 18 | unitTests :: TestTree 19 | unitTests = testGroup "Unit Tests" [testCase "2+2 = 4" testAddition] 20 | where 21 | testAddition :: Assertion 22 | testAddition = (2 + 2) @?= (4 :: Integer) 23 | 24 | 25 | properties :: TestTree 26 | properties = 27 | testGroup 28 | "Properties" 29 | [testProperty "Addition is Communative" testAdditionCommunative] 30 | where 31 | testAdditionCommunative :: Property 32 | testAdditionCommunative = property $ do 33 | let genInt = Gen.int $ Range.linear 0 9001 34 | (a, b) <- forAll $ (,) <$> genInt <*> genInt 35 | (a + b) === (b + a) 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Pavan Rikhi (c) 2020 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Pavan Rikhi nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## master 4 | 5 | 6 | ## v0.1.3.2 7 | 8 | * Support breaking changes in `hledger-lib` v1.41. 9 | 10 | 11 | ## v0.1.3.1 12 | 13 | * AlphaVantage changed the message field for API errors to `Error Message` so 14 | we now try to parse this field out of the response as well. 15 | 16 | 17 | ## v0.1.3.0 18 | 19 | * Change `Prices` volume field from `Integer` to `Scientific` to support 20 | decimal amounts returned by cryptocurrency routes. 21 | * AlphaVantage changed the information message field from `Note` to 22 | `Information` so we now attempt to parse both and throw an `ApiError` if 23 | either exist. This usually occurs when you've run out of API calls for the 24 | day. 25 | * AlphaVantage changed the `DIGITAL_CURRENCY_DAILY` endpoint to return the same 26 | price fields as the `TIME_SERIES_DAILY` endpoint, so we dropped the 27 | `CryptoPrices` type and return the `Prices` type from both the stock & crypto 28 | API calls. 29 | * AlphaVantage has swapped premium-only endpoints on us again - now 30 | `TIME_SERIES_DAILY` is free and `TIME_SERIES_DAILY_ADJUSTED` is paid-only so 31 | we had to switch back. 32 | 33 | 34 | ## v0.1.2.2 35 | 36 | * Switch from the (now premium-only) `TIME_SERIES_DAILY` AlphaVantage endpoint 37 | to the free `TIME_SERIES_DAILY_ADJUSTED` endpoint. 38 | * Bump package dependencies. 39 | 40 | 41 | ## v0.1.2.1 42 | 43 | * Fix breaking changes in `hledger-lib` v1.26. 44 | 45 | 46 | ## v0.1.2.0 47 | 48 | * Add support for fetching cryptocurrency prices with the `-c` flag and 49 | `cryptocurrencies` config option. 50 | * Add support for config file at `$XDG_CONFIG_HOME/hstockquotes/config.yaml` 51 | with `api-key`, `exclude`, & `rate-limit` options. 52 | 53 | 54 | ## v0.1.1.0 55 | 56 | * Don't write out a journal file if no prices were successfully fetched. 57 | * Log API errors to `stderr` instead of `stdout`. 58 | * Improve error messages when the AlphaVantage API returns a 59 | rate-limit-exceeded error. 60 | * Improve documentation in README & `--help` flag. 61 | * Add trailing newline to generated files. 62 | 63 | 64 | ## v0.1.0.0 65 | 66 | * Initial release 67 | -------------------------------------------------------------------------------- /package.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: 4 | hledger-stockquotes 5 | version: 6 | 0.1.3.2 7 | github: 8 | prikhi/hledger-stockquotes 9 | homepage: 10 | https://github.com/prikhi/hledger-stockquotes#readme 11 | license: 12 | BSD3 13 | license-file: 14 | LICENSE 15 | author: 16 | Pavan Rikhi 17 | maintainer: 18 | pavan.rikhi@gmail.com 19 | copyright: 20 | 2020 Pavan Rikhi 21 | category: 22 | Finance, Console 23 | extra-source-files: 24 | - README.md 25 | - CHANGELOG.md 26 | 27 | 28 | synopsis: 29 | Generate HLedger Price Directives From Daily Stock Quotes. 30 | 31 | description: | 32 | @hledger-stockquotes@ is an addon for that 33 | reads your journal file, pulls the historical stock prices for commodities, 34 | and writes out a new journal file containing the respective price 35 | directives. 36 | 37 | The is used to fetch the 38 | stock quotes and you will need a 39 | to use this 40 | program. 41 | 42 | You can install @hledger-stockquotes@ with Stack: @stack install --resolver 43 | nightly hledger-stockquotes@. Then run @hledger-stockquotes --help@ to see 44 | the usage instructions & all available options. 45 | 46 | 47 | ghc-options: 48 | - -Wall 49 | - -Wcompat 50 | - -Wincomplete-record-updates 51 | - -Wincomplete-uni-patterns 52 | - -Wredundant-constraints 53 | - -O2 54 | 55 | 56 | dependencies: 57 | - base >= 4.7 && < 5 58 | 59 | 60 | library: 61 | source-dirs: 62 | src 63 | dependencies: 64 | - aeson >= 1 && < 3 65 | - bytestring < 1 66 | - containers < 1 67 | - hledger-lib >= 1.26 && < 2 68 | - req >= 3 && < 4 69 | - safe >= 0.3.5 && < 1 70 | - scientific < 1 71 | - split < 1 72 | - text < 3 73 | - time < 2 74 | - unordered-containers < 0.3 75 | 76 | executables: 77 | hledger-stockquotes: 78 | source-dirs: 79 | app 80 | main: 81 | Main.hs 82 | ghc-options: 83 | - -threaded 84 | - -rtsopts 85 | - -with-rtsopts "-N -T" 86 | dependencies: 87 | - hledger-stockquotes 88 | - aeson >= 1 && < 3 89 | - bytestring < 1 90 | - cmdargs >= 0.6 && < 1 91 | - containers < 1 92 | - directory < 2 93 | - raw-strings-qq < 2 94 | - safe-exceptions 95 | - text < 3 96 | - time < 2 97 | - xdg-basedir < 1 98 | - yaml < 1 99 | 100 | tests: 101 | hledger-stockquotes-test: 102 | main: Spec.hs 103 | source-dirs: 104 | tests 105 | ghc-options: 106 | - -threaded 107 | - -rtsopts 108 | - -with-rtsopts "-N -T" 109 | dependencies: 110 | - hledger-stockquotes 111 | - hedgehog 112 | - tasty 113 | - tasty-hedgehog 114 | - tasty-hunit 115 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # yamllint disable rule:line-length 3 | 4 | name: CI Build 5 | 6 | on: 7 | # Run Daily 8 | schedule: 9 | - cron: '0 0 * * *' 10 | # Run on Push 11 | push: 12 | # Run on Tag Creation 13 | create: 14 | # Allow Running Manually 15 | workflow_dispatch: 16 | 17 | jobs: 18 | build-stack: 19 | name: Stack 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Cache Stack Artifacts 24 | uses: actions/cache@v4 25 | with: 26 | key: stack-build-lts-${{ github.ref }}-${{ github.sha }} 27 | path: | 28 | ~/.stack/ 29 | .stack-work/ 30 | restore-keys: | 31 | stack-build-lts-${{ github.ref }}- 32 | stack-build-lts- 33 | stack-build- 34 | - uses: haskell-actions/setup@v2 35 | with: 36 | enable-stack: true 37 | stack-no-global: true 38 | - run: stack test --fast --haddock --pedantic 39 | 40 | # Stackage Nightly - Failures Allowed 41 | build-nightly: 42 | name: Stackage Nightly 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v4 46 | - name: Get Current Date 47 | id: date 48 | run: echo -e "::set-output name=year::$(date +%Y)\n::set-output name=month::$(date +%m)\n::set-output name=day::$(date +%d)" 49 | - name: Cache Stack Artifacts 50 | uses: actions/cache@v4 51 | with: 52 | key: stack-build-nightly-${{ steps.date.outputs.year }}-${{ steps.date.outputs.month }}-${{ steps.date.outputs.day }}-${{ github.ref }}-${{ github.sha }} 53 | path: | 54 | ~/.stack/ 55 | .stack-work/ 56 | restore-keys: | 57 | stack-build-nightly-${{ steps.date.outputs.year }}-${{ steps.date.outputs.month }}-${{ steps.date.outputs.day }}-${{ github.ref }}- 58 | stack-build-nightly-${{ steps.date.outputs.year }}-${{ steps.date.outputs.month }}-${{ steps.date.outputs.day }}- 59 | stack-build-nightly-${{ steps.date.outputs.year }}-${{ steps.date.outputs.month }}- 60 | stack-build-nightly-${{ steps.date.outputs.year }}- 61 | stack-build-nightly- 62 | - uses: haskell-actions/setup@v2 63 | with: 64 | enable-stack: true 65 | stack-no-global: true 66 | - run: stack test --fast --haddock --pedantic --resolver nightly 67 | continue-on-error: true 68 | 69 | # Cabal Builds w/ 3 Latest GHC Versions 70 | build-cabal: 71 | name: GHC / Cabal 72 | runs-on: ubuntu-latest 73 | strategy: 74 | max-parallel: 3 75 | matrix: 76 | ghc: ['9.2', '9.4', '9.6'] 77 | steps: 78 | - uses: actions/checkout@v4 79 | - name: Cache Cabal Artifacts 80 | uses: actions/cache@v4 81 | with: 82 | key: cabal-build-${{ matrix.ghc }}-${{ github.ref }}-${{ github.sha }} 83 | path: | 84 | ~/.cabal/packages/ 85 | ~/.cabal/store 86 | dist-newstyle/ 87 | restore-keys: | 88 | cabal-build-${{ matrix.ghc }}-${{ github.ref }}- 89 | cabal-build-${{ matrix.ghc }}- 90 | cabal-build- 91 | - uses: haskell-actions/setup@v2 92 | with: 93 | ghc-version: ${{ matrix.ghc }} 94 | cabal-version: latest 95 | enable-stack: true 96 | - run: cabal update 97 | - run: cabal new-test --enable-tests && cabal new-haddock 98 | # Allow failures - hledger test compilation borked 99 | continue-on-error: true 100 | -------------------------------------------------------------------------------- /hledger-stockquotes.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.37.0. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: hledger-stockquotes 8 | version: 0.1.3.2 9 | synopsis: Generate HLedger Price Directives From Daily Stock Quotes. 10 | description: @hledger-stockquotes@ is an addon for that 11 | reads your journal file, pulls the historical stock prices for commodities, 12 | and writes out a new journal file containing the respective price 13 | directives. 14 | . 15 | The is used to fetch the 16 | stock quotes and you will need a 17 | to use this 18 | program. 19 | . 20 | You can install @hledger-stockquotes@ with Stack: @stack install --resolver 21 | nightly hledger-stockquotes@. Then run @hledger-stockquotes --help@ to see 22 | the usage instructions & all available options. 23 | category: Finance, Console 24 | homepage: https://github.com/prikhi/hledger-stockquotes#readme 25 | bug-reports: https://github.com/prikhi/hledger-stockquotes/issues 26 | author: Pavan Rikhi 27 | maintainer: pavan.rikhi@gmail.com 28 | copyright: 2020 Pavan Rikhi 29 | license: BSD3 30 | license-file: LICENSE 31 | build-type: Simple 32 | extra-source-files: 33 | README.md 34 | CHANGELOG.md 35 | 36 | source-repository head 37 | type: git 38 | location: https://github.com/prikhi/hledger-stockquotes 39 | 40 | library 41 | exposed-modules: 42 | Hledger.StockQuotes 43 | Hledger.StockQuotes.Compat 44 | Web.AlphaVantage 45 | other-modules: 46 | Paths_hledger_stockquotes 47 | hs-source-dirs: 48 | src 49 | ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints -O2 50 | build-depends: 51 | aeson >=1 && <3 52 | , base >=4.7 && <5 53 | , bytestring <1 54 | , containers <1 55 | , hledger-lib >=1.26 && <2 56 | , req ==3.* 57 | , safe >=0.3.5 && <1 58 | , scientific <1 59 | , split <1 60 | , text <3 61 | , time <2 62 | , unordered-containers <0.3 63 | default-language: Haskell2010 64 | 65 | executable hledger-stockquotes 66 | main-is: Main.hs 67 | other-modules: 68 | Paths_hledger_stockquotes 69 | hs-source-dirs: 70 | app 71 | ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints -O2 -threaded -rtsopts -with-rtsopts "-N -T" 72 | build-depends: 73 | aeson >=1 && <3 74 | , base >=4.7 && <5 75 | , bytestring <1 76 | , cmdargs >=0.6 && <1 77 | , containers <1 78 | , directory <2 79 | , hledger-stockquotes 80 | , raw-strings-qq <2 81 | , safe-exceptions 82 | , text <3 83 | , time <2 84 | , xdg-basedir <1 85 | , yaml <1 86 | default-language: Haskell2010 87 | 88 | test-suite hledger-stockquotes-test 89 | type: exitcode-stdio-1.0 90 | main-is: Spec.hs 91 | other-modules: 92 | Paths_hledger_stockquotes 93 | hs-source-dirs: 94 | tests 95 | ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints -O2 -threaded -rtsopts -with-rtsopts "-N -T" 96 | build-depends: 97 | base >=4.7 && <5 98 | , hedgehog 99 | , hledger-stockquotes 100 | , tasty 101 | , tasty-hedgehog 102 | , tasty-hunit 103 | default-language: Haskell2010 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hledger-stockquotes 2 | 3 | [![hledger-stockquotes Build Status](https://github.com/prikhi/hledger-stockquotes/actions/workflows/main.yml/badge.svg)](https://github.com/prikhi/hledger-stockquotes/actions/workflows/main.yml) 4 | 5 | `hledger-stockquotes` is a CLI addon for [hledger](https://hledger.org) that 6 | reads a journal file and pulls the historical prices for commodities from 7 | [AlphaVantage](https://www.alphavantage.co/). To use this application, you'll 8 | need a [free AlphaVantage API key](https://www.alphavantage.co/support/#api-key). 9 | 10 | 11 | ## Usage 12 | 13 | `hledger-stockquotes` parses your journal file, determines what commodities are 14 | defined, and queries AlphaVantage for prices on the date range present in the 15 | journal file. 16 | 17 | By default, the program will use the HLedger default file location of 18 | `~/.hledger.journal`. A `LEDGER_FILE` environmental variable can be used to 19 | override the location. The `-f` flag can be used to override both the default 20 | and `LEDGER_FILE` locations. 21 | 22 | At the bare minimum, you need to set an `ALPHAVANTAGE_KEY` environmental 23 | variable or use the `-a` switch to specify your AlphaVantage key: 24 | 25 | ```sh 26 | hledger-stockquotes -a MY_API_KEY -f accounting.journal 27 | ``` 28 | 29 | This will print out price directive to a `prices.journal` file. 30 | 31 | 32 | ### Custom Output Files 33 | 34 | The output file can be set with the `-o` flag: 35 | 36 | ```sh 37 | hledger-stockquotes -a MY_API_KEY -o prices/2021.journal 38 | ``` 39 | 40 | NOTE: the contents of the output file will be overwritten if the file already 41 | exists! 42 | 43 | 44 | ### Excluding Commodities 45 | 46 | By default, we query AlphaVantage for all non-USD commodities included in your 47 | journal file. We do not currently support AlphaVantage's FOREX API route, so if 48 | you have those commodities, `stockquotes` will print an error when fetching 49 | them. You can exclude commodities by passing them as arguments to 50 | `hledger-stockquotes`: 51 | 52 | ```sh 53 | hledger-stockquotes -a MY_API_KEY AUTO TA_VFFVX 54 | ``` 55 | 56 | NOTE: hledger defines an `AUTO` commodity if you use the default commodity 57 | directive(`D`). 58 | 59 | 60 | ### Cryptocurrencies 61 | 62 | You can specify a list of cryptocurrencies that you wish to pull prices for 63 | with the `-c` or `--crypto` flag. You can pass a comma-separated list of 64 | currencies or pass the flag multiple times. We will split the commodities from 65 | your journal file into a list of equities & cryptocurrencies and hit the 66 | appropriate AlphaVantage route for each. 67 | 68 | ```sh 69 | hledger-stockquotes -a MY_API_KEY -c BTC,ETH --crypto XMR -c BNB 70 | ``` 71 | 72 | 73 | ### API Limits 74 | 75 | AlphaVantage has an API request limit of 5 requests per minute. 76 | `hledger-stockquotes` enforces this limit on a per-command basis. A single run 77 | will fetch 5 price histories, wait 60 seconds, fetch 5 more, etc. Running 78 | multiple `hledger-stockquotes` commands in sequence will not enforce this limit 79 | over multiple runs and may result in API errors. You can ignore the request 80 | limiting with the `-n` flag. To test a command without hitting the API, pass 81 | the `--dry-run` flag. This will simply print out the commodities and date 82 | ranges that would be queried instead of making requests to AlphaVantage. 83 | 84 | 85 | ### Configuration File 86 | 87 | `hledger-stockquotes` can also be configured via a YAML file at 88 | `$XDG_CONFIG_HOME/hledger-stockquotes/config.yaml`(`$XDG_CONFIG_HOME` is 89 | usually `~/.config/`). 90 | 91 | You can set the `api-key`, `rate-limit`, `cryptocurrencies`, `exclude`, & 92 | `commodity-aliases` options via this file: 93 | 94 | ```yaml 95 | rate-limit: false 96 | api-key: DeAdBeEf9001 97 | cryptocurrencies: 98 | - BTC 99 | - XMR 100 | exclude: 101 | - USD 102 | - AUTO 103 | commodity-aliases: 104 | MY_BTC_CURRENCY: BTC 105 | 401K_VTSAX: VTSAX 106 | ``` 107 | 108 | CLI flags & environmental variables will override config file settings. 109 | 110 | 111 | ### Aliases 112 | 113 | By specifying the `commedity-aliases` option in your configuration file, 114 | you can rename the commodities used in your journal to the commodities 115 | expected by AlphaVantage. 116 | 117 | Keys in the map should be your journal commities while their values are the 118 | AlphaVantage ticker symbols: 119 | 120 | ```yaml 121 | commodity-aliases: 122 | MY_VTSAX: VTSAX 123 | MY_BTC_CURRENCY: BTC 124 | ``` 125 | 126 | Renaming is done after commodity exclusion, but before bucketing them into 127 | equities & cryptocurrencies so the `exclude` list should use your symbols while 128 | the `cryptocurrencies` list should use AlphaVantage's: 129 | 130 | ```code 131 | journal -> exclude -> commodity-aliases -> cryptocurrencies 132 | ``` 133 | 134 | Specifying aliases via command line options or environmental variable 135 | is not currently supported. 136 | 137 | 138 | ### Additional Documentation 139 | 140 | The `--help` flag provides more thorough documentation on all available flags: 141 | 142 | ```sh 143 | hledger-stockquotes --help 144 | ``` 145 | 146 | 147 | ## Build / Install 148 | 149 | This project has not yet been packaged for any OSes or Linux distributions, so 150 | you'll have to clone this repository & compile/install the code yourself: 151 | 152 | ```sh 153 | git clone https://github.com/prikhi/hledger-stockquotes.git 154 | cd hledger-stockquotes 155 | stack install 156 | ``` 157 | 158 | This will put the `hledger-stockquotes` exe into your `~/.local/bin/` 159 | directory. Ensure that the directory is included in your `PATH` environmental 160 | variable. Then you can run the application: 161 | 162 | ```sh 163 | hledger-stockquotes --help 164 | ``` 165 | 166 | Since the executable has the `hledger-` prefix, you can also use it with the 167 | `hledger` command: 168 | 169 | ```sh 170 | hledger stockquotes -- --help 171 | ``` 172 | 173 | 174 | ## Development/Manual Builds 175 | 176 | You can build the project with stack: `stack build` 177 | 178 | For development, you can enable fast builds with file-watching, 179 | documentation-building, & test-running: `stack test --haddock --fast --file-watch` 180 | 181 | To build & open the documentation, run `stack haddock --open hledger-stockquotes` 182 | 183 | To install the executable to `~/.local/bin`, run `stack install`. 184 | 185 | 186 | ## LICENSE 187 | 188 | BSD-3 189 | -------------------------------------------------------------------------------- /src/Web/AlphaVantage.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveFunctor #-} 2 | {-# LANGUAGE DeriveGeneric #-} 3 | {-# LANGUAGE OverloadedStrings #-} 4 | {-# LANGUAGE RecordWildCards #-} 5 | {-# LANGUAGE ScopedTypeVariables #-} 6 | 7 | {- | A minimal client for the AlphaVantage API. 8 | 9 | Currently only supports the @TIME_SERIES_DAILY@ & @DIGITAL_CURRENCY_DAILY@ 10 | endpoints. 11 | -} 12 | module Web.AlphaVantage 13 | ( Config (..) 14 | , AlphaVantageResponse (..) 15 | , Prices (..) 16 | , getDailyPrices 17 | , getDailyCryptoPrices 18 | ) where 19 | 20 | import Control.Applicative 21 | ( (<|>) 22 | ) 23 | import Data.Aeson 24 | ( FromJSON (..) 25 | , Value (Object) 26 | , withObject 27 | , (.:) 28 | , (.:?) 29 | ) 30 | import Data.Aeson.Types (Parser) 31 | import Data.Scientific (Scientific) 32 | import Data.Time 33 | ( Day 34 | , defaultTimeLocale 35 | , parseTimeM 36 | ) 37 | import GHC.Generics (Generic) 38 | import Network.HTTP.Req 39 | ( GET (..) 40 | , NoReqBody (..) 41 | , defaultHttpConfig 42 | , https 43 | , jsonResponse 44 | , req 45 | , responseBody 46 | , runReq 47 | , (/~) 48 | , (=:) 49 | ) 50 | import Text.Read (readMaybe) 51 | 52 | import qualified Data.HashMap.Strict as HM 53 | import qualified Data.List as L 54 | import qualified Data.Text as T 55 | 56 | 57 | -- | Configuration for the AlphaVantage API Client. 58 | newtype Config 59 | = Config 60 | { cApiKey :: T.Text 61 | -- ^ Your API Key. 62 | } 63 | deriving (Show, Read, Eq, Generic) 64 | 65 | 66 | -- | Wrapper type enumerating between successful responses and error 67 | -- responses with notes. 68 | data AlphaVantageResponse a 69 | = ApiResponse a 70 | | ApiError T.Text 71 | deriving (Show, Read, Eq, Generic, Functor) 72 | 73 | 74 | -- | Check for errors by attempting to parse a @Error Message@, @Note@ or 75 | -- @Information@ field. If one does not exist, parse the inner type. 76 | instance (FromJSON a) => FromJSON (AlphaVantageResponse a) where 77 | parseJSON = withObject "AlphaVantageResponse" $ \v -> do 78 | mbErrorMessage <- v .:? "Error Message" 79 | mbErrorNote <- v .:? "Note" 80 | mbErrorInfo <- v .:? "Information" 81 | case mbErrorMessage <|> mbErrorNote <|> mbErrorInfo of 82 | Nothing -> ApiResponse <$> parseJSON (Object v) 83 | Just note -> return $ ApiError note 84 | 85 | 86 | -- | List of Daily Prices for a Stock. 87 | newtype PriceList 88 | = PriceList 89 | { fromPriceList :: [(Day, Prices)] 90 | } 91 | deriving (Show, Read, Eq, Generic) 92 | 93 | 94 | instance FromJSON PriceList where 95 | parseJSON = withObject "PriceList" $ \v -> do 96 | inner <- v .: "Time Series (Daily)" 97 | let daysAndPrices = HM.toList inner 98 | PriceList 99 | <$> mapM 100 | (\(d, ps) -> (,) <$> parseDay d <*> parseJSON ps) 101 | daysAndPrices 102 | where 103 | parseDay = parseTimeM True defaultTimeLocale "%F" 104 | 105 | 106 | -- | The Single-Day Price Quotes & Volume for a Stock,. 107 | data Prices = Prices 108 | { pOpen :: Scientific 109 | -- ^ Day's Opening Price 110 | , pHigh :: Scientific 111 | -- ^ High Price of the Day 112 | , pLow :: Scientific 113 | -- ^ Low Price of the Day 114 | , pClose :: Scientific 115 | -- ^ Day's Closing Price 116 | , pVolume :: Scientific 117 | -- ^ Trading Volume for the Day 118 | } 119 | deriving (Show, Read, Eq, Generic) 120 | 121 | 122 | instance FromJSON Prices where 123 | parseJSON = withObject "Prices" $ \v -> do 124 | pOpen <- parseScientific $ v .: "1. open" 125 | pHigh <- parseScientific $ v .: "2. high" 126 | pLow <- parseScientific $ v .: "3. low" 127 | pClose <- parseScientific $ v .: "4. close" 128 | pVolume <- parseScientific $ v .: "5. volume" 129 | return Prices {..} 130 | 131 | 132 | -- | List of Daily Prices for a Cryptocurrency. 133 | newtype CryptoPriceList 134 | = CryptoPriceList 135 | { fromCryptoPriceList :: [(Day, Prices)] 136 | } 137 | deriving (Show, Read, Eq, Generic) 138 | 139 | 140 | instance FromJSON CryptoPriceList where 141 | parseJSON = withObject "CryptoPriceList" $ \v -> do 142 | inner <- v .: "Time Series (Digital Currency Daily)" 143 | let daysAndPrices = HM.toList inner 144 | CryptoPriceList 145 | <$> mapM 146 | ( \(d, ps) -> (,) <$> parseAlphavantageDay d <*> parseJSON ps 147 | ) 148 | daysAndPrices 149 | 150 | 151 | parseAlphavantageDay :: String -> Parser Day 152 | parseAlphavantageDay = parseTimeM True defaultTimeLocale "%F" 153 | 154 | 155 | parseScientific :: (MonadFail m) => m String -> m Scientific 156 | parseScientific parser = do 157 | val <- parser 158 | case readMaybe val of 159 | Just x -> return x 160 | Nothing -> fail $ "Could not parse number: " ++ val 161 | 162 | 163 | -- | Fetch the Daily Prices for a Stock, returning only the prices between 164 | -- the two given dates. 165 | getDailyPrices 166 | :: Config 167 | -> T.Text 168 | -> Day 169 | -> Day 170 | -> IO (AlphaVantageResponse [(Day, Prices)]) 171 | getDailyPrices cfg symbol startDay endDay = do 172 | resp <- 173 | runReq defaultHttpConfig $ 174 | req 175 | GET 176 | (https "www.alphavantage.co" /~ ("query" :: T.Text)) 177 | NoReqBody 178 | jsonResponse 179 | ( ("function" =: ("TIME_SERIES_DAILY" :: T.Text)) 180 | <> ("symbol" =: symbol) 181 | <> ("outputsize" =: ("full" :: T.Text)) 182 | <> ("datatype" =: ("json" :: T.Text)) 183 | <> ("apikey" =: cApiKey cfg) 184 | ) 185 | return . fmap (filterByDate startDay endDay . fromPriceList) $ 186 | responseBody 187 | resp 188 | 189 | 190 | -- | Fetch the Daily Prices for a Cryptocurrency, returning only the prices 191 | -- between the two given dates. 192 | getDailyCryptoPrices 193 | :: Config 194 | -> T.Text 195 | -> T.Text 196 | -> Day 197 | -> Day 198 | -> IO (AlphaVantageResponse [(Day, Prices)]) 199 | getDailyCryptoPrices cfg symbol market startDay endDay = do 200 | resp <- 201 | runReq defaultHttpConfig $ 202 | req 203 | GET 204 | (https "www.alphavantage.co" /~ ("query" :: T.Text)) 205 | NoReqBody 206 | jsonResponse 207 | ( ("function" =: ("DIGITAL_CURRENCY_DAILY" :: T.Text)) 208 | <> ("symbol" =: symbol) 209 | <> ("market" =: market) 210 | <> ("apikey" =: cApiKey cfg) 211 | ) 212 | return 213 | . fmap (filterByDate startDay endDay . fromCryptoPriceList) 214 | $ responseBody resp 215 | 216 | 217 | -- | Filter a list of prices to be within a range of two 'Day's. 218 | filterByDate :: Day -> Day -> [(Day, a)] -> [(Day, a)] 219 | filterByDate startDay endDay = 220 | takeWhile ((<= endDay) . fst) 221 | . dropWhile ((< startDay) . fst) 222 | . L.sortOn fst 223 | -------------------------------------------------------------------------------- /src/Hledger/StockQuotes.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE NumericUnderscores #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | {-# LANGUAGE ScopedTypeVariables #-} 4 | {-# LANGUAGE TupleSections #-} 5 | 6 | -- | Helper functions for the @hledger-stockquotes@ application. 7 | module Hledger.StockQuotes 8 | ( getCommoditiesAndDateRange 9 | , fetchPrices 10 | , makePriceDirectives 11 | , unaliasAndBucketCommodities 12 | , reAliasCommodities 13 | ) where 14 | 15 | import Control.Concurrent (threadDelay) 16 | import Control.Exception 17 | ( SomeException 18 | , displayException 19 | , try 20 | ) 21 | import Data.List.Split (chunksOf) 22 | import Data.Maybe (catMaybes, fromMaybe) 23 | import Data.Text.Encoding (encodeUtf8) 24 | import Data.Time 25 | ( Day 26 | , UTCTime (utctDay) 27 | , defaultTimeLocale 28 | , formatTime 29 | , fromGregorian 30 | , getCurrentTime 31 | , toGregorian 32 | ) 33 | import Hledger 34 | ( CommoditySymbol 35 | , Journal (..) 36 | , Transaction (..) 37 | , definputopts 38 | , readJournalFile 39 | , runExceptT 40 | ) 41 | import Safe.Foldable 42 | ( maximumMay 43 | , minimumMay 44 | ) 45 | import System.IO 46 | ( hPutStrLn 47 | , stderr 48 | ) 49 | 50 | import Hledger.StockQuotes.Compat (allJournalCommodities) 51 | import Web.AlphaVantage 52 | ( AlphaVantageResponse (..) 53 | , Config 54 | , Prices (..) 55 | , getDailyCryptoPrices 56 | , getDailyPrices 57 | ) 58 | 59 | import qualified Data.ByteString.Lazy as LBS 60 | import qualified Data.ByteString.Lazy.Char8 as LC 61 | import qualified Data.List as L 62 | import qualified Data.List.NonEmpty as NE 63 | import qualified Data.Map.Strict as M 64 | import qualified Data.Set as S 65 | import qualified Data.Text as T 66 | 67 | 68 | -- | Given a list of Commodities to exclude and a Journal File, return the 69 | -- Commodities in the Journal and the minimum/maximum days from the 70 | -- Journal. 71 | getCommoditiesAndDateRange 72 | :: [T.Text] -> FilePath -> IO ([CommoditySymbol], Day, Day) 73 | getCommoditiesAndDateRange excluded journalPath = do 74 | journal <- 75 | fmap (either error id) . runExceptT $ 76 | readJournalFile 77 | definputopts 78 | journalPath 79 | currentTime <- getCurrentTime 80 | let commodities = filter (`notElem` excluded) $ allJournalCommodities journal 81 | dates = map tdate $ jtxns journal 82 | currentYear = (\(y, _, _) -> y) $ toGregorian $ utctDay currentTime 83 | minDate = case minimumMay dates of 84 | Just d -> d 85 | Nothing -> fromGregorian currentYear 1 1 86 | maxDate = case maximumMay dates of 87 | Just d -> d 88 | Nothing -> utctDay currentTime 89 | return (L.sort $ L.nub commodities, minDate, maxDate) 90 | 91 | 92 | -- | Fetch the Prices for the Commodities from the AlphaVantage API, 93 | -- limiting the returned prices between the given Days. 94 | -- 95 | -- Note: Fetching errors are currently logged to 'stderr'. 96 | fetchPrices 97 | :: Config 98 | -- ^ AlphaVantage Configuration 99 | -> [CommoditySymbol] 100 | -- ^ Commodities to Fetch 101 | -> [T.Text] 102 | -- ^ Commodities to Classify as Cryptocurrencies 103 | -> M.Map T.Text T.Text 104 | -- ^ Map of aliases to transform journal commodities 105 | -> Day 106 | -- ^ Start of Price Range 107 | -> Day 108 | -- ^ End of Price Range 109 | -> Bool 110 | -- ^ Rate Limit Requests 111 | -> IO [(CommoditySymbol, [(Day, Prices)])] 112 | fetchPrices cfg symbols cryptoCurrencies aliases start end rateLimit = do 113 | let (stockSymbols, cryptoSymbols) = 114 | unaliasAndBucketCommodities symbols cryptoCurrencies aliases 115 | genericAction = 116 | map FetchStock stockSymbols <> map FetchCrypto cryptoSymbols 117 | if rateLimit 118 | then fmap catMaybes $ rateLimitActions $ map fetch genericAction 119 | else catMaybes <$> mapM fetch genericAction 120 | where 121 | fetch 122 | :: AlphaRequest -> IO (Maybe (CommoditySymbol, [(Day, Prices)])) 123 | fetch req = do 124 | (symbol, label, resp) <- case req of 125 | FetchStock symbol -> 126 | (symbol,"Stock",) 127 | <$> try (getDailyPrices cfg symbol start end) 128 | FetchCrypto symbol -> 129 | (symbol,"Cryptocurrency",) 130 | <$> try 131 | ( getDailyCryptoPrices cfg symbol "USD" start end 132 | ) 133 | case resp of 134 | Left (e :: SomeException) -> do 135 | logError $ 136 | "Error Fetching Prices for " 137 | <> label 138 | <> " `" 139 | <> T.unpack symbol 140 | <> "`:\n\t" 141 | ++ displayException e 142 | ++ "\n" 143 | return Nothing 144 | Right (ApiError note) -> do 145 | logError $ 146 | "Error Fetching Prices for " 147 | <> label 148 | <> " `" 149 | <> T.unpack symbol 150 | <> "`:\n\t" 151 | <> T.unpack note 152 | <> "\n" 153 | return Nothing 154 | Right (ApiResponse prices) -> return $ Just (symbol, prices) 155 | 156 | logError :: String -> IO () 157 | logError = hPutStrLn stderr 158 | 159 | 160 | -- | Given a list of commodities from a journal, a list a cryptocurrencies, 161 | -- and a map of aliases, return the a list of AlphaVantage equities 162 | -- & cryptocurencies. 163 | unaliasAndBucketCommodities 164 | :: [CommoditySymbol] 165 | -- ^ Journal symbols 166 | -> [T.Text] 167 | -- ^ Cryptocurrency symbols 168 | -> M.Map T.Text T.Text 169 | -- ^ Aliases 170 | -> ([CommoditySymbol], [CommoditySymbol]) 171 | unaliasAndBucketCommodities symbols cryptoCurrencies aliases = 172 | L.partition (`notElem` cryptoCurrencies) $ 173 | S.toList $ 174 | S.fromList $ 175 | map transformAliases symbols 176 | where 177 | transformAliases :: T.Text -> T.Text 178 | transformAliases original = 179 | fromMaybe original $ M.lookup original aliases 180 | 181 | 182 | -- | Given a list of paired unaliased symbols, the original journal 183 | -- commodities, and the map of aliases, generate a new list of paired 184 | -- symbols that reflects the commodities in the original journal. 185 | -- 186 | -- Pairs with symbols in the journal but not in the aliases will be 187 | -- unaltered. Pairs with aliases only in the journal will return only alias 188 | -- items. Pairs for multiple aliases with return a set of items for each 189 | -- alias. Pairs with symbols and aliases in the journal will return both 190 | -- sets of items. 191 | reAliasCommodities 192 | :: [(CommoditySymbol, a)] 193 | -- ^ Unaliased pairs of symbols 194 | -> [CommoditySymbol] 195 | -- ^ Original symbols from the journal 196 | -> M.Map T.Text T.Text 197 | -- ^ Aliases 198 | -> [(CommoditySymbol, a)] 199 | reAliasCommodities symbolPairs journalSymbols aliases = 200 | concatMap reAlias symbolPairs 201 | where 202 | reAlias :: (CommoditySymbol, a) -> [(CommoditySymbol, a)] 203 | reAlias s@(cs, a) = case M.lookup cs reverseAliases of 204 | Nothing -> 205 | [s] 206 | Just revAliases -> 207 | map (,a) $ filter (`elem` journalSymbols) $ NE.toList revAliases 208 | reverseAliases :: M.Map T.Text (NE.NonEmpty T.Text) 209 | reverseAliases = 210 | let journalSymbolPairs = map (\s -> (s, NE.singleton s)) journalSymbols 211 | in M.fromListWith (<>) 212 | . (<> journalSymbolPairs) 213 | . map (\(k, v) -> (v, NE.singleton k)) 214 | $ M.assocs aliases 215 | 216 | 217 | -- | Types of AlphaVantage requests we make. Unified under one type so we 218 | -- write a generic fetching function that can be rate limited. 219 | data AlphaRequest 220 | = FetchStock CommoditySymbol 221 | | FetchCrypto CommoditySymbol 222 | 223 | 224 | -- | Perform the actions at a rate of 5 per minute, then return all the 225 | -- results. 226 | -- 227 | -- Note: Will log waiting times to stdout. 228 | rateLimitActions :: [IO a] -> IO [a] 229 | rateLimitActions a = case chunksOf 5 a of 230 | [first] -> sequence first 231 | first : rest -> do 232 | rest_ <- concat <$> mapM runAndDelay rest 233 | first_ <- sequence first 234 | return $ first_ ++ rest_ 235 | [] -> return [] 236 | where 237 | runAndDelay actions = do 238 | results <- sequence actions 239 | putStrLn "Waiting 60 seconds to respect API rate limits." 240 | threadDelay (60 * 1_000_000) 241 | return results 242 | 243 | 244 | -- | Build the Price Directives for the Daily Prices of the given 245 | -- Commodities. 246 | makePriceDirectives 247 | :: [(CommoditySymbol, [(Day, Prices)])] -> LBS.ByteString 248 | makePriceDirectives = (<> "\n") . LBS.intercalate "\n\n" . map makeDirectives 249 | where 250 | makeDirectives 251 | :: (CommoditySymbol, [(Day, Prices)]) -> LBS.ByteString 252 | makeDirectives (symbol, prices) = 253 | LBS.intercalate "\n" $ 254 | ("; " <> LBS.fromStrict (encodeUtf8 symbol)) 255 | : map (makeDirective symbol) prices 256 | makeDirective :: CommoditySymbol -> (Day, Prices) -> LBS.ByteString 257 | makeDirective symbol (day, prices) = 258 | LBS.intercalate 259 | " " 260 | [ "P" 261 | , LC.pack $ formatTime defaultTimeLocale "%F" day 262 | , LBS.fromStrict $ encodeUtf8 symbol 263 | , "$" <> LC.pack (show $ pClose prices) 264 | ] 265 | -------------------------------------------------------------------------------- /app/Main.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveDataTypeable #-} 2 | {-# LANGUAGE LambdaCase #-} 3 | {-# LANGUAGE NamedFieldPuns #-} 4 | {-# LANGUAGE OverloadedStrings #-} 5 | {-# LANGUAGE QuasiQuotes #-} 6 | {-# LANGUAGE RecordWildCards #-} 7 | {-# LANGUAGE TupleSections #-} 8 | {-# LANGUAGE ViewPatterns #-} 9 | 10 | module Main where 11 | 12 | import Control.Applicative ((<|>)) 13 | import Control.Exception.Safe (try) 14 | import Control.Monad (forM_) 15 | import Data.Aeson 16 | ( FromJSON (..) 17 | , withObject 18 | , (.:?) 19 | ) 20 | import Data.Maybe (fromMaybe) 21 | import Data.Time 22 | ( Day 23 | , defaultTimeLocale 24 | , formatTime 25 | ) 26 | import Data.Version (showVersion) 27 | import Data.Yaml (prettyPrintParseException) 28 | import Data.Yaml.Config 29 | ( ignoreEnv 30 | , loadYamlSettings 31 | ) 32 | import System.Console.CmdArgs 33 | ( Data 34 | , Typeable 35 | , args 36 | , cmdArgs 37 | , details 38 | , enum 39 | , explicit 40 | , help 41 | , helpArg 42 | , ignore 43 | , name 44 | , program 45 | , summary 46 | , typ 47 | , (&=) 48 | ) 49 | import System.Directory (doesFileExist) 50 | import System.Environment (lookupEnv) 51 | import System.Environment.XDG.BaseDir (getUserConfigFile) 52 | import System.Exit (exitFailure) 53 | import System.IO 54 | ( hPutStrLn 55 | , stderr 56 | ) 57 | import Text.RawString.QQ (r) 58 | 59 | import Hledger.StockQuotes 60 | import Paths_hledger_stockquotes (version) 61 | import Web.AlphaVantage 62 | 63 | import qualified Data.ByteString.Lazy as LBS 64 | import qualified Data.Map as M 65 | import qualified Data.Text as T 66 | 67 | 68 | main :: IO () 69 | main = do 70 | cfgArgs <- cmdArgs argSpec 71 | cfgFile <- loadConfigFile 72 | AppConfig {..} <- mergeArgsEnvCfg cfgFile cfgArgs 73 | let cfg = Config $ T.pack apiKey 74 | (commodities, start, end) <- 75 | getCommoditiesAndDateRange 76 | (T.pack <$> excludedCurrencies) 77 | journalFile 78 | if not dryRun 79 | then do 80 | prices <- 81 | fetchPrices 82 | cfg 83 | commodities 84 | cryptoCurrencies 85 | aliases 86 | start 87 | end 88 | rateLimit 89 | if null prices 90 | then logError "No price directives were able to be fetched." 91 | else LBS.writeFile outputFile $ makePriceDirectives prices 92 | else do 93 | putStrLn $ 94 | "Querying from " 95 | <> showDate start 96 | <> " to " 97 | <> showDate end 98 | let (stocks, cryptos) = 99 | unaliasAndBucketCommodities commodities cryptoCurrencies aliases 100 | putStrLn "Querying Stocks:" 101 | forM_ stocks $ \commodity -> putStrLn $ "\t" <> T.unpack commodity 102 | putStrLn "Querying CryptoCurrencies:" 103 | forM_ cryptos $ \commodity -> putStrLn $ "\t" <> T.unpack commodity 104 | let reAliased = map fst $ reAliasCommodities (fmap (,()) $ stocks <> cryptos) commodities aliases 105 | putStrLn "Writing Prices for:" 106 | forM_ reAliased $ \commodity -> putStrLn $ "\t" <> T.unpack commodity 107 | where 108 | showDate :: Day -> String 109 | showDate = formatTime defaultTimeLocale "%Y-%m-%d" 110 | 111 | 112 | logError :: String -> IO () 113 | logError = hPutStrLn stderr . ("[ERROR] " <>) 114 | 115 | 116 | -- CONFIGURATION 117 | 118 | data AppConfig = AppConfig 119 | { apiKey :: String 120 | , rateLimit :: Bool 121 | , journalFile :: FilePath 122 | , outputFile :: FilePath 123 | , excludedCurrencies :: [String] 124 | , cryptoCurrencies :: [T.Text] 125 | , dryRun :: Bool 126 | , aliases :: M.Map T.Text T.Text 127 | } 128 | deriving (Show, Eq) 129 | 130 | 131 | defaultExcludedCurrencies :: [String] 132 | defaultExcludedCurrencies = ["$", "USD"] 133 | 134 | 135 | -- | Merge the Arguments, Environmental Variables, & Configuration File 136 | -- into an 'AppConfig. 137 | -- 138 | -- Arguments override environmental variables, which overrides the 139 | -- configuration file. 140 | mergeArgsEnvCfg :: ConfigFile -> Args -> IO AppConfig 141 | mergeArgsEnvCfg ConfigFile {..} Args {..} = do 142 | envJournalFile <- lookupEnv "LEDGER_FILE" 143 | envApiKey <- lookupEnv "ALPHAVANTAGE_KEY" 144 | apiKey <- case argApiKey <|> envApiKey <|> cfgApiKey of 145 | Just k -> return k 146 | Nothing -> 147 | logError 148 | "Pass an AlphaVantage API Key with `-a` or $ALPHAVANTAGE_KEY." 149 | >> exitFailure 150 | let journalFile = 151 | fromMaybe "~/.hledger.journal" $ argJournalFile <|> envJournalFile 152 | rateLimit = 153 | fromMaybe True $ either (const cfgRateLimit) Just argRateLimit 154 | excludedCurrencies = 155 | if argExcludedCurrencies == defaultExcludedCurrencies 156 | then fromMaybe defaultExcludedCurrencies cfgExcludedCurrencies 157 | else argExcludedCurrencies 158 | cryptoCurrencies = 159 | if null argCryptoCurrencies 160 | then maybe [] (map T.pack) cfgCryptoCurrencies 161 | else concatMap (T.splitOn "," . T.pack) argCryptoCurrencies 162 | outputFile = argOutputFile 163 | dryRun = argDryRun 164 | aliases = fromMaybe M.empty cfgAliases 165 | return AppConfig {..} 166 | 167 | 168 | data ConfigFile = ConfigFile 169 | { cfgApiKey :: Maybe String 170 | , cfgRateLimit :: Maybe Bool 171 | , cfgExcludedCurrencies :: Maybe [String] 172 | , cfgCryptoCurrencies :: Maybe [String] 173 | , cfgAliases :: Maybe (M.Map T.Text T.Text) 174 | } 175 | deriving (Show, Eq) 176 | 177 | 178 | instance FromJSON ConfigFile where 179 | parseJSON = withObject "ConfigFile" $ \o -> do 180 | cfgApiKey <- o .:? "api-key" 181 | cfgRateLimit <- o .:? "rate-limit" 182 | cfgExcludedCurrencies <- o .:? "exclude" 183 | cfgCryptoCurrencies <- o .:? "cryptocurrencies" 184 | cfgAliases <- o .:? "commodity-aliases" 185 | return ConfigFile {..} 186 | 187 | 188 | loadConfigFile :: IO ConfigFile 189 | loadConfigFile = do 190 | configFile <- getUserConfigFile "hledger-stockquotes" "config.yaml" 191 | hasConfig <- doesFileExist configFile 192 | if hasConfig 193 | then 194 | try (loadYamlSettings [configFile] [] ignoreEnv) >>= \case 195 | Left (lines . prettyPrintParseException -> errorMsg) -> 196 | hPutStrLn stderr "[WARN] Invalid Configuration File Format:" 197 | >> mapM_ (hPutStrLn stderr . ("\t" <>)) errorMsg 198 | >> return defaultConfig 199 | Right c -> return c 200 | else return defaultConfig 201 | where 202 | defaultConfig :: ConfigFile 203 | defaultConfig = ConfigFile Nothing Nothing Nothing Nothing Nothing 204 | 205 | 206 | data Args = Args 207 | { argApiKey :: Maybe String 208 | , argRateLimit :: Either () Bool 209 | , argJournalFile :: Maybe FilePath 210 | , argOutputFile :: FilePath 211 | , argExcludedCurrencies :: [String] 212 | , argCryptoCurrencies :: [String] 213 | , argDryRun :: Bool 214 | } 215 | deriving (Data, Typeable, Show, Eq) 216 | 217 | 218 | argSpec :: Args 219 | argSpec = 220 | Args 221 | { argApiKey = 222 | Nothing 223 | &= help "Your AlphaVantage API key. Default: $ALPHAVANTAGE_KEY" 224 | &= explicit 225 | &= name "api-key" 226 | &= name "a" 227 | &= typ "ALPHAVANTAGE_KEY" 228 | , argRateLimit = 229 | enum 230 | [ Left () 231 | &= help "Fall back to the configuration file, or True." 232 | &= ignore 233 | , Right True 234 | &= help "Apply rate-limting for the API" 235 | &= explicit 236 | &= name "rate-limit" 237 | &= name "r" 238 | , Right False 239 | &= help "Disable rate-limiting for the API" 240 | &= explicit 241 | &= name "no-rate-limit" 242 | &= name "n" 243 | ] 244 | , argJournalFile = 245 | Nothing 246 | &= help 247 | "Journal file to read commodities from. Default: $LEDGER_FILE or ~/.hledger.journal" 248 | &= explicit 249 | &= name "journal-file" 250 | &= name "f" 251 | &= typ "FILE" 252 | , argOutputFile = 253 | "prices.journal" 254 | &= help 255 | "File to write prices into. Existing files will be overwritten. Default: prices.journal" 256 | &= explicit 257 | &= name "output-file" 258 | &= name "o" 259 | &= typ "FILE" 260 | , argCryptoCurrencies = 261 | [] 262 | &= help 263 | "Cryptocurrencies to fetch prices for. Flag can be passed multiple times." 264 | &= explicit 265 | &= name "c" 266 | &= name "crypto" 267 | &= typ "TICKER,..." 268 | , argExcludedCurrencies = 269 | defaultExcludedCurrencies 270 | &= args 271 | &= typ 272 | "EXCLUDED_CURRENCY ..." 273 | , argDryRun = 274 | False 275 | &= explicit 276 | &= name "dry-run" 277 | &= name "d" 278 | &= help 279 | "Print the commodities and dates that would be processed." 280 | } 281 | &= summary 282 | ( "hledger-stockquotes v" 283 | ++ showVersion version 284 | ++ ", Pavan Rikhi 2020" 285 | ) 286 | &= program "hledger-stockquotes" 287 | &= helpArg [name "h"] 288 | &= help "Generate HLedger Price Directives From Daily Stock Quotes." 289 | &= details programDetails 290 | 291 | 292 | programDetails :: [String] 293 | programDetails = 294 | lines 295 | [r| 296 | hledger-stockquotes reads a HLedger journal file, queries the AlphaVantage 297 | stock quote API, and writes a new journal file containing price directives 298 | for each commodity. 299 | 300 | 301 | DESCRIPTION 302 | 303 | By default, we find all non-USD commodities in your journal file and query 304 | AlphaVantage for their stock prices over the date range used in the journal 305 | file. Currently, we only support public U.S. equities & cryptocurrencies 306 | & do not call out to AlphaVantage's FOREX API routes. 307 | 308 | If you have commodities that are not supported by AlphaVantage, 309 | hledger-stockquotes will output an error when attempting to processing 310 | them. To avoid processing of unsupported currencies, you can pass in any 311 | commodities to exclude as arguments. If you use the default commodity 312 | directive in your journal file, hledger will include an `AUTO` commodity 313 | when parsing your journal. 314 | 315 | 316 | CRYPTOCURRENCIES 317 | 318 | We support feching daily closing prices for all cryptocurrencies supported 319 | by AlphaVantage. Use the `-c` flag to specify which commodities are 320 | cryptocurrencies. You can pass the flag multiple times or specify them as 321 | a comma-separated list. For the listed cryptocurrencies, we will hit 322 | AlphaVantage's Daily Crypto Prices API route instead of the normal Stock 323 | Prices route. 324 | 325 | 326 | API LIMITS 327 | 328 | AlphaVantage's API limits users to 5 requests per minute. We respect this 329 | limit by waiting for 60 seconds after every 5 commities we process. You 330 | can ignore the rate-limiting by using the `-n` flag, but requests are more 331 | likely to fail. You can use the `-d` flag to print out the dates 332 | & currencies that we will fetch to avoid any unecessary processing or API 333 | requests. 334 | 335 | 336 | OUTPUT FILE 337 | 338 | You can use the `-o` flag to set the file we will write the generated price 339 | directives into. By default, we write to `prices.journal`. 340 | 341 | Warning: the output file will always be overwritten with the new price 342 | directives. We currently do not support appending to the output file. 343 | 344 | 345 | ENVIRONMENTAL VARIABLES 346 | 347 | If no `-f` flag is passed and the LEDGER_FILE environmental variable is 348 | set, the program will use that as the default HLedger file. Otherwise 349 | ~/.hledger.journal will be used. 350 | 351 | Instead of passing the `-a` flag with your AlphaVantage API key, you can 352 | set the ALPHAVANTAGE_KEY environmental variable instead. 353 | 354 | 355 | CONFIGURATION FILE 356 | 357 | If you have common options you constantly pass to the application, you can 358 | specify them in a YAML configuration file. We attempt to parse 359 | a configuration file in $XDG_CONFIG_HOME/hledger-stockquotes/config.yaml. 360 | It currently supports the following top-level keys: 361 | 362 | - `api-key`: (string) Your AlphaVantage API Key 363 | - `cryptocurrencies`: (list of string) Cryptocurrencies to Fetch 364 | - `exclude`: (list of strings) Currencies to Exclude 365 | - `rate-limit`: (bool) Obey AlphaVantage's Rate Limit 366 | - `commodity-aliases`: (map of strings) Rename journal commodities before 367 | querying AlphaVantage 368 | 369 | Environmental variables will overide any config file options, and CLI flags 370 | will override both environmental variables & config file options. 371 | 372 | 373 | ALIASES 374 | 375 | By specifying the `commedity-aliases` option in your configuration file, 376 | you can rename the commodities used in your journal to the commodities 377 | expected by AlphaVantage. 378 | 379 | Keys in the map should be your journal commities while their values are the 380 | AlphaVantage ticker symbols: 381 | 382 | commodity-aliases: 383 | MY_VTSAX: VTSAX 384 | MY_BTC_CURRENCY: BTC 385 | 386 | Renaming is done after commodity exclusion, but before bucketing them into 387 | equities & cryptocurrencies so the `exclude` list should use your symbols 388 | while the `cryptocurrencies` list should use AlphaVantage's: 389 | 390 | journal -> exclude -> aliases -> cryptocurrencies 391 | 392 | Specifying aliases via command line options or environmental variables is 393 | not currently supported. 394 | 395 | 396 | USAGE EXAMPLES 397 | 398 | Fetch prices for all commodities in the default journal file: 399 | hledger-stockquotes -a 400 | 401 | Output prices into a custom journal file: 402 | hledger-stockquotes -a -o prices/2021.journal 403 | 404 | Fetch prices for all commodities, including Bitcoin: 405 | hledger-stockquotes -a -c BTC 406 | 407 | Ignore the default, foreign, & crypto commodities: 408 | hledger-stockquotes -a AUTO BTC ETH EUR 409 | |] 410 | --------------------------------------------------------------------------------