├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── lak.md ├── lak.png ├── portfolio.yaml └── recipes.md ├── lakshmi ├── __init__.py ├── analyze.py ├── assets.py ├── cache.py ├── constants.py ├── data │ ├── Account.yaml │ ├── AssetClass.yaml │ ├── Checkpoint.yaml │ ├── EEBonds.yaml │ ├── IBonds.yaml │ ├── ManualAsset.yaml │ ├── TickerAsset.yaml │ └── VanguardFund.yaml ├── lak.py ├── lakshmi.py ├── performance.py ├── table.py └── utils.py ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── data ├── SBCPrice-EE.html ├── SBCPrice-I.html ├── price.json └── profile.json ├── integration_test.sh ├── test_analyze.py ├── test_assets.py ├── test_cache.py ├── test_data.py ├── test_lak.py ├── test_lakshmi.py ├── test_performance.py ├── test_table.py └── test_utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.swp 3 | __pycache__ 4 | build 5 | dist 6 | lakshmi.egg-info 7 | venv 8 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | known_third_party = click,curl_cffi,ibonds,numpy,pyxirr,requests,setuptools,tabulate,yaml,yfinance 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: check-yaml 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | # isort 11 | - repo: https://github.com/asottile/seed-isort-config 12 | rev: v2.2.0 13 | hooks: 14 | - id: seed-isort-config 15 | - repo: https://github.com/PyCQA/isort 16 | rev: 6.0.1 17 | hooks: 18 | - id: isort 19 | # flake8 20 | - repo: https://github.com/pycqa/flake8 21 | rev: 7.2.0 22 | hooks: 23 | - id: flake8 24 | args: # arguments to configure flake8 25 | - "--max-line-length=79" 26 | - "--max-complexity=18" 27 | - "--select=B,C,E,F,W,T4,B9" 28 | - "--ignore=W503" 29 | - "--per-file-ignores=lakshmi/lak.py:F811" 30 | # unittest 31 | - repo: local 32 | hooks: 33 | - id: unittest 34 | name: unittest 35 | entry: python -m unittest discover 36 | language: python 37 | additional_dependencies: [click, curl_cffi, ibonds, pyxirr, PyYAML, 38 | requests, tabulate, yfinance] 39 | types: [python] 40 | pass_filenames: false 41 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to 6 | [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | 9 | ## [Unreleased] 10 | 11 | ## [v3.0.2] - 2025-05-01 12 | ### Fixed 13 | - YFRateLimitError when fetching price data for tickers. 14 | 15 | ## [v3.0.1] - 2024-10-25 16 | ### Fixed 17 | - Fixed crash when a portfolio consists of only ManualAssets. 18 | 19 | ## [v3.0.0] - 2024-07-05 20 | ### Changed 21 | - `lak list performance` and `lak info performance` commands will print 22 | the performance stats till today as opposed to the last saved checkpoint. 23 | - Deleted unnecessary lakshmi.analyze.Analyzer interface. 24 | - Rename lakshmi.Portfolio.assets to lakshmi.Portfolio.list_assets for 25 | consistency. 26 | ### Fixed 27 | - Vanguard's website SSL certificate was throwing errors. Disable SSL checks 28 | until I figure out a better solution. 29 | 30 | ## [v2.12.1] - 2024-05-02 31 | ### Fixed 32 | - AttributeError when ibond interest rates are fetched. 33 | - Add dependency to latest version of yfinance (removes some warnings that 34 | older version is throwing). 35 | - Renamed lakshmi.Portfolio.assets to lakshmi.Portfolio.list_assets for 36 | consistency. 37 | 38 | ## [v2.12.0] - 2023-11-18 39 | ### Fixed 40 | - yfinance threw error while fetching names of tickers from yahoo finance. 41 | The latest version fixes that -- lakshmi now explicitly depends on the latest 42 | version. 43 | 44 | ## [v2.11.0] - 2023-06-27 45 | ### Fixed 46 | - Connection issues with the Vanguard website. 47 | 48 | ### Changed 49 | - If for some reason interest rate data is outdated (e.g. if user doesn't 50 | update ibonds module), IBonds class will no longer abort when printing 51 | interest rates for IBonds. Instead it will just output empty interest rates. 52 | IBonds value method will still abort if the missing interest rate are needed 53 | to calculate the current value of IBonds. 54 | - `lak edit` commands now accept comma-separated floats (e.g. when entering 55 | shares or dollar values). This makes it easy to paste values from websites. 56 | 57 | ## [v2.10.0] - 2023-05-27 58 | ### Fixed 59 | - lakshmi was stuck pulling prices for I/EE bonds. Treasury Direct seems to be 60 | blocking http. Use https instead of http for EE bonds (also see below). 61 | 62 | ### Changed 63 | - For fetching I Bonds, lakshmi now uses the `ibonds` package instead of 64 | Treasury Direct website. The I Bond values are calculated locally instead of 65 | fetching them from internet, making updating the prices of I Bonds much 66 | faster. Also, we now return the fixed rate in addition to the composite rate 67 | when fetching information about I Bonds (e.g. when using `lak info asset` 68 | command or `lakshmi.assets.IBonds.list_bonds()` method). 69 | 70 | ## [v2.9.1] - 2023-02-10 71 | ### Fixed 72 | - 'Cannot retrieve ticker' errors. 73 | 74 | ## [v2.9.0] - 2023-01-30 75 | ### Added 76 | - Option in `lak list assets` that omits printing of the long name (the 77 | 'Asset' column) if short name is being printed. 78 | - Three Options in `lak list whatifs` to print Asset short name, omit printing 79 | of long name if short name is being printed, and approximate number of shares 80 | that need to be bought or sold (corresponding to the dollar delta returned). 81 | The last field is only printed for assets that have a concept of shares. 82 | ### Fixed 83 | - Started using the new fast\_info from yfinance to fetch prices. Hopefully 84 | this should fix the temporary errors with fetching ticker prices. 85 | 86 | ## [v2.8.0] - 2022-09-28 87 | ### Changed 88 | - `lak` commands now don't print stack trace by default. Added a flag `--debug` 89 | to enable printing of the stack trace. 90 | 91 | ## [v2.7.0] - 2022-07-23 92 | ### Added 93 | - Added functionality in `lak list lots` to optionally print account names 94 | and terms for the tax lots. 95 | 96 | ### Changed 97 | - `lak analyze allocate' now supports asset classes with zero desired ratio. 98 | Thanks [rapidleft](https://github.com/rapidleft). 99 | 100 | ## [v2.6.0] - 2022-06-21 101 | ### Added 102 | - Added functionality in the `cache` module to prefetch multiple cached objects 103 | in parallel threads. 104 | - Added prefetch method in assets that calls the newly added functionality 105 | in the `cache` module. Also, added a prefetch method to portfolio that 106 | prefetches the prices/names for all the assets in the portfolio in parallel. 107 | 108 | ### Changed 109 | - lak command that access the whole portfolio now uses prefetch to 110 | speed up refreshing the prices of the portfolio by using multiple threads to 111 | do so. 112 | 113 | ## [v2.5.0] - 2022-04-22 114 | ### Added 115 | - A new command `lak analyze allocate` which suggests how to allocate new cash, 116 | while making sure the actual asset allocation remains close to the desired 117 | allocation. This command can also be used to get rebalancing suggestions or to 118 | withdraw money from the portfolio. In all cases, it will suggest changes that 119 | will minimize the relative difference between actual asset allocation and the 120 | desired asset allocation. 121 | - A new 122 | [recipes doc](https://github.com/sarvjeets/lakshmi/blob/develop/docs/recipes.md) 123 | documenting tips and tricks for using Lakshmi. 124 | 125 | ### Changed 126 | - Changed some of the common methods to return percentages rounded to 1 digit 127 | rather than 0. 128 | - Earlier asset classes with no money mapped to them were not returned when 129 | returning asset allocation. Now all asset classes are returned regardless of 130 | whether they have money mapped or not. 131 | 132 | ## [v2.4.1] - 2022-02-23 133 | ### Fixed 134 | - Relaxed Python requirement to 3.7. 135 | 136 | ## [v2.4.0] - 2022-02-21 137 | ### Added 138 | - A new command `lak list accounts` that allows printing account values and 139 | percentages by accounts or by account types. 140 | ### Fixed 141 | - The spinner chars were not showing properly on MS Windows 11. Changed to 142 | a simpler spinner. 143 | 144 | ## [v2.3.0] - 2022-01-25 145 | ### Added 146 | - A new module `lakshmi.performance` that adds ability to checkpoint 147 | portfolio balances and display stats about portfolio's performance over time. 148 | - New commands in `lak` that exposes some functionality of the 149 | `lakshmi.performance` module: 150 | - `lak add checkpoint` 151 | - `lak edit checkpoint` 152 | - `lak delete checkpoint` 153 | - `lak list checkpoints` 154 | - `lak list performance` 155 | - `lak info performance` 156 | - Support in `.lakrc` to specify where the portfolio performance related data 157 | (checkpoints) are stored. 158 | ### Fixed 159 | - Help message now shows default values for `lak analyze rebalance`. 160 | - Added validation for I/EE bond purchase dates. 161 | 162 | ## [v2.2.0] - 2021-11-26 163 | ### Added 164 | - [New flag](https://github.com/sarvjeets/lakshmi/blob/develop/docs/lak.md#lakrc) 165 | in `lak` + environment variable support for specifying the `.lakrc` file. 166 | - Changelog (this file). 167 | - Contributing guidelines and development instructions for Lakshmi. 168 | ### Changed 169 | - Dependabot is disbled for this project. 170 | - Optimized away unnecessary calls when force refreshing the cached values 171 | (`lak -r` flag). 172 | ### Fixed 173 | - Incorrect error handling when `.lakrc` file couldn't be parsed. 174 | 175 | ## [v2.1.2] - 2021-10-24 176 | ### Added 177 | - pre-commit CI now runs for every push and PRs. 178 | ### Fixed 179 | - Fix for assets with missing name fields (e.g. 'BTC-USD'). 180 | Thanks [bolapara](https://github.com/bolapara). 181 | 182 | ## [v2.1.1] - 2021-10-22 183 | ### Added 184 | - Doc-strings added to all the files. 185 | - Dependabot to auto-update dependencies. 186 | ### Fixed 187 | - Documentation. 188 | 189 | ## [v2.1.0] - 2021-09-06 190 | ### Added 191 | - Added pre-commit to the project. 192 | - Support for calling user defined function on cache misses in `lakshmi.cache`. 193 | - Progress bar for slow commands. 194 | - `lak list lots` command. 195 | ### Changed 196 | - `lakshmi.lak` module moved from `lakshmi/lak` to `lakshmi/` directory. 197 | - Optimized code to prevent unnecessary calls to slow functions. 198 | ### Fixed 199 | - Broken link to `lak.md` from `README.md`. 200 | - User-agent is now set correctly. 201 | - `lak list` warnings for what-ifs are now printed consistently. 202 | - Documentation. 203 | 204 | ## [v2.0.0] - 2021-08-21 205 | ### Added 206 | - Detailed documentation for `lak` command: `docs/lak.md`. 207 | - Integration test for `lak` command. 208 | ### Changed 209 | - `lak whatif` and `lak info` command now require a asset or account as a sub-command. 210 | - All function names changed to snake case + PEP8 style formatting. 211 | - Visibility of some members in classes changed to private. 212 | - Short option for --asset-class in `lak list aa` changed from -a to -c (This reduces confusion, -a is used in other commands for specifying asset) 213 | ### Fixed 214 | - Language and documentation for `lak` command-line help messages. 215 | - Relax the dependencies to any non-major release of those packages. 216 | - Typos and language in `README.md`. 217 | - Some tests were not running due to duplicate names. 218 | 219 | ## [v1.0.4] - 2021-08-05 220 | ### Fixed 221 | - Moved `data/` files inside lakshmi to include it in wheel package. 222 | 223 | ## [v1.0.3] - 2021-08-05 224 | ### Fixed 225 | - Added `MANIFEST.in` file to include `data/` files in the release package. 226 | 227 | ## [v1.0.2] - 2021-07-31 228 | ### Fixed 229 | - lak add asset command wasn't working due to a typo. Fixed. 230 | 231 | ## [v1.0.1] - 2021-07-31 232 | ### Fixed 233 | - `lak init` command no longer asks user to run lak init first! 234 | 235 | ## [v1.0.0] - 2021-07-30 236 | ### Added 237 | - First release of `lakshmi` library and `lak` tool. 238 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Lakshmi 2 | 3 | As an open-source project, Lakshmi welcomes contributions of any form. 4 | Some examples of how you can contribute: 5 | 6 | - Fix or add documentation. 7 | - Improve code quality and readability. 8 | - Find and report any bugs. Even better fix them! 9 | - Send requests for features that you would like to see in lakshmi. 10 | - Implement new features that you would like to see in Lakshmi. Please see 11 | the [section](#vision) below for some direction on what kind of features 12 | would be a good addition to Lakshmi. A running list of features and ideas is 13 | also available at the [projects](https://github.com/sarvjeets/lakshmi/projects) 14 | page. 15 | - Last but not least, please use it, send feedback and share it with others. 16 | 17 | ## Vision 18 | 19 | Lakshmi is very much focussed on the 20 | [Bogleheads philosopy](https://www.bogleheads.org/wiki/Bogleheads%C2%AE_investment_philosophy). 21 | I would like to keep it simple and as much as possible align with 22 | the teachings of John Bogle and views of the current advisory board over at the 23 | Bogleheads forum. This tool is meant to make investing simpler for the 24 | masses, and discourage harmful practices such as day trading 25 | or speculatiion. When thinking of new and useful features for Lakshmi, 26 | please consider if it is inline with the Boglehead way of thinking. 27 | 28 | Lakshmi is divided into two parts: A core library (`lakshmi`) and simple 29 | interfaces over the core library (currently only `lak` CLI is implemented). 30 | The interfaces themselves are meant to be lightweight wrappers over the core 31 | library, and most of the functionality should be implemented 32 | directly in the library. At some point, it would be nice to add a web & 33 | Andriod/iOS app interfaces for Lakshmi as well. 34 | 35 | − [Sarvjeet](https://github.com/sarvjeets) 36 | 37 | ## Best practices 38 | - Please read up the Development section in the [README](./README.md) file. 39 | - All the development is done on the develop branch. Please fork off your 40 | feature branch from it, and prefer rebases instead of merges to pull new 41 | changes from the upstream branch. 42 | - Please write tests to ensure your new feature or bug fix is tested. Please 43 | run all tests and the pre-submit before sending out the pull request. 44 | - When reporting a bug, please list the contents of your .lakrc and portfolio 45 | file whenever relevant. 46 | - If you are planning to work on a non-trivial feature, please discuss 47 | the implementation over email or with a shared Google document. This will 48 | prevent wasted time and effort on your part and will make the pull requests 49 | easier to review. 50 | - If in doubt, please feel free to contact [me](https://github.com/sarvjeets) 51 | over email first. 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sarvjeet Singh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include lakshmi/data/* 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lakshmi 2 | 3 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/sarvjeets/lakshmi/develop.svg)](https://results.pre-commit.ci/latest/github/sarvjeets/lakshmi/develop) 4 | [![Downloads](https://pepy.tech/badge/lakshmi)](https://pepy.tech/project/lakshmi) 5 | [![Downloads](https://pepy.tech/badge/lakshmi/month)](https://pepy.tech/project/lakshmi) 6 | 7 | ![Screenshot of lak in action](./docs/lak.png) 8 | (Screenshot of the `lak` command in action) 9 | 10 | ## Background 11 | This project is inspired by 12 | [Bogleheads forum](http://bogleheads.org). Bogleheads focus on a simple but 13 | [powerful philosophy](https://www.bogleheads.org/wiki/Bogleheads%C2%AE_investment_philosophy) 14 | that allows investors to achieve above-average 15 | returns after costs. This tool is built around the same principles to help 16 | an _average_ investor manage their investing portfolio. 17 | 18 | Lakshmi (meaning "She who leads to one's goal") is one of the principal 19 | goddesses in Hinduism. She is the goddess of wealth, fortune, power, health, 20 | love, beauty, joy and prosperity. 21 | 22 | ## Introduction 23 | This project consists of a library module (`lakshmi`) and a command-line 24 | tool (`lak`) that exposes some of the functionality of the library. The library 25 | provides useful abstractions and tools to manage your investing portfolio. 26 | 27 | [Bogleheads wiki](https://www.bogleheads.org/wiki/Main_Page) is a great 28 | resource for introduction to basic investing concepts like asset-allocation, 29 | asset-location, etc. 30 | 31 | The following features are currently available: 32 | 33 | - Specify and track asset allocation across accounts. 34 | - Ability to add/edit/delete accounts and assets (funds, stocks, ETFs, etc.) 35 | inside those accounts. The market value of these assets is automatically 36 | updated. 37 | - Support for running what-if scenarios to see how it impacts the overall asset 38 | allocation. 39 | - Suggests which funds to allocate new money to (or withdraw money from) to 40 | keep the actual asset allocation close to the desired asset allocation. 41 | - Suggests how to rebalance the funds in a given account to bring the actual 42 | asset allocation close to the desired asset allocation. 43 | - Ability to track portfolio performance 44 | ([IRR](https://www.investopedia.com/terms/i/irr.asp#:~:text=The%20internal%20rate%20of%20return,a%20discounted%20cash%20flow%20analysis.)) 45 | and cash flows. 46 | - Supports manual assets, assets with ticker, Vanguard funds (that don't 47 | have associated ticker symbols), 48 | [EE Bonds](https://www.treasurydirect.gov/indiv/products/prod_eebonds_glance.htm) 49 | and 50 | [I Bonds](https://www.treasurydirect.gov/indiv/research/indepth/ibonds/res_ibonds.htm). 51 | - Listing current values of assets, asset allocation and asset location. 52 | - Tracking of tax-lot information for assets. 53 | - Analysis of portfolio to identify if there is need to rebalance or 54 | if there are losses that can be 55 | [harvested](https://www.bogleheads.org/wiki/Tax_loss_harvesting). 56 | 57 | ## Installation 58 | 59 | This project can be installed via [pip](https://pip.pypa.io/en/stable/). 60 | To install the library and the lak command line tool, run: 61 | 62 | ``` 63 | pip install lakshmi 64 | ``` 65 | 66 | ## Command-line interface 67 | 68 | For detailed help on the CLI, please see [lak user guide](./docs/lak.md). 69 | For tips and tricks, please refer to [Lakshmi Recipes](./docs/recipes.md). 70 | 71 | The simplest way to use this project is via the `lak` command. To access the 72 | up to date help, run: 73 | 74 | ``` 75 | $ lak --help 76 | Usage: lak [OPTIONS] COMMAND [ARGS]... 77 | 78 | lak is a simple command line tool inspired by Bogleheads philosophy. 79 | Detailed user guide is available at: 80 | https://sarvjeets.github.io/lakshmi/docs/lak.html 81 | 82 | Options: 83 | --version Show the version and exit. 84 | -r, --refresh Re-fetch all data instead of using previously cached 85 | data. For large portfolios, this would be extremely slow. 86 | -c, --config PATH The configuration file. [env var: LAK_CONFIG; default: 87 | ~/.lakrc] 88 | --debug If set, prints stack track when an exception is raised. 89 | --help Show this message and exit. 90 | 91 | Commands: 92 | add Add new entities to the portfolio. 93 | analyze Analyze the portfolio. 94 | delete Delete different entities from the portfolio. 95 | edit Edit parts of the portfolio. 96 | info Print detailed information about parts of the portfolio. 97 | init Initializes a new portfolio by adding asset classes. 98 | list Command to list various parts of the portfolio. 99 | whatif Run hypothetical what if scenarios by modifying the total... 100 | ``` 101 | The following section gives a quick summary of how to create a new portfolio. 102 | For detailed help, please read 103 | [creating a portfolio](./docs/lak.md#creating-a-portfolio) section of the 104 | [lak user guide](./docs/lak.md). 105 | 106 | A new portfolio can be created by either: 107 | 108 | 1. Copying an [existing](./docs/portfolio.yaml) 109 | portfolio file to ~/portfolio.yaml and editing it, OR 110 | 2. Using the `lak` commands to create a new portfolio. 111 | 112 | The following command will open up an editor to input the desired asset 113 | allocation: 114 | 115 | ``` 116 | $ lak init 117 | ``` 118 | 119 | Accounts (His/Her 401(k), Roth IRAs, Taxable, etc.) can be added via 120 | the `lak add account` command: 121 | 122 | ``` 123 | $ lak add account 124 | # Use the above command multiple times to add more accounts. 125 | ``` 126 | 127 | Assets can be added to an account via the `lak add asset` command. Different 128 | kinds of assets can be added to a portfolio. For a complete list, pull up the 129 | help for the command: 130 | 131 | ``` 132 | $ lak add asset --help 133 | Usage: lak add asset [OPTIONS] 134 | 135 | Add a new asset to the portfolio. 136 | 137 | Options: 138 | -p, --asset-type [ManualAsset|TickerAsset|VanguardFund|IBonds|EEBonds] 139 | Add this type of asset. [required] 140 | -t, --account substr Add asset to this account (a substring that 141 | matches the account name). [required] 142 | --help Show this message and exit. 143 | ``` 144 | 145 | TickerAsset represents an asset with a ticker symbol. The value of these assets 146 | is updated automatically. To add a TickerAsset: 147 | 148 | ``` 149 | lak add asset -p TickerAsset -t account_str 150 | ``` 151 | where account_str is a sub-string that uniquely matches an account added previously. 152 | 153 | That's it. To view all the assets, asset allocation and asset location, run: 154 | 155 | ``` 156 | lak list assets total aa al 157 | ``` 158 | 159 | ## Library 160 | 161 | The `lakshmi` library can also be used directly. The modules and classes are 162 | well documented and there are numerous examples for using each method or class 163 | in the [tests](https://github.com/sarvjeets/lakshmi/tree/develop/tests) 164 | accompanying this package. The 165 | [example portfolio](./docs/portfolio.yaml) can be constructed and the asset 166 | allocation, etc. can be printed by the following piece of python code: 167 | 168 | ```python 169 | from lakshmi import Account, AssetClass, Portfolio 170 | from lakshmi.assets import TaxLot, TickerAsset 171 | from lakshmi.table import Table 172 | 173 | 174 | def main(): 175 | asset_class = ( 176 | AssetClass('All') 177 | .add_subclass(0.6, AssetClass('Equity') 178 | .add_subclass(0.6, AssetClass('US')) 179 | .add_subclass(0.4, AssetClass('Intl'))) 180 | .add_subclass(0.4, AssetClass('Bonds'))) 181 | portfolio = Portfolio(asset_class) 182 | 183 | (portfolio 184 | .add_account(Account('Schwab Taxable', 'Taxable') 185 | .add_asset(TickerAsset('VTI', 1, {'US': 1.0}) 186 | .set_lots([TaxLot('2021/07/31', 1, 226)])) 187 | .add_asset(TickerAsset('VXUS', 1, {'Intl': 1.0}) 188 | .set_lots([TaxLot('2021/07/31', 1, 64.94)]))) 189 | .add_account(Account('Roth IRA', 'Tax-Exempt') 190 | .add_asset(TickerAsset('VXUS', 1, {'Intl': 1.0}))) 191 | .add_account(Account('Vanguard 401(k)', 'Tax-Deferred') 192 | .add_asset(TickerAsset('VBMFX', 20, {'Bonds': 1.0})))) 193 | 194 | # Save the portfolio 195 | # portfolio.Save('portfolio.yaml') 196 | print('\n' + portfolio.asset_allocation_compact().string() + '\n') 197 | print(Table(2, coltypes=['str', 'dollars']) 198 | .add_row(['Total Assets', portfolio.total_value()]).string()) 199 | print('\n' + portfolio.asset_allocation(['US', 'Intl', 'Bonds']).string()) 200 | print('\n' + portfolio.assets().string() + '\n') 201 | print(portfolio.asset_location().string()) 202 | 203 | 204 | if __name__ == "__main__": 205 | main() 206 | ``` 207 | 208 | ## Development 209 | Here are the steps to download the source code and start developing on 210 | Lakshmi: 211 | 212 | ```shell 213 | # Fork and clone this repo. 214 | $ git clone https://github.com/yourusername/lakshmi.git 215 | $ cd lakshmi 216 | 217 | # All development is done on the 'develop' branch 218 | $ git checkout develop 219 | 220 | # Setting up a virtual environment is strongly recommended. Install virtualenv 221 | # by one of the following: 222 | # pip install virtualenv --user # If you have pip installed 223 | # sudo apt-get install python-virtualenv # Ubuntu 224 | # sudo pacman -S python-virtualenv # Arch linux 225 | $ virtualenv venv 226 | # Activate the virtual environment 227 | $ source venv/bin/activate 228 | 229 | # Install all the dependencies 230 | $ pip install -r requirements.txt 231 | 232 | # Run unittests 233 | $ python -m unittest 234 | 235 | # Install pre-commit hooks to run it automatically on commits 236 | $ pre-commit install 237 | # Run pre-commit manually 238 | $ pre-commit run --all-files 239 | 240 | # Create your own bug or feature branch and start developing. Remember to 241 | # run tests (and add them when necessary) and pre-commit hooks on changes. 242 | ``` 243 | 244 | ## License 245 | Distributed under the MIT License. See `LICENSE` for more information. 246 | 247 | ## Acknowledgements 248 | 249 | I am indebted to the following folks whose wisdom has helped me 250 | tremendously in my investing journey: 251 | [John Bogle](https://en.wikipedia.org/wiki/John_C._Bogle), 252 | [Taylor Larimore](https://www.bogleheads.org/wiki/Taylor_Larimore), 253 | [Nisiprius](https://www.bogleheads.org/forum/viewtopic.php?t=242756), 254 | [Livesoft](https://www.bogleheads.org/forum/viewtopic.php?t=237269), 255 | [Mel Lindauer](https://www.bogleheads.org/wiki/Mel_Lindauer) and 256 | [LadyGeek](https://www.bogleheads.org/blog/2018/12/04/interview-with-ladygeek-bogleheads-site-administrator/). 257 | 258 | This project would not have been possible without my wife 259 | [Niharika](http://niharika.org), who helped me come up with the initial idea 260 | and encouraged me to start working on this project. 261 | 262 | 263 | ## The not-so-fine print 264 | 265 | _The author is not a financial adviser and you agree to treat this tool 266 | for informational purposes only. The author does not promise or guarantee 267 | that the information provided by this tool is correct, current, or complete, 268 | and it may contain technical inaccuracies or errors. The author is not 269 | liable for any losses that you might incur by acting on the information 270 | provided by this tool. Accordingly, you should confirm the accuracy and 271 | completeness of all content, and seek professional advice taking into 272 | account your own personal situation, before making any decision based 273 | on information from this tool._ 274 | 275 | In a nutshell: 276 | * The information provided by this tool is not financial advice. 277 | * The author is not an expert or financial adviser. 278 | * Consult a financial and/or tax adviser before taking action. 279 | -------------------------------------------------------------------------------- /docs/lak.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarvjeets/lakshmi/086c6d5f76af662fc656b33ef9aa8c4a4e03c514/docs/lak.png -------------------------------------------------------------------------------- /docs/portfolio.yaml: -------------------------------------------------------------------------------- 1 | Asset Classes: 2 | Name: All 3 | Children: 4 | - Ratio: 0.6 5 | Name: Equity 6 | Children: 7 | - Ratio: 0.6 8 | Name: US 9 | - Ratio: 0.4 10 | Name: Intl 11 | - Ratio: 0.4 12 | Name: Bonds 13 | Accounts: 14 | - Name: Schwab Taxable 15 | Account Type: Taxable 16 | Assets: 17 | - TickerAsset: 18 | Ticker: VTI 19 | Shares: 1 20 | Asset Mapping: 21 | US: 1.0 22 | Tax Lots: 23 | - Date: 2021/07/31 24 | Quantity: 1 25 | Unit Cost: 226 26 | - TickerAsset: 27 | Ticker: VXUS 28 | Shares: 1 29 | Asset Mapping: 30 | Intl: 1.0 31 | Tax Lots: 32 | - Date: 2021/07/31 33 | Quantity: 1 34 | Unit Cost: 64.94 35 | - Name: Roth IRA 36 | Account Type: Tax-Exempt 37 | Assets: 38 | - TickerAsset: 39 | Ticker: VXUS 40 | Shares: 1 41 | Asset Mapping: 42 | Intl: 1.0 43 | - Name: Vanguard 401(k) 44 | Account Type: Tax-Deferred 45 | Assets: 46 | - TickerAsset: 47 | Ticker: VBMFX 48 | Shares: 20 49 | Asset Mapping: 50 | Bonds: 1.0 51 | -------------------------------------------------------------------------------- /docs/recipes.md: -------------------------------------------------------------------------------- 1 | # Lakshmi Recipes 2 | 3 | ## Table of Contents 4 | 5 | * [Introduction](#introduction) 6 | * [Clean-up config files](#clean-up-config-files) 7 | * [Turn on auto-completion](#turn-on-auto-completion) 8 | * [Manage multiple portfolios](#manage-multiple-portfolios) 9 | * [Setting up automatic emails](#setting-up-automatic-emails) 10 | * [How to reorder list of accounts or assets](#how-to-reorder-list-of-accounts-or-assets) 11 | 12 | ## Introduction 13 | This document goes through some suggestions, tips and tricks for 14 | using the `lak` tool. For detailed help on the `lak` tool, please 15 | see the [lak user guide](./lak.md). 16 | 17 | ## Clean-up config files 18 | By default, the lak config files are in the user home directory. 19 | This clutters up the home directory, and many users would prefer to move these 20 | files elsewhere. `lak` supports overriding these defaults. For linux-based 21 | systems, it is recommended that the config files are moved to `~/.config/lak` 22 | directory and the cache is moved to `~/.cache/lakshmicache`. 23 | 24 | First move the config files and cache (if some of these 25 | files don't exist, you can safely ignore them): 26 | 27 | ```shell 28 | mkdir -p ~/.config/lak 29 | mkdir -p ~/.cache 30 | # Move all the config files to .config/lak 31 | mv ~/portfolio.yaml ~/.performance.yaml ~/.lakrc ~/.config/lak 32 | # Move cache to .cache 33 | mv ~/.lakshmicache ~/.cache/lakshmicache 34 | ``` 35 | 36 | Now modify `lakrc` to point to new configuration directories. Edit 37 | `~/.config/lak/.lakrc` and replace the existing paths with the new locations: 38 | 39 | ``` 40 | portfolio: '~/.config/lak/portfolio.yaml' 41 | performance: ~/.config/lak/.performance.yaml 42 | cache: '~/.cache/lakshmicache' 43 | ``` 44 | 45 | Finally, make sure `lak` is reading its config from the new file. In your 46 | shell configuration file (e.g. `.bashrc` for Bash or `.zshrc` for Zsh), 47 | add the following line: 48 | 49 | ```shell 50 | export LAK_CONFIG=~/.config/lak/.lakrc 51 | ``` 52 | 53 | ## Turn on auto-completion 54 | 55 | `lak` is built on top of `click` which supports 56 | [shell completion](https://click.palletsprojects.com/en/8.0.x/shell-completion) 57 | for Bash, Zsh and Fish shells. Shell completion is very useful and suggests 58 | commands, option names for commands, etc. 59 | 60 | Completion can be enabled by invoking `lak` during start-up for every shell 61 | session, but this is slow. The recommended method is to generate a config 62 | file once and source that into shell of your choice. 63 | 64 | For Bash: 65 | 66 | ```shell 67 | _LAK_COMPLETE=bash_source lak > ~/.config/.lak-complete.bash 68 | 69 | # Source the file in ~/.bashrc. 70 | . ~/.config/lak-complete.bash 71 | ``` 72 | 73 | For Zsh: 74 | 75 | ```shell 76 | _LAK_COMPLETE=zsh_souce lak > ~/.config/.lak-complete.zsh 77 | 78 | # Source the file in ~/.zshrc. 79 | . ~/.config/lak-complete.zsh 80 | ``` 81 | 82 | For Fish: 83 | 84 | ```shell 85 | # Save the script to ~/.config/fish/completions/lak.fish: 86 | _LAK_COMPLETE=fish_source lak > ~/.config/fish/completions/lak.fish 87 | ``` 88 | 89 | ## Manage multiple portfolios 90 | 91 | Many times users will find themselves managing multiple independent portfolios. 92 | The recommended way for doing this is to create multiple `lak` config files, 93 | each pointing to a difference portfolio file (and optionally a perfomance 94 | file if checkpointing is used). For example: 95 | 96 | ``` 97 | # ~/.config/lak/lakrc1 98 | portfolio: '~/.config/lak/portfolio1.yaml' 99 | performance: ~/.config/lak/.performance1.yaml 100 | cache: '~/.cache/lakshmicache' # This can be shared. 101 | ``` 102 | 103 | ``` 104 | # ~/.config/lak/lakrc2 105 | portfolio: '~/.config/lak/portfolio2.yaml' 106 | performance: ~/.config/lak/.performance2.yaml 107 | cache: '~/.cache/lakshmicache' # This can be shared. 108 | ``` 109 | 110 | ``` 111 | # ~/.config/lak/lakrc3 112 | portfolio: '~/.config/lak/portfolio3.yaml' 113 | performance: ~/.config/lak/.performance3.yaml 114 | cache: '~/.cache/lakshmicache' # This can be shared. 115 | ``` 116 | 117 | Then you can create aliases in your shell config file (`.bashrc` for bash or 118 | `.zshrc` for Zsh): 119 | 120 | ```shell 121 | alias lak1='LAK_CONFIG=~/.config/lak/.lakrc1 lak' 122 | alias lak2='LAK_CONFIG=~/.config/lak/.lakrc2 lak' 123 | alias lak3='LAK_CONFIG=~/.config/lak/.lakrc3 lak' 124 | ``` 125 | 126 | After this, `lak1` command can be used to manage portfolio 1 and so on. 127 | 128 | ## Setting up automatic emails 129 | 130 | One of the benefits of using `lak` is that users can automate a lot of 131 | portfolio monitoring tasks. Instead of accessing your portfolio through 132 | the `lak` tool manually, users can create a script to send them emails 133 | periodically. For example, below is a shell script to send an HTML email 134 | about the portfolio status: 135 | 136 | ```shell 137 | #!/bin/bash 138 | 139 | EMAIL=YOUR_EMAIL_HERE@DOMAIN.com 140 | PATH=PATH_TO_LAK_TOOL:$PATH 141 | 142 | # This depends on the ssmtp tool. You can replace this with your installed 143 | # email program. 144 | cat << EMAIL_END | ssmtp $EMAIL 145 | MIME-Version: 1.0 146 | Content-Type: text/html; charset=utf-8 147 | To:${EMAIL} 148 | Subject: Portfolio Update 149 | 150 | 151 | 152 | 159 | 160 | 161 | 162 |

tl;dr

163 |
164 | $(lak analyze tlh -p 10 -d 20000 rebalance)
165 | 
166 | 167 |


168 | 169 |

Asset Allocation

170 | $(lak list -f html aa -c 'US,Intl,Bonds') 171 | 172 |
173 | 174 | $(lak list -f html total) 175 | 176 |


177 | 178 |

Assets

179 | $(lak list -f html assets) 180 | 181 |


182 | 183 |

Tax lots

184 | $(lak list -f html lots) 185 | 186 |

Performance

187 | $(lak list -f html performance) 188 | 189 | 190 | 191 | EMAIL_END 192 | ``` 193 | 194 | A scheduling program like [cron](ttps://wiki.archlinux.org/title/cron) can be 195 | used to run this script periodically. For example, to send this email monthly 196 | on the 5th day at 5am: 197 | ``` 198 | # Entry for lak in crontab: 199 | 00 05 5 * * portfolio_email.sh 200 | ``` 201 | ## How to reorder list of accounts or assets 202 | There is currently no automated way to re-order list of accounts or assets 203 | appearing in `lak list accounts` or `lak list assets`. But the 204 | [portfolio file](./lak.md#portfolio) can be manually edited and different 205 | sections can be moved around to achieve this. Please see 206 | [Portfolio file syntax](./lak.md#portfolio-file-syntax) for help on the syntax 207 | of this file. 208 | -------------------------------------------------------------------------------- /lakshmi/__init__.py: -------------------------------------------------------------------------------- 1 | from .lakshmi import * # noqa: F401,F403 2 | 3 | del lakshmi # noqa: 821 4 | -------------------------------------------------------------------------------- /lakshmi/analyze.py: -------------------------------------------------------------------------------- 1 | """Classes to analyze the portfolio. 2 | 3 | These classes are not meant to change the portfolio. They are used for 4 | just analyzing the portfolio and output the results (if any). 5 | """ 6 | import numpy as np 7 | 8 | from lakshmi.table import Table 9 | from lakshmi.utils import format_money 10 | 11 | 12 | class TLH: 13 | """Tax loss harvesting opportunity analyzer. 14 | 15 | This triggers a message with details of tax lots that could be sold to 16 | harvest the losses. 17 | """ 18 | 19 | def __init__(self, max_percentage, max_dollars=None): 20 | """Triggers if a lot has lost more than max_percentage or the total 21 | loss for a given asset exceeds max_dollars. 22 | 23 | Args: 24 | max_percentage: A float representing the max percentage loss after 25 | which a lot can be harvested. 26 | max_dollars: A float representing the max amount of loss (in 27 | dollars) for one or more lots (of the same security), beyond which 28 | we should recommend selling all the lots that are below their 29 | cost basis. 30 | """ 31 | assert max_percentage > 0.0 and max_percentage < 1.0, ( 32 | 'max_percetage should be between 0% and 100% (exclusive).') 33 | self.max_percentage = max_percentage 34 | if max_dollars: 35 | assert max_dollars > 0, 'max_dollars should be positive.' 36 | self.max_dollars = max_dollars 37 | 38 | def _return_lots_to_sell(self, price, tax_lots): 39 | """Helper function that returns which lots to harvest. 40 | 41 | Args: 42 | price: The current price of the security. 43 | tax_lots: A list of lakshmi.assets.TaxLot (all belonging 44 | to the same security). 45 | 46 | Returns: A list of [lot date, loss in dollar, loss in percent] 47 | that could be loss harvested. 48 | """ 49 | percent_lots = [] 50 | negative_lots = [] 51 | total_loss = 0.0 52 | 53 | for lot in tax_lots: 54 | loss = (lot.unit_cost - price) * lot.quantity 55 | loss_percent = (lot.unit_cost - price) / lot.unit_cost 56 | if loss > 0: 57 | negative_lots.append([lot.date, loss, loss_percent]) 58 | total_loss += loss 59 | if loss_percent > self.max_percentage: 60 | percent_lots.append([lot.date, loss, loss_percent]) 61 | 62 | if self.max_dollars is not None and total_loss > self.max_dollars: 63 | return negative_lots 64 | else: 65 | return percent_lots 66 | 67 | def analyze(self, portfolio): 68 | """Returns which lots can be tax loss harvested from a portfolio. 69 | 70 | Args: 71 | portfolio: A lakshmi.Portfolio object representing a portfolio. 72 | 73 | Returns: A lakshmi.table.Table object with tax lots to harvest with 74 | columns corespnding to 'Account', 'Asset', 'Date', 'Loss' and 'Loss%'. 75 | """ 76 | ret_val = Table( 77 | 5, 78 | headers=['Account', 'Asset', 'Date', 'Loss', 'Loss%'], 79 | coltypes=['str', 'str', 'str', 'dollars', 'percentage']) 80 | for account in portfolio.accounts(): 81 | for asset in account.assets(): 82 | if hasattr(asset, 'get_lots') and asset.get_lots(): 83 | for lot in self._return_lots_to_sell( 84 | asset.price(), asset.get_lots()): 85 | ret_val.add_row( 86 | [account.name(), asset.short_name()] + lot) 87 | return ret_val 88 | 89 | 90 | class BandRebalance: 91 | """Triggers if portfolio asset class targets are outside the bands 92 | specified. This considers an asset class outside bound if the absolute 93 | difference in percentage allocation is more than max_abs_percent different 94 | from target allocation or more than max_relative_percent different from the 95 | target allocation. 96 | 97 | A popular version is the 5/25 band based rebalancing rule. For more 98 | information, please see 5/25 on: 99 | https://www.bogleheads.org/wiki/Rebalancing 100 | """ 101 | 102 | def __init__(self, max_abs_percent=0.05, max_relative_percent=0.25): 103 | """Constructor to set the bands. An asset class is considered outside 104 | the rebalance bands if the differnt between the desired and actual 105 | ratio of that asset class exceeds the lessor of max absolute or max 106 | relative percentage. 107 | 108 | Args: 109 | max_abs_percent: A float (ratio) representing the maximum absolute 110 | percent that an asset can deviate before it's considered outside 111 | the rebalancing band. 112 | max_relative_percent: A float (ratio) representing the maximum 113 | relative percent that an asset can deviate before it's considered 114 | outside the rebalancing band. 115 | """ 116 | assert max_abs_percent > 0 and max_abs_percent < 1.0 117 | assert max_relative_percent > 0 and max_relative_percent < 1.0 118 | self.max_abs_percent = max_abs_percent 119 | self.max_relative_percent = max_relative_percent 120 | 121 | def analyze(self, portfolio): 122 | """Returns asset classes that are outside the rebalancing bands. 123 | 124 | Args: 125 | portfolio: A lakshmi.Portfolio object representing the portfolio 126 | that is being analyzed. 127 | 128 | Returns: A lakshmi.table.Table object with columns representing 129 | Asset Class, Actual % of asset class in portfolio, Desired % of asset 130 | class in the portfolio, Value in dollars of the asset class and the 131 | difference between Desired and actual dollar values. 132 | """ 133 | aa = portfolio.asset_allocation(portfolio.asset_classes.leaves()) 134 | headers = ['Class', 'Actual%', 'Desired%', 'Value', 'Difference'] 135 | assert headers == aa.headers() 136 | ret_val = Table( 137 | 5, 138 | headers, 139 | ['str', 'percentage', 'percentage', 'dollars', 'delta_dollars']) 140 | for row in aa.list(): 141 | abs_percent = abs(row[1] - row[2]) 142 | rel_percent = abs_percent / row[2] if row[2] != 0 else 0 143 | if ( 144 | abs_percent >= self.max_abs_percent 145 | or rel_percent >= self.max_relative_percent 146 | ): 147 | ret_val.add_row(row) 148 | 149 | return ret_val 150 | 151 | 152 | class _Solver: 153 | """Internal class to help allocate cash to portfolio (helper class for the 154 | next class). 155 | 156 | I tried using scipy optimize instead of this custom solver and it was 157 | extremely flaky for this purpose (even after scaling/conditioning the 158 | inputs well). So finally, I came up theis heuristic algo which I 'feel' 159 | works, but I haven't proved it formally. This class can be further 160 | optimized/fixed, but for now it serves its purpose. -- sarvjeets 161 | 162 | Notation: 163 | - f_i: Money in asset i orginally. 164 | - x_i: New money to be added to asset i (what we are solving for). 165 | - A_j: Money in asset class j. 166 | - C_j: Money in asset class j before adding new money (implementd as 167 | self.money) 168 | - d_j: Desired ratio of asset class j (implemented as self.desired_ratio). 169 | - a_{ij}: Ratio of asset i in asset class j (implemented as 170 | self.allocation). 171 | 172 | So, 173 | A_j = \\sum_i a_{ij} (f_i + x_i) 174 | \\sum_j A_j = T (a constant, given by self.total) 175 | 176 | Error E = \\sum_j (A_j/(d_j T) - 1)^2 177 | 178 | Derivative wrt a asset i (implemented as self.derivative) 179 | dE/dx_i = (2/T) \\sum_j (a_{ij} / d_j) (A_j/(d_j T) - 1) 180 | Rate of change of above derivative wrt asset k: 181 | d^2E/{dx_i dx_k} = (2/T^2) \\sum_j (a_{ij} a_{kj} / d^2_j) 182 | 183 | The algorithm (very loose description here, if this works, I'll probably 184 | write it more formally): 185 | 0. Without lost of generality, assume cash to be allocated is positive ( 186 | the other way round just replaces min with max in the following algo). We 187 | also dedup same assets so that the equations in Step 2 has a unique 188 | solution. 189 | 1. Start by the lowest derivative asset. 190 | 2. Add money to asset to match the derivative of a target asset. Pick the 191 | next target asset based on whichever target asset leads to lowest 192 | derivative. 193 | 3. Now we have two assets with same derivatives, keep repeating step 2 194 | until all assets have same derivatives. 195 | 4. If we run out of cash to allocate before all assets have same 196 | derivative, exit early. 197 | 5. If we still have money after all assets have same derivative, add extra 198 | money to all assets while keeping their derivatives the same. 199 | 200 | There is special case where we run out of money in an asset. In that case, 201 | we just zero out the asset, take it from the list of assets that are 202 | being optimized and go back to step 2. 203 | 204 | For step 2, we solve the following linear equations: 205 | k is summing over assets with same derivatives. n is target asset to which 206 | we are not adding money (aka x_n = 0). 207 | 208 | For each i: 209 | dE/dx_i + \\sum_{k != i} x_k d^2E/{dx_i dx_k} = 210 | dE/dx_n + \\sum_k x_k d^2E/{dx_i dx_k} 211 | (This equation is solved in self.compute_delta). 212 | 213 | self.allocate_all_cash solves: 214 | dE/dx_i + \\sum_{k != i} x_k d^2E/{dx_i dx_k} = 215 | dE/dx_i (without any additional money allocated to it) + 0.1 216 | which simplifies to: 217 | for each i: 218 | \\sum_k (x_k \\sum_j a{ij} a_{kj} / d^2_j = 0.1T 219 | """ 220 | 221 | def __init__(self, aa, assets, cash, total): 222 | """ 223 | Args: 224 | aa: Map of asset class name to a tuple of desired_ratio and 225 | money allocated to it. 226 | assets: List of lakshmi.Assets to which to allocate the money to. 227 | cash: The cash to be allocated. 228 | total: The total value of the portfolio (T in equation above). 229 | """ 230 | self.aa = aa 231 | self.total = total 232 | self.cash = cash 233 | self.assets = assets 234 | # Used to pick either the min or max gradient. 235 | self.best_gradient_fn = np.argmin if cash > 0 else np.argmax 236 | self.adjusted_values = [asset.adjusted_value() for asset in assets] 237 | 238 | def desired_ratio(self, asset_class): 239 | """Helper function to give desired ratio of an asset class.""" 240 | return self.aa[asset_class][0] 241 | 242 | def money(self, asset_class): 243 | """Helper function to give money allocated to an asset class.""" 244 | return self.aa[asset_class][1] 245 | 246 | def asset_classes(self): 247 | """Returns list of asset classes.""" 248 | return self.aa.keys() 249 | 250 | def allocation(self, i, j): 251 | """Returns allocation of asset i in asset class j.""" 252 | return self.assets[i].class2ratio.get(j, 0.0) 253 | 254 | def update_aa(self, new_money): 255 | """Updates self.aa with new_money. 256 | 257 | Args: 258 | new_money: A list of deltas to be which new_money[i] is for 259 | self.assets[i]. 260 | """ 261 | for asset, money in zip(self.assets, new_money): 262 | for ac, ratio in asset.class2ratio.items(): 263 | self.aa[ac] = self.aa[ac][0], self.aa[ac][1] + ratio * money 264 | 265 | def derivative(self, i): 266 | """Computes dE/dx_i. The actual derivative has (2/T) extra 267 | term, but as it is common in all assets, we ignore it (the rest of the 268 | computations in equations are done correctly to account for a missing 269 | 2/T factor). 270 | """ 271 | sum = 0.0 272 | for j in self.asset_classes(): 273 | rel_ratio = (self.money(j) / (self.desired_ratio(j) * self.total) 274 | - 1) 275 | sum += rel_ratio * (self.allocation(i, j) / self.desired_ratio(j)) 276 | return sum 277 | 278 | def compute_delta(self, source_assets, target_asset): 279 | """Heart of this solver. This computes deltas (x) on the source_ 280 | assets, so that the derivative of the error function wrt source_assets 281 | is equal to the target_asset. 282 | 283 | Args: 284 | source_assets: A set of indices referring to self.assets 285 | representing assets on which to compute deltas on. 286 | target_asset: An index referring to an asset in self.assets. The 287 | delta is computed for source_assets to make their error 288 | derivative equal to the target_asset. 289 | 290 | Returns: A tuple of computed deltas and the final derivative. The 291 | derivative of target asset can change based on the deltas computed. 292 | """ 293 | a = [] 294 | b = [] 295 | n = target_asset 296 | alpha = self.derivative(n) 297 | 298 | for i in source_assets: 299 | equation_i = [] 300 | for k in source_assets: 301 | coeff = 0.0 302 | for j in self.asset_classes(): 303 | coeff += ( 304 | (self.allocation(k, j) / self.desired_ratio(j) ** 2) 305 | * (self.allocation(i, j) - self.allocation(n, j))) 306 | equation_i.append(coeff) 307 | a.append(equation_i) 308 | const_i = self.total * alpha 309 | for j in self.asset_classes(): 310 | const_i += ( 311 | (self.allocation(i, j) / self.desired_ratio(j) ** 2) 312 | * (self.desired_ratio(j) * self.total - self.money(j))) 313 | b.append(const_i) 314 | 315 | solution = np.linalg.lstsq(a, b, rcond=None)[0].tolist() 316 | x = np.zeros(len(self.assets)) 317 | 318 | final_derivative = alpha 319 | for k in source_assets: 320 | x[k] = solution.pop(0) 321 | sum = 0.0 322 | for j in self.asset_classes(): 323 | sum += (self.allocation(n, j) * self.allocation(k, j) 324 | / self.desired_ratio(j) ** 2) 325 | final_derivative += sum * x[k] / self.total 326 | 327 | return x, final_derivative 328 | 329 | def allocate_all_cash(self, cash_to_allocate, equal_gradient_assets): 330 | """Allocates all the remaining cash. This function assumes that 331 | all the derivatives of the error function wrt assets are the same. 332 | Then it solves for extra deltas in each asset that will increase 333 | the derivative by 0.1 (arbitrary number). It then re-scales this 334 | number to make sure that the new deltas equal to cash_to_allocate. 335 | The exact computation is listed in the class level comment. 336 | 337 | Args: 338 | cash_to_allocate: The cash to allocate. 339 | 340 | Returns: deltas (x), one for each self.asset that sums up to 341 | cash_to_allocate and ensures the final derivatives of error function 342 | wrt all assets are equal. 343 | """ 344 | a = [] 345 | for i in equal_gradient_assets: 346 | equation_i = [] 347 | for k in equal_gradient_assets: 348 | coeff = 0.0 349 | for j in self.asset_classes(): 350 | coeff += (self.allocation(i, j) * self.allocation(k, j) 351 | / self.desired_ratio(j) ** 2) 352 | equation_i.append(coeff) 353 | a.append(equation_i) 354 | x = np.linalg.lstsq(a, [0.1 * self.total] * len(equal_gradient_assets), 355 | rcond=None)[0] 356 | x *= cash_to_allocate / np.sum(x) 357 | 358 | ret_val = [0] * len(self.assets) 359 | for i, j in zip(equal_gradient_assets, 360 | range(len(equal_gradient_assets))): 361 | ret_val[i] = x[j] 362 | 363 | return ret_val 364 | 365 | def bound_at_zero(self, x, deltas, equal_gradient_assets, zeroed_assets): 366 | """Ensure that none of the solution exceeds the available money in 367 | assets. If that happens, it zeros out the asset, removes the asset 368 | from the set of assets that are being optmized (equal_gradient_assets). 369 | It additinally adds the funds to zeroed_assets. 370 | 371 | Args: 372 | x: The current solution. 373 | deltas: The new delta on the solution that is being considered to 374 | be added to x. 375 | equal_gradient_assets: Assets that have equal error gradients. 376 | zeroed_assets: Assets that have zero balance. 377 | 378 | Returns: 379 | None, if applying (x+deltas) to assets would not cause their 380 | balance to become negative. 381 | new_deltas, a list of len(self.assets), otherwise. new_deltas 382 | is computed to zero out money in any asset which would have 383 | gotton a negative balance if (x+deltas) was applied to it. 384 | """ 385 | if self.cash > 0: 386 | # in this case, we don't have to worry about bounding the money, 387 | # as we only add money to assets and never remove it. 388 | return None 389 | 390 | new_deltas = [0] * len(deltas) 391 | adjusted = False 392 | 393 | for i in equal_gradient_assets: 394 | money_removed = -(x[i] + deltas[i]) 395 | if money_removed > self.adjusted_values[i]: 396 | # Withdrew too much money. 397 | new_deltas[i] = -(self.adjusted_values[i] + x[i]) 398 | zeroed_assets.add(i) 399 | adjusted = True 400 | else: 401 | new_deltas[i] = 0 402 | 403 | equal_gradient_assets -= zeroed_assets 404 | return new_deltas if adjusted else None 405 | 406 | def solve(self): 407 | """The main method. This method implements the algorithm listed in the 408 | class comment. 409 | """ 410 | # Pick the lowest error gradient asset. 411 | equal_gradient_assets = set([self.best_gradient_fn( 412 | [self.derivative(i) for i in range(len(self.assets))])]) 413 | zeroed_assets = set({}) 414 | x = np.zeros(len(self.assets)) 415 | left_cash = self.cash 416 | 417 | # Keep equalizing and adding an asset to equal_gradient_assets. 418 | while ((len(equal_gradient_assets) 419 | + len(zeroed_assets) != len(self.assets)) 420 | and abs(left_cash) > 1e-6): 421 | # Handle the case when we zero out all equal_gradient_assets. 422 | if len(equal_gradient_assets) == 0: 423 | equal_gradient_assets.add(self.best_gradient_fn( 424 | [self.derivative(i) for i in range(len(self.assets)) if 425 | i not in zeroed_assets])) 426 | 427 | derivatives = [] 428 | deltas = [] 429 | indices = [] 430 | # Compute the min increase in gradient among the remaining assets. 431 | for n in set(range(len(self.assets))) - equal_gradient_assets: 432 | delta, derivative = self.compute_delta( 433 | equal_gradient_assets, n) 434 | derivatives.append(derivative) 435 | deltas.append(delta) 436 | indices.append(n) 437 | 438 | # Pick the highest or lowest derivative. 439 | best_asset_index = self.best_gradient_fn(derivatives) 440 | best_delta = deltas[best_asset_index] 441 | new_cash = np.sum(best_delta) 442 | 443 | if abs(new_cash) >= abs(left_cash): 444 | # We have allocated more cash than what was left. 445 | best_delta = best_delta * left_cash / new_cash 446 | new_cash = left_cash 447 | 448 | new_delta = self.bound_at_zero( 449 | x, best_delta, equal_gradient_assets, zeroed_assets) 450 | if new_delta: 451 | best_delta = new_delta 452 | new_cash = np.sum(new_delta) 453 | x += best_delta 454 | self.update_aa(best_delta) 455 | left_cash -= new_cash 456 | if not new_delta: # No zeroed assets. 457 | equal_gradient_assets.add(indices[best_asset_index]) 458 | 459 | # Handle any left over cash. 460 | while abs(left_cash) > 1e-6: 461 | delta = self.allocate_all_cash(left_cash, equal_gradient_assets) 462 | new_delta = self.bound_at_zero(x, delta, equal_gradient_assets, 463 | zeroed_assets) 464 | if new_delta: 465 | delta = new_delta 466 | x += delta 467 | self.update_aa(delta) 468 | left_cash -= np.sum(delta) 469 | 470 | return x 471 | 472 | 473 | class Allocate: 474 | """Allocates any unallocated cash in the account to assets. 475 | 476 | If an account has any unallocated cash (aka what if) then this class 477 | allocates that cash to the assets in the account. The allocation is done 478 | with the goal of minimizing the relative ratio of actual allocation of 479 | asset classes to the desired allocation. Cash could be negative in which 480 | case money is removed from the assets. 481 | 482 | The allocation to assets is done via lakshmi.Portfolio.what_if function; 483 | and hence can be reset easily (by calling 484 | lakshmi.Portfolio.reset.what_ifs). 485 | """ 486 | 487 | def __init__(self, account_name, exclude_assets=[], rebalance=False): 488 | """ 489 | Args: 490 | account_name: The full name of the account to analyze. 491 | exclude_assets: A list of asset short names (strings) which 492 | will not be allocated any new cash from the account as a result 493 | of calling analyze. 494 | rebalance: If False, money is either only added (in case cash is 495 | positive) or removed (in case cash is negative) from the assets. 496 | If set, money is added and removed (as needed) from assets 497 | to minimize the relative difference from the desired asset 498 | allocation. 499 | """ 500 | self.account_name = account_name 501 | self.exclude_assets = exclude_assets 502 | self.rebalance = rebalance 503 | 504 | def _zero_ratio_asset(self, asset, zero_acs): 505 | """Returns if asset has a asset class which is subset of zero_acs.""" 506 | for ac in asset.class2ratio.keys(): 507 | if ac in zero_acs: 508 | return True 509 | return False 510 | 511 | def _apply_whatifs(self, portfolio, assets, deltas, saved_whatifs={}, 512 | zero_ratio_assets=[]): 513 | """Apply whatifs given by deltas to assets in the portfolio.""" 514 | table = Table(2, ['Asset', 'Delta'], ['str', 'delta_dollars']) 515 | for asset, delta in zip(assets, deltas): 516 | portfolio.what_if(self.account_name, 517 | asset.short_name(), 518 | float(delta)) # delta could be a np.float 519 | saved = saved_whatifs.get(asset.short_name(), 0) 520 | table.add_row([asset.short_name(), delta + saved]) 521 | for asset in zero_ratio_assets: 522 | saved = saved_whatifs.get(asset.short_name(), 0) 523 | table.add_row([asset.short_name(), saved]) 524 | return table 525 | 526 | def analyze(self, portfolio): 527 | """Modifies portfolio by allocating any cash and returns the resulting 528 | changes. 529 | 530 | Args: 531 | portfolio: The portfolio on which to operate on. This portfolio is 532 | modified by applying what_ifs to the assets in the provided 533 | account. 534 | 535 | Returns: 536 | A table.Table of asset names and an delta for each of the asset. 537 | 538 | Throws: 539 | AssertionError: In case of 540 | - There is no cash to allocation and rebalance is False. 541 | - An asset class's desired allocation ratio is zero. 542 | - No assets are present in the Account after taking out the 543 | exlcuded assets. 544 | - Cash to withdraw is more than the total value of assets in the 545 | portfolio. 546 | - For some reason, we can't minimize the difference between 547 | relative error of actual vs desired allocation, given all the 548 | constraints. 549 | """ 550 | account = portfolio.get_account(self.account_name) 551 | cash = account.available_cash() 552 | assert cash != 0 or self.rebalance, ( 553 | f'No available cash to allocate in {self.account_name}.') 554 | assets = [x for x in account.assets() if x.short_name() not in 555 | self.exclude_assets] 556 | assert len(assets) != 0, 'No assets to allocate cash to.' 557 | 558 | saved_whatifs = {} 559 | if self.rebalance: 560 | # Withdraw all cash and reallocate. 561 | for asset in assets: 562 | saved_whatifs[asset.short_name()] = -asset.adjusted_value() 563 | portfolio.what_if(self.account_name, asset.short_name(), 564 | -asset.adjusted_value()) 565 | cash = account.available_cash() 566 | 567 | total = sum([asset.adjusted_value() for asset in assets]) 568 | if abs(total + cash) < 1e-6: 569 | # Withdraw all cash and return. 570 | return self._apply_whatifs( 571 | portfolio, assets, 572 | [-asset.adjusted_value() for asset in assets]) 573 | assert -cash < total, ( 574 | f'Cash to withdraw ({format_money(-cash)}) is more than the total ' 575 | f'available money in the account ({format_money(total)}).') 576 | 577 | # Map of leave asset classes -> (desired%, money). 578 | asset_allocation = {} 579 | # Asset classes with zero ratio. 580 | zero_ratio_acs = set() 581 | for row in portfolio.asset_allocation( 582 | portfolio.asset_classes.leaves()).list(): 583 | ac = row[0] 584 | ratio = row[2] 585 | money = row[3] 586 | if abs(ratio) < 1e-6: 587 | zero_ratio_acs.add(ac) 588 | else: 589 | asset_allocation[ac] = ratio, money 590 | 591 | zero_ratio_assets = [] 592 | filtered_assets = [] 593 | for asset in assets: 594 | if zero_ratio_acs and self._zero_ratio_asset(asset, 595 | zero_ratio_acs): 596 | zero_ratio_assets.append(asset) 597 | else: 598 | filtered_assets.append(asset) 599 | assets = filtered_assets 600 | 601 | if cash < 0 and not self.rebalance: 602 | for asset in zero_ratio_assets: 603 | withdraw = min(-cash, asset.adjusted_value()) 604 | saved_whatifs[asset.short_name()] = -withdraw 605 | portfolio.what_if(self.account_name, asset.short_name(), 606 | -withdraw) 607 | cash += withdraw 608 | 609 | for asset in assets: 610 | for ac in asset.class2ratio.keys(): 611 | assert asset_allocation[ac][0] != 0, ( 612 | f'Desired ratio of asset class {ac} cannot be zero.') 613 | 614 | result = _Solver( 615 | asset_allocation, assets, cash, portfolio.total_value()).solve() 616 | return self._apply_whatifs(portfolio, assets, result, saved_whatifs, 617 | zero_ratio_assets) 618 | -------------------------------------------------------------------------------- /lakshmi/cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | This class is used to cache return value of functions on disk for a specified 3 | number of days. This is used by lakshmi.assets module to cache name/ asset 4 | value (i.e the slow functions). For examples on how to use this class, please 5 | see the tests (tests/test_cache.py file). 6 | 7 | Currently, this module can only be used on functions which are class members 8 | and the function itself must take no arguments. These restrictions can be 9 | easily relaxed, but so far that all usecases don't need anything more than what 10 | is currently implemented. 11 | 12 | In addition to caching values, this class also allows one to optionally call 13 | a user-specified function on cache-misses (currently used to show a progress 14 | bar to the user via the lak CLI). 15 | """ 16 | 17 | import concurrent.futures 18 | import functools 19 | import pickle 20 | from abc import ABC, abstractmethod 21 | from datetime import datetime 22 | from hashlib import md5 23 | from pathlib import Path 24 | 25 | # Inspired by https://pypi.org/project/cache-to-disk/. I tried using other 26 | # options such as requests-cache, but it was too slow compared to the solution 27 | # implemented here. 28 | 29 | 30 | class Cacheable(ABC): 31 | """Interface that declares that a particular class's method return 32 | values could be cached. The methods should not take a parameter, 33 | and cache_key() + method name should uniquely imply the return 34 | value of that class method.""" 35 | @abstractmethod 36 | def cache_key(self): 37 | """Unique string value used as key for caching.""" 38 | pass 39 | 40 | 41 | def get_file_age(file): 42 | """Returns the age of file. 43 | 44 | Args: 45 | file: A PosixPath object representing a file. 46 | 47 | Returns: An int represeting the age in days. 48 | """ 49 | return (datetime.today() 50 | - datetime.fromtimestamp(file.stat().st_mtime)).days 51 | 52 | 53 | # Constants 54 | # Default cache directory if none is specified. 55 | _DEFAULT_DIR = Path.home() / '.lakshmicache' 56 | 57 | _CACHE_STR = 'cache_dir' 58 | _FORCE_STR = 'force_refresh' 59 | _FORCED_FILES_STR = 'forced_files' 60 | _MISS_FUNC_STR = 'miss_func' 61 | 62 | # Dict (string -> object) to keep cache context. 63 | # Description of keys to what is stored: 64 | # _CACHE_STR: 65 | # The pathlib.Path object specifying cache directory. If set to None, 66 | # caching is disabled. Default: _DEFAULT_DIR 67 | # _FORCE_STR: 68 | # If set to True, new values are re-generated once even if a cached one is 69 | # available. This is meant for data that is cached for < month (stock prices 70 | # and Treasury Bond value). Values that are cached for > 40 days ignore this 71 | # flag. Default: False 72 | # _FORCED_FILES_STR: 73 | # A set of files which are already refreshed once due to _ctx[_FORCE_STR] 74 | # being set to True. this is used to ensure we don't re-fetch same values 75 | # multiple times in a session. 76 | # _MISS_FUNC_STR: 77 | # If set, this function is called for every cache miss. 78 | _ctx = {_FORCE_STR: False} 79 | 80 | 81 | def set_force_refresh(v): 82 | """Sets whether cached values should be refreshed. 83 | 84 | Args: 85 | v: Boolean representing if cached values should be re-generated. 86 | """ 87 | _ctx[_FORCE_STR] = v 88 | _ctx[_FORCED_FILES_STR] = set() 89 | 90 | 91 | def set_cache_miss_func(f): 92 | """Sets the function to call for cache-misses in case the cached function 93 | is directly called. This func is called periodically if prefetch() is 94 | used to fetch multiple cached values. This is useful for displaying 95 | progress bar while waiting for slow functions to complete. 96 | 97 | Args: 98 | f: The function to call whenever a cache-miss happens (i.e. whenever 99 | the underlying function is called instead of using a cached value) OR 100 | this function is called periodically when using prefetch to fetch 101 | multiple values in parallel. 102 | """ 103 | if f: 104 | _ctx[_MISS_FUNC_STR] = f 105 | else: 106 | # Clear out previously set function, if any. 107 | _ctx.pop(_MISS_FUNC_STR, None) 108 | 109 | 110 | def set_cache_dir(cache_dir): 111 | """Sets the cache directory. 112 | 113 | If the cache directory is not specified, default ~/.lakshmicache 114 | is used. 115 | 116 | Args: 117 | cache_dir: The pathlib.Path object specifying cache directory. 118 | If set to None, caching is disabled. 119 | """ 120 | _ctx[_CACHE_STR] = cache_dir 121 | if cache_dir is None: 122 | return 123 | cache_dir.mkdir(exist_ok=True) # Create cache dir if one doesn't exist. 124 | # Delete old files whose cache values are invalid already. 125 | for file in cache_dir.glob('*_*.lkc'): 126 | days = int(file.name.split('_')[0]) 127 | if get_file_age(file) >= days: 128 | file.unlink() 129 | 130 | 131 | def _valid_cached_value(file, days, add_to_ignored=True): 132 | """Helper function to check if the cached value from file is valid. 133 | 134 | Args: 135 | file: The Path object representing a file potentially containing 136 | previously cached value. 137 | days: Number of days after which the cached value becomes invalid. 138 | add_to_ignored: If force_refresh is set and this arg is set, the 139 | file is added to an ignored set of files so that this function doesn't 140 | return False for the same file if it is called again (this prevents 141 | refreshing the same cached value multiple times). 142 | 143 | Returns: True iff the cached value in file is valid. 144 | """ 145 | MAX_DAYS_TO_FORCE_REFRESH = 40 146 | if ( 147 | _ctx[_FORCE_STR] 148 | and days < MAX_DAYS_TO_FORCE_REFRESH 149 | and file.name not in _ctx[_FORCED_FILES_STR] 150 | ): 151 | # Ignore cached value. 152 | if add_to_ignored: 153 | _ctx[_FORCED_FILES_STR].add(file.name) 154 | return False 155 | return (file.exists() and get_file_age(file) < days) 156 | 157 | 158 | def _call_func(class_obj, func): 159 | """Helper function to return value of class_obj.func(). 160 | 161 | In addition to calling function, this helper also calls the 162 | cache_miss function if one is set in the context. 163 | 164 | Args: 165 | class_obj: The object of a particular class implementing Cacheable 166 | interface. 167 | func: The function whose return values has to be cached. Assumed 168 | to take no parameters. 169 | 170 | Returns: The return value of the func. 171 | """ 172 | if _MISS_FUNC_STR in _ctx: 173 | _ctx[_MISS_FUNC_STR]() 174 | return func(class_obj) 175 | 176 | 177 | def _cache_filename(class_obj, func, days): 178 | """Returns the filename to be used for the args.""" 179 | key = f'{func.__qualname__}_{class_obj.cache_key()}' 180 | return f'{days}_{md5(key.encode("utf8")).hexdigest()}.lkc' 181 | 182 | 183 | def cache(days): 184 | """Returns decorator that caches functions return value on disk for 185 | specified number of days. 186 | 187 | Args: 188 | days: Number of days for which to cache the return value of the 189 | function. 190 | 191 | Returns: The decorator. 192 | """ 193 | def decorator(func): 194 | @functools.wraps(func) 195 | def new_func(class_obj): 196 | if _CACHE_STR not in _ctx: 197 | # Cache dir not set. Set to default. 198 | set_cache_dir(_DEFAULT_DIR) 199 | cache_dir = _ctx[_CACHE_STR] 200 | if not cache_dir: 201 | return _call_func(class_obj, func) 202 | 203 | file = cache_dir / _cache_filename(class_obj, func, days) 204 | if _valid_cached_value(file, days): 205 | return pickle.loads(file.read_bytes()) 206 | value = _call_func(class_obj, func) 207 | file.write_bytes(pickle.dumps(value)) 208 | return value 209 | new_func.cached_days = days 210 | return new_func 211 | return decorator 212 | 213 | 214 | class _Prefetch: 215 | """Class to help with prefetching and caching of multiple values in 216 | parallel threads.""" 217 | 218 | def __init__(self): 219 | self.cache_key_to_funcs = {} 220 | if _CACHE_STR not in _ctx: 221 | # Cache dir not set. Set to default. 222 | set_cache_dir(_DEFAULT_DIR) 223 | self.cache_dir = _ctx[_CACHE_STR] 224 | 225 | def _return_cached_funcs(self, class_obj): 226 | all_methods = [getattr(class_obj, f) for f in dir(class_obj)] 227 | return [f for f in all_methods if callable(f) 228 | and hasattr(f, 'cached_days')] 229 | 230 | def add(self, class_obj): 231 | """Add class_obj to the list of objects whose cached methods are to be 232 | prefetched.""" 233 | if not self.cache_dir: 234 | # Caching is disabled, don't prefetch. 235 | return 236 | 237 | cache_key = class_obj.cache_key() 238 | if self.cache_key_to_funcs.get(cache_key) is not None: 239 | # Already added to be prefetched. 240 | return 241 | 242 | funcs_to_refresh = [] 243 | for func in self._return_cached_funcs(class_obj): 244 | file = self.cache_dir / _cache_filename( 245 | class_obj, func, func.cached_days) 246 | if not _valid_cached_value(file, func.cached_days, 247 | add_to_ignored=False): 248 | funcs_to_refresh.append(func) 249 | 250 | self.cache_key_to_funcs[cache_key] = funcs_to_refresh 251 | 252 | def fetch(self): 253 | """Fetch all cached methods of objects added earlier via add() call 254 | in parallel threads.""" 255 | def prefetch_fn(funcs): 256 | for f in funcs: 257 | f() 258 | 259 | # Reset cache miss func to None so it is not called from multiple 260 | # threads in parallel. 261 | cache_miss_func = None 262 | if _MISS_FUNC_STR in _ctx: 263 | cache_miss_func = _ctx[_MISS_FUNC_STR] 264 | set_cache_miss_func(None) 265 | 266 | fs = [] 267 | with concurrent.futures.ThreadPoolExecutor() as executor: 268 | fs = [executor.submit(prefetch_fn, func_list) for func_list in 269 | self.cache_key_to_funcs.values()] 270 | while len(fs) != 0: 271 | fs = concurrent.futures.wait(fs, timeout=0.1).not_done 272 | if cache_miss_func: 273 | cache_miss_func() 274 | 275 | # Reset the map, so it can be optionally used again with add() method. 276 | self.cache_key_to_funcs = {} 277 | # Restore cache miss funcion. 278 | set_cache_miss_func(cache_miss_func) 279 | 280 | 281 | # Global object of type Prefetch used for prefetching cached functions using 282 | # parallel threads. 283 | _prefetch_obj = None 284 | 285 | 286 | def prefetch_add(class_obj): 287 | """Add class_obj to list of objects who cached methods are to be 288 | prefetched.""" 289 | global _prefetch_obj 290 | if _prefetch_obj is None: 291 | _prefetch_obj = _Prefetch() 292 | _prefetch_obj.add(class_obj) 293 | 294 | 295 | def prefetch(): 296 | """Fetch all cached methods of objects added earlier via the prefetch_add() 297 | call in parallel threads.""" 298 | if _prefetch_obj is not None: 299 | _prefetch_obj.fetch() 300 | -------------------------------------------------------------------------------- /lakshmi/constants.py: -------------------------------------------------------------------------------- 1 | """Lakshmi constants.""" 2 | 3 | NAME = 'lakshmi' 4 | VERSION = '3.0.2' 5 | -------------------------------------------------------------------------------- /lakshmi/data/Account.yaml: -------------------------------------------------------------------------------- 1 | # An Account represents a collection of Assets (e.g. ETFs or Funds) 2 | 3 | # Name of the account, must be unique among all accounts in the portfolio. 4 | Name: Unique-name-for-account 5 | 6 | # Type of the account, e.g. Taxable, Tax-Exempt, Tax-Deferred, etc. This 7 | # name is used to group accounts when listing the Asset Location with 8 | # the 'lak list al' command. 9 | Account Type: Tax-Exempt 10 | -------------------------------------------------------------------------------- /lakshmi/data/AssetClass.yaml: -------------------------------------------------------------------------------- 1 | # The asset classes across which to track asset allocation 2 | # (e.g. with lak list aa) command. 3 | # The following shows an example of a nested asset allocation: 4 | # All -> Equity 60% and Bonds 40% 5 | # Equity -> US 60% and Intl 40% 6 | # 7 | # Name of the asset class. 8 | Name: All 9 | # Optionally the children of this asset class (each of which 10 | # is itself an asset class and can have more asset classes 11 | # as children). 12 | Children: 13 | - Ratio: 0.6 14 | Name: Equity 15 | Children: 16 | - Ratio: 0.6 17 | Name: US 18 | - Ratio: 0.4 19 | Name: Intl 20 | - Ratio: 0.4 21 | Name: Bonds 22 | -------------------------------------------------------------------------------- /lakshmi/data/Checkpoint.yaml: -------------------------------------------------------------------------------- 1 | # The total value of the portfolio (after all the inflows and outflows). 2 | Portfolio Value: 10,000.00 3 | # The amount of money added to the portfolio (optional). 4 | Inflow: 100.00 5 | # The amount of money withdrawn from the portfolio (optional). 6 | Outflow: 0 7 | -------------------------------------------------------------------------------- /lakshmi/data/EEBonds.yaml: -------------------------------------------------------------------------------- 1 | # EE Bonds are bought at Treasury Direct website. This asset allows 2 | # you to track the current value of your EE Bonds purchased from 3 | # Treasury Direct. 4 | 5 | # How this asset maps to the asset classes defined in the portfolio. 6 | # This is used to generate asset allocation (e.g. when using 7 | # `lak list aa` command) 8 | Asset Mapping: 9 | Bonds: 1.0 10 | 11 | # List of EE Bonds with the purchase month and denomination. 12 | Bonds: 13 | - Issue Date: 11/2012 14 | Denomination: 10,000 15 | - Issue Date: 01/2013 16 | Denomination: 10,000 17 | -------------------------------------------------------------------------------- /lakshmi/data/IBonds.yaml: -------------------------------------------------------------------------------- 1 | # I Bonds are bought at TreasuryDirect website. This asset allows 2 | # you to track the current value of your I Bonds purchased from 3 | # Treasury Direct. 4 | 5 | 6 | # How this asset maps to the asset classes defined in the portfolio. 7 | # This is used to generate asset allocation (e.g. when using 8 | # `lak list aa` command) 9 | Asset Mapping: 10 | Bonds: 1.0 11 | 12 | # List of I Bonds with the purchase month and denomination. 13 | Bonds: 14 | - Issue Date: 11/2012 15 | Denomination: 10,000 16 | - Issue Date: 01/2013 17 | Denomination: 10,000 18 | -------------------------------------------------------------------------------- /lakshmi/data/ManualAsset.yaml: -------------------------------------------------------------------------------- 1 | # A ManualAsset's value is not updated automatically and is manually 2 | # specified. 3 | 4 | # A unique name for this asset (in the account). 5 | Name: Cash 6 | 7 | # The value of this asset. 8 | Value: 1,000 9 | 10 | # How this asset maps to the asset classes defined in the portfolio. 11 | # This is used to generate asset allocation (e.g. when using 12 | # `lak list aa` command) 13 | Asset Mapping: 14 | Bonds: 1.0 15 | -------------------------------------------------------------------------------- /lakshmi/data/TickerAsset.yaml: -------------------------------------------------------------------------------- 1 | # A TickerAsset represents an asset with a ticker symbol. The value of 2 | # these assets can be pulled and updated from the internet (Yahoo Finance). 3 | 4 | # The ticker symbol, e.g. 'VTI' or 'VXUS' 5 | Ticker: ITOT 6 | 7 | # The number of shares owned. 8 | Shares: 1,234.5 9 | 10 | # How this asset maps to the asset classes defined in the portfolio. 11 | # This is used to generate asset allocation (e.g. when using 12 | # `lak list aa` command) 13 | Asset Mapping: 14 | US: 1.0 15 | 16 | # Optionally the tax lot information for the asset. 17 | Tax Lots: 18 | - Date: 2020/03/23 19 | Quantity: 1,234.5 20 | Unit Cost: 50.39 21 | -------------------------------------------------------------------------------- /lakshmi/data/VanguardFund.yaml: -------------------------------------------------------------------------------- 1 | # This asset is used to track Vanguard funds that don't have a 2 | # ticker symbol associalted with them. The vakue of this asset 3 | # is automatically updated by pulling the current price from 4 | # the vanguard website. An example is 'Vanguard Institutional 5 | # Total Bond Market Index Trust'. 6 | 7 | # The fund id (numeric) for this fund. 8 | Fund Id: 7555 9 | 10 | # The total number of shared 11 | Shares: 2,121.11 12 | 13 | # How this asset maps to the asset classes defined in the portfolio. 14 | # This is used to generate asset allocation (e.g. when using 15 | # `lak list aa` command) 16 | Asset Mapping: 17 | Bonds: 1.0 18 | 19 | # Optionally the tax lot information for the asset. 20 | Tax Lots: 21 | - Date: 2021/04/28 22 | Quantity: 2,121.11 23 | Unit Cost: 110 24 | -------------------------------------------------------------------------------- /lakshmi/performance.py: -------------------------------------------------------------------------------- 1 | """This module contains all classes and functions related to checkpointing 2 | and computing portfolio's performance.""" 3 | 4 | import bisect 5 | from dataclasses import dataclass 6 | from datetime import datetime, timedelta 7 | 8 | import yaml 9 | from pyxirr import xirr 10 | 11 | from lakshmi import utils 12 | from lakshmi.table import Table 13 | 14 | 15 | class Checkpoint: 16 | """Class representing a single checkpoint of the portfolio. Each checkpoint 17 | represents a single day. The checkpoint contains the portfolio value, and 18 | money inflows and outflows on that day. 19 | """ 20 | 21 | def __init__(self, checkpoint_date, portfolio_value, inflow=0, outflow=0): 22 | """Constructs a new checkpoint for the given date. 23 | 24 | Args: 25 | checkpoint_date: Date in 'YYYY/MM/DD' format. 26 | portfolio_value: The value of the portfolio on that date. 27 | inflow: The amount of money flowing into the portfolio on date. 28 | outflow: The amount of money flowing out of the portfolio on date. 29 | """ 30 | self._date = utils.validate_date(checkpoint_date) 31 | 32 | assert portfolio_value > 0, 'Portfolio value must be positive' 33 | assert inflow >= 0, 'Inflow must be non-negative' 34 | assert outflow >= 0, 'Outflow must be non-negative' 35 | 36 | # Round portfolio value to 2 degits. 37 | self._portfolio_value = round(portfolio_value, 2) 38 | self._inflow = inflow 39 | self._outflow = outflow 40 | 41 | def to_dict(self, show_empty_cashflow=False, show_date=True): 42 | """Converts this object to a dictionary. 43 | 44 | Args: 45 | show_empty_cashflow: If set to True, inflows and outflows are 46 | shown even if they are empty. 47 | show_date: If set to False, date is not included in the dictionary 48 | output. 49 | 50 | Returns: 51 | A dictionary object representing this object. 52 | """ 53 | d = {} 54 | if show_date: 55 | d['Date'] = self._date 56 | d['Portfolio Value'] = self._portfolio_value 57 | if show_empty_cashflow or self._inflow > 0: 58 | d['Inflow'] = self._inflow 59 | if show_empty_cashflow or self._outflow > 0: 60 | d['Outflow'] = self._outflow 61 | return d 62 | 63 | @classmethod 64 | def from_dict(cls, d, date=None): 65 | """Returns a new object given dictionary representation. 66 | 67 | This function is reverse of the above function. Optionally, it takes 68 | in a date which is used to set the date if it is specified. If it is 69 | not specified than this function expects that a data is specified via 70 | d. This is a reverse function of to_dict(). 71 | 72 | Args: 73 | d: A dictionary representing this object (usually a output of 74 | to_dict method). 75 | date: Date for this checkpoint (Format: YYYY/MM/DD). 76 | 77 | Returns: A newly initialized object of type Checkpoint. 78 | 79 | Raises: 80 | AssertionError if the dictionary format is not as expected. 81 | """ 82 | if not date: 83 | date = d.pop('Date') 84 | ret_obj = cls(date, d.pop('Portfolio Value'), d.pop('Inflow', 0), 85 | d.pop('Outflow', 0)) 86 | assert len(d) == 0, f'Extra attributes found while parsing: {list(d)}' 87 | return ret_obj 88 | 89 | def get_date(self): 90 | """Returns date of this checkpoint in 'YYYY/MM/DD' format.""" 91 | return self._date 92 | 93 | def get_portfolio_value(self): 94 | """Returns the checkpoint's portfolio value.""" 95 | return self._portfolio_value 96 | 97 | def get_inflow(self): 98 | """Returns the money flowing in.""" 99 | return self._inflow 100 | 101 | def get_outflow(self): 102 | """Returns the money flowing out.""" 103 | return self._outflow 104 | 105 | 106 | class Timeline: 107 | """Class representing a collection of checkpoints.""" 108 | 109 | _DATE_FMT = '%Y/%m/%d' 110 | 111 | def __init__(self, checkpoints): 112 | """Returns a new object given a list of checkpoints.""" 113 | assert len(checkpoints) > 0 114 | 115 | self._checkpoints = {} 116 | self._dates = [] 117 | for cp in checkpoints: 118 | cp_date = cp.get_date() 119 | assert cp_date not in self._dates, ( 120 | f'Cannot have two checkpoints with the same date ({cp_date})') 121 | self._dates.append(cp_date) 122 | self._checkpoints[cp_date] = cp 123 | self._dates.sort() 124 | 125 | def to_list(self): 126 | """Returns this object as a list of checkpoints.""" 127 | return [self._checkpoints[date].to_dict() for date in self._dates] 128 | 129 | @classmethod 130 | def from_list(cls, timeline_list): 131 | """Returns a new object given a list (reverse of method above).""" 132 | return cls([Checkpoint.from_dict(cp) for cp in timeline_list]) 133 | 134 | def to_table(self, begin=None, end=None): 135 | """Convert this timeline to a Table. 136 | 137 | This function is useful for pretty-printing this object. 138 | 139 | Args: 140 | begin: If specified, start printing checkpoints from this date 141 | (inclusive). Format: 'YYYY/MM/DD'. If None, start from the earliest 142 | checkpoint date. 143 | end: If specified, stop printing checkpoints after this date 144 | (inclusive). Format: 'YYYY/MM/DD'. If None, end at the last 145 | checkpoint date. 146 | 147 | Returns: A lakshmi.table.Table object. 148 | """ 149 | table = Table(4, 150 | headers=['Date', 'Portfolio Value', 'Inflow', 'Outflow'], 151 | coltypes=['str', 'dollars', 'dollars', 'dollars']) 152 | begin_pos = bisect.bisect_left(self._dates, begin) if begin else None 153 | end_pos = bisect.bisect_right(self._dates, end) if end else None 154 | for date in self._dates[begin_pos:end_pos]: 155 | cp = self._checkpoints[date] 156 | table.add_row([cp.get_date(), 157 | cp.get_portfolio_value(), 158 | cp.get_inflow(), 159 | cp.get_outflow()]) 160 | return table 161 | 162 | def has_checkpoint(self, date): 163 | """Retuns true iff there is a checkpoint for date.""" 164 | return utils.validate_date(date) in self._checkpoints 165 | 166 | def begin(self): 167 | """Returns the beginnning date of this timeline.""" 168 | return self._dates[0] 169 | 170 | def end(self): 171 | """Returns the end date of this timeline.""" 172 | return self._dates[-1] 173 | 174 | def covers(self, date): 175 | """Returns true if date is within the timeline.""" 176 | date = utils.validate_date(date) 177 | return (date >= self.begin() and date <= self.end()) 178 | 179 | @staticmethod 180 | def _interpolate_checkpoint(date, checkpoint1, checkpoint2): 181 | """Given checkpoints 1 and 2, returns new checkpoint for date.""" 182 | date1 = datetime.strptime(checkpoint1.get_date(), Timeline._DATE_FMT) 183 | date2 = datetime.strptime(checkpoint2.get_date(), Timeline._DATE_FMT) 184 | given_date = datetime.strptime(date, Timeline._DATE_FMT) 185 | val1 = checkpoint1.get_portfolio_value() 186 | val2 = (checkpoint2.get_portfolio_value() 187 | - checkpoint2.get_inflow() 188 | + checkpoint2.get_outflow()) 189 | 190 | interpolated_value = val1 + (val2 - val1) * ( 191 | (given_date - date1) / (date2 - date1)) 192 | return Checkpoint(date, interpolated_value) 193 | 194 | def get_checkpoint(self, date, interpolate=False): 195 | """Returns checkpoint for a given date. 196 | 197 | This function will return the checkpoint for date if it already exists 198 | in the Timeline. If there is no checkpoint for date, this function 199 | throws an error if interpolate is False. If interpolate is set to True 200 | this function will linearly interpolate the portfolio values around 201 | the given date and return a newly created checkpoint with that 202 | calculated value. 203 | 204 | Args: 205 | date: The date (in 'YYYY/MM/DD' format) for which to return the 206 | checkpoint. 207 | interpolate: If True, computes and returns a checkpoint for a date 208 | even if one doesn't exists in the Timeline. 209 | Returns: 210 | Checkpoint object corresponding to date. 211 | Raises: 212 | AssertionError, if interpolate is False and there is no checkpoint 213 | for date. 214 | AssertError, If date is not without the range of checkpoints in 215 | this timeline. 216 | """ 217 | date = utils.validate_date(date) 218 | 219 | if self.has_checkpoint(date): 220 | return self._checkpoints[date] 221 | 222 | # date is not one of the saved checkpoints... 223 | 224 | assert interpolate, f'{date} is not one of the saved checkpoints.' 225 | 226 | # ... and it's OK to interpolate 227 | 228 | assert self.covers(date), ( 229 | f'{date} is not in the range of the saved checkpoints. ' 230 | f'Begin={self.begin()}, End={self.end()}') 231 | 232 | pos = bisect.bisect(self._dates, date) 233 | return Timeline._interpolate_checkpoint( 234 | date, 235 | self._checkpoints[self._dates[pos - 1]], 236 | self._checkpoints[self._dates[pos]]) 237 | 238 | def insert_checkpoint(self, checkpoint, replace=False): 239 | """Inserts given checkpoint to the timeline. 240 | 241 | Args: 242 | checkpoint: Checkpoint to insert. 243 | replace: If True, the new checkpoint overwrites a checkpoint with 244 | the same date present in the timeline. 245 | 246 | Raises: 247 | AssertionError if replace is False and another checkpoint with the 248 | same date is present in the timeline. 249 | """ 250 | date = checkpoint.get_date() 251 | 252 | if replace and self.has_checkpoint(date): 253 | self._checkpoints[date] = checkpoint 254 | return 255 | 256 | assert not self.has_checkpoint(date), ( 257 | f'Cannot insert two checkpoints with the same date ({date}).') 258 | pos = bisect.bisect(self._dates, date) 259 | self._dates.insert(pos, date) 260 | self._checkpoints[date] = checkpoint 261 | 262 | def delete_checkpoint(self, date): 263 | """Removes checkpoint corresponding to date.""" 264 | date = utils.validate_date(date) 265 | assert date in self._checkpoints 266 | self._checkpoints.pop(date) 267 | self._dates.remove(date) 268 | 269 | @dataclass 270 | class PerformanceData: 271 | # List of dates (datetime objects). Used to compute XIRR. 272 | dates: list 273 | # List of cashflows on the above dates. Money flowing out of portfolio 274 | # is considered positive. Used to compute XIRR. 275 | amounts: list 276 | # Beginning balance. 277 | begin_balance: float 278 | # Ending balance. 279 | end_balance: float 280 | # Sum of all inflows to the portfolio. 281 | inflows: float = 0.0 282 | # Sum of all outflows from the portfolio. 283 | outflows: float = 0.0 284 | 285 | def get_performance_data(self, begin, end): 286 | """Returns data in a format to help calculate XIRR. 287 | 288 | Args: 289 | begin: Begin date in YYYY/MM/DD format. If None, start from the 290 | earliest checkpoint date. 291 | end: End date in YYYY/MM/DD format. If None, ends at the last 292 | checkpoint date. 293 | 294 | Returns: PerformanceData object. 295 | """ 296 | if not begin: 297 | begin = self.begin() 298 | if not end: 299 | end = self.end() 300 | 301 | assert utils.validate_date(begin) != utils.validate_date(end) 302 | 303 | dates = [] 304 | amounts = [] 305 | inflows = 0.0 306 | outflows = 0.0 307 | 308 | begin_checkpoint = self.get_checkpoint(begin, True) 309 | dates.append(datetime.strptime(begin_checkpoint.get_date(), 310 | Timeline._DATE_FMT)) 311 | amounts.append(-begin_checkpoint.get_portfolio_value()) 312 | 313 | begin_pos = bisect.bisect_right(self._dates, begin) 314 | end_pos = bisect.bisect_left(self._dates, end) 315 | for date in self._dates[begin_pos:end_pos]: 316 | dates.append(datetime.strptime(date, Timeline._DATE_FMT)) 317 | checkpoint = self._checkpoints[date] 318 | amounts.append(checkpoint.get_outflow() - checkpoint.get_inflow()) 319 | inflows += checkpoint.get_inflow() 320 | outflows += checkpoint.get_outflow() 321 | 322 | end_checkpoint = self.get_checkpoint(end, True) 323 | dates.append( 324 | datetime.strptime(end_checkpoint.get_date(), Timeline._DATE_FMT)) 325 | amounts.append(end_checkpoint.get_portfolio_value() 326 | + end_checkpoint.get_outflow() 327 | - end_checkpoint.get_inflow()) 328 | inflows += end_checkpoint.get_inflow() 329 | outflows += end_checkpoint.get_outflow() 330 | return Timeline.PerformanceData( 331 | dates=dates, amounts=amounts, inflows=inflows, outflows=outflows, 332 | begin_balance=begin_checkpoint.get_portfolio_value(), 333 | end_balance=end_checkpoint.get_portfolio_value()) 334 | 335 | 336 | class Performance: 337 | """Class to compute performance stats given a Timeline object.""" 338 | 339 | _TIME_PERIODS = [timedelta(days=30), 340 | timedelta(days=30) * 3, 341 | timedelta(days=30) * 6, 342 | timedelta(days=365), 343 | timedelta(days=365) * 3, 344 | timedelta(days=365) * 10] 345 | _TIME_PERIODS_NAMES = ['1 Month', 346 | '3 Months', 347 | '6 Months', 348 | '1 Year', 349 | '3 Years', 350 | '10 Years'] 351 | 352 | def __init__(self, timeline): 353 | self._timeline = timeline 354 | 355 | def get_timeline(self): 356 | """Returns the timeline.""" 357 | return self._timeline 358 | 359 | def to_dict(self): 360 | """Converts this object to a dictionary.""" 361 | return {'Timeline': self._timeline.to_list()} 362 | 363 | @classmethod 364 | def from_dict(cls, d): 365 | """Creates a new Performance object from a dict (reverse of above).""" 366 | ret_obj = cls(Timeline.from_list(d.pop('Timeline'))) 367 | assert len(d) == 0, f'Extra attributes found: {list(d.keys())}' 368 | return ret_obj 369 | 370 | def save(self, filename): 371 | """Save this Object to a file.""" 372 | with open(filename, 'w') as f: 373 | yaml.dump(self.to_dict(), f) 374 | 375 | @classmethod 376 | def load(cls, filename): 377 | """Load Performance object from a file.""" 378 | with open(filename) as f: 379 | return cls.from_dict(yaml.load(f.read(), Loader=yaml.SafeLoader)) 380 | 381 | def _get_periods(self): 382 | """Returns periods for which summary stats should be printed.""" 383 | # We only show 3 _TIME_PERIODS based on timeline_period 384 | timeline_period = ( 385 | datetime.strptime(self._timeline.end(), Timeline._DATE_FMT) 386 | - datetime.strptime(self._timeline.begin(), Timeline._DATE_FMT)) 387 | end_index = bisect.bisect_left(Performance._TIME_PERIODS, 388 | timeline_period) 389 | begin_index = max(0, end_index - 3) 390 | return (Performance._TIME_PERIODS[begin_index:end_index], 391 | Performance._TIME_PERIODS_NAMES[begin_index:end_index]) 392 | 393 | @staticmethod 394 | def _create_summary_row(period_name, perf_data): 395 | """Helper method to create a row in summary table.""" 396 | change = perf_data.end_balance - perf_data.begin_balance 397 | return [period_name, perf_data.inflows, perf_data.outflows, 398 | change, change / perf_data.begin_balance, 399 | xirr(perf_data.dates, perf_data.amounts)] 400 | 401 | def summary_table(self): 402 | """Returns summary of performance during different periods.""" 403 | table = Table(6, 404 | headers=['Period', 'Inflows', 'Outflows', 405 | 'Portfolio Change', 'Change %', 'IRR'], 406 | coltypes=['str', 'dollars', 'dollars', 'delta_dollars', 407 | 'percentage_1', 'percentage_1']) 408 | # Not enough data for any points. 409 | if self._timeline.begin() == self._timeline.end(): 410 | return table 411 | 412 | # Add rows for atmost 3 periods. 413 | periods, period_names = self._get_periods() 414 | for period, period_name in zip(periods, period_names): 415 | begin_date_str = ( 416 | datetime.strptime(self._timeline.end(), Timeline._DATE_FMT) 417 | - period).strftime(Timeline._DATE_FMT) 418 | table.add_row(Performance._create_summary_row( 419 | period_name, self._timeline.get_performance_data( 420 | begin_date_str, self._timeline.end()))) 421 | 422 | # Add row for 'Overall' time period 423 | table.add_row(Performance._create_summary_row( 424 | 'Overall', self._timeline.get_performance_data( 425 | self._timeline.begin(), self._timeline.end()))) 426 | return table 427 | 428 | def get_info(self, begin, end): 429 | """Get information about the performance in [begin, end]. 430 | 431 | This method prints detailed information about performance of portfolio 432 | (as given by checkpoints). 433 | 434 | Args: 435 | begin: Begin date in YYYY/MM/DD format. If None, starts at the 436 | earliest checkpoint date. 437 | end: End date in YYYY/MM/DD format. If None, ends at the last 438 | checkpoint date. 439 | 440 | Returns: A formatted string suitable for pretty-printing. 441 | """ 442 | # Make dates strings cannonical. 443 | begin = utils.validate_date(begin) if begin else self._timeline.begin() 444 | end = utils.validate_date(end) if end else self._timeline.end() 445 | if begin == end: 446 | # Not enought checkpoints to compute performance. 447 | return '' 448 | 449 | data = self._timeline.get_performance_data(begin, end) 450 | change = data.end_balance - data.begin_balance 451 | 452 | table = Table(2, coltypes=['str', 'str']) 453 | growth = round(100 * change / data.begin_balance, 1) 454 | irr = round(100 * xirr(data.dates, data.amounts), 1) 455 | table.set_rows([ 456 | ['Start date', begin], 457 | ['End date', end], 458 | ['Beginning balance', utils.format_money(data.begin_balance)], 459 | ['Ending balance', utils.format_money(data.end_balance)], 460 | ['Inflows', utils.format_money(data.inflows)], 461 | ['Outflows', utils.format_money(data.outflows)], 462 | ['Portfolio growth', utils.format_money_delta(change)], 463 | ['Market growth', utils.format_money_delta( 464 | change - data.inflows + data.outflows)], 465 | ['Portfolio growth %', f'{growth}%'], 466 | ['Internal Rate of Return', f'{irr}%']]) 467 | return table.string(tablefmt='plain') 468 | -------------------------------------------------------------------------------- /lakshmi/table.py: -------------------------------------------------------------------------------- 1 | """Module to help output and print tables.""" 2 | 3 | from tabulate import tabulate 4 | 5 | import lakshmi.utils as utils 6 | 7 | 8 | class Table(): 9 | """This class helps format, process and print tabular information.""" 10 | # Mapping from column type to a function that formats the cell 11 | # entries and converts them to a string. 12 | # Standard coltypes are: 13 | # 'str': Column is already in string format. 14 | # 'dollars': Column is a float which represents a dollar value. 15 | # 'delta_dollars': Column represents a positive or negative dollar 16 | # difference. 17 | # 'percentage': A float representing a percentage. 18 | # 'float': Float. 19 | coltype2func = { 20 | 'str': lambda x: x, 21 | 'dollars': lambda x: utils.format_money(x), 22 | 'delta_dollars': lambda x: utils.format_money_delta(x), 23 | 'percentage': lambda x: f'{round(100 * x)}%', 24 | 'percentage_1': lambda x: f'{round(100 * x, 1)}%', 25 | 'float': lambda x: str(float(x)), 26 | } 27 | 28 | # Mapping of column type to how it should be aligned. Most values are 29 | # self explanatory. 'float' is aligned on the decimal point. 30 | coltype2align = { 31 | 'str': 'left', 32 | 'dollars': 'right', 33 | 'delta_dollars': 'right', 34 | 'percentage': 'right', 35 | 'percentage_1': 'right', 36 | 'float': 'decimal', 37 | } 38 | 39 | def __init__(self, numcols, headers=(), coltypes=None): 40 | """ 41 | Args: 42 | numcols: Number of columns (required) 43 | headers: Header row (optional) 44 | coltypes: The type of columns, if not provided the columns are 45 | assumed to be strings. 46 | """ 47 | assert numcols >= 0 48 | self._numcols = numcols 49 | 50 | if headers: 51 | assert len(headers) == numcols 52 | self._headers = headers 53 | 54 | if coltypes: 55 | assert len(coltypes) == numcols 56 | assert set(coltypes).issubset( 57 | Table.coltype2func.keys()), 'Bad column type in coltypes' 58 | self._coltypes = coltypes 59 | else: 60 | self._coltypes = ['str'] * self._numcols 61 | 62 | self._rows = [] 63 | 64 | def add_row(self, row): 65 | """Add a new row to the table. 66 | 67 | Args: 68 | row: A list of column entries representing a row. 69 | """ 70 | assert len(row) <= self._numcols 71 | self._rows.append(row) 72 | return self 73 | 74 | def set_rows(self, rows): 75 | """Replaces all rows of this table by rows. 76 | 77 | Args: 78 | rows: A list (rows) of list (columns) of cell entries. 79 | """ 80 | assert max(map(len, rows)) <= self._numcols 81 | self._rows = rows 82 | 83 | def headers(self): 84 | """Returns the header row.""" 85 | return self._headers 86 | 87 | def col_align(self): 88 | """Returns the column alignment parameters. 89 | 90 | Returns: A list of strings, where each value represents 91 | the value of coltype2align map. These alignment parameters are 92 | dependent on the column types specified while constructing this 93 | object. 94 | """ 95 | return list(map(lambda x: Table.coltype2align[x], self._coltypes)) 96 | 97 | def list(self): 98 | """Returns the table as a list (row) of lists (raw columns). 99 | 100 | This function doesn't perform any string conversion on the cell values. 101 | """ 102 | return self._rows 103 | 104 | def str_list(self): 105 | """Returns the table as a list (row) of list of strings (columns). 106 | 107 | This function converts the raw value of a cell to string based on its 108 | column type. 109 | """ 110 | ret_list = [] 111 | for row in self.list(): 112 | ret_row = [] 113 | for col_num in range(len(row)): 114 | if row[col_num] is None: 115 | ret_row.append('') 116 | else: 117 | ret_row.append(Table.coltype2func[self._coltypes[col_num]]( 118 | row[col_num])) 119 | ret_list.append(ret_row) 120 | return ret_list 121 | 122 | def string(self, tablefmt='simple'): 123 | """Returns the table as a formatted string.""" 124 | str_list = self.str_list() 125 | if not str_list: 126 | return '' 127 | 128 | return tabulate(str_list, 129 | headers=self.headers(), 130 | tablefmt=tablefmt, 131 | colalign=self.col_align()) 132 | -------------------------------------------------------------------------------- /lakshmi/utils.py: -------------------------------------------------------------------------------- 1 | """Common utils for Lakshmi.""" 2 | 3 | import re 4 | from datetime import datetime 5 | 6 | import yaml 7 | 8 | 9 | def format_money(x): 10 | """Formats input (money) to a string. 11 | 12 | For example, if x=5.238, the output is '$5.24'. 13 | 14 | Args: 15 | x: Float (non-negative) representing dollars. 16 | """ 17 | return '${:,.2f}'.format(x) 18 | 19 | 20 | def format_money_delta(x): 21 | """Formats input (money delta) into a string. 22 | 23 | For example, if x=-23.249m the output is '-$23.25'. 24 | 25 | Args: 26 | x: Float (postive or negative) representating dollars. 27 | """ 28 | return '{}${:,.2f}'.format('-' if x < 0 else '+', abs(x)) 29 | 30 | 31 | def validate_date(date_text): 32 | """Validates if the date is in the YYYY/MM/DD format. 33 | 34 | This function either throws a ValueError or returns the date_text 35 | formatted according to YYYY/MM/DD format. 36 | 37 | Args: 38 | date_text: Date text to be validated. 39 | 40 | Returns: 41 | Correctly formatted string representing date_text date. 42 | 43 | Throws: 44 | ValueError if date is not in YYYY/MM/DD format. 45 | """ 46 | return datetime.strptime(date_text, '%Y/%m/%d').strftime('%Y/%m/%d') 47 | 48 | 49 | def get_loader(): 50 | """Returns a SafeLoader that can parse comma-separated float values.""" 51 | def parse_comma_float(loader, node): 52 | value = loader.construct_scalar(node) 53 | return float(value.replace(',', '')) 54 | 55 | loader = yaml.SafeLoader 56 | loader.add_constructor(u'comma_float', parse_comma_float) 57 | loader.add_implicit_resolver(u'comma_float', 58 | re.compile(r'^-?[\d,]+\.?\d+$'), 59 | None) 60 | return loader 61 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.4 2 | backports.entry-points-selectable==1.3.0 3 | beautifulsoup4==4.13.3 4 | bleach==6.2.0 5 | certifi==2025.1.31 6 | cffi==1.17.1 7 | cfgv==3.4.0 8 | chardet==5.2.0 9 | charset-normalizer==3.4.1 10 | click==8.1.8 11 | colorama==0.4.6 12 | commonmark==0.9.1 13 | cryptography==44.0.2 14 | distlib==0.3.9 15 | docutils==0.21.2 16 | filelock==3.18.0 17 | frozendict==2.4.6 18 | html5lib==1.1 19 | ibonds==1.0.5 20 | id==1.5.0 21 | identify==2.6.9 22 | idna==3.10 23 | importlib_metadata==8.6.1 24 | jaraco.classes==3.4.0 25 | jaraco.context==6.0.1 26 | jaraco.functools==4.1.0 27 | jeepney==0.9.0 28 | keyring==25.6.0 29 | lxml==5.3.1 30 | markdown-it-py==3.0.0 31 | mdurl==0.1.2 32 | more-itertools==10.6.0 33 | multitasking==0.0.11 34 | nh3==0.2.21 35 | nodeenv==1.9.1 36 | numpy==2.2.4 37 | packaging==24.2 38 | pandas==2.2.3 39 | peewee==3.17.9 40 | pkginfo==1.12.1.2 41 | platformdirs==4.3.7 42 | pre_commit==4.2.0 43 | pycparser==2.22 44 | Pygments==2.19.1 45 | pyparsing==3.2.3 46 | python-dateutil==2.9.0.post0 47 | pytz==2025.2 48 | pyxirr==0.10.6 49 | PyYAML==6.0.2 50 | readme_renderer==44.0 51 | requests==2.32.3 52 | requests-toolbelt==1.0.0 53 | rfc3986==2.0.0 54 | rich==13.9.4 55 | SecretStorage==3.3.3 56 | setuptools==78.1.0 57 | six==1.17.0 58 | soupsieve==2.6 59 | tabulate==0.9.0 60 | toml==0.10.2 61 | tqdm==4.67.1 62 | twine==6.1.0 63 | typing_extensions==4.13.0 64 | tzdata==2025.2 65 | urllib3==2.3.0 66 | virtualenv==20.29.3 67 | webencodings==0.5.1 68 | wheel==0.45.1 69 | yfinance==0.2.55 70 | zipp==3.21.0 71 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | from lakshmi.constants import NAME, VERSION 4 | 5 | with open('README.md', 'r', encoding='utf-8') as fh: 6 | # Replace relative links to absolute links. 7 | long_description = ( 8 | fh.read() 9 | .replace('](./', '](https://sarvjeets.github.io/lakshmi/') 10 | .replace('lak.md', 'lak.html')) 11 | 12 | setup( 13 | name=NAME, 14 | version=VERSION, 15 | author='Sarvjeet Singh', 16 | author_email='sarvjeet@gmail.com', 17 | description=('Investing library and command-line interface ' 18 | 'inspired by the Bogleheads philosophy'), 19 | long_description=long_description, 20 | long_description_content_type='text/markdown', 21 | url='https://github.com/sarvjeets/lakshmi', 22 | license="MIT", 23 | platforms='any', 24 | classifiers=[ 25 | 'Development Status :: 5 - Production/Stable', 26 | 'Operating System :: OS Independent', 27 | 'Programming Language :: Python :: 3', 28 | ], 29 | py_modules=['lakshmi'], 30 | packages=find_packages(exclude=['tests', 'tests.*']), 31 | include_package_data=True, 32 | install_requires=[ 33 | 'click~=8.0', 34 | 'curl_cffi~=0.10', 35 | 'ibonds>=1.0.2,<2.0', 36 | 'numpy>=1.22,<3.0', 37 | 'pyxirr~=0.6', 38 | 'PyYAML>=5.4,<7.0', 39 | 'requests~=2.25', 40 | 'tabulate~=0.8', 41 | 'yfinance>=0.2.38,<0.3', 42 | ], 43 | entry_points={ 44 | 'console_scripts': [ 45 | 'lak = lakshmi.lak:lak', 46 | ], 47 | }, 48 | test_suite='tests', 49 | python_requires='>=3.7', 50 | ) 51 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarvjeets/lakshmi/086c6d5f76af662fc656b33ef9aa8c4a4e03c514/tests/__init__.py -------------------------------------------------------------------------------- /tests/data/SBCPrice-EE.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Calculate the Value of Your Paper Savings Bond(s) 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 | 91 | 92 | 93 | 94 |
95 | 103 |
104 | 105 |
106 | 107 | 108 | 119 | 120 | 121 | 122 | 123 |
124 | 125 |

TOOLS

126 | 141 | 144 | 145 |
146 | 147 | 148 | 149 |
150 | 151 | 152 | 153 |

Calculate the Value of Your Paper Savings Bond(s)

154 | 155 |
156 | 157 |
158 |
159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 184 | 189 | 190 | 191 | 192 |
SAVINGS BOND CALCULATOR
Value as of:Savings Bond Calculator Help
Series:Denomination:Bond Serial Number:Issue Date:
179 | 183 | 185 | 188 |
193 | 195 | 196 |
197 |
198 | 199 |
200 | 201 |

Calculator Results for Redemption Date 04/2021

202 | 203 |
204 | 205 |
206 | 207 |
208 |
209 |
210 | Instructions 211 |

How to Use the Savings Bond Calculator

212 |
213 | 214 |
215 | Notes Description 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 |
NINot Issued
NENot eligible for payment
P5Includes 3 month interest penalty
MAMatured and not earning interest
234 |
235 |
236 |
237 | 238 |
239 |
240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 |
Total PriceTotal ValueTotal InterestYTD Interest
$500.00$500.40$0.40$0.00
254 |
255 |
256 | 257 |

Bonds: 1-1 of 1

258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 |
Serial #SeriesDenomIssue
Date
Next
Accrual
Final
Maturity
Issue
Price
InterestInterest
Rate
ValueNote 
NAEE$1,00003/202005/202103/2050$500.00$0.400.10%$500.40P5
295 | 296 | 297 | 298 |
299 | 300 |

CALCULATE ANOTHER BOND

301 | 302 | 303 |
304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 |
321 | 322 | 323 |
324 | 325 | 326 | 327 |
328 |
329 |
330 | Survey 331 | 332 |

How would you rate this tool?

333 | 334 | Excellent
335 | Good
336 | Fair
337 | Poor
338 |
339 | 340 | 341 | 342 | 343 | 344 | 345 |
346 |
347 |
348 | 349 | 350 | 351 |
352 | 353 | 354 | 355 | 362 | 363 | 364 |
365 | 366 | 388 | 389 | 390 |
391 | 392 | 393 | 394 | -------------------------------------------------------------------------------- /tests/data/SBCPrice-I.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Calculate the Value of Your Paper Savings Bond(s) 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 | 91 | 92 | 93 | 94 |
95 | 103 |
104 | 105 |
106 | 107 | 108 | 119 | 120 | 121 | 122 | 123 |
124 | 125 |

TOOLS

126 | 141 | 144 | 145 |
146 | 147 | 148 | 149 |
150 | 151 | 152 | 153 |

Calculate the Value of Your Paper Savings Bond(s)

154 | 155 |
156 | 157 |
158 |
159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 184 | 189 | 190 | 191 | 192 |
SAVINGS BOND CALCULATOR
Value as of:Savings Bond Calculator Help
Series:Denomination:Bond Serial Number:Issue Date:
179 | 183 | 185 | 188 |
193 | 195 | 196 |
197 |
198 | 199 |
200 | 201 |

Calculator Results for Redemption Date 04/2021

202 | 203 |
204 | 205 |
206 | 207 |
208 |
209 |
210 | Instructions 211 |

How to Use the Savings Bond Calculator

212 |
213 | 214 |
215 | Notes Description 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 |
NINot Issued
NENot eligible for payment
P5Includes 3 month interest penalty
MAMatured and not earning interest
234 |
235 |
236 |
237 | 238 |
239 |
240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 |
Total PriceTotal ValueTotal InterestYTD Interest
$1,000.00$1,015.60$15.60$4.40
254 |
255 |
256 | 257 |

Bonds: 1-1 of 1

258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 |
Serial #SeriesDenomIssue
Date
Next
Accrual
Final
Maturity
Issue
Price
InterestInterest
Rate
ValueNote 
NAI$1,00003/202005/202103/2050$1,000.00$15.601.88%$1,015.60P5
295 | 296 | 297 | 298 |
299 | 300 |

CALCULATE ANOTHER BOND

301 | 302 | 303 |
304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 |
321 | 322 | 323 |
324 | 325 | 326 | 327 |
328 |
329 |
330 | Survey 331 | 332 |

How would you rate this tool?

333 | 334 | Excellent
335 | Good
336 | Fair
337 | Poor
338 |
339 | 340 | 341 | 342 | 343 | 344 | 345 |
346 |
347 |
348 | 349 | 350 | 351 |
352 | 353 | 354 | 355 | 362 | 363 | 364 |
365 | 366 | 388 | 389 | 390 |
391 | 392 | 393 | 394 | -------------------------------------------------------------------------------- /tests/data/price.json: -------------------------------------------------------------------------------- 1 | {"currentPrice": {"yield": {"hasDisclaimer": false}, "dailyPrice": {"regular": {"asOfDate": "2021-04-15T00:00:00-04:00", "price": "116.66", "priceChangeAmount": "0.52", "priceChangePct": "0.45", "currOrPrmlFlag": "CURR", "currOrPrmlValue": "Price"}}, "highLow": {"regular": {"highDate": "2020-08-06T00:00:00-04:00", "highPrice": "120.210000", "lowDate": "2021-03-18T00:00:00-04:00", "lowPrice": "114.970000", "spreadPrice": "5.240000", "spreadPct": "4.56", "hasMultipleHighDates": false, "hasMultipleLowDates": false, "highDates": [{}], "lowDates": [{}]}}}, "historicalPrice": {"isMultiYears": false, "nav": [{"item": [{"asOfDate": "2021-04-15T00:00:00-04:00", "price": "116.66"}, {"asOfDate": "2021-04-14T00:00:00-04:00", "price": "116.14"}, {"asOfDate": "2021-04-13T00:00:00-04:00", "price": "116.24"}, {"asOfDate": "2021-04-12T00:00:00-04:00", "price": "115.93"}, {"asOfDate": "2021-04-09T00:00:00-04:00", "price": "116.01"}, {"asOfDate": "2021-04-08T00:00:00-04:00", "price": "116.11"}, {"asOfDate": "2021-04-07T00:00:00-04:00", "price": "115.90"}, {"asOfDate": "2021-04-06T00:00:00-04:00", "price": "115.99"}, {"asOfDate": "2021-04-05T00:00:00-04:00", "price": "115.67"}, {"asOfDate": "2021-04-01T00:00:00-04:00", "price": "115.86"}]}], "marketPrice": [{}]}} 2 | -------------------------------------------------------------------------------- /tests/data/profile.json: -------------------------------------------------------------------------------- 1 | {"fundProfile": {"fundId": "1884", "citFundId": "7555", "instrumentId": 27075102, "shortName": "Inst Tot Bd Mkt Ix Tr", "longName": "Vanguard Institutional Total Bond Market Index Trust", "inceptionDate": "2016-06-24T00:00:00-04:00", "newspaperAbbreviation": "VanTBdMIxInsSel ", "style": "Bond Funds", "type": "Bond Funds", "category": "Intermediate-Term Bond", "customizedStyle": "Bond - Inter-term Investment", "fixedIncomeInvestmentStyleId": "2", "fixedIncomeInvestmentStyleName": "Intermediate-term Treasury", "secDesignation": "", "maximumYearlyInvestment": "", "expenseRatio": "0.0100", "expenseRatioAsOfDate": "2020-04-28T00:00:00-04:00", "isInternalFund": true, "isExternalFund": false, "isMutualFund": true, "isETF": false, "isVLIP": false, "isVVAP": false, "is529": false, "hasAssociatedInvestorFund": true, "hasMoreThan1ShareClass": true, "isPESite": true, "fundFact": {"isActiveFund": true, "isClosed": false, "isClosedToNewInvestors": false, "isFundOfFunds": false, "isMSCIIndexedFund": false, "isIndex": true, "isLoadFund": false, "isMoneyMarket": false, "isBond": true, "isBalanced": false, "isStock": false, "isInternational": false, "isMarketNeutralFund": false, "isInternationalStockFund": false, "isInternationalBalancedFund": false, "isDomesticStockFund": false, "isTaxable": true, "isTaxExempt": false, "isTaxManaged": false, "isTaxableBondFund": true, "isTaxExemptBondFund": false, "isTaxExemptMoneyMarketFund": false, "isTaxSensitiveFund": true, "isSpecialtyStockFund": false, "isHybridFund": false, "isGlobal": false, "isManagedPayoutFund": false, "isGNMAFund": false, "isInvestorShare": false, "isAdmiralShare": false, "isInstitutionalShare": false, "isAdmiralFund": false, "isStableValueFund": false, "isCompanyStockFund": false, "isREITFund": false, "isVariableInsuranceFund": false, "isComingledTrustFund": false, "isConvertibleFund": false, "isAssetAllocationFund": false, "isStateMunicipalBond": false, "isNationalMunicipalBond": false, "isQualifiedOnly": false, "isPreciousMetalsFund": false, "mIsVIPSFund": false, "isSectorSpecific": false, "hasOtherIndex": false, "isTargetRetirementFund": false, "isRetirementSavingsTrustFund": false, "isNon40ActFund": true, "isUnfundedFund": false, "isCreditSuisseFund": false, "isKaiserFund": false, "isFundAccessFund": false, "isFundTransferableToVGI": false, "hasTransactionFee": false, "isNTFFund": false, "hasMoreThan1ShareClass": false, "isOpenToFlagship": false, "isOpenToFlagshipPlus": false, "isCitFund": true, "isAcctType15Fund": false, "isEtfOfEtfs": false, "isStandaloneEtf": false}, "associatedFundIds": {"investorFundId": "0084", "admiralFundId": "0584", "etfFundId": "0928", "institutionalFundId": "0222", "institutionalPlusFundId": "0850"}, "fundCategory": {"customizedHighCategoryName": "Bond - Inter-term Investment", "high": {"type": "HIGH", "id": 3, "name": "Bond Funds"}, "mid": {"type": "MID", "id": 31, "name": "Bond Funds"}, "low": {"type": "LOW", "id": 3105, "name": "Intermediate-Term Bond"}}, "largeTransactionAmount": 25000000, "qualifiedTransactionAmount": 50000000, "minimumInitialInvestment": 3000000000.0, "signalFundFlag": false}, "historicalReturn": {"percent": "8.85", "startDate": "1972-12-31T00:00:00-05:00", "endDate": "2011-12-31T00:00:00-05:00"}} 2 | -------------------------------------------------------------------------------- /tests/integration_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # There are a number of unit-tests for lakshmi. This files add some 4 | # integration tests for lak to make sure all commands are running without 5 | # errors. It doesn't check for correctness -- that is left to unittest. 6 | # For example, it can catch issues with packaging files incorrectly. 7 | 8 | # Tests if command works 9 | testcmd () { 10 | GREEN='\033[0;32m' 11 | RED='\033[0;31m' 12 | NC='\033[0m' 13 | 14 | printf "$1" 15 | if eval $1 > /dev/null ; then 16 | printf "%$((COLUMNS - ${#1}))b\n" "[ ${GREEN}OK${NC} ]" 17 | else 18 | printf "%$((COLUMNS - ${#1}))b\n" "[ ${RED}FAIL${NC} ]" 19 | fi 20 | } 21 | 22 | # Constants 23 | TMP_DIR='/tmp/lakshmi_integration_test' 24 | 25 | # Setup from arguments 26 | if [ "$#" -eq 1 ]; then 27 | CACHE=$1 28 | elif [ "$#" -eq 0 ]; then 29 | CACHE='~/.lakshmicache' 30 | else 31 | echo Usage: $0 ''; exit 2 32 | fi 33 | 34 | cleanup () { 35 | test -d $TMP_DIR && rm -r $TMP_DIR 36 | } 37 | trap cleanup EXIT 38 | 39 | # Make temp directory 40 | mkdir -p $TMP_DIR 41 | 42 | # Create new lakrc for testing 43 | cat << HERE > $TMP_DIR/lakrc 44 | portfolio: $TMP_DIR/portfolio.yaml 45 | cache: $CACHE 46 | performance: $TMP_DIR/performance.yaml 47 | HERE 48 | 49 | export LAK_CONFIG=$TMP_DIR/lakrc 50 | 51 | # Default values for files is OK, just touch the file to fool lak into 52 | # believing that the user editted the file. 53 | export EDITOR=touch 54 | 55 | echo "Testing binary: `command -v lak`" 56 | testcmd "lak init" 57 | testcmd "lak add account" 58 | testcmd "lak add asset -t account -p ManualAsset" 59 | testcmd "lak add asset -t account -p TickerAsset" 60 | testcmd "lak add asset -t account -p VanguardFund" 61 | testcmd "lak add asset -t account -p IBonds" 62 | testcmd "lak add asset -t account -p EEBonds" 63 | testcmd "lak add checkpoint -e" 64 | testcmd "lak list al" 65 | testcmd "lak list aa" 66 | testcmd "lak list aa --no-compact" 67 | testcmd "lak list aa -c US,Intl,Bonds" 68 | testcmd "lak list assets -s -q" 69 | testcmd "lak list total" 70 | testcmd "lak list whatifs" 71 | testcmd "lak list lots" 72 | testcmd "lak list checkpoints" 73 | testcmd "lak list performance" 74 | testcmd "lak info account -t account" 75 | testcmd "lak info asset -a ITOT" 76 | testcmd "lak info asset -a I\ Bonds" 77 | testcmd "lak info asset -a EE" 78 | testcmd "lak info performance" 79 | testcmd "lak whatif account -t account 10" 80 | testcmd "lak whatif account -t account -10" 81 | testcmd "lak whatif asset -a ITOT 10" 82 | testcmd "lak whatif asset -a ITOT -10" 83 | testcmd "lak analyze tlh" 84 | testcmd "lak analyze rebalance" 85 | testcmd "lak analyze allocate -r -t account" 86 | testcmd "lak edit account -t account" 87 | testcmd "lak edit asset -a ITOT" 88 | testcmd "lak edit checkpoint --date `date +%Y/%m/%d`" 89 | testcmd "lak delete asset -a ITOT --yes" 90 | testcmd "lak delete account -t account --yes" 91 | testcmd "lak delete checkpoint --yes --date `date +%Y/%m/%d`" 92 | -------------------------------------------------------------------------------- /tests/test_assets.py: -------------------------------------------------------------------------------- 1 | """Tests for lakshmi.assets module.""" 2 | import datetime 3 | import json 4 | import pathlib 5 | import unittest 6 | from unittest.mock import MagicMock, patch 7 | 8 | import ibonds 9 | 10 | import lakshmi.assets as assets 11 | import lakshmi.cache 12 | 13 | 14 | class AssetsTest(unittest.TestCase): 15 | @classmethod 16 | def setUpClass(cls): 17 | lakshmi.cache.set_cache_dir(None) # Disable caching. 18 | cls.data_dir = (pathlib.Path(__file__).parent / 'data') 19 | 20 | def test_dict_manual_asset_with_what_if(self): 21 | manual_asset = assets.ManualAsset('Cash', 100.5, {'Fixed Income': 1.0}) 22 | manual_asset.what_if(100) 23 | manual_asset = assets.from_dict(assets.to_dict(manual_asset)) 24 | self.assertEqual('Cash', manual_asset.name()) 25 | self.assertAlmostEqual(100.5, manual_asset.value()) 26 | self.assertAlmostEqual(200.5, manual_asset.adjusted_value()) 27 | self.assertEqual({'Fixed Income': 1.0}, manual_asset.class2ratio) 28 | 29 | def test_asset_bad_what_if(self): 30 | a = assets.ManualAsset('Cash', 100, {'All': 1.0}) 31 | a.what_if(-101) 32 | self.assertAlmostEqual(0, a.adjusted_value()) 33 | 34 | def test_dict_manual_asset(self): 35 | manual_asset = assets.ManualAsset('Cash', 100.5, {'Fixed Income': 1.0}) 36 | manual_asset = assets.from_dict(assets.to_dict(manual_asset)) 37 | self.assertEqual('Cash', manual_asset.name()) 38 | self.assertAlmostEqual(100.5, manual_asset.adjusted_value()) 39 | self.assertEqual({'Fixed Income': 1.0}, manual_asset.class2ratio) 40 | 41 | def test_manual_asset_to_table(self): 42 | manual_asset = assets.ManualAsset('Cash', 100.5, {'Fixed Income': 1.0}) 43 | expected = [['Name:', 'Cash'], 44 | ['Asset Class Mapping:', 'Fixed Income 100%'], 45 | ['Value:', '$100.50']] 46 | self.assertListEqual(expected, manual_asset.to_table().str_list()) 47 | 48 | manual_asset.what_if(-100) 49 | expected = [['Name:', 'Cash'], 50 | ['Asset Class Mapping:', 'Fixed Income 100%'], 51 | ['Adjusted Value:', '$0.50'], 52 | ['What if:', '-$100.00']] 53 | self.assertListEqual(expected, manual_asset.to_table().str_list()) 54 | 55 | @patch('yfinance.Ticker') 56 | def test_bad_ticker(self, MockTicker): 57 | bad_ticker = MagicMock() 58 | bad_ticker.info = {} 59 | bad_ticker.fast_info = {} 60 | MockTicker.return_value = bad_ticker 61 | 62 | ticker_asset = assets.TickerAsset('bad', 10, {'All': 1.0}) 63 | with self.assertRaisesRegex(assets.NotFoundError, 64 | 'Cannot retrieve ticker'): 65 | ticker_asset.name() 66 | with self.assertRaisesRegex(assets.NotFoundError, 67 | 'Cannot retrieve ticker'): 68 | ticker_asset.value() 69 | 70 | MockTicker.assert_called_once() 71 | 72 | @patch('yfinance.Ticker') 73 | def test_good_ticker(self, MockTicker): 74 | ticker = MagicMock() 75 | ticker.info = {'longName': 'Vanguard Cash Reserves Federal'} 76 | ticker.fast_info = {'lastPrice': 1.0} 77 | MockTicker.return_value = ticker 78 | 79 | vmmxx = assets.TickerAsset('VMMXX', 100.0, {'All': 1.0}) 80 | self.assertAlmostEqual(100.0, vmmxx.value()) 81 | self.assertEqual('Vanguard Cash Reserves Federal', vmmxx.name()) 82 | self.assertEqual('VMMXX', vmmxx.short_name()) 83 | 84 | MockTicker.assert_called_once() 85 | 86 | @patch('yfinance.Ticker') 87 | def test_missing_longname(self, MockTicker): 88 | ticker = MagicMock() 89 | ticker.info = {'shortName': 'Bitcoin USD', 90 | 'name': 'Bitcoin'} 91 | MockTicker.return_value = ticker 92 | 93 | btc = assets.TickerAsset('BTC-USD', 1.0, {'All': 1.0}) 94 | self.assertEqual('Bitcoin USD', btc.name()) 95 | 96 | MockTicker.assert_called_once() 97 | 98 | @patch('yfinance.Ticker') 99 | def test_missing_longname_shortname(self, MockTicker): 100 | ticker = MagicMock() 101 | ticker.info = {'name': 'Bitcoin'} 102 | ticker.fast_info = {'regularMarketPrice': 1.0} 103 | MockTicker.return_value = ticker 104 | 105 | btc = assets.TickerAsset('BTC-USD', 1.0, {'All': 1.0}) 106 | self.assertEqual('Bitcoin', btc.name()) 107 | 108 | MockTicker.assert_called_once() 109 | 110 | def test_tax_lots_ticker(self): 111 | vmmxx = assets.TickerAsset('VMMXX', 100.0, {'All': 1.0}) 112 | lots = [assets.TaxLot('2012/12/12', 50, 1.0), 113 | assets.TaxLot('2013/12/12', 30, 0.9)] 114 | with self.assertRaisesRegex(AssertionError, 115 | 'Lots provided should sum up to 100.0'): 116 | vmmxx.set_lots(lots) 117 | 118 | lots.append(assets.TaxLot('2014/12/31', 20, 0.9)) 119 | vmmxx.set_lots(lots) 120 | self.assertListEqual(lots, vmmxx.get_lots()) 121 | 122 | @patch('lakshmi.assets.TickerAsset.price') 123 | def test_list_lots(self, mock_price): 124 | mock_price.return_value = 15.0 125 | 126 | vti = assets.TickerAsset('VTI', 100.0, {'All': 1.0}) 127 | lots = [assets.TaxLot('2011/01/01', 50, 10.0), 128 | assets.TaxLot('2012/01/01', 50, 20.0)] 129 | vti.set_lots(lots) 130 | 131 | self.assertListEqual( 132 | [['2011/01/01', '50.0', '$500.00', '+$250.00', '50.0%'], 133 | ['2012/01/01', '50.0', '$1,000.00', '-$250.00', '-25.0%']], 134 | vti.list_lots().str_list()) 135 | 136 | @patch('lakshmi.assets._today') 137 | @patch('lakshmi.assets.TickerAsset.price') 138 | def test_list_lots_with_term(self, mock_price, mock_today): 139 | mock_price.return_value = 15.0 140 | mock_today.return_value = datetime.datetime.strptime( 141 | '2012/12/01', '%Y/%m/%d') 142 | 143 | vti = assets.TickerAsset('VTI', 150.0, {'All': 1.0}) 144 | lots = [assets.TaxLot('2011/01/01', 50, 10.0), 145 | assets.TaxLot('2012/01/01', 50, 20.0), 146 | assets.TaxLot('2012/11/01', 50, 20.0)] 147 | vti.set_lots(lots) 148 | 149 | self.assertListEqual( 150 | [['2011/01/01', '50.0', '$500.00', '+$250.00', '50.0%', 'LT'], 151 | ['2012/01/01', '50.0', '$1,000.00', '-$250.00', '-25.0%', 'ST'], 152 | ['2012/11/01', '50.0', '$1,000.00', '-$250.00', '-25.0%', '30']], 153 | vti.list_lots(include_term=True).str_list()) 154 | 155 | @patch('lakshmi.assets.TickerAsset.name') 156 | @patch('lakshmi.assets.TickerAsset.price') 157 | def test_ticker_asset_to_table(self, mock_price, mock_name): 158 | mock_price.return_value = 10.0 159 | mock_name.return_value = 'Google Inc' 160 | 161 | goog = assets.TickerAsset('GOOG', 100.0, {'All': 1.0}) 162 | expected = [['Ticker:', 'GOOG'], 163 | ['Name:', 'Google Inc'], 164 | ['Asset Class Mapping:', 'All 100%'], 165 | ['Value:', '$1,000.00'], 166 | ['Price:', '$10.00']] 167 | self.assertListEqual(expected, goog.to_table().str_list()) 168 | 169 | @patch('yfinance.Ticker') 170 | def test_dict_ticker(self, MockTicker): 171 | ticker = MagicMock() 172 | ticker.info = {'longName': 'Vanguard Cash Reserves Federal'} 173 | ticker.fast_info = {'lastPrice': 1.0} 174 | MockTicker.return_value = ticker 175 | 176 | vmmxx = assets.TickerAsset('VMMXX', 100.0, {'All': 1.0}) 177 | lots = [assets.TaxLot('2012/12/12', 50, 1.0), 178 | assets.TaxLot('2013/12/12', 50, 0.9)] 179 | vmmxx.set_lots(lots) 180 | vmmxx.what_if(-10) 181 | vmmxx = assets.from_dict(assets.to_dict(vmmxx)) 182 | self.assertEqual('VMMXX', vmmxx.short_name()) 183 | self.assertEqual(100.0, vmmxx.shares()) 184 | self.assertEqual({'All': 1.0}, vmmxx.class2ratio) 185 | self.assertAlmostEqual(90.0, vmmxx.adjusted_value()) 186 | self.assertEqual(2, len(vmmxx.get_lots())) 187 | 188 | @patch('requests.Session.get') 189 | def test_vanguard_funds_name(self, mock_get): 190 | mock_res = MagicMock() 191 | 192 | with open(self.data_dir / 'profile.json') as data_file: 193 | mock_res.json.return_value = json.load(data_file) 194 | 195 | mock_get.return_value = mock_res 196 | 197 | fund = assets.VanguardFund(7555, 10, {'All': 1.0}) 198 | self.assertEqual( 199 | 'Vanguard Institutional Total Bond Market Index Trust', 200 | fund.name()) 201 | self.assertEqual('7555', fund.short_name()) 202 | mock_get.assert_called_once_with( 203 | 'https://api.vanguard.com/rs/ire/01/pe/fund/7555/profile.json', 204 | headers={'Referer': 'https://vanguard.com/'}, verify=False) 205 | 206 | @patch('requests.Session.get') 207 | def test_vanguard_funds_value(self, mock_get): 208 | mock_res = MagicMock() 209 | 210 | with open(self.data_dir / 'price.json') as data_file: 211 | mock_res.json.return_value = json.load(data_file) 212 | mock_get.return_value = mock_res 213 | 214 | fund = assets.VanguardFund(7555, 10, {'All': 1.0}) 215 | self.assertEqual(1166.6, fund.value()) 216 | mock_get.assert_called_once_with( 217 | 'https://api.vanguard.com/rs/ire/01/pe/fund/7555/price.json', 218 | headers={'Referer': 'https://vanguard.com/'}, verify=False) 219 | fund.set_lots([assets.TaxLot('2012/12/30', 10, 1.0)]) 220 | 221 | @patch('lakshmi.assets.VanguardFund.value') 222 | def test_dict_vanguard_fund(self, mock_value): 223 | mock_value.return_value = 100.0 224 | fund = assets.VanguardFund(1234, 20, {'Bonds': 1.0}) 225 | fund.set_lots([assets.TaxLot('2021/05/15', 20, 5.0)]) 226 | fund.what_if(100) 227 | fund = assets.from_dict(assets.to_dict(fund)) 228 | self.assertEqual('1234', fund.short_name()) 229 | self.assertEqual(20, fund.shares()) 230 | self.assertEqual({'Bonds': 1.0}, fund.class2ratio) 231 | self.assertEqual(1, len(fund.get_lots())) 232 | self.assertEqual(100, fund._delta) 233 | 234 | @patch('lakshmi.assets.VanguardFund.name') 235 | @patch('lakshmi.assets.VanguardFund.price') 236 | def test_vangurd_fund_asset_to_table(self, mock_price, mock_name): 237 | mock_price.return_value = 10.0 238 | mock_name.return_value = 'Vanguardy Fund' 239 | 240 | fund = assets.VanguardFund(123, 100.0, {'All': 1.0}) 241 | expected = [['Fund id:', '123'], 242 | ['Name:', 'Vanguardy Fund'], 243 | ['Asset Class Mapping:', 'All 100%'], 244 | ['Value:', '$1,000.00'], 245 | ['Price:', '$10.00']] 246 | self.assertListEqual(expected, fund.to_table().str_list()) 247 | 248 | @patch('lakshmi.assets._today_date') 249 | @patch('lakshmi.assets.IBonds._InterestRates.get') 250 | def test_i_bonds(self, mock_get, mock_today): 251 | INTEREST_RATE_DATA = """ 252 | 2020-11-01: 253 | - 0.00 254 | - 0.84 255 | 2021-05-01: 256 | - 0.00 257 | - 1.77 258 | """ 259 | mock_get.return_value = ibonds.InterestRates(INTEREST_RATE_DATA) 260 | ibond_asset = assets.IBonds({'All': 1.0}) 261 | ibond_asset.add_bond('11/2020', 10000) 262 | 263 | self.assertEqual('I Bonds', ibond_asset.name()) 264 | self.assertEqual('I Bonds', ibond_asset.short_name()) 265 | 266 | mock_today.return_value = datetime.date(2020, 11, 2) 267 | self.assertListEqual( 268 | [['11/2020', '$10,000.00', '0.00%', '1.68%', '$10,000.00']], 269 | ibond_asset.list_bonds().str_list()) 270 | 271 | # Test the case where the interest rate data is not up to date. 272 | mock_today.return_value = datetime.date(2021, 11, 1) 273 | self.assertListEqual( 274 | [['11/2020', '$10,000.00', '0.00%', '', '$10,172.00']], 275 | ibond_asset.list_bonds().str_list()) 276 | 277 | @patch('lakshmi.assets.IBonds._InterestRates.get') 278 | @patch('lakshmi.assets.IBonds.value') 279 | def test_dict_i_bonds(self, mock_value, mock_get): 280 | mock_value.return_value = 11000 281 | mock_get.return_value = None 282 | ibonds = assets.IBonds({'B': 1.0}) 283 | ibonds.add_bond('02/2020', 10000) 284 | ibonds.what_if(-100.0) 285 | 286 | ibonds = assets.from_dict(assets.to_dict(ibonds)) 287 | self.assertEqual('I Bonds', ibonds.name()) 288 | self.assertEqual({'B': 1.0}, ibonds.class2ratio) 289 | self.assertAlmostEqual(-100.0, ibonds._delta) 290 | self.assertEqual(1, len(ibonds.bonds())) 291 | 292 | @patch('datetime.datetime') 293 | @patch('requests.post') 294 | def test_ee_bonds(self, mock_post, mock_date): 295 | mock_res = MagicMock() 296 | with open(self.data_dir / 'SBCPrice-EE.html') as html_file: 297 | mock_res.text = html_file.read() 298 | mock_post.return_value = mock_res 299 | mock_date.now.strftime.return_value = '04/2021' 300 | # Bypass issue date validation. 301 | mock_strptime = MagicMock() 302 | mock_strptime.strftime.return_value = '03/2020' 303 | mock_date.strptime.return_value = mock_strptime 304 | 305 | eebonds = assets.EEBonds({'All': 1.0}) 306 | eebonds.add_bond('3/2020', 10000) 307 | 308 | mock_post.asset_called_once_with( 309 | 'http://www.treasurydirect.gov/BC/SBCPrice', 310 | data={ 311 | 'RedemptionDate': '04/2021', 312 | 'Series': 'EE', 313 | 'Denomination': '500', 314 | 'IssueDate': '03/2020', 315 | 'btnAdd.x': 'CALCULATE'}) 316 | 317 | self.assertEqual('EE Bonds', eebonds.name()) 318 | self.assertEqual('EE Bonds', eebonds.short_name()) 319 | self.assertAlmostEqual(10008.0, eebonds.value()) 320 | self.assertListEqual( 321 | [['03/2020', '$10,000.00', '0.10%', '$10,008.00']], 322 | eebonds.list_bonds().str_list()) 323 | 324 | @patch('lakshmi.assets.EEBonds.value') 325 | def test_dict_ee_bonds(self, mock_value): 326 | mock_value.return_value = 10010 327 | eebonds = assets.EEBonds({'B': 1.0}) 328 | eebonds.add_bond('02/2020', 10000) 329 | eebonds.what_if(-100.0) 330 | eebonds = assets.from_dict(assets.to_dict(eebonds)) 331 | self.assertEqual('EE Bonds', eebonds.name()) 332 | self.assertEqual({'B': 1.0}, eebonds.class2ratio) 333 | self.assertAlmostEqual(-100.0, eebonds._delta) 334 | self.assertEqual(1, len(eebonds.bonds())) 335 | 336 | 337 | if __name__ == '__main__': 338 | unittest.main() 339 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | """Tests for lakshmi.cache module.""" 2 | import pickle 3 | import unittest 4 | from pathlib import Path 5 | from unittest.mock import Mock, patch 6 | 7 | import lakshmi.cache as cache 8 | 9 | 10 | class Cached(cache.Cacheable): 11 | def __init__(self, key, value): 12 | self.key = key 13 | self.value = value 14 | 15 | def cache_key(self): 16 | return self.key 17 | 18 | @cache.cache(2) 19 | def get_value(self): 20 | return self.value 21 | 22 | @cache.cache(365) 23 | def get_long_cached_value(self): 24 | return self.value 25 | 26 | 27 | class CacheTest(unittest.TestCase): 28 | def setUp(self): 29 | # Reset cache dir setting. 30 | cache._prefetch_obj = None 31 | cache._ctx.pop(cache._CACHE_STR, None) 32 | cache.set_force_refresh(False) 33 | cache.set_cache_miss_func(None) 34 | 35 | @patch('pathlib.Path.exists') 36 | @patch('lakshmi.cache.get_file_age') 37 | def test_disabled_cache(self, get_file_age, exists): 38 | cache.set_cache_dir(None) # Disble caching. 39 | c = Cached('key1', 1) 40 | self.assertEqual(1, c.get_value()) 41 | c.value = 2 42 | self.assertEqual(2, c.get_value()) 43 | get_file_age.assert_not_called() 44 | exists.assert_not_called() 45 | 46 | def test_disabled_cache_with_func(self): 47 | cache.set_cache_dir(None) # Disble caching. 48 | mocked_obj = Mock() 49 | cache.set_cache_miss_func(mocked_obj.func) 50 | 51 | c = Cached('key1', 1) 52 | self.assertEqual(1, c.get_value()) 53 | mocked_obj.func.assert_called_once() 54 | 55 | @patch('pathlib.Path.read_bytes') 56 | @patch('pathlib.Path.write_bytes') 57 | @patch('pathlib.Path.exists') 58 | @patch('lakshmi.cache.get_file_age') 59 | @patch('lakshmi.cache.set_cache_dir') 60 | def test_default_cache_miss( 61 | self, set_cache_dir, get_file_age, exists, write_bytes, 62 | read_bytes): 63 | def side_effect(x): 64 | cache._ctx[cache._CACHE_STR] = x 65 | set_cache_dir.side_effect = side_effect 66 | exists.return_value = False 67 | 68 | c = Cached('key1', 1) 69 | self.assertEqual(1, c.get_value()) 70 | 71 | set_cache_dir.assert_called_once() 72 | get_file_age.assert_not_called() 73 | exists.assert_called_once() 74 | write_bytes.assert_called_once_with(pickle.dumps(1)) 75 | read_bytes.assert_not_called() 76 | 77 | @patch('pathlib.Path.write_bytes') 78 | @patch('pathlib.Path.exists') 79 | @patch('lakshmi.cache.set_cache_dir') 80 | def test_default_cache_miss_with_func( 81 | self, set_cache_dir, exists, write_bytes): 82 | def side_effect(x): 83 | cache._ctx[cache._CACHE_STR] = x 84 | set_cache_dir.side_effect = side_effect 85 | exists.return_value = False 86 | mocked_obj = Mock() 87 | cache.set_cache_miss_func(mocked_obj.func) 88 | 89 | c = Cached('key1', 1) 90 | self.assertEqual(1, c.get_value()) 91 | 92 | set_cache_dir.assert_called_once() 93 | exists.assert_called_once() 94 | write_bytes.assert_called_once_with(pickle.dumps(1)) 95 | mocked_obj.func.assert_called_once() 96 | 97 | @patch('pathlib.Path.read_bytes') 98 | @patch('pathlib.Path.write_bytes') 99 | @patch('pathlib.Path.exists') 100 | @patch('lakshmi.cache.get_file_age') 101 | @patch('lakshmi.cache.set_cache_dir') 102 | def test_default_cache_hit( 103 | self, set_cache_dir, get_file_age, exists, write_bytes, 104 | read_bytes): 105 | def side_effect(x): 106 | cache._ctx[cache._CACHE_STR] = x 107 | set_cache_dir.side_effect = side_effect 108 | exists.return_value = True 109 | get_file_age.return_value = 1 110 | read_bytes.return_value = pickle.dumps(1) # Cache 1. 111 | 112 | c = Cached('key2', 2) 113 | self.assertEqual(1, c.get_value()) # Cached value. 114 | 115 | set_cache_dir.assert_called_once() 116 | get_file_age.assert_called_once() 117 | exists.assert_called_once() 118 | write_bytes.assert_not_called() 119 | read_bytes.assert_called_once() 120 | 121 | @patch('pathlib.Path.read_bytes') 122 | @patch('pathlib.Path.exists') 123 | @patch('lakshmi.cache.get_file_age') 124 | @patch('lakshmi.cache.set_cache_dir') 125 | def test_default_cache_hit_with_func( 126 | self, set_cache_dir, get_file_age, exists, read_bytes): 127 | def side_effect(x): 128 | cache._ctx[cache._CACHE_STR] = x 129 | set_cache_dir.side_effect = side_effect 130 | exists.return_value = True 131 | get_file_age.return_value = 1 132 | read_bytes.return_value = pickle.dumps(1) # Cache 1. 133 | mocked_obj = Mock() 134 | cache.set_cache_miss_func(mocked_obj.func) 135 | 136 | c = Cached('key2', 2) 137 | self.assertEqual(1, c.get_value()) # Cached value. 138 | 139 | set_cache_dir.assert_called_once() 140 | get_file_age.assert_called_once() 141 | exists.assert_called_once() 142 | read_bytes.assert_called_once() 143 | mocked_obj.func.assert_not_called() 144 | 145 | @patch('pathlib.Path.read_bytes') 146 | @patch('pathlib.Path.write_bytes') 147 | @patch('pathlib.Path.exists') 148 | @patch('lakshmi.cache.get_file_age') 149 | @patch('lakshmi.cache.set_cache_dir') 150 | def test_set_cache( 151 | self, set_cache_dir, get_file_age, exists, write_bytes, 152 | read_bytes): 153 | cache._ctx[cache._CACHE_STR] = Path('/fake/dir') 154 | exists.return_value = False 155 | 156 | c = Cached('key1', 1) 157 | self.assertEqual(1, c.get_value()) 158 | 159 | set_cache_dir.assert_not_called() 160 | get_file_age.assert_not_called() 161 | exists.assert_called_once() 162 | write_bytes.assert_called_once_with(pickle.dumps(1)) 163 | read_bytes.assert_not_called() 164 | 165 | @patch('pathlib.Path.read_bytes') 166 | @patch('pathlib.Path.write_bytes') 167 | @patch('pathlib.Path.exists') 168 | @patch('lakshmi.cache.get_file_age') 169 | @patch('lakshmi.cache.set_cache_dir') 170 | def test_force_refresh( 171 | self, set_cache_dir, get_file_age, exists, write_bytes, 172 | read_bytes): 173 | cache._ctx[cache._CACHE_STR] = Path('/fake/dir') 174 | cache.set_force_refresh(True) 175 | 176 | c = Cached('key2', 2) 177 | self.assertEqual(2, c.get_value()) # Cached value not used. 178 | 179 | set_cache_dir.assert_not_called() 180 | get_file_age.assert_not_called() 181 | exists.assert_not_called() 182 | write_bytes.assert_called_once_with(pickle.dumps(2)) 183 | read_bytes.assert_not_called() 184 | 185 | @patch('pathlib.Path.read_bytes') 186 | @patch('pathlib.Path.write_bytes') 187 | @patch('pathlib.Path.exists') 188 | @patch('lakshmi.cache.get_file_age') 189 | @patch('lakshmi.cache.set_cache_dir') 190 | def test_force_refresh_with_long_cached_value( 191 | self, set_cache_dir, get_file_age, exists, write_bytes, 192 | read_bytes): 193 | cache._ctx[cache._CACHE_STR] = Path('/fake/dir') 194 | cache.set_force_refresh(True) 195 | exists.return_value = True 196 | get_file_age.return_value = 5 197 | read_bytes.return_value = pickle.dumps(1) # Cache 1. 198 | 199 | c = Cached('key2', 2) 200 | self.assertEqual(1, c.get_long_cached_value()) # Cached value. 201 | 202 | set_cache_dir.assert_not_called() 203 | get_file_age.assert_called_once() 204 | exists.assert_called_once() 205 | write_bytes.assert_not_called() 206 | read_bytes.assert_called_once() 207 | 208 | @patch('pathlib.Path.read_bytes') 209 | @patch('pathlib.Path.write_bytes') 210 | @patch('pathlib.Path.exists') 211 | @patch('lakshmi.cache.get_file_age') 212 | @patch('lakshmi.cache.set_cache_dir') 213 | def test_force_refresh_called_twice( 214 | self, set_cache_dir, get_file_age, exists, write_bytes, 215 | read_bytes): 216 | # While this test is not logically "correct", it makes the 217 | # testing easier and more robust. In reality the cached 218 | # value would be 2 and not 3. 219 | cache._ctx[cache._CACHE_STR] = Path('/fake/dir') 220 | cache.set_force_refresh(True) 221 | get_file_age.return_value = 1 222 | read_bytes.return_value = pickle.dumps(3) # Cache 3. 223 | 224 | c = Cached('key2', 2) 225 | self.assertEqual(2, c.get_value()) # Cached value not used. 226 | self.assertEqual(3, c.get_value()) # Cached value used. 227 | 228 | set_cache_dir.assert_not_called() 229 | get_file_age.assert_called_once() 230 | exists.assert_called_once() 231 | write_bytes.assert_called_once_with(pickle.dumps(2)) 232 | read_bytes.assert_called_once() 233 | 234 | @patch('pathlib.Path.read_bytes') 235 | @patch('pathlib.Path.write_bytes') 236 | @patch('pathlib.Path.exists') 237 | @patch('lakshmi.cache.get_file_age') 238 | @patch('lakshmi.cache.set_cache_dir') 239 | def test_old_cache( 240 | self, set_cache_dir, get_file_age, exists, write_bytes, 241 | read_bytes): 242 | cache._ctx[cache._CACHE_STR] = Path('/fake/dir') 243 | exists.return_value = True 244 | get_file_age.return_value = 2 245 | 246 | c = Cached('key1', 1) 247 | self.assertEqual(1, c.get_value()) 248 | 249 | set_cache_dir.assert_not_called() 250 | get_file_age.assert_called_once() 251 | exists.assert_called_once() 252 | write_bytes.assert_called_once_with(pickle.dumps(1)) 253 | read_bytes.assert_not_called() 254 | 255 | @patch('lakshmi.cache.set_cache_dir') 256 | def test_return_cached_funcs(self, set_cache_dir): 257 | def side_effect(x): 258 | cache._ctx[cache._CACHE_STR] = x 259 | set_cache_dir.side_effect = side_effect 260 | prefetch = cache._Prefetch() 261 | c = Cached('key1', 1) 262 | self.assertEqual(2, len(prefetch._return_cached_funcs(c))) 263 | 264 | @patch('pathlib.Path.read_bytes') 265 | @patch('pathlib.Path.write_bytes') 266 | @patch('pathlib.Path.exists') 267 | @patch('lakshmi.cache.get_file_age') 268 | @patch('lakshmi.cache.set_cache_dir') 269 | def test_cache_miss_prefetch( 270 | self, set_cache_dir, get_file_age, exists, write_bytes, 271 | read_bytes): 272 | def side_effect(x): 273 | cache._ctx[cache._CACHE_STR] = x 274 | set_cache_dir.side_effect = side_effect 275 | exists.return_value = False 276 | 277 | c = Cached('key1', 1) 278 | cache.prefetch_add(c) 279 | cache.prefetch() 280 | 281 | set_cache_dir.assert_called_once() 282 | get_file_age.assert_not_called() 283 | exists.assert_called() 284 | self.assertEqual(2, write_bytes.call_count) 285 | read_bytes.assert_not_called() 286 | 287 | @patch('pathlib.Path.read_bytes') 288 | @patch('pathlib.Path.write_bytes') 289 | @patch('pathlib.Path.exists') 290 | @patch('lakshmi.cache.get_file_age') 291 | @patch('lakshmi.cache.set_cache_dir') 292 | def test_force_refresh_with_prefetch( 293 | self, set_cache_dir, get_file_age, exists, write_bytes, 294 | read_bytes): 295 | cache._ctx[cache._CACHE_STR] = Path('/fake/dir') 296 | cache.set_force_refresh(True) 297 | get_file_age.return_value = 2 298 | 299 | c = Cached('key2', 9) 300 | cache.prefetch_add(c) 301 | cache.prefetch() 302 | self.assertEqual(9, c.get_value()) 303 | 304 | set_cache_dir.assert_not_called() 305 | self.assertEqual(2, get_file_age.call_count) 306 | self.assertEqual(2, exists.call_count) 307 | self.assertEqual(2, write_bytes.call_count) 308 | read_bytes.assert_not_called() 309 | 310 | def test_prefetch_add_not_called(self): 311 | # Do not fail if there are no functions to be prefetched. 312 | cache.prefetch() 313 | 314 | 315 | if __name__ == '__main__': 316 | unittest.main() 317 | -------------------------------------------------------------------------------- /tests/test_data.py: -------------------------------------------------------------------------------- 1 | """Tests for lakshmi/data directory. Simply checks if the files parses.""" 2 | import unittest 3 | from pathlib import Path 4 | from unittest.mock import patch 5 | 6 | import ibonds 7 | import yaml 8 | 9 | import lakshmi 10 | from lakshmi import utils 11 | 12 | 13 | class DataTest(unittest.TestCase): 14 | def parse_dict(self, filename, function): 15 | file_path = (Path(__file__).parents[1].absolute() / filename) 16 | d = yaml.load(file_path.read_text(), Loader=utils.get_loader()) 17 | return function(d) 18 | 19 | def test_account(self): 20 | self.assertIsNotNone(self.parse_dict('lakshmi/data/Account.yaml', 21 | lakshmi.Account.from_dict)) 22 | 23 | def test_asset_class(self): 24 | self.assertIsNotNone(self.parse_dict('lakshmi/data/AssetClass.yaml', 25 | lakshmi.AssetClass.from_dict)) 26 | 27 | def test_ee_bonds(self): 28 | self.assertIsNotNone(self.parse_dict('lakshmi/data/EEBonds.yaml', 29 | lakshmi.assets.EEBonds.from_dict)) 30 | 31 | @patch('lakshmi.assets.IBonds._InterestRates.get') 32 | def test_i_bonds(self, mock_get): 33 | INTEREST_RATE_DATA = """ 34 | 2020-11-01: 35 | - 0.00 36 | - 0.84 37 | 2021-05-01: 38 | - 0.00 39 | - 1.77 40 | """ 41 | mock_get.return_value = ibonds.InterestRates(INTEREST_RATE_DATA) 42 | self.assertIsNotNone(self.parse_dict('lakshmi/data/IBonds.yaml', 43 | lakshmi.assets.IBonds.from_dict)) 44 | 45 | def test_manual_asset(self): 46 | self.assertIsNotNone( 47 | self.parse_dict( 48 | 'lakshmi/data/ManualAsset.yaml', 49 | lakshmi.assets.ManualAsset.from_dict)) 50 | 51 | def test_ticker_asset(self): 52 | self.assertIsNotNone( 53 | self.parse_dict( 54 | 'lakshmi/data/TickerAsset.yaml', 55 | lakshmi.assets.TickerAsset.from_dict)) 56 | 57 | def test_vanguard_fund(self): 58 | self.assertIsNotNone( 59 | self.parse_dict( 60 | 'lakshmi/data/VanguardFund.yaml', 61 | lakshmi.assets.VanguardFund.from_dict)) 62 | 63 | def test_checkpoint(self): 64 | self.assertIsNotNone( 65 | self.parse_dict( 66 | 'lakshmi/data/Checkpoint.yaml', 67 | lambda x: lakshmi.performance.Checkpoint.from_dict( 68 | x, date='2021/01/01'))) 69 | 70 | def test_portfolio(self): 71 | self.assertIsNotNone(self.parse_dict('docs/portfolio.yaml', 72 | lakshmi.Portfolio.from_dict)) 73 | 74 | 75 | if __name__ == '__main__': 76 | unittest.main() 77 | -------------------------------------------------------------------------------- /tests/test_lak.py: -------------------------------------------------------------------------------- 1 | """Tests for lakshmi.lak application.""" 2 | import unittest 3 | from pathlib import Path 4 | from unittest.mock import patch 5 | 6 | import click 7 | from click.testing import CliRunner 8 | 9 | from lakshmi import Account, AssetClass, Portfolio, lak 10 | from lakshmi.assets import ManualAsset 11 | from lakshmi.performance import Checkpoint, Performance, Timeline 12 | 13 | 14 | class TestLakContext(lak.LakContext): 15 | """A testing version of LakContext that doesn't load or save 16 | portfolio.""" 17 | 18 | def __init__(self): 19 | self.portfolio_filename = 'test_portfolio.yaml' 20 | self.performance_filename = 'test_performance.yaml' 21 | self.continued = False 22 | self.warned = False 23 | self.whatifs = None 24 | self.tablefmt = None 25 | self.saved_portfolio = False 26 | self.saved_performance = False 27 | 28 | self.portfolio = Portfolio( 29 | AssetClass('All') 30 | .add_subclass(0.5, AssetClass('Stocks')) 31 | .add_subclass(0.5, AssetClass('Bonds'))).add_account( 32 | Account('Schwab', 'Taxable').add_asset( 33 | ManualAsset('Test Asset', 100.0, {'Stocks': 1.0}))) 34 | 35 | self.performance = Performance(Timeline([ 36 | Checkpoint('2021/1/1', 100), 37 | Checkpoint('2021/1/2', 105.01, inflow=10, outflow=5)])) 38 | 39 | def get_portfolio(self): 40 | return self.portfolio 41 | 42 | def save_portfolio(self): 43 | self.saved_portfolio = True 44 | 45 | def get_performance(self): 46 | if self.performance: 47 | return self.performance 48 | raise click.ClickException('Performance file not found.') 49 | 50 | def save_performance(self): 51 | self.saved_performance = True 52 | 53 | def reset(self): 54 | """Reset the state for a new command (except the portfolio).""" 55 | self.continued = False 56 | self.warned = False 57 | self.whatifs = None 58 | self.tablefmt = None 59 | self.saved_portfolio = False 60 | 61 | 62 | def run_lak(args): 63 | return CliRunner().invoke(lak.lak, args.split(' ')) 64 | 65 | 66 | class LakTest(unittest.TestCase): 67 | def setUp(self): 68 | lak.lakctx = TestLakContext() 69 | 70 | @patch('lakshmi.lak.LakContext._return_config') 71 | @patch('lakshmi.cache') 72 | @patch('pathlib.Path.exists') 73 | def test_lak_context_init_with_no_config( 74 | self, mock_exists, mock_cache, mock_return_config): 75 | mock_return_config.return_value = {} 76 | mock_exists.return_value = True 77 | 78 | lakctx = lak.LakContext('unused') 79 | self.assertFalse(lakctx.continued) 80 | self.assertIsNone(lakctx.whatifs) 81 | self.assertIsNone(lakctx.portfolio) 82 | self.assertEqual( 83 | str(Path(lak.LakContext.DEFAULT_PORTFOLIO).expanduser()), 84 | lakctx.portfolio_filename) 85 | mock_cache.set_cache_dir.assert_not_called() 86 | 87 | @patch('lakshmi.lak.LakContext._return_config') 88 | @patch('lakshmi.cache') 89 | @patch('pathlib.Path.exists') 90 | def test_lak_context_portfolio_file_not_found( 91 | self, mock_exists, mock_cache, mock_return_config): 92 | mock_return_config.return_value = { 93 | 'portfolio': 'portfolio.yaml'} 94 | mock_exists.return_value = False 95 | 96 | # This shouldn't raise an exception until the portfolio 97 | # is actually loaded. 98 | lakctx = lak.LakContext('unused') 99 | 100 | with self.assertRaisesRegex( 101 | click.ClickException, 102 | 'Portfolio file portfolio.yaml does not'): 103 | lakctx.get_portfolio() 104 | 105 | mock_cache.set_cache_dir.assert_not_called() 106 | mock_exists.assert_called_with() 107 | 108 | @patch('lakshmi.lak.LakContext._return_config') 109 | @patch('builtins.open') 110 | def test_lak_context_performance_file_not_found( 111 | self, mock_open, mock_return_config): 112 | mock_return_config.return_value = { 113 | 'performance': 'performance.yaml'} 114 | mock_open.side_effect = FileNotFoundError('Not found (unused)') 115 | 116 | lakctx = lak.LakContext('unused') 117 | with self.assertRaisesRegex( 118 | click.ClickException, 119 | 'Performance file performance.yaml not found.'): 120 | lakctx.get_performance() 121 | 122 | mock_open.assert_called_once() 123 | 124 | def test_list_total(self): 125 | result = run_lak('list -f plain total') 126 | self.assertEqual(0, result.exit_code) 127 | self.assertIn('Total Assets $100.00', result.output) 128 | self.assertNotIn('\n\n', result.output) 129 | self.assertFalse(lak.lakctx.saved_portfolio) 130 | 131 | def test_list_with_chaining(self): 132 | result = run_lak('list al total') 133 | self.assertEqual(0, result.exit_code) 134 | # Test that the separater was printed. 135 | self.assertIn('\n\n', result.output) 136 | self.assertFalse(lak.lakctx.saved_portfolio) 137 | 138 | def test_list_aa_no_args(self): 139 | result = run_lak('list aa') 140 | self.assertEqual(0, result.exit_code) 141 | # Check if compact version was printed. 142 | self.assertRegex(result.output, r'Class +A% +D%') 143 | self.assertFalse(lak.lakctx.saved_portfolio) 144 | 145 | def test_list_aa_no_compact(self): 146 | result = run_lak('list aa --no-compact') 147 | self.assertEqual(0, result.exit_code) 148 | # Check if tree version was printed. 149 | self.assertRegex(result.output, r'Class +Actual% .+Value\n') 150 | self.assertFalse(lak.lakctx.saved_portfolio) 151 | 152 | def test_list_aa_class_with_bad_args(self): 153 | result = run_lak('list aa --no-compact --asset-class a,b,c') 154 | self.assertEqual(2, result.exit_code) 155 | self.assertTrue('is only supported' in result.output) 156 | self.assertFalse(lak.lakctx.saved_portfolio) 157 | 158 | def test_list_aa_class(self): 159 | result = run_lak('list aa --asset-class Stocks,Bonds') 160 | self.assertEqual(0, result.exit_code) 161 | # Check if correct version was printed. 162 | self.assertRegex(result.output, r'Class +Actual% .+Difference\n') 163 | self.assertFalse(lak.lakctx.saved_portfolio) 164 | 165 | def test_list_assets(self): 166 | result = run_lak('list assets') 167 | self.assertEqual(0, result.exit_code) 168 | self.assertRegex(result.output, r'Account +Asset +Value\n') 169 | self.assertFalse(lak.lakctx.saved_portfolio) 170 | 171 | def test_list_assets_no_names(self): 172 | result = run_lak('list assets --no-long-name') 173 | self.assertEqual(1, result.exit_code) 174 | 175 | def test_list_accounts(self): 176 | result = run_lak('list accounts') 177 | self.assertEqual(0, result.exit_code) 178 | self.assertRegex(result.output, r'Account +Account Type +Value') 179 | self.assertFalse(lak.lakctx.saved_portfolio) 180 | 181 | def test_list_lots(self): 182 | result = run_lak('list lots') 183 | self.assertEqual(0, result.exit_code) 184 | self.assertEqual('', result.output) 185 | self.assertFalse(lak.lakctx.saved_portfolio) 186 | 187 | def test_list_checkpoints_no_dates(self): 188 | result = run_lak('list checkpoints') 189 | self.assertEqual(0, result.exit_code) 190 | self.assertRegex(result.output, r'2021/01/01 +\$100.00',) 191 | self.assertFalse(lak.lakctx.saved_performance) 192 | 193 | def test_list_checkpoints_with_date(self): 194 | result = run_lak('list checkpoints -b 2021/01/02') 195 | self.assertEqual(0, result.exit_code) 196 | self.assertNotRegex(result.output, r'2021/01/01 +\$100.00',) 197 | self.assertRegex(result.output, r'2021/01/02 +\$105.01',) 198 | self.assertFalse(lak.lakctx.saved_performance) 199 | 200 | @patch('lakshmi.lak._get_todays_checkpoint') 201 | @patch('lakshmi.lak._today') 202 | def test_list_performance(self, mock_today, mock_today_checkpoint): 203 | mock_today.return_value = '2021/01/10' 204 | mock_today_checkpoint.return_value = Checkpoint('2021/01/10', 200) 205 | result = run_lak('list performance') 206 | 207 | mock_today.assert_called_once() 208 | mock_today_checkpoint.assert_called_once() 209 | self.assertEqual(0, result.exit_code) 210 | # Check if $10 inflow is there and +$100.00 portfolio change is also 211 | # in the output (because of today's checkpoint). 212 | self.assertRegex(result.output, r'Overall +\$10.00.+\+\$100.00',) 213 | self.assertFalse(lak.lakctx.saved_performance) 214 | self.assertFalse(lak.lakctx.performance.get_timeline().has_checkpoint( 215 | '2021/01/10')) 216 | 217 | def test_list_what_ifs_empty(self): 218 | result = run_lak('list whatifs') 219 | self.assertEqual(0, result.exit_code) 220 | self.assertEqual('', result.output) 221 | self.assertFalse(lak.lakctx.saved_portfolio) 222 | 223 | def test_what_if(self): 224 | result = run_lak('whatif asset -a Test -100') 225 | self.assertEqual(0, result.exit_code) 226 | self.assertEqual('', result.output) 227 | self.assertTrue(lak.lakctx.saved_portfolio) 228 | lak.lakctx.reset() 229 | 230 | result = run_lak('list whatifs') 231 | self.assertEqual(0, result.exit_code) 232 | self.assertRegex(result.output, r'Account +Cash\n') 233 | self.assertRegex(result.output, r'Account +Asset +Delta\n') 234 | self.assertFalse(lak.lakctx.saved_portfolio) 235 | lak.lakctx.reset() 236 | 237 | result = run_lak('list assets') 238 | self.assertEqual(0, result.exit_code) 239 | self.assertIn('Hypothetical what ifs', result.output) 240 | self.assertFalse(lak.lakctx.saved_portfolio) 241 | lak.lakctx.reset() 242 | 243 | result = run_lak('whatif --reset') 244 | self.assertEqual(0, result.exit_code) 245 | self.assertEqual('', result.output) 246 | self.assertTrue(lak.lakctx.saved_portfolio) 247 | lak.lakctx.reset() 248 | 249 | result = run_lak('list whatifs') 250 | self.assertEqual(0, result.exit_code) 251 | self.assertEqual('', result.output) 252 | self.assertFalse(lak.lakctx.saved_portfolio) 253 | 254 | def test_what_if_forced(self): 255 | result = run_lak('whatif asset -a Test -100') 256 | self.assertEqual(0, result.exit_code) 257 | self.assertEqual('', result.output) 258 | lak.lakctx.reset() 259 | 260 | result = run_lak('list whatifs -s') 261 | self.assertEqual(0, result.exit_code) 262 | self.assertRegex(result.output, r'Account +Cash\n') 263 | self.assertRegex(result.output, r'Account +Name +Asset +Delta\n') 264 | lak.lakctx.reset() 265 | 266 | result = run_lak('list assets whatifs -s') 267 | self.assertEqual(0, result.exit_code) 268 | self.assertRegex(result.output, r'Account +Cash\n') 269 | self.assertRegex(result.output, r'Account +Name +Asset +Delta\n') 270 | self.assertFalse(lak.lakctx.saved_portfolio) 271 | lak.lakctx.reset() 272 | 273 | def test_what_if_account(self): 274 | result = run_lak('whatif account -t Schwab -100') 275 | self.assertEqual(0, result.exit_code) 276 | self.assertEqual('', result.output) 277 | self.assertTrue(lak.lakctx.saved_portfolio) 278 | lak.lakctx.reset() 279 | 280 | result = run_lak('list whatifs') 281 | self.assertEqual(0, result.exit_code) 282 | self.assertRegex(result.output, r'Account +Cash\n') 283 | self.assertNotRegex(result.output, r'Account +Asset +Delta\n') 284 | self.assertFalse(lak.lakctx.saved_portfolio) 285 | lak.lakctx.reset() 286 | 287 | def test_info_account(self): 288 | result = run_lak('info account -t Schwab') 289 | self.assertEqual(0, result.exit_code) 290 | self.assertRegex(result.output, r'Name: +Schwab\n') 291 | self.assertFalse(lak.lakctx.saved_portfolio) 292 | 293 | def test_info_asset(self): 294 | result = run_lak('info asset -a Test') 295 | self.assertEqual(0, result.exit_code) 296 | self.assertRegex(result.output, r'Name: +Test Asset\n') 297 | self.assertFalse(lak.lakctx.saved_portfolio) 298 | 299 | @patch('lakshmi.lak._get_todays_checkpoint') 300 | @patch('lakshmi.lak._today') 301 | def test_info_performance(self, mock_today, mock_today_checkpoint): 302 | mock_today.return_value = '2021/01/10' 303 | mock_today_checkpoint.return_value = Checkpoint('2021/01/10', 200) 304 | result = run_lak('info performance --begin 2021/1/1') 305 | 306 | mock_today.assert_called_once() 307 | mock_today_checkpoint.assert_called_once() 308 | self.assertEqual(0, result.exit_code) 309 | self.assertRegex( 310 | result.output, 311 | r'Start date +2021/01/01\nEnd date +2021/01/10\n') 312 | self.assertFalse(lak.lakctx.saved_portfolio) 313 | self.assertFalse(lak.lakctx.performance.get_timeline().has_checkpoint( 314 | '2021/01/10')) 315 | 316 | @patch('click.edit') 317 | @patch('pathlib.Path.read_text') 318 | def test_edit_and_parse_with_no_dict(self, mock_read_text, mock_edit): 319 | mock_read_text.return_value = 'a: b' 320 | mock_edit.return_value = 'c: d' 321 | 322 | actual = lak.edit_and_parse(None, lambda x: x, 'test_file') 323 | 324 | self.assertEqual({'c': 'd'}, actual) 325 | mock_read_text.assert_called_once() 326 | mock_edit.assert_called_with('a: b') 327 | 328 | @patch('click.edit') 329 | @patch('pathlib.Path.read_text') 330 | def test_edit_and_parse_with_dict(self, mock_read_text, mock_edit): 331 | mock_read_text.return_value = 'a: b' 332 | mock_edit.return_value = 'c: d\n\n' + lak._HELP_MSG_PREFIX 333 | 334 | actual = lak.edit_and_parse({'e': 'f'}, lambda x: x, 'test_file') 335 | 336 | self.assertEqual({'c': 'd'}, actual) 337 | mock_read_text.assert_called_once() 338 | mock_edit.assert_called_with( 339 | 'e: f\n' + lak._HELP_MSG_PREFIX + '# a: b') 340 | 341 | @patch('click.edit') 342 | @patch('pathlib.Path.read_text') 343 | def test_edit_and_parse_with_comma_floats(self, mock_read_text, mock_edit): 344 | mock_read_text.return_value = 'a: b' 345 | mock_edit.return_value = 'c: 123,456.78\n\n' + lak._HELP_MSG_PREFIX 346 | 347 | actual = lak.edit_and_parse({'e': 'f'}, lambda x: x, 'test_file') 348 | 349 | self.assertEqual({'c': 123456.78}, actual) 350 | mock_read_text.assert_called_once() 351 | mock_edit.assert_called_with( 352 | 'e: f\n' + lak._HELP_MSG_PREFIX + '# a: b') 353 | 354 | @patch('click.edit') 355 | @patch('pathlib.Path.read_text') 356 | def test_edit_and_parse_aborted(self, mock_read_text, mock_edit): 357 | mock_read_text.return_value = 'a: b' 358 | mock_edit.return_value = None 359 | 360 | with self.assertRaises(click.Abort): 361 | lak.edit_and_parse(None, lambda x: x, 'test_file') 362 | 363 | mock_read_text.assert_called_once() 364 | mock_edit.assert_called_with('a: b') 365 | 366 | @patch('click.echo') 367 | @patch('click.confirm') 368 | @patch('click.edit') 369 | @patch('pathlib.Path.read_text') 370 | def test_edit_and_parse_user_aborted(self, mock_read_text, mock_edit, 371 | mock_confirm, mock_echo): 372 | mock_read_text.return_value = 'a: b' 373 | mock_edit.return_value = 'c: d' 374 | mock_confirm.return_value = False 375 | 376 | def parse_fn(x): 377 | raise Exception('Better luck next time') 378 | 379 | with self.assertRaises(click.Abort): 380 | lak.edit_and_parse(None, parse_fn, 'test_file') 381 | 382 | mock_read_text.assert_called_once() 383 | mock_edit.assert_called_with('a: b') 384 | mock_confirm.assert_called_once() 385 | mock_echo.assert_called_with('Error parsing file: ' 386 | "Exception('Better luck next time')") 387 | 388 | @patch('click.echo') 389 | @patch('click.confirm') 390 | @patch('click.edit') 391 | @patch('pathlib.Path.read_text') 392 | def test_edit_and_parse_user_fixed(self, mock_read_text, mock_edit, 393 | mock_confirm, mock_echo): 394 | mock_read_text.return_value = 'a: b' 395 | mock_edit.side_effect = ['c~~d', 'c: d'] 396 | mock_confirm.return_value = True 397 | 398 | def parse_fn(x): 399 | if x == 'c~~d': 400 | raise Exception('Better luck next time') 401 | else: 402 | return x 403 | 404 | actual = lak.edit_and_parse(None, parse_fn, 'test_file') 405 | self.assertEqual({'c': 'd'}, actual) 406 | 407 | mock_read_text.assert_called_once() 408 | mock_edit.assert_has_calls([unittest.mock.call('a: b'), 409 | unittest.mock.call('c~~d')]) 410 | mock_confirm.assert_called_once() 411 | mock_echo.assert_called_with('Error parsing file: ' 412 | "Exception('Better luck next time')") 413 | 414 | @patch('pathlib.Path.exists') 415 | def test_init_portfolio_exists(self, mock_exists): 416 | mock_exists.return_value = True 417 | 418 | result = run_lak('init') 419 | self.assertEqual(1, result.exit_code) 420 | self.assertIn('Portfolio file already', result.output) 421 | self.assertFalse(lak.lakctx.saved_portfolio) 422 | 423 | @patch('lakshmi.lak.edit_and_parse') 424 | @patch('pathlib.Path.exists') 425 | def test_init_portfolio(self, mock_exists, mock_parse): 426 | mock_exists.return_value = False 427 | mock_parse.return_value = AssetClass('Money') 428 | 429 | result = run_lak('init') 430 | self.assertEqual(0, result.exit_code) 431 | self.assertTrue(lak.lakctx.saved_portfolio) 432 | self.assertEqual('Money', lak.lakctx.portfolio.asset_classes.name) 433 | 434 | @patch('lakshmi.lak.edit_and_parse') 435 | def test_edit_asset_class(self, mock_parse): 436 | mock_parse.return_value = AssetClass('Money') 437 | 438 | previous_ac_dict = lak.lakctx.portfolio.asset_classes.to_dict() 439 | result = run_lak('edit assetclass') 440 | self.assertEqual(0, result.exit_code) 441 | self.assertTrue(lak.lakctx.saved_portfolio) 442 | self.assertEqual('Money', lak.lakctx.portfolio.asset_classes.name) 443 | 444 | mock_parse.assert_called_with(previous_ac_dict, 445 | unittest.mock.ANY, 446 | 'AssetClass.yaml') 447 | 448 | def test_edit_account_bad_name(self): 449 | result = run_lak('edit account -t Yolo') 450 | self.assertEqual(1, result.exit_code) 451 | self.assertFalse(lak.lakctx.saved_portfolio) 452 | 453 | @patch('lakshmi.lak.edit_and_parse') 454 | def test_edit_account_change_type(self, mock_parse): 455 | mock_parse.return_value = Account('Schwab', 'Tax-exempt') 456 | 457 | result = run_lak('edit account -t Schwab') 458 | self.assertEqual(0, result.exit_code) 459 | self.assertTrue(lak.lakctx.saved_portfolio) 460 | 461 | accounts = list(lak.lakctx.portfolio.accounts()) 462 | self.assertEqual(1, len(accounts)) 463 | self.assertEqual('Tax-exempt', accounts[0].account_type) 464 | self.assertEqual(1, len(accounts[0].assets())) 465 | 466 | mock_parse.assert_called_with(Account('Schwab', 'Taxable').to_dict(), 467 | unittest.mock.ANY, 468 | 'Account.yaml') 469 | 470 | @patch('lakshmi.lak.edit_and_parse') 471 | def test_edit_account_change_name(self, mock_parse): 472 | mock_parse.return_value = Account('Vanguard', 'Taxable') 473 | 474 | result = run_lak('edit account -t Schwab') 475 | self.assertEqual(0, result.exit_code) 476 | self.assertTrue(lak.lakctx.saved_portfolio) 477 | 478 | accounts = list(lak.lakctx.portfolio.accounts()) 479 | self.assertEqual(1, len(accounts)) 480 | self.assertEqual('Vanguard', accounts[0].name()) 481 | self.assertEqual(1, len(accounts[0].assets())) 482 | 483 | mock_parse.assert_called_with(Account('Schwab', 'Taxable').to_dict(), 484 | unittest.mock.ANY, 485 | 'Account.yaml') 486 | 487 | @patch('lakshmi.lak.edit_and_parse') 488 | def test_edit_asset(self, mock_parse): 489 | mock_parse.return_value = ManualAsset( 490 | 'Tasty Asset', 100.0, {'Stocks': 1.0}) 491 | 492 | result = run_lak('edit asset -a Test') 493 | self.assertEqual(0, result.exit_code) 494 | self.assertTrue(lak.lakctx.saved_portfolio) 495 | 496 | account = lak.lakctx.portfolio.get_account('Schwab') 497 | self.assertEqual(1, len(account.assets())) 498 | self.assertEqual('Tasty Asset', list(account.assets())[0].name()) 499 | 500 | mock_parse.assert_called_with( 501 | ManualAsset('Test Asset', 100.0, {'Stocks': 1.0}).to_dict(), 502 | unittest.mock.ANY, 503 | 'ManualAsset.yaml') 504 | 505 | @patch('lakshmi.lak.edit_and_parse') 506 | def test_edit_checkpoint(self, mock_parse): 507 | mock_parse.return_value = Checkpoint('2021/1/2', 105.01, 508 | inflow=10, outflow=1) 509 | result = run_lak('edit checkpoint --date 2021/01/02') 510 | self.assertEqual(0, result.exit_code) 511 | self.assertTrue(lak.lakctx.saved_performance) 512 | self.assertEqual( 513 | 1, lak.lakctx.get_performance().get_timeline().get_checkpoint( 514 | '2021/01/02').get_outflow()) 515 | 516 | @patch('lakshmi.lak.edit_and_parse') 517 | def test_add_account(self, mock_parse): 518 | mock_parse.return_value = Account('Vanguard', 'Taxable') 519 | 520 | result = run_lak('add account') 521 | self.assertEqual(0, result.exit_code) 522 | self.assertTrue(lak.lakctx.saved_portfolio) 523 | 524 | self.assertEqual(2, len(lak.lakctx.portfolio.accounts())) 525 | mock_parse.assert_called_with(None, 526 | Account.from_dict, 527 | 'Account.yaml') 528 | 529 | @patch('lakshmi.lak.edit_and_parse') 530 | def test_add_asset(self, mock_parse): 531 | mock_parse.return_value = ManualAsset( 532 | 'Tasty Asset', 100.0, {'Stocks': 1.0}) 533 | 534 | result = run_lak('add asset -t Schwab -p ManualAsset') 535 | self.assertEqual(0, result.exit_code) 536 | self.assertTrue(lak.lakctx.saved_portfolio) 537 | 538 | account = lak.lakctx.portfolio.get_account('Schwab') 539 | self.assertEqual(2, len(account.assets())) 540 | 541 | mock_parse.assert_called_with( 542 | None, 543 | ManualAsset.from_dict, 544 | 'ManualAsset.yaml') 545 | 546 | @patch('lakshmi.lak._today') 547 | def test_add_checkpoint(self, mock_today): 548 | mock_today.return_value = '2021/01/31' 549 | 550 | result = run_lak('add checkpoint') 551 | self.assertEqual(0, result.exit_code) 552 | self.assertTrue(lak.lakctx.saved_performance) 553 | self.assertEqual( 554 | 100.0, 555 | lak.lakctx.get_performance().get_timeline().get_checkpoint( 556 | '2021/01/31').get_portfolio_value()) 557 | 558 | @patch('lakshmi.lak._today') 559 | def test_add_checkpoint_to_empty(self, mock_today): 560 | mock_today.return_value = '2100/01/31' 561 | lak.lakctx.performance = None 562 | 563 | result = run_lak('add checkpoint') 564 | self.assertEqual(0, result.exit_code) 565 | self.assertTrue(lak.lakctx.saved_performance) 566 | self.assertEqual('2100/01/31', 567 | lak.lakctx.get_performance().get_timeline().begin()) 568 | 569 | @patch('lakshmi.lak.edit_and_parse') 570 | @patch('lakshmi.lak._today') 571 | def test_add_checkpoint_and_edit(self, mock_today, mock_parse): 572 | mock_today.return_value = '2021/01/31' 573 | mock_parse.return_value = Checkpoint('2021/01/31', 500.0) 574 | 575 | result = run_lak('add checkpoint --edit') 576 | self.assertEqual(0, result.exit_code) 577 | self.assertTrue(lak.lakctx.saved_performance) 578 | self.assertEqual( 579 | 500.0, 580 | lak.lakctx.get_performance().get_timeline().get_checkpoint( 581 | '2021/01/31').get_portfolio_value()) 582 | 583 | def test_delete_account(self): 584 | result = run_lak('delete account -t Schwab --yes') 585 | self.assertEqual(0, result.exit_code) 586 | self.assertTrue(lak.lakctx.saved_portfolio) 587 | self.assertEqual(0, len(lak.lakctx.portfolio.accounts())) 588 | 589 | def test_delete_asset(self): 590 | result = run_lak('delete asset -a Test --yes') 591 | self.assertEqual(0, result.exit_code) 592 | self.assertTrue(lak.lakctx.saved_portfolio) 593 | self.assertEqual( 594 | 0, len(lak.lakctx.portfolio.get_account('Schwab').assets())) 595 | 596 | def test_delete_checkpoint(self): 597 | result = run_lak('delete checkpoint --date 2021/1/1 --yes') 598 | self.assertEqual(0, result.exit_code) 599 | self.assertTrue(lak.lakctx.saved_performance) 600 | self.assertEqual('2021/01/02', 601 | lak.lakctx.get_performance().get_timeline().begin()) 602 | 603 | def test_analyze_tlh(self): 604 | result = run_lak('analyze tlh') 605 | self.assertEqual(0, result.exit_code) 606 | self.assertIn('No tax lots', result.output) 607 | 608 | def test_analyze_rebalance(self): 609 | result = run_lak('analyze rebalance') 610 | self.assertEqual(0, result.exit_code) 611 | self.assertRegex(result.output, r'Bonds +0') 612 | 613 | def test_analyze_allocate_no_cash(self): 614 | result = run_lak('analyze allocate -t Schwab') 615 | self.assertEqual(1, result.exit_code) 616 | self.assertIn('No available cash', str(result.exception)) 617 | self.assertFalse(lak.lakctx.saved_portfolio) 618 | 619 | def test_analyze_allocate(self): 620 | self.assertEqual(0, run_lak('whatif account -t Schwab 100').exit_code) 621 | result = run_lak('analyze allocate -t Schwab') 622 | self.assertEqual(0, result.exit_code) 623 | self.assertIn('+$100.00', result.output) 624 | self.assertTrue(lak.lakctx.saved_portfolio) 625 | self.assertEqual(0, run_lak('whatif -r').exit_code) 626 | 627 | 628 | if __name__ == '__main__': 629 | unittest.main() 630 | -------------------------------------------------------------------------------- /tests/test_performance.py: -------------------------------------------------------------------------------- 1 | """Test for lakshmi.performance module.""" 2 | 3 | import unittest 4 | from datetime import datetime 5 | 6 | from lakshmi.performance import Checkpoint, Performance, Timeline 7 | 8 | 9 | class PerformanceTest(unittest.TestCase): 10 | 11 | def test_checkpoint(self): 12 | c = Checkpoint('2020/11/11', 100) # Default constructor. 13 | self.assertEqual(100, c.get_portfolio_value()) 14 | self.assertEqual('2020/11/11', c.get_date()) 15 | 16 | c = Checkpoint.from_dict(c.to_dict()) 17 | self.assertEqual(100, c.get_portfolio_value()) 18 | self.assertEqual('2020/11/11', c.get_date()) 19 | 20 | c = Checkpoint('2020/1/1', 200, inflow=100, outflow=50) 21 | self.assertEqual(200, c.get_portfolio_value()) 22 | self.assertEqual('2020/01/01', c.get_date()) 23 | self.assertEqual(100, c.get_inflow()) 24 | self.assertEqual(50, c.get_outflow()) 25 | 26 | c = Checkpoint.from_dict(c.to_dict()) 27 | self.assertEqual(200, c.get_portfolio_value()) 28 | self.assertEqual('2020/01/01', c.get_date()) 29 | self.assertEqual(100, c.get_inflow()) 30 | self.assertEqual(50, c.get_outflow()) 31 | 32 | self.assertNotIn('Date', c.to_dict(show_date=False)) 33 | 34 | c = Checkpoint.from_dict(c.to_dict(show_date=False), '2020/12/12') 35 | self.assertEqual(200, c.get_portfolio_value()) 36 | self.assertEqual('2020/12/12', c.get_date()) 37 | self.assertEqual(100, c.get_inflow()) 38 | self.assertEqual(50, c.get_outflow()) 39 | 40 | def test_show_empty(self): 41 | c = Checkpoint('2020/11/11', 100, inflow=10) 42 | d = c.to_dict() 43 | self.assertFalse('Outflow' in d) 44 | d = c.to_dict(show_empty_cashflow=True) 45 | self.assertTrue('Outflow' in d) 46 | 47 | def test_empty_timeline(self): 48 | with self.assertRaises(AssertionError): 49 | Timeline([]) 50 | 51 | def test_single_entry_timeline(self): 52 | cp = Checkpoint('2021/1/1', 100.0) 53 | timeline = Timeline([cp]) 54 | 55 | self.assertTrue(timeline.has_checkpoint('2021/01/1')) 56 | self.assertFalse(timeline.has_checkpoint('2021/01/02')) 57 | 58 | self.assertEqual('2021/01/01', timeline.begin()) 59 | self.assertEqual('2021/01/01', timeline.end()) 60 | 61 | self.assertFalse(timeline.covers('2020/12/31')) 62 | self.assertTrue(timeline.covers('2021/1/1')) 63 | 64 | self.assertEqual(100, timeline.get_checkpoint('2021/01/1') 65 | .get_portfolio_value()) 66 | self.assertEqual(100, timeline.get_checkpoint('2021/01/1', True) 67 | .get_portfolio_value()) 68 | with self.assertRaises(AssertionError): 69 | timeline.get_checkpoint('2021/01/02') 70 | with self.assertRaises(AssertionError): 71 | timeline.get_checkpoint('2021/01/02', True) 72 | 73 | with self.assertRaises(AssertionError): 74 | timeline.insert_checkpoint(cp) 75 | 76 | cp_replace = Checkpoint('2021/1/1', 150.0) 77 | timeline.insert_checkpoint(cp_replace, replace=True) 78 | self.assertEqual(150, timeline.get_checkpoint('2021/1/1') 79 | .get_portfolio_value()) 80 | 81 | cp1 = Checkpoint('2020/1/1', 200) 82 | timeline.insert_checkpoint(cp1) 83 | self.assertEqual('2020/01/01', timeline.begin()) 84 | 85 | def test_timeline(self): 86 | checkpoints = [ 87 | Checkpoint('2021/1/1', 100), 88 | Checkpoint('2021/3/1', 500), 89 | Checkpoint('2021/1/31', 300, inflow=150, outflow=50)] 90 | timeline = Timeline(checkpoints) 91 | 92 | self.assertTrue(timeline.has_checkpoint('2021/01/1')) 93 | self.assertFalse(timeline.has_checkpoint('2021/01/02')) 94 | 95 | self.assertEqual('2021/01/01', timeline.begin()) 96 | self.assertEqual('2021/03/01', timeline.end()) 97 | 98 | self.assertFalse(timeline.covers('2020/12/31')) 99 | self.assertTrue(timeline.covers('2021/1/1')) 100 | self.assertTrue(timeline.covers('2021/1/2')) 101 | self.assertTrue(timeline.covers('2021/3/1')) 102 | 103 | self.assertEqual(300, timeline.get_checkpoint('2021/01/31') 104 | .get_portfolio_value()) 105 | self.assertEqual(300, timeline.get_checkpoint('2021/01/31', True) 106 | .get_portfolio_value()) 107 | 108 | with self.assertRaises(AssertionError): 109 | timeline.get_checkpoint('2021/01/15') 110 | 111 | self.assertEqual(150, timeline.get_checkpoint('2021/01/16', True) 112 | .get_portfolio_value()) 113 | 114 | def test_timeline_to_list(self): 115 | checkpoints = [ 116 | Checkpoint('2021/1/1', 100), 117 | Checkpoint('2021/3/1', 500), 118 | Checkpoint('2021/1/31', 300, inflow=150, outflow=50)] 119 | timeline = Timeline(checkpoints) 120 | timeline_list = timeline.to_list() 121 | self.assertEqual([ 122 | {'Date': '2021/01/01', 'Portfolio Value': 100}, 123 | {'Date': '2021/01/31', 'Portfolio Value': 300, 124 | 'Inflow': 150, 'Outflow': 50}, 125 | {'Date': '2021/03/01', 'Portfolio Value': 500}], 126 | timeline_list) 127 | timeline = Timeline.from_list(timeline_list) 128 | self.assertTrue(timeline.has_checkpoint('2021/01/01')) 129 | self.assertTrue(timeline.has_checkpoint('2021/01/31')) 130 | self.assertTrue(timeline.has_checkpoint('2021/03/01')) 131 | 132 | def test_timeline_to_table(self): 133 | checkpoints = [ 134 | Checkpoint('2021/1/1', 100), 135 | Checkpoint('2021/3/1', 500), 136 | Checkpoint('2021/1/31', 300, inflow=150, outflow=50)] 137 | timeline = Timeline(checkpoints) 138 | self.assertEqual( 139 | [['2021/01/01', '$100.00', '$0.00', '$0.00'], 140 | ['2021/01/31', '$300.00', '$150.00', '$50.00'], 141 | ['2021/03/01', '$500.00', '$0.00', '$0.00']], 142 | timeline.to_table().str_list()) 143 | self.assertEqual( 144 | [['2021/01/01', '$100.00', '$0.00', '$0.00']], 145 | timeline.to_table('2021/01/01', '2021/01/02').str_list()) 146 | self.assertEqual( 147 | [['2021/03/01', '$500.00', '$0.00', '$0.00']], 148 | timeline.to_table('2021/02/01', '2021/04/01').str_list()) 149 | self.assertEqual( 150 | [['2021/01/31', '$300.00', '$150.00', '$50.00']], 151 | timeline.to_table('2021/01/31', '2021/01/31').str_list()) 152 | self.assertEqual( 153 | [], timeline.to_table('2021/01/15', '2021/01/17').str_list()) 154 | 155 | def test_get_performance_data(self): 156 | checkpoints = [ 157 | Checkpoint('2021/1/1', 100), 158 | Checkpoint('2021/1/31', 300, inflow=150, outflow=50), 159 | Checkpoint('2021/3/1', 500, inflow=10, outflow=20)] 160 | timeline = Timeline(checkpoints) 161 | data = timeline.get_performance_data('2021/01/01', '2021/03/01') 162 | self.assertEqual( 163 | [datetime(2021, 1, 1), datetime(2021, 1, 31), 164 | datetime(2021, 3, 1)], 165 | data.dates) 166 | self.assertEqual([-100, -100, 510], data.amounts) 167 | self.assertEqual(100, data.begin_balance) 168 | self.assertEqual(500, data.end_balance) 169 | self.assertEqual(160, data.inflows) 170 | self.assertEqual(70, data.outflows) 171 | 172 | data = timeline.get_performance_data('2021/01/16', '2021/01/31') 173 | self.assertEqual([datetime(2021, 1, 16), datetime(2021, 1, 31)], 174 | data.dates) 175 | self.assertEqual([-150, 200], data.amounts) 176 | self.assertEqual(150, data.begin_balance) 177 | self.assertEqual(300, data.end_balance) 178 | self.assertEqual(150, data.inflows) 179 | self.assertEqual(50, data.outflows) 180 | 181 | def test_get_performance_data_none_dates(self): 182 | checkpoints = [ 183 | Checkpoint('2021/1/1', 100), 184 | Checkpoint('2021/1/31', 300, inflow=150, outflow=50), 185 | Checkpoint('2021/3/1', 500, inflow=10, outflow=20)] 186 | timeline = Timeline(checkpoints) 187 | data = timeline.get_performance_data(None, None) 188 | self.assertEqual(100, data.begin_balance) 189 | self.assertEqual(500, data.end_balance) 190 | 191 | def test_performance_to_dict(self): 192 | perf = Performance(Timeline([Checkpoint('2021/1/1', 100)])) 193 | perf = Performance.from_dict(perf.to_dict()) 194 | self.assertEqual('2021/01/01', perf.get_timeline().begin()) 195 | self.assertEqual('2021/01/01', perf.get_timeline().end()) 196 | 197 | def test_summary_table_single_date(self): 198 | perf_table = Performance(Timeline([ 199 | Checkpoint('2021/1/1', 100)])).summary_table() 200 | self.assertEqual([], perf_table.str_list()) 201 | 202 | def test_summary_table_1month(self): 203 | checkpoints = [ 204 | Checkpoint('2021/1/1', 100), 205 | Checkpoint('2021/1/31', 200, inflow=100, outflow=50), 206 | Checkpoint('2021/2/1', 210)] 207 | perf = Performance(Timeline(checkpoints)) 208 | # We should only return 1 period. 209 | self.assertEqual( 210 | ['1 Month'], perf._get_periods()[1]) 211 | 212 | perf_table = perf.summary_table() 213 | self.assertEqual(2, len(perf_table.list())) 214 | # Only check basic values (these functions are unittested elsewhere) 215 | self.assertEqual(['1 Month', '$100.00', '$50.00'], 216 | perf_table.str_list()[0][:3]) 217 | self.assertEqual( 218 | ['Overall', '$100.00', '$50.00', '+$110.00', '110.0%'], 219 | perf_table.str_list()[1][:5]) 220 | 221 | def test_summary_table_1year(self): 222 | checkpoints = [ 223 | Checkpoint('2021/1/1', 100), 224 | Checkpoint('2022/2/1', 210)] 225 | perf = Performance(Timeline(checkpoints)) 226 | self.assertEqual( 227 | ['3 Months', '6 Months', '1 Year'], perf._get_periods()[1]) 228 | self.assertEqual(4, len(perf.summary_table().list())) 229 | 230 | def test_performance_get_info(self): 231 | checkpoints = [ 232 | Checkpoint('2020/1/1', 1000), 233 | Checkpoint('2021/1/1', 500, outflow=1000), 234 | Checkpoint('2022/1/1', 1000)] 235 | info = Performance(Timeline(checkpoints)).get_info(None, None) 236 | 237 | self.assertRegex(info, r'Start date +2020/01/01') 238 | self.assertRegex(info, r'End date +2022/01/01') 239 | self.assertRegex(info, r'Begin.+ \$1,000.00') 240 | self.assertRegex(info, r'Ending.+ \$1,000\.00') 241 | self.assertRegex(info, r'Inflows + \$0') 242 | self.assertRegex(info, r'Outflows + \$1,000\.00') 243 | self.assertRegex(info, r'Portfolio growth +\+\$0\.00') 244 | self.assertRegex(info, r'Market growth +\+\$1,000\.00') 245 | self.assertRegex(info, r'Portfolio growth \% +0\.0%') 246 | self.assertRegex(info, r'Internal.+61\.6%') 247 | -------------------------------------------------------------------------------- /tests/test_table.py: -------------------------------------------------------------------------------- 1 | """Tests for lakshmi.table module.""" 2 | import unittest 3 | 4 | from lakshmi.table import Table 5 | 6 | 7 | class TableTest(unittest.TestCase): 8 | def test_sanity(self): 9 | self.assertEqual(len(Table.coltype2func), len(Table.coltype2align)) 10 | 11 | def test_empty_table(self): 12 | t = Table(3) 13 | self.assertListEqual([], t.list()) 14 | self.assertListEqual([], t.str_list()) 15 | self.assertEqual('', t.string()) 16 | 17 | def test_no_headers_and_coltypes(self): 18 | t = Table(3) 19 | t.add_row(['1', '2', '3']) 20 | self.assertListEqual([['1', '2', '3']], t.list()) 21 | self.assertListEqual([['1', '2', '3']], t.str_list()) 22 | self.assertGreater(len(t.string()), 0) 23 | 24 | def test_bad_coltypes(self): 25 | with self.assertRaisesRegex(AssertionError, 26 | 'Bad column type in coltypes'): 27 | Table(2, coltypes=[None, 'str']) 28 | 29 | def test_set_rows(self): 30 | t = Table(3) 31 | t.set_rows([['1', '2']]) 32 | self.assertListEqual([['1', '2']], t.str_list()) 33 | 34 | def test_headers_and_diff_coltypes(self): 35 | headers = ['1', '2', '3', '4', '5', '6'] 36 | t = Table( 37 | 6, 38 | headers=headers, 39 | coltypes=[ 40 | 'str', 41 | 'dollars', 42 | 'delta_dollars', 43 | 'percentage', 44 | 'percentage_1', 45 | 'float']) 46 | 47 | rows = [['r1', 3, 4.1, 0.5, 0.5, 1], 48 | ['r6', 8, -9.2, 0.1, 0.5557, 2.345]] 49 | t.set_rows(rows) 50 | 51 | self.assertListEqual(headers, t.headers()) 52 | self.assertListEqual( 53 | ['left', 'right', 'right', 'right', 'right', 'decimal'], 54 | t.col_align()) 55 | 56 | self.assertListEqual(rows, t.list()) 57 | self.assertListEqual( 58 | [['r1', '$3.00', '+$4.10', '50%', '50.0%', '1.0'], 59 | ['r6', '$8.00', '-$9.20', '10%', '55.6%', '2.345']], 60 | t.str_list()) 61 | self.assertGreater(len(t.string()), 0) 62 | 63 | def test_mismatched_num_cols(self): 64 | with self.assertRaises(AssertionError): 65 | Table(2, headers=['1']) 66 | with self.assertRaises(AssertionError): 67 | Table(2, headers=['1', '2', '3']) 68 | with self.assertRaises(AssertionError): 69 | Table(2, coltypes=['str']) 70 | with self.assertRaises(AssertionError): 71 | Table(2, headers=['str', 'str', 'str']) 72 | 73 | def test_too_many_cols(self): 74 | t = Table(2) 75 | with self.assertRaises(AssertionError): 76 | t.add_row(['1', '2', '3']) 77 | with self.assertRaises(AssertionError): 78 | t.set_rows([['a', 'b'], ['1', '2', '3']]) 79 | 80 | def test_too_few_cols(self): 81 | t = Table(2) 82 | t.add_row(['1']) 83 | t.set_rows([['1', '2'], ['1'], ['a']]) 84 | 85 | 86 | if __name__ == '__main__': 87 | unittest.main() 88 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Tests for lakshmi.utils module.""" 2 | import unittest 3 | 4 | import yaml 5 | 6 | from lakshmi import utils 7 | 8 | 9 | class UtilsTest(unittest.TestCase): 10 | def test_format_money(self): 11 | self.assertEqual('$42.42', utils.format_money(42.421)) 12 | self.assertEqual('$100.00', utils.format_money(100.00)) 13 | 14 | def test_format_money_delta(self): 15 | self.assertEqual('+$10.00', utils.format_money_delta(10)) 16 | self.assertEqual('-$20.05', utils.format_money_delta(-20.049)) 17 | 18 | def test_validate_date_no_error(self): 19 | self.assertEqual('2021/11/21', utils.validate_date('2021/11/21')) 20 | 21 | def test_validate_date_corrected(self): 22 | self.assertEqual('2020/01/01', utils.validate_date('2020/1/1')) 23 | 24 | def test_validate_date_errors(self): 25 | with self.assertRaises(ValueError): 26 | utils.validate_date('01/23/2021') 27 | 28 | with self.assertRaises(ValueError): 29 | utils.validate_date('2021/02/29') # 2021 is not leap year. 30 | 31 | def test_resolver(self): 32 | data = 'a: 100.22\nb: 122,121,000.22\nc: -121,122.12\nd: 1,000' 33 | loaded = yaml.load(data, Loader=utils.get_loader()) 34 | self.assertEqual(100.22, loaded['a']) 35 | self.assertEqual(122121000.22, loaded['b']) 36 | self.assertEqual(-121122.12, loaded['c']) 37 | self.assertEqual(1000, loaded['d']) 38 | --------------------------------------------------------------------------------