├── .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 | [](https://results.pre-commit.ci/latest/github/sarvjeets/lakshmi/develop)
4 | [](https://pepy.tech/project/lakshmi)
5 | [](https://pepy.tech/project/lakshmi)
6 |
7 | 
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 |
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 |