├── .gitchangelog.rc ├── .github └── workflows │ ├── conventionalcommits.yml │ ├── pythonpackage.yml │ └── pythonpublish.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── TODO.txt ├── design.md ├── fava_investor ├── __init__.py ├── cli │ ├── __init__.py │ └── investor.py ├── common │ ├── __init__.py │ ├── beancountinvestorapi.py │ ├── clicommon.py │ ├── favainvestorapi.py │ └── libinvestor.py ├── examples │ ├── beancount-example.config │ ├── example.beancount │ └── huge-example.beancount ├── modules │ ├── __init__.py │ ├── assetalloc_account │ │ ├── TODO.txt │ │ ├── __init__.py │ │ ├── assetalloc_account.py │ │ └── libaaacc.py │ ├── assetalloc_class │ │ ├── README.md │ │ ├── __init__.py │ │ ├── assetalloc_class.py │ │ ├── example.beancount │ │ ├── libassetalloc.py │ │ ├── multicurrency.beancount │ │ └── test_asset_allocation.py │ ├── cashdrag │ │ ├── README.md │ │ ├── __init__.py │ │ ├── cashdrag.py │ │ ├── design.md │ │ └── libcashdrag.py │ ├── minimizegains │ │ ├── README.md │ │ ├── TODO.md │ │ ├── __init__.py │ │ ├── example.beancount │ │ ├── libminimizegains.py │ │ ├── minimizegains.py │ │ └── test_minimizegains.py │ ├── summarizer │ │ ├── README.md │ │ ├── __init__.py │ │ ├── design.md │ │ ├── example.beancount │ │ ├── libsummarizer.py │ │ └── summarizer.py │ └── tlh │ │ ├── README.md │ │ ├── TODO.txt │ │ ├── __init__.py │ │ ├── example.beancount │ │ ├── libtlh.py │ │ ├── test_libtlh.py │ │ ├── tlh.jpg │ │ ├── tlh.py │ │ └── wash_substantially_identical.beancount ├── pythonanywhere │ ├── commodities.beancount │ ├── example.beancount │ ├── fava_config.beancount │ ├── favainvestor_pythonanywhere_com_wsgi.py │ ├── scripts │ └── update.bash ├── templates │ └── Investor.html └── util │ ├── __init__.py │ ├── cachedtickerinfo.py │ ├── experimental │ ├── __init__.py │ └── scaled_navs.py │ ├── relatetickers.py │ ├── test_relatetickers.py │ └── ticker_util.py ├── requirements.txt ├── screenshot-assetalloc.png ├── screenshot.png └── setup.py /.gitchangelog.rc: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8; mode: python -*- 2 | ## 3 | ## Format 4 | ## 5 | ## ACTION: [AUDIENCE:] COMMIT_MSG [!TAG ...] 6 | ## 7 | ## Description 8 | ## 9 | ## ACTION is one of 'chg', 'fix', 'new' 10 | ## 11 | ## Is WHAT the change is about. 12 | ## 13 | ## 'chg' is for refactor, small improvement, cosmetic changes... 14 | ## 'fix' is for bug fixes 15 | ## 'new' is for new features, big improvement 16 | ## 17 | ## AUDIENCE is optional and one of 'dev', 'usr', 'pkg', 'test', 'doc' 18 | ## 19 | ## Is WHO is concerned by the change. 20 | ## 21 | ## 'dev' is for developpers (API changes, refactors...) 22 | ## 'usr' is for final users (UI changes) 23 | ## 'pkg' is for packagers (packaging changes) 24 | ## 'test' is for testers (test only related changes) 25 | ## 'doc' is for doc guys (doc only changes) 26 | ## 27 | ## COMMIT_MSG is ... well ... the commit message itself. 28 | ## 29 | ## TAGs are additionnal adjective as 'refactor' 'minor' 'cosmetic' 30 | ## 31 | ## They are preceded with a '!' or a '@' (prefer the former, as the 32 | ## latter is wrongly interpreted in github.) Commonly used tags are: 33 | ## 34 | ## 'refactor' is obviously for refactoring code only 35 | ## 'minor' is for a very meaningless change (a typo, adding a comment) 36 | ## 'cosmetic' is for cosmetic driven change (re-indentation, 80-col...) 37 | ## 'wip' is for partial functionality but complete subfunctionality. 38 | ## 39 | ## Example: 40 | ## 41 | ## new: usr: support of bazaar implemented 42 | ## chg: re-indentend some lines !cosmetic 43 | ## new: dev: updated code to be compatible with last version of killer lib. 44 | ## fix: pkg: updated year of licence coverage. 45 | ## new: test: added a bunch of test around user usability of feature X. 46 | ## fix: typo in spelling my name in comment. !minor 47 | ## 48 | ## Please note that multi-line commit message are supported, and only the 49 | ## first line will be considered as the "summary" of the commit message. So 50 | ## tags, and other rules only applies to the summary. The body of the commit 51 | ## message will be displayed in the changelog without reformatting. 52 | 53 | 54 | ## 55 | ## ``ignore_regexps`` is a line of regexps 56 | ## 57 | ## Any commit having its full commit message matching any regexp listed here 58 | ## will be ignored and won't be reported in the changelog. 59 | ## 60 | ignore_regexps = [ 61 | r'^(minor|cosmetic|refactor|ci|wip|doc)', 62 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[p|P]kg:', 63 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[d|D]ev:', 64 | r'^(.{3,3}\s*:)?\s*[fF]irst commit.?\s*$', 65 | r'^$', ## ignore commits with empty messages 66 | ] 67 | 68 | 69 | ## ``section_regexps`` is a list of 2-tuples associating a string label and a 70 | ## list of regexp 71 | ## 72 | ## Commit messages will be classified in sections thanks to this. Section 73 | ## titles are the label, and a commit is classified under this section if any 74 | ## of the regexps associated is matching. 75 | ## 76 | ## Please note that ``section_regexps`` will only classify commits and won't 77 | ## make any changes to the contents. So you'll probably want to go check 78 | ## ``subject_process`` (or ``body_process``) to do some changes to the subject, 79 | ## whenever you are tweaking this variable. 80 | ## 81 | section_regexps = [ 82 | ('New', [ 83 | r'^[fF]eat\([Mm]ajor\):.*$', 84 | ]), 85 | ('Improvements', [ 86 | r'^[fF]eat:.*$', 87 | r'^[fF]eat(.*):.*$', 88 | ]), 89 | ('Fixes', [ 90 | r'^[fF]ix\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 91 | ]), 92 | 93 | ('Other', None ## Match all lines 94 | ), 95 | 96 | ] 97 | 98 | 99 | ## ``body_process`` is a callable 100 | ## 101 | ## This callable will be given the original body and result will 102 | ## be used in the changelog. 103 | ## 104 | ## Available constructs are: 105 | ## 106 | ## - any python callable that take one txt argument and return txt argument. 107 | ## 108 | ## - ReSub(pattern, replacement): will apply regexp substitution. 109 | ## 110 | ## - Indent(chars=" "): will indent the text with the prefix 111 | ## Please remember that template engines gets also to modify the text and 112 | ## will usually indent themselves the text if needed. 113 | ## 114 | ## - Wrap(regexp=r"\n\n"): re-wrap text in separate paragraph to fill 80-Columns 115 | ## 116 | ## - noop: do nothing 117 | ## 118 | ## - ucfirst: ensure the first letter is uppercase. 119 | ## (usually used in the ``subject_process`` pipeline) 120 | ## 121 | ## - final_dot: ensure text finishes with a dot 122 | ## (usually used in the ``subject_process`` pipeline) 123 | ## 124 | ## - strip: remove any spaces before or after the content of the string 125 | ## 126 | ## - SetIfEmpty(msg="No commit message."): will set the text to 127 | ## whatever given ``msg`` if the current text is empty. 128 | ## 129 | ## Additionally, you can `pipe` the provided filters, for instance: 130 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') | Indent(chars=" ") 131 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') 132 | #body_process = noop 133 | body_process = ReSub(r'((^|\n)[A-Z]\w+(-\w+)*: .*(\n\s+.*)*)+$', r'') | strip 134 | 135 | 136 | ## ``subject_process`` is a callable 137 | ## 138 | ## This callable will be given the original subject and result will 139 | ## be used in the changelog. 140 | ## 141 | ## Available constructs are those listed in ``body_process`` doc. 142 | subject_process = (strip | 143 | ReSub(r'^([Ff]ix|[Ff]eat|[Bb]uild|[Dd]oc)\s*(\(([\w\/]*)\))?:(.*)', r'\1/\3:\4') | 144 | ReSub(r'\/:', r':') | 145 | ReSub(r'([Ff]eat|[Ff]ix)\/major:\s*', r'') | 146 | ReSub(r'([Ff]eat|[Ff]ix)\/', r'') | 147 | ReSub(r'([Ff]eat|[Ff]ix): ', r'') | 148 | SetIfEmpty("No commit message.") | final_dot) 149 | 150 | 151 | ## ``tag_filter_regexp`` is a regexp 152 | ## 153 | ## Tags that will be used for the changelog must match this regexp. 154 | ## 155 | tag_filter_regexp = r'^[0-9]+\.[0-9]+(\.[0-9]+)?$' 156 | 157 | 158 | ## ``unreleased_version_label`` is a string or a callable that outputs a string 159 | ## 160 | ## This label will be used as the changelog Title of the last set of changes 161 | ## between last valid tag and HEAD if any. 162 | unreleased_version_label = "(unreleased)" 163 | 164 | 165 | ## ``output_engine`` is a callable 166 | ## 167 | ## This will change the output format of the generated changelog file 168 | ## 169 | ## Available choices are: 170 | ## 171 | ## - rest_py 172 | ## 173 | ## Legacy pure python engine, outputs ReSTructured text. 174 | ## This is the default. 175 | ## 176 | ## - mustache() 177 | ## 178 | ## Template name could be any of the available templates in 179 | ## ``templates/mustache/*.tpl``. 180 | ## Requires python package ``pystache``. 181 | ## Examples: 182 | ## - mustache("markdown") 183 | ## - mustache("restructuredtext") 184 | ## 185 | ## - makotemplate() 186 | ## 187 | ## Template name could be any of the available templates in 188 | ## ``templates/mako/*.tpl``. 189 | ## Requires python package ``mako``. 190 | ## Examples: 191 | ## - makotemplate("restructuredtext") 192 | ## 193 | # output_engine = rest_py 194 | #output_engine = mustache("restructuredtext") 195 | output_engine = mustache("markdown-reds") 196 | #output_engine = makotemplate("restructuredtext") 197 | 198 | 199 | ## ``include_merge`` is a boolean 200 | ## 201 | ## This option tells git-log whether to include merge commits in the log. 202 | ## The default is to include them. 203 | include_merge = True 204 | 205 | 206 | ## ``log_encoding`` is a string identifier 207 | ## 208 | ## This option tells gitchangelog what encoding is outputed by ``git log``. 209 | ## The default is to be clever about it: it checks ``git config`` for 210 | ## ``i18n.logOutputEncoding``, and if not found will default to git's own 211 | ## default: ``utf-8``. 212 | #log_encoding = 'utf-8' 213 | 214 | 215 | ## ``publish`` is a callable 216 | ## 217 | ## Sets what ``gitchangelog`` should do with the output generated by 218 | ## the output engine. ``publish`` is a callable taking one argument 219 | ## that is an interator on lines from the output engine. 220 | ## 221 | ## Some helper callable are provided: 222 | ## 223 | ## Available choices are: 224 | ## 225 | ## - stdout 226 | ## 227 | ## Outputs directly to standard output 228 | ## (This is the default) 229 | ## 230 | ## - FileInsertAtFirstRegexMatch(file, pattern, idx=lamda m: m.start()) 231 | ## 232 | ## Creates a callable that will parse given file for the given 233 | ## regex pattern and will insert the output in the file. 234 | ## ``idx`` is a callable that receive the matching object and 235 | ## must return a integer index point where to insert the 236 | ## the output in the file. Default is to return the position of 237 | ## the start of the matched string. 238 | ## 239 | ## - FileRegexSubst(file, pattern, replace, flags) 240 | ## 241 | ## Apply a replace inplace in the given file. Your regex pattern must 242 | ## take care of everything and might be more complex. Check the README 243 | ## for a complete copy-pastable example. 244 | ## 245 | # publish = FileInsertIntoFirstRegexMatch( 246 | # "CHANGELOG.rst", 247 | # r'/(?P[0-9]+\.[0-9]+(\.[0-9]+)?)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n/', 248 | # idx=lambda m: m.start(1) 249 | # ) 250 | #publish = stdout 251 | 252 | # publish = FileInsertAtFirstRegexMatch( 253 | # "CHANGELOG.md", 254 | # r'^# Changelog', 255 | # idx=lambda m: m.start(1) 256 | # ) 257 | 258 | OUTPUT_FILE = "CHANGELOG.md" 259 | INSERT_POINT_REGEX = r'''(?isxu) 260 | ^ 261 | ( 262 | \s*\#\s+Changelog\s*(\n|\r\n|\r) ## ``Changelog`` line 263 | ) 264 | 265 | ( ## Match all between changelog and release rev 266 | ( 267 | (?! 268 | (?<=(\n|\r)) ## look back for newline 269 | \#\#\s+%(rev)s ## revision 270 | \s+ 271 | \([0-9]+-[0-9]{2}-[0-9]{2}\)(\n|\r\n|\r) ## date 272 | ) 273 | . 274 | )* 275 | ) 276 | 277 | (?P\#\#\s+(?P%(rev)s)) 278 | ''' % {'rev': r"[0-9]+\.[0-9]+(\.[0-9]+)?"} 279 | 280 | revs = [ 281 | 282 | 283 | Caret(FileFirstRegexMatch(OUTPUT_FILE, INSERT_POINT_REGEX)), 284 | "HEAD" 285 | ] 286 | 287 | publish = FileRegexSubst(OUTPUT_FILE, INSERT_POINT_REGEX, r"\1\o\n\g") 288 | 289 | 290 | 291 | 292 | ## ``revs`` is a list of callable or a list of string 293 | ## 294 | ## callable will be called to resolve as strings and allow dynamical 295 | ## computation of these. The result will be used as revisions for 296 | ## gitchangelog (as if directly stated on the command line). This allows 297 | ## to filter exaclty which commits will be read by gitchangelog. 298 | ## 299 | ## To get a full documentation on the format of these strings, please 300 | ## refer to the ``git rev-list`` arguments. There are many examples. 301 | ## 302 | ## Using callables is especially useful, for instance, if you 303 | ## are using gitchangelog to generate incrementally your changelog. 304 | ## 305 | ## Some helpers are provided, you can use them:: 306 | ## 307 | ## - FileFirstRegexMatch(file, pattern): will return a callable that will 308 | ## return the first string match for the given pattern in the given file. 309 | ## If you use named sub-patterns in your regex pattern, it'll output only 310 | ## the string matching the regex pattern named "rev". 311 | ## 312 | ## - Caret(rev): will return the rev prefixed by a "^", which is a 313 | ## way to remove the given revision and all its ancestor. 314 | ## 315 | ## Please note that if you provide a rev-list on the command line, it'll 316 | ## replace this value (which will then be ignored). 317 | ## 318 | ## If empty, then ``gitchangelog`` will act as it had to generate a full 319 | ## changelog. 320 | ## 321 | ## The default is to use all commits to make the changelog. 322 | #revs = ["^1.0.3", ] 323 | #revs = [ 324 | # Caret( 325 | # FileFirstRegexMatch( 326 | # "CHANGELOG.rst", 327 | # r"(?P[0-9]+\.[0-9]+(\.[0-9]+)?)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n")), 328 | # "HEAD" 329 | #] 330 | revs = [] 331 | -------------------------------------------------------------------------------- /.github/workflows/conventionalcommits.yml: -------------------------------------------------------------------------------- 1 | name: Conventional Commits 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | types: [opened, reopened, edited] 7 | 8 | jobs: 9 | build: 10 | name: Conventional Commits 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - uses: webiny/action-conventional-commits@v1.1.0 16 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | types: [opened, reopened, edited] 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: ["3.9", "3.10", "3.12"] 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v3 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | python -m pip install flake8 pytest 32 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 33 | - name: Lint with flake8 34 | run: | 35 | # stop the build if there are Python syntax errors or undefined names 36 | # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 37 | flake8 . --count --max-complexity=12 --max-line-length=127 --show-source --statistics \ 38 | --ignore=E121,E123,E126,E226,E24,E704,W503,W504,E402,F722 39 | - name: Test with pytest 40 | run: | 41 | pytest 42 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: __token__ 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | err 2 | .*.swp 3 | .*.picklecache 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | notes.txt 135 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0 (2025-01-19) 4 | Investor is now compatible with Beancount v3 and Fava 1.30 5 | 6 | ### Improvements 7 | - scaled_navs now only considers last 10 most recent ratios 8 | 9 | ### Fixes 10 | - scaled-navs was ignoring date if specified 2024-04-24 10:15 +0200 c202048 Adriano Di Luzio o fix: assetalloc_class fail due to favapricemap vs beancount pricemap (#92) 11 | - assetalloc_class fail due to favapricemap vs beancount pricemap (#92) 12 | 13 | ## 0.7.0 (2024-02-02) 14 | 15 | This release brings fava_investor up to date with upstream changes in Fava. Primarily, 16 | asset allocation by class was fixed. Thanks to contributors below. 17 | 18 | ### Fixes 19 | 20 | - Fix assetalloc_class chart (#93) [Adriano Di Luzio] 21 | - remove dead code for Fava $' instead of just 36 | matching '', so that specifying a currency only matches that 37 | currency exactly, instead of also matching currencies that contain the 38 | specified currency. 39 | - Preserve costs during tax adjustment (#83) [korrat] 40 | 41 | * Preserve costs during tax adjustment 42 | 43 | Previously, tax adjustment discarded the cost of positions, causing 44 | beancount.core.convert.convert_position to miss some conversion paths 45 | via the cost currency. This necessitated a fallback to operating 46 | currencies for finding viable transitive conversions. 47 | 48 | With this patch, tax adjustment preserves the cost of positions. 49 | Therefore, the cost currency is still available for automatic detection 50 | of transitive conversions when computing the asset allocation. 51 | 52 | One important assumption is that tax adjustment only ever encounters 53 | costs after realization, as having a cost spec for the total cost would 54 | change the cost per unit. This assumption is checked via assert and has 55 | been manually tested without error on the multicurrency example. 56 | 57 | The patch leaves the fallback logic for conversion via operating 58 | currencies in place as an alternative when conversion via cost currency 59 | fails. 60 | * Fix lint warnings 61 | 62 | 63 | - add --tax-burden to minimizegains. [Red S] 64 | Interpolate tax burden from table for a specified amount 65 | - add config table to gains minimizer. [Red S] 66 | - minor: sort asset allocation output. [Red S] 67 | - minor: clean up columns in gains minimizer. [Red S] 68 | - minor: remove prefixes in asset allocation for clearer output. [Red S] 69 | cli only 70 | - minor: minimizegains: add avg and marginal tax percentage columns. [Red S] 71 | - minor: cashdrag: add `min_threshold` option; convert to primary currency. [Red S] 72 | 73 | ### Fixes 74 | 75 | - summarizer fail due to favapricemap vs beancount pricemap. [Red S] 76 | - cashdrag failed with command line (only worked with fava) [Red S] 77 | 78 | - use beancount's convert_position, not fava 79 | - asset_alloc_by_account to work with upstream changes. [Red S] 80 | 81 | - fix cost_or_value from upstream changes 82 | - broken tests. [Red S] 83 | - breaks with new fava versions #86. [Red S] 84 | - print warning when skipping negative positions in asset alloc. [Red S] 85 | - minimizegains column deletion was incorrect. [Red S] 86 | 87 | ### Other 88 | 89 | ## 0.5.0 (2022-12-25) 90 | 91 | ### Improvements 92 | 93 | - asset allocation by class: fixed chart placement using upstream changes [Red S] 94 | 95 | 96 | ## 0.4.0 (2022-11-22) 97 | 98 | ### New 99 | 100 | - minimizegains module. works on cli and fava. [Red S] 101 | 102 | ### Improvements 103 | 104 | - tlh/relatetickers: rename 'tlh_partners' to 'a__tlh_partners' [Red S] 105 | In line with the other metadata fields, `tlh_partners` has been renamed 106 | to `a__tlh_partners` and is now both an input and and auto-generated 107 | field 108 | - tlh/relatetickers: rename and add 'a__equivalents' [Red S] 109 | In line with the other metadata fields, `equivalent` has been renamed to 110 | `a__equivalents` and is now both an input and and auto-generated field 111 | - tlh/relatetickers: remove tlh_partners_meta_label feature. [Red S] 112 | this simplifies config, and clarifies. 113 | - tlh: removed susubstantially_identical_meta_label feature. [Red S] 114 | this is a simplification to provide clarity. 115 | - tlh/relatetickers: Show only same type of TLH partners. [Red S] 116 | 117 | ### Fixes 118 | 119 | - #79 bump fava dependency to 1.22 due to upstream filtering changes. [Red S] 120 | - minor: spelling. [Red S] 121 | - beancountinvestorapi open method needed to index into list. [Red S] 122 | - ticker_util: substidentical and a__substidentical are both accepted. [Red S] 123 | - fix (summarizer, tlh, util): rename "similar" to "identical" [Red S] 124 | 125 | IRS uses substantially identical 126 | - libminimizegains: couldn't use arbitrary currencies. [Red S] 127 | 128 | ### Other 129 | 130 | - relatetickers: unit test. [Red S] 131 | - pythonanywhere update.bash. [Red S] 132 | - pythonanywhere example asset alloc looks nicer now. [Red S] 133 | - pythonanywhere example is not too shabby now. #72 #24. [Red S] 134 | - ci: fix test. [Red S] 135 | - refactor: add tlh substantially identical based wash sale example. [Red S] 136 | - refactor: rename similars to idents. [Red S] 137 | - ci: flake ignore. [Red S] 138 | - doc/tlh: specifying substantially identical, and equivalent metadata. [Red S] 139 | - refactor: remove 'substidenticals' meta label. [Red S] 140 | 141 | use only a__substidenticals, which is both an input field, and gets 142 | overwritten 143 | - ci: flake. [Red S] 144 | - ci: flake. [Red S] 145 | - doc: feature todos for minimizegains. [Red S] 146 | - doc: minimizegains README.txt. [Red S] 147 | - wip: minimizegains. [Red S] 148 | 149 | 150 | ## 0.3.0 (2022-08-07) 151 | ### New 152 | 153 | - new module: summarizer to summarize and display reasonably arbitrary tables of account 154 | and commodity metadata 155 | - Modules now respect GUI context (time interval). For example, see what your cashdrag 156 | was or what you could have TLH'ed on any arbitrary day in the past 157 | - experimental utility: scaled mutual fund NAV estimator: estimate a mutual fund's NAV 158 | based on current intra-day value of its corresponding NAV 159 | 160 | ### Improvements 161 | 162 | - add footer to pretty_print. cashdrag uses it. 163 | - assetalloc_class: read fava config from beancount file for command line. 164 | - cashdrag: read fava config from beancount file for command line. 165 | - cli: consolidated all modules into `investor` command. 166 | - enable fava context and filters. Fixes #36. Also upstream favaledger. 167 | - pager via click. 168 | - ticker-util now puts asset allocation info it can find. 169 | - tlh: comma betweeen similars and alts lists. 170 | - tlh: read fava config from beancount file for command line. 171 | - better example (still WIP) 172 | - pythonanywhere config and example 173 | 174 | ### Fixes 175 | 176 | - fix: use filtered entries in query_shell. 177 | - fix: #61 Show welcome message on initial screen when no modules are selected. 178 | - fix/favainvestorapi: #67 get_account_open() returns empty. 179 | 180 | ### Other 181 | 182 | - several upgrades for fava 1.22 [Aaron Lindsay, Red S] 183 | - several doc upgrades 184 | - several refactors 185 | - refactor: ticker-util cleanup; _ to - in options. 186 | - test: fix assetalloc_class pytests. 187 | - wip: demo example #72. 188 | 189 | 190 | ## 0.2.5 (2022-06-12) 191 | ### New 192 | - ticker-util. See [here](https://groups.google.com/g/beancount/c/eewOW4HQKOI) 193 | 194 | ### Improvements 195 | - tlh: allow specifying tlh_partner meta label. [Red S] 196 | - tlh: also consider substantially similar tickers in wash sale computations. [Red S] 197 | - tlh docs. [Red S] 198 | - tlh new feature: wash_ids. [Red S] 199 | - tlh wash_id: configurable metadata label, bugfixes. [Red S] 200 | - tlh: what not to buy now includes similars. [Red S] 201 | - rename env var to BEAN_COMMODITIES_FILE. [Red S] 202 | 203 | 204 | ### Other 205 | 206 | - build: requirements via pigar. [Red S] 207 | - doc: create changelog + gitchangelog config. [Red S] 208 | - doc: examples. [Red S] 209 | - doc: README upate. Relaxed requirements. [Red S] 210 | - refactor: favainvestorapi cleanup. [Red S] 211 | - refactor: upgrade deprecated asyncio code. [Red S] 212 | - ticker-util: and ticker-relate: major refactor into a single utility. [Red S] 213 | - ticker-util: available keys. [Red S] 214 | - ticker-util: click: relate subcommand group. [Red S] 215 | - ticker_util: feature: add from commodities file. [Red S] 216 | - ticker-util: feature add: include undeclared. [Red S] 217 | - ticker-util: features: specify metadata, appends as cli args. [Red S] also: empty substsimilar metadata is excluded 218 | - ticker-util: header. [Red S] 219 | - ticker-util: moved to click. [Red S] 220 | 221 | 222 | ## 0.2.4 (2022-05-12) 223 | 224 | 225 | ### Other 226 | 227 | - tlh: bug in wash sale (31 vs 30 days) [Red S] 228 | - Flake. [Red S] 229 | - Pythonpackage workflow. [Red S] 230 | - . [Red S] 231 | - tlh: sort main table by harvestable losses. [Red S] 232 | 233 | ## 0.2.3 (2022-05-11) 234 | 235 | 236 | ### Improvements 237 | 238 | - TLH: screenshot update. [Red S] 239 | - Example update for article. [Red S] 240 | - tlh: html notes. [Red S] 241 | - tlh: rename subst to alt. [Red S] 242 | - tlh: clarify safe to harvest date. [Red S] 243 | - tlh: sort by harvestable loss. [Red S] 244 | - tLh: add built in options for account_field. [Red S] 245 | - tlh README. [Red S] 246 | - add subst column to TLH "Losses by Commodity" [Red S] 247 | - Show tlh alternate. [Red S] 248 | - tlh: show get one but leaf. [Red S] 249 | 250 | 251 | ## 0.2.2 (2022-04-27) 252 | ### Improvements 253 | - Add long/short info to TLH. [Red S] 254 | - Asset allocation by class: add multi currency support #32. [Red S] 255 | - requires all operating currencies to be specified 256 | 257 | ### Fixes 258 | - Fix for upstream changes: Use `url_for` instead of `url_for_current` [Aaron Lindsay] 259 | - Unbreak account_open_metadata. #35 [Martin Michlmayr] 260 | - Support fava's newly modified querytable macro. [Aaron Lindsay] 261 | 262 | ### Other 263 | - README updates, including #55. [Red S] 264 | - Example account hierarchy update. [Red S] 265 | - Fix assetalloc_class pytests. [Red S] 266 | - tlh fix pytests. [Red S] 267 | 268 | ## 0.2.1 (2021-01-10) 269 | - Macro asset_tree fix to make toggling work in fava v1.15 onwards. [Red S] 270 | - Table update to include footer (can't merge this until fava release) [Red S] 271 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | *Contributions welcome!* See [design.md](design.md) or the issues in github for more on 2 | some of the areas to contribute. 3 | 4 | ### Pre-requisities 5 | If you want to develop or contribute to fava_investor, make sure you have Python 3 (with 6 | pip). Install beancount and fava: 7 | 8 | `pip install fava` 9 | 10 | # Installation 11 | 12 | ### Install locally 13 | 14 | In the folder of your beancount journal file 15 | ```bash 16 | pip3 install fava 17 | git clone https://github.com/redstreet/fava_investor.git 18 | 19 | # Assuming you did this in the same directory of your beancount source, invoke the fava 20 | # extension using the line below, given the actual module lives in a subdirectory that 21 | # is also named fava_investor: 22 | # 2010-01-01 custom "fava-extension" "fava_investor.fava_investor" "{...}" 23 | ``` 24 | ### Install via pip to develop extension 25 | ```bash 26 | git clone https://github.com/redstreet/fava_investor.git 27 | pip install -e ./fava_investor 28 | ``` 29 | 30 | ### Running fava_investor 31 | ``` 32 | cd fava_investor 33 | fava example.beancount 34 | # or: 35 | fava huge-example.beancount 36 | ``` 37 | Then, point your browser to: http://localhost:5000/test 38 | 39 | As shown in the screenshots above, a link to Investor should appear in Fava. 40 | 41 | ### Problems 42 | 43 | If you see this in the Fava error page: 44 | `"Importing module "fava_investor" failed."` 45 | 46 | That usually means the module was not able to be loaded. Try running python3 47 | interactively and typing: 48 | 49 | `import fava_investor.fava_investor` 50 | 51 | That should succeed, or tell you what the failure was. 52 | 53 | 54 | # Contributing code 55 | Fork the repo on github and submit pull requests. 56 | 57 | See Philosophy section below before you contribute 58 | 59 | ### Contribution Guidelines 60 | 61 | Each module should include a Fava plugin, a Beancount library, and a Beancount based CLI 62 | (command line interface). APIs in `fava_investor/common/{favainvestorapi.py, 63 | beancountinvestorapi.py}` allow for easily developing these three interfaces to the 64 | library. The goal is to keep the modules agnostic to whether they are running within a 65 | beancount or fava context. 66 | 67 | Take a look at the `tlh` module to understand how to approach this. It is divided into three files: 68 | - `libtlh.py`: main library, agnostic to Fava/beancount. Calls the functions it needs via common/*investorapi 69 | - `tlh.py`: command line client that calls the library 70 | - fava_investor/`__init.py__`: fava interface that calls the library 71 | 72 | Of course, tests and html templates exist in their own files. 73 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include fava_investor/templates/*.html 2 | include fava_investor/pythonanywhere/* 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fava Investor 2 | 3 | Fava Investor aims to be a comprehensive set of reports, analyses, and tools for 4 | investments, for [Beancount](https://beancount.github.io/) and 5 | [Fava](https://github.com/beancount/fava) (software for 6 | [plain text, double entry bookkeeping](https://plaintextaccounting.org/)). It is developed as a 7 | collection of modules, with each module offering a *Fava plugin, a Beancount library, and 8 | a shell command*. 9 | 10 | ### Current modules: 11 | - [Visual, tree structured asset allocation by class](https://github.com/redstreet/fava_investor/tree/main/fava_investor/modules/assetalloc_class#readme) 12 | - Asset allocation by account 13 | - [Tax loss harvestor](https://github.com/redstreet/fava_investor/tree/main/fava_investor/modules/tlh#readme) 14 | - [Cash drag analysis](https://github.com/redstreet/fava_investor/tree/main/fava_investor/modules/cashdrag#readme) 15 | - [Summarizer](https://github.com/redstreet/fava_investor/tree/main/fava_investor/modules/summarizer#readme) 16 | - [Gains minimizer](https://github.com/redstreet/fava_investor/blob/main/fava_investor/modules/minimizegains#readme) 17 | 18 | ### Demo 19 | ***Play with the live demo at 20 | [pythonanywhere](http://favainvestor.pythonanywhere.com/example-beancount-file/extension/Investor/)***. 21 | 22 | 23 | #### Screenshots (dated): 24 | ![Screenshot: TLH](./screenshot.png) 25 | ![Screenshot: Asset Allocation](./screenshot-assetalloc.png) 26 | 27 | ### Utilities 28 | 29 | Fava Investor ships with `ticker-util`, which is a collection of utilities for: 30 | - downloading information from Yahoo for commodities (tickers), and annotating your 31 | commodity declarations with metadata 32 | - discovering relationships between tickers in your Beancount file, such as equivalent 33 | and substantially identical tickers, and tax loss harvesting partner groups, from a 34 | minimal and incomplete specification 35 | - providing ISIN an other ticker identifying information to your importers 36 | 37 | For more, install fava_investor via pip, and then see: 38 | ``` 39 | ticker-util --help 40 | ticker-util relate --help 41 | ``` 42 | 43 | 44 | ## Installation 45 | ```bash 46 | pip3 install fava-investor 47 | ``` 48 | 49 | Or to install the bleeding edge version from git: 50 | ```bash 51 | pip3 install git+https://github.com/redstreet/fava_investor 52 | ``` 53 | See [#55](https://github.com/redstreet/fava_investor/issues/55) for MacOS installation. 54 | 55 | Note the latest version of Fava Investor is compatible with both Beancount v2 and v3. 56 | 57 | ## Running Fava Investor 58 | ### Running in Fava: 59 | Add this to your beancount source, and start up fava as usual: 60 | ``` 61 | 2000-01-01 custom "fava-extension" "fava_investor" "{}" 62 | ``` 63 | 64 | You should now see an 'Investor' link in the sidebar in fava. For more on how to 65 | configure the extension, see the included `huge-example.beancount`. 66 | 67 | ### Running on the Command-Line: 68 | The command line interface (CLI) is accessed using the `investor` command, which has 69 | subcommands for each module. Eg: 70 | 71 | ``` 72 | investor assetalloc-class 73 | investor tlh 74 | investor --help 75 | ``` 76 | 77 | Both the CLI and the utility (`ticker-util`) use [click](https://click.palletsprojects.com/en/8.1.x/). 78 | [See here](https://click.palletsprojects.com/en/8.1.x/shell-completion/#enabling-completion) 79 | to enable shell completion in zsh, bash, or fish, which is highly recommended. 80 | 81 | ## Problems? 82 | - Monitor the terminal you are running fava from to look for error output from 83 | fava_investor 84 | - Include the error messages you see above when opening bug reports or asking for help 85 | 86 | ## Contributing 87 | 88 | Features, fixes, and improvements welcome. Remember: 89 | - Feel free to send send pull requests. Please include unit tests 90 | - For larger changes or changes that might need discussion, please reach out and discuss 91 | first to save time (open an issue) 92 | - Please squash your commits (reasonably) 93 | - Use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages 94 | 95 | Thank you for contributing! 96 | 97 | ## Related Projects 98 | - [Fava Dashboards](https://github.com/andreasgerstmayr/fava-dashboards) 99 | - [Fava Portfolio Returns](https://github.com/andreasgerstmayr/fava-portfolio-returns) 100 | - [Beangrow](https://github.com/beancount/beangrow) 101 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/TODO.txt -------------------------------------------------------------------------------- /design.md: -------------------------------------------------------------------------------- 1 | # Desired feature list 2 | 3 | The goal is to develop a comprehensive set of reports and tools related to investments 4 | for fava. This document contains list of potential features that we would like to 5 | consider for implementation. 6 | 7 | ## Reports 8 | 9 | ### Asset allocation: By asset class: 10 | - port [this](https://github.com/redstreet/beancount_asset_allocation) to fava 11 | - Pie chart, hierarchical 12 | - Reference chart 13 | - top level alone chart (to simplify complex portfolios)? 14 | 15 | - Tax adjusted 16 | 17 | ### Asset allocation: By account: 18 | - like the current one 19 | 20 | ### Asset allocation: Tax Treatment: 21 | - taxable, tax-deferred, etc. 22 | - configure using parent account or metadata 23 | 24 | ### IRR (internal rate of return): 25 | - across specified portfolio 26 | - drillable down to account-level and holding-leve 27 | - advanced: show tax drag (difficult to quantify) 28 | 29 | ### Net worth: 30 | - across time (redundant?) 31 | - show split of contributions, income (dividends, gains, etc.), and appreciation 32 | - filterable (by account?) 33 | - across arbitrary time periods 34 | - related to IRR above 35 | 36 | ### Savings rate: 37 | - absolute number across time 38 | - as %age of gross, net 39 | 40 | ### Summary stats: 41 | - number of unique funds owned 42 | - number of brokerage accounts 43 | 44 | 45 | ## Tools 46 | 47 | ### Cash drag analysis: 48 | - cash as percentage of portfolio, and where it is 49 | 50 | ### Tax loss harvester: 51 | - suck this in 52 | 53 | ### Rebalancing: 54 | - consider plugging into a rebalancing tool 55 | ([example1](https://github.com/AlexisDeschamps/portfolio-rebalancer), 56 | [example2](https://github.com/hoostus/lazy_rebalance)) 57 | 58 | -------------------------------------------------------------------------------- /fava_investor/__init__.py: -------------------------------------------------------------------------------- 1 | """Fava Investor: Investing related reports and tools for Beancount/Fava""" 2 | 3 | from fava.ext import FavaExtensionBase 4 | 5 | from .modules.tlh import libtlh 6 | from .modules.assetalloc_class import libassetalloc 7 | from .modules.assetalloc_account import libaaacc 8 | from .modules.cashdrag import libcashdrag 9 | from .modules.summarizer import libsummarizer 10 | from .modules.minimizegains import libminimizegains 11 | from .common.favainvestorapi import FavaInvestorAPI 12 | 13 | 14 | class Investor(FavaExtensionBase): # pragma: no cover 15 | report_title = "Investor" 16 | 17 | # AssetAllocClass 18 | # ----------------------------------------------------------------------------------------------------------- 19 | def build_assetalloc_by_class(self): 20 | accapi = FavaInvestorAPI() 21 | return libassetalloc.assetalloc(accapi, self.config.get('asset_alloc_by_class', {})) 22 | 23 | # AssetAllocAccount 24 | # ----------------------------------------------------------------------------------------------------------- 25 | def build_aa_by_account(self): 26 | accapi = FavaInvestorAPI() 27 | return libaaacc.portfolio_accounts(accapi, self.config.get('asset_alloc_by_account', [])) 28 | 29 | # Cash Drag 30 | # ----------------------------------------------------------------------------------------------------------- 31 | def build_cashdrag(self): 32 | accapi = FavaInvestorAPI() 33 | return libcashdrag.find_loose_cash(accapi, self.config.get('cashdrag', {})) 34 | 35 | # Summarizer (metadata info) 36 | # ----------------------------------------------------------------------------------------------------------- 37 | def build_summarizer(self): 38 | accapi = FavaInvestorAPI() 39 | return libsummarizer.build_tables(accapi, self.config.get('summarizer', {})) 40 | 41 | # TaxLossHarvester 42 | # ----------------------------------------------------------------------------------------------------------- 43 | def build_tlh_tables(self): 44 | accapi = FavaInvestorAPI() 45 | return libtlh.get_tables(accapi, self.config.get('tlh', {})) 46 | 47 | # Gains Minimizer 48 | # ----------------------------------------------------------------------------------------------------------- 49 | def build_minimizegains(self): 50 | accapi = FavaInvestorAPI() 51 | return libminimizegains.find_minimized_gains(accapi, self.config.get('minimizegains', {})) 52 | 53 | def recently_sold_at_loss(self): 54 | accapi = FavaInvestorAPI() 55 | return libtlh.recently_sold_at_loss(accapi, self.config.get('tlh', {})) 56 | -------------------------------------------------------------------------------- /fava_investor/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/fava_investor/cli/__init__.py -------------------------------------------------------------------------------- /fava_investor/cli/investor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Main command line interface for investor""" 3 | 4 | import click 5 | # import fava_investor.modules.assetalloc_account as assetalloc_account 6 | import fava_investor.modules.assetalloc_class.assetalloc_class as assetalloc_class 7 | import fava_investor.modules.cashdrag.cashdrag as cashdrag 8 | import fava_investor.modules.summarizer.summarizer as summarizer 9 | import fava_investor.modules.tlh.tlh as tlh 10 | import fava_investor.modules.minimizegains.minimizegains as minimizegains 11 | 12 | 13 | @click.group() 14 | def cli(): 15 | pass 16 | 17 | 18 | # cli.add_command(assetalloc_account.assetalloc_account) 19 | cli.add_command(assetalloc_class.assetalloc_class) 20 | cli.add_command(cashdrag.cashdrag) 21 | cli.add_command(summarizer.summarizer) 22 | cli.add_command(tlh.tlh) 23 | cli.add_command(minimizegains.minimizegains) 24 | 25 | 26 | if __name__ == '__main__': 27 | cli() 28 | -------------------------------------------------------------------------------- /fava_investor/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/fava_investor/common/__init__.py -------------------------------------------------------------------------------- /fava_investor/common/beancountinvestorapi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from collections import namedtuple 4 | from beancount.core import convert 5 | from beancount import loader 6 | from beancount.core import getters 7 | from beancount.core import prices 8 | from beancount.core import realization 9 | from beanquery import query 10 | from beancount.core.data import Open 11 | from beancount.core.data import Custom 12 | import ast 13 | 14 | 15 | class AccAPI: 16 | def __init__(self, beancount_file, options): 17 | self.entries, _, self.options_map = loader.load_file(beancount_file) 18 | self.options = options 19 | self.convert_position = convert.convert_position 20 | 21 | def end_date(self): 22 | return None # Only used in fava (UI selection context) 23 | 24 | def build_price_map(self): 25 | return prices.build_price_map(self.entries) 26 | 27 | def build_beancount_price_map(self): 28 | return self.build_price_map() 29 | 30 | def build_filtered_price_map(self, pos, base_currency): 31 | """Ignore filtering since we are not in fava. Return all prices""" 32 | return prices.build_price_map(self.entries) 33 | 34 | def get_commodity_directives(self): 35 | return getters.get_commodity_directives(self.entries) 36 | 37 | def realize(self): 38 | return realization.realize(self.entries) 39 | 40 | def root_tree(self): 41 | from fava.core import Tree 42 | return Tree(self.entries) 43 | 44 | # rrr = realization.realize(self.entries) 45 | # import pdb; pdb.set_trace() 46 | # return realization.realize(self.entries) 47 | 48 | def query_func(self, sql): 49 | rtypes, rrows = query.run_query(self.entries, self.options_map, sql) 50 | 51 | # Convert this into Beancount v2 format, so the rows are namedtuples 52 | field_names = [t.name for t in rtypes] 53 | rtypes = [(t.name, t.datatype) for t in rtypes] 54 | Row = namedtuple("Row", field_names) 55 | rrows = [Row(*row) for row in rrows] 56 | return rtypes, rrows 57 | 58 | def get_operating_currencies(self): 59 | return self.options_map['operating_currency'] 60 | 61 | def get_operating_currencies_regex(self): 62 | currencies = self.get_operating_currencies() 63 | return '(' + '|'.join(currencies) + ')' 64 | 65 | def get_account_open_close(self): 66 | return getters.get_account_open_close(self.entries) 67 | 68 | def get_account_open(self): 69 | oc = getters.get_account_open_close(self.entries) 70 | opens = [v for k, v in oc.items() if isinstance(v[0], Open)] 71 | return opens 72 | 73 | # def cost_or_value(self, node, date, include_children): 74 | # invent inventory.reduce(get_market_value, g.ledger.price_map, date) 75 | # if include_children: 76 | # return cost_or_value(node.balance_children, date) 77 | # return cost_or_value(node.balance, date) 78 | 79 | def get_custom_config(self, module_name): 80 | """Get fava config for the given plugin that can then be used on the command line""" 81 | _extension_entries = [e for e in self.entries 82 | if isinstance(e, Custom) and e.type == 'fava-extension'] 83 | config_meta = {entry.values[0].value: 84 | (entry.values[1].value if (len(entry.values) == 2) else None) 85 | for entry in _extension_entries} 86 | 87 | all_configs = {k: ast.literal_eval(v) for k, v in config_meta.items() if 'fava_investor' in k} 88 | 89 | # extract conig for just this module: 90 | module_config = [v[module_name] for k, v in all_configs.items() if module_name in v] 91 | if module_config: 92 | return module_config[0] 93 | return {} 94 | -------------------------------------------------------------------------------- /fava_investor/common/clicommon.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """CLI tools common library""" 3 | 4 | import click 5 | import csv 6 | import tabulate 7 | tabulate.PRESERVE_WHITESPACE = True 8 | 9 | 10 | def pretty_print_table(title, rtypes, rrows, footer=None, **kwargs): 11 | title_out = click.style(title + '\n', bg='green', fg='white') 12 | if footer: 13 | rrows += [(i[1] for i in footer)] 14 | 15 | if rrows: 16 | headers = [i[0] for i in rtypes] 17 | options = {'tablefmt': 'simple'} 18 | options.update(kwargs) 19 | return click.style(title_out + tabulate.tabulate(rrows, headers=headers, **options) + '\n\n') 20 | else: 21 | return click.style(title_out + '(empty table)' + '\n\n') 22 | 23 | 24 | def pretty_print_table_bare(rrows): 25 | print(tabulate.tabulate(rrows, tablefmt='simple')) 26 | 27 | 28 | def write_table_csv(filename, table): 29 | """ Write table to csv file """ 30 | with open(filename, 'w') as csvfile: 31 | writer = csv.writer(csvfile) 32 | headers = [i[0] for i in table[1][0]] 33 | writer.writerow(headers) 34 | 35 | for row in table[1][1]: 36 | writer.writerow(row) 37 | -------------------------------------------------------------------------------- /fava_investor/common/favainvestorapi.py: -------------------------------------------------------------------------------- 1 | from beancount.core import getters 2 | from collections import namedtuple 3 | from fava.core.conversion import cost_or_value as cost_or_value_without_context 4 | from fava import __version__ as fava_version 5 | from packaging import version 6 | from fava.context import g 7 | from fava.core.conversion import convert_position 8 | from beancount.core import realization 9 | from beancount.core import prices 10 | from beanquery import query 11 | 12 | 13 | class FavaInvestorAPI: 14 | def __init__(self): 15 | self.convert_position = convert_position 16 | 17 | def build_price_map(self): 18 | return g.ledger.prices 19 | 20 | def build_beancount_price_map(self): 21 | return prices.build_price_map(g.ledger.all_entries) 22 | 23 | def build_filtered_price_map(self, pcur, base_currency): 24 | """pcur and base_currency are currency strings""" 25 | return {(pcur, base_currency): g.filtered.prices(pcur, base_currency)} 26 | 27 | def end_date(self): 28 | return g.filtered.end_date 29 | 30 | def get_commodity_directives(self): 31 | return {entry.currency: entry for entry in g.filtered.ledger.all_entries_by_type.Commodity} 32 | 33 | def realize(self): 34 | return realization.realize(g.filtered.entries) 35 | 36 | def root_tree(self): 37 | return g.filtered.root_tree 38 | 39 | def query_func(self, sql): 40 | # Based on the fava version, determine if we need to add a new 41 | # positional argument to fava's execute_query() 42 | if version.parse(fava_version) >= version.parse("1.30"): 43 | rtypes, rrows = query.run_query(g.filtered.entries, g.ledger.options, sql) 44 | 45 | # Convert this into Beancount v2 format, so the rows are namedtuples 46 | field_names = [t.name for t in rtypes] 47 | Row = namedtuple("Row", field_names) 48 | rtypes = [(t.name, t.datatype) for t in rtypes] 49 | rrows = [Row(*row) for row in rrows] 50 | 51 | elif version.parse(fava_version) >= version.parse("1.22"): 52 | _, rtypes, rrows = g.ledger.query_shell.execute_query(g.filtered.entries, sql) 53 | else: 54 | _, rtypes, rrows = g.ledger.query_shell.execute_query(sql) 55 | return rtypes, rrows 56 | 57 | def get_operating_currencies(self): 58 | return g.ledger.options["operating_currency"] # TODO: error check 59 | 60 | def get_operating_currencies_regex(self): 61 | currencies = self.get_operating_currencies() 62 | return '(' + '|'.join(currencies) + ')' 63 | 64 | def get_account_open_close(self): 65 | return getters.get_account_open_close(g.filtered.entries) 66 | 67 | def get_account_open(self): 68 | # TODO: below is probably fava only, and needs to be made beancount friendly 69 | return g.ledger.all_entries_by_type.Open 70 | 71 | def cost_or_value(self, node, date, include_children): 72 | nodes = node.balance 73 | if include_children: 74 | nodes = node.balance_children 75 | return cost_or_value_without_context(nodes, g.conversion, g.ledger.prices, date) 76 | -------------------------------------------------------------------------------- /fava_investor/common/libinvestor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import collections 4 | import decimal 5 | from beancount.core.inventory import Inventory 6 | from beancount.core import convert # noqa: F401 7 | from beancount.core.convert import convert_position 8 | 9 | 10 | class Node(object): 11 | """Generic tree implementation. Consider replacing this with anytree""" 12 | 13 | def __init__(self, name): 14 | self.name = name 15 | self.children = [] 16 | self.parent = None 17 | 18 | def add_child(self, obj): 19 | self.children.append(obj) 20 | obj.parent = self 21 | 22 | def find_child(self, name): 23 | for child in self.children: 24 | if child.name == name: 25 | return child 26 | return None 27 | 28 | def __lt__(self, other): 29 | return self.name < other.name 30 | 31 | def pre_order(self, level=0): 32 | yield self, level 33 | for c in sorted(self.children): 34 | yield from c.pre_order(level + 1) 35 | 36 | 37 | def val(inv): 38 | if inv is not None: 39 | pos = inv.get_only_position() 40 | if pos is not None: 41 | return pos.units.number 42 | if inv.is_empty(): 43 | return 0 44 | return None 45 | 46 | 47 | def remove_column(col_name, rows, types): 48 | """Remove a column by name from a beancount query return pair of rows and types""" 49 | try: 50 | col = [i for i in types if i[0] == col_name][0] 51 | except IndexError: # Col not found 52 | return rows, types 53 | idx = types.index(col) 54 | 55 | del types[idx] 56 | RetRow = collections.namedtuple('RetRow', [i[0] for i in types]) 57 | rrows = [] 58 | for r in rows: 59 | tmp = list(r) 60 | del tmp[idx] 61 | rrows.append(RetRow(*tmp)) 62 | return rrows, types 63 | 64 | 65 | def insert_column(cols, col_name, col_type, new_col_name, new_col_type=str): 66 | """Inserts a column right after col_name. in a list of data types returned by a beancount query 67 | If col_type is specified (is not None), changes the type of col_name to col_type.""" 68 | 69 | retval = [] 70 | for col, ctype in cols: 71 | if col == col_name: 72 | if col_type is None: 73 | col_type = ctype 74 | retval.append((col_name, col_type)) 75 | retval.append((new_col_name, new_col_type)) 76 | else: 77 | retval.append((col, ctype)) 78 | return retval 79 | 80 | 81 | def build_table_footer(types, rows, accapi): 82 | """Build a footer with sums by default. Looks like: [(, ), ...]""" 83 | 84 | def sum_inventories(invs): 85 | """Sum the given list of inventory into a single inventory""" 86 | retval = Inventory() 87 | for i in invs: 88 | retval.add_inventory(i) 89 | return retval 90 | 91 | ret_types = [t[1] for t in types] 92 | ret_values = [] 93 | for label, t in types: 94 | total = '' 95 | if t == Inventory: 96 | total = sum_inventories([getattr(r, label) for r in rows]) 97 | total = total.reduce(convert_position, accapi.get_operating_currencies()[0], 98 | accapi.build_beancount_price_map()) 99 | elif t == decimal.Decimal: 100 | total = sum([getattr(r, label) for r in rows]) 101 | ret_values.append(total) 102 | return list(zip(ret_types, ret_values)) 103 | 104 | 105 | def build_config_table(options): 106 | """Build a table listing the config options.""" 107 | 108 | retrow_types = [('Key', str), ('Value', str)] 109 | RetRow = collections.namedtuple('RetRow', [i[0] for i in retrow_types]) 110 | rrows = [RetRow(k, str(v)) for k, v in options.items()] 111 | return 'Config Summary', (retrow_types, rrows, None, None) 112 | 113 | 114 | def split_currency(value): 115 | units = value.get_only_position().units 116 | return units.number, units.currency 117 | -------------------------------------------------------------------------------- /fava_investor/examples/beancount-example.config: -------------------------------------------------------------------------------- 1 | ; fava_investor config for example.beancount that ships with beancount v2 source 2 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 3 | 'tlh' : { 4 | 'accounts_pattern': 'Assets:US', 5 | 'loss_threshold': 0, 6 | 'wash_pattern': 'Assets:', 7 | 'account_field': 2, 8 | }, 9 | 10 | 'asset_alloc_by_account': [ 11 | { 12 | 'title': 'Allocation by Account', 13 | 'pattern_type': 'account_name', 14 | 'pattern': 'Assets:.*', 15 | }, 16 | ], 17 | 18 | 19 | 'asset_alloc_by_class' : { 20 | 'accounts_patterns': ['Assets:'], 21 | }, 22 | 23 | 'cashdrag': { 24 | 'accounts_pattern': '^Assets:.*', 25 | 'accounts_exclude_pattern': '^Assets:(Cash-In-Wallet.*)', 26 | 'metadata_label_cash' : 'asset_allocation_Bond_Cash' 27 | }, 28 | 'summarizer': [ 29 | { 'title' : 'Commodities Summary', 30 | 'directive_type' : 'commodities', 31 | 'active_only': True, 32 | 'columns' : ['export', 'name', 'price'], 33 | 'sort_by' : 1, 34 | }, 35 | { 'title' : 'Institution Contact Info', 36 | 'directive_type' : 'accounts', 37 | 'acc_pattern' : '^Assets:', 38 | 'meta_prefix' : '', 39 | 'columns' : ['address', 'institution', 'phone', 'account'], 40 | }, 41 | ] 42 | }" 43 | 44 | -------------------------------------------------------------------------------- /fava_investor/examples/example.beancount: -------------------------------------------------------------------------------- 1 | option "title" "Test" 2 | option "operating_currency" "USD" 3 | option "render_commas" "True" 4 | 5 | 2010-01-01 open Assets:Investments:Taxable:XTrade 6 | asset_allocation_tax_adjustment: 95 7 | 8 | 2010-01-01 open Assets:Investments:Taxable:FreeTrader 9 | 2010-01-01 open Assets:Investments:Tax-Deferred:YTrade 10 | asset_allocation_tax_adjustment: 55 11 | 12 | 2010-01-01 open Assets:Investments:Tax-Deferred:ZTrade 13 | asset_allocation_tax_adjustment: 55 14 | 15 | 2010-01-01 open Assets:Investments:Tax-Free:YTrade 16 | 2010-01-01 open Assets:Bank 17 | 18 | 2010-01-01 commodity BNCT 19 | asset_allocation_equity_international: 80 20 | asset_allocation_bond: 20 21 | 22 | 2010-01-01 commodity COFE 23 | asset_allocation_equity: 70 24 | asset_allocation_bond_municipal: 10 25 | asset_allocation_realestate: 20 26 | 27 | 28 | 2020-02-01 * "Buy stock" 29 | Assets:Investments:Taxable:XTrade 7000 BNCT {200 USD} 30 | Assets:Bank 31 | 32 | 2020-02-02 * "Buy stock" 33 | Assets:Investments:Taxable:XTrade 20 COFE {100 USD} 34 | Assets:Bank 35 | 36 | 2020-02-03 * "Buy stock" 37 | Assets:Investments:Taxable:XTrade 20 UPUP {10 USD} 38 | Assets:Bank 39 | 40 | 2020-02-10 * "Buy stock" 41 | Assets:Investments:Taxable:XTrade 20 DNRCNT {10 USD} 42 | Assets:Bank 43 | 44 | 2020-02-20 * "Buy stock" 45 | Assets:Investments:Tax-Deferred:YTrade 20 DNRCNT {100 USD} 46 | Assets:Bank 47 | 48 | 2020-02-20 * "Buy stock" 49 | Assets:Investments:Tax-Deferred:ZTrade 50 HOOLI {800 USD} 50 | Assets:Bank 51 | 52 | 2020-02-20 * "Buy stock" 53 | Assets:Investments:Tax-Free:YTrade 1000 HOOLI {800 USD} 54 | Assets:Bank 55 | 56 | 2020-02-20 * "Buy stock" 57 | Assets:Investments:Taxable:XTrade 1 BNCT {195 USD} 58 | Assets:Bank 59 | 60 | 2020-02-21 * "Sell stock" 61 | Assets:Investments:Taxable:XTrade -2 BNCT {200 USD} 62 | Assets:Bank 63 | 64 | 2020-02-22 * "Buy stock" 65 | Assets:Investments:Taxable:FreeTrader 500 HOOLI {800 USD} 66 | Assets:Bank 67 | 68 | 2020-03-08 price BNCT 150 USD 69 | 2020-03-08 price COFE 95 USD 70 | 2020-03-08 price UPUP 25 USD 71 | 2020-03-08 price DNRCNT 98 USD 72 | 2020-03-08 price HOOLI 800 USD 73 | 74 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 75 | 'tlh' : { 76 | 'account_field': 'account', 77 | 'accounts_pattern': 'Assets:Investments:Taxable', 78 | 'loss_threshold': 50, 79 | 'wash_pattern': 'Assets:Investments', 80 | }, 81 | 82 | 'asset_alloc_by_account': [{ 83 | 'title': 'Allocation by Account', 84 | 'pattern_type': 'account_name', 85 | 'pattern': 'Assets:Investments:.*', 86 | }, 87 | { 88 | 'title': 'Allocation by Taxability', 89 | 'pattern_type': 'account_name', 90 | 'pattern': 'Assets:Investments:[^:]*$', 91 | 'include_children': True, 92 | }], 93 | 94 | 'asset_alloc_by_class' : { 95 | 'accounts_patterns': ['Assets:.*'], 96 | }, 97 | }" 98 | -------------------------------------------------------------------------------- /fava_investor/modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/fava_investor/modules/__init__.py -------------------------------------------------------------------------------- /fava_investor/modules/assetalloc_account/TODO.txt: -------------------------------------------------------------------------------- 1 | - remove fava dependency from libaaacc.py 2 | - create a cli to use libaaacc.py 3 | - add unit tests 4 | 5 | -------------------------------------------------------------------------------- /fava_investor/modules/assetalloc_account/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/fava_investor/modules/assetalloc_account/__init__.py -------------------------------------------------------------------------------- /fava_investor/modules/assetalloc_account/assetalloc_account.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Description: CLI for asset allocation 3 | 4 | import fava_investor.modules.assetalloc_account.libaaacc as libaaacc 5 | import beancountinvestorapi as api 6 | from beancount.core import realization 7 | from beancount.core import display_context 8 | import click 9 | import os 10 | import sys 11 | import tabulate 12 | tabulate.PRESERVE_WHITESPACE = True 13 | 14 | 15 | sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..', 'common')) 16 | 17 | 18 | def print_balances_tree(realacc, accapi): 19 | print() 20 | print('Account balances:') 21 | dformat = accapi.options_map['dcontext'].build(alignment=display_context.Align.DOT, 22 | reserved=2) 23 | realization.dump_balances(realacc, dformat, file=sys.stdout) 24 | 25 | 26 | def formatted_tree(root): 27 | rows = [] 28 | for n, level in root.pre_order(0): 29 | rows.append((' ' * level + n.name, '{:,.0f}'.format(n.balance_children), 30 | '{:.1f}%'.format(n.percentage_children))) 31 | 32 | return tabulate.tabulate(rows, 33 | headers=['asset_type', 'amount', 'percentage'], 34 | colalign=('left', 'decimal', 'right'), 35 | tablefmt='simple') 36 | 37 | 38 | @click.command() 39 | @click.argument('beancount-file', type=click.Path(exists=True), envvar='BEANCOUNT_FILE') 40 | @click.option('-d', '--dump-balances-tree', help='Show tree', is_flag=True) 41 | def assetalloc_account(beancount_file, dump_balances_tree): 42 | """Beancount Asset Allocation (By Account) Analyzer. 43 | 44 | The BEANCOUNT_FILE environment variable can optionally be set instead of specifying the file on the 45 | command line. 46 | 47 | The configuration for this module is expected to be supplied as a custom directive like so in your 48 | beancount file: 49 | 50 | \b 51 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 52 | 'asset_alloc_by_account' : { 53 | 'title': 'Allocation by Account', 54 | 'pattern_type': 'account_name', 55 | 'pattern': 'Assets:Investments:[^:]*:[^:]*$', 56 | 'include:children': True, 57 | }}", 58 | """ 59 | sys.exit("Error: CLI not yet implemented") 60 | 61 | accapi = api.AccAPI(beancount_file, {}) 62 | config = accapi.get_custom_config('asset_alloc_by_account') 63 | 64 | # TODO: cost_or_value needs to be implemented in beancountinvestorapi 65 | asset_buckets_tree, realacc = libaaacc.portfolio_accounts(accapi, config) 66 | 67 | click.echo_via_pager(formatted_tree(asset_buckets_tree)) 68 | 69 | if dump_balances_tree: 70 | print_balances_tree(realacc, accapi) 71 | 72 | 73 | if __name__ == '__main__': 74 | assetalloc_account() 75 | -------------------------------------------------------------------------------- /fava_investor/modules/assetalloc_account/libaaacc.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python3 2 | 3 | import re 4 | from beancount.core.number import Decimal 5 | 6 | 7 | def portfolio_accounts(accapi, configs): 8 | """An account tree based on matching regex patterns.""" 9 | 10 | portfolios = [] 11 | for config in configs: 12 | pattern_type = config['pattern_type'] 13 | func = globals()['by_' + pattern_type] 14 | portfolio = func(accapi, config) 15 | portfolios.append(portfolio) 16 | 17 | return portfolios 18 | 19 | 20 | def by_account_name(accapi, config): 21 | """Returns portfolio info based on matching account name.""" 22 | 23 | tree = accapi.root_tree() 24 | pattern = config['pattern'] 25 | include_children = config.get('include_children', False) 26 | title = config.get('title', f"Account names matching: '{pattern}'") 27 | 28 | selected_accounts = [] 29 | regexer = re.compile(pattern) 30 | for acct in tree.keys(): 31 | if regexer.match(acct) is not None and acct not in selected_accounts: 32 | selected_accounts.append(acct) 33 | 34 | selected_nodes = [tree[x] for x in selected_accounts] 35 | portfolio_data = asset_allocation(selected_nodes, accapi, include_children) 36 | return title, portfolio_data 37 | 38 | 39 | def by_account_open_metadata(accapi, config): 40 | """ Returns portfolio info based on matching account open metadata. """ 41 | 42 | metadata_key = config['metadata_key'] 43 | pattern = config['pattern'] 44 | title = config.get('title', 'Accounts with {} metadata matching {}'.format(metadata_key, pattern)) 45 | 46 | selected_accounts = [] 47 | include_children = config.get('include_children', False) 48 | regexer = re.compile(pattern) 49 | for entry in accapi.get_account_open(): 50 | if metadata_key in entry.meta and regexer.match(entry.meta[metadata_key]) is not None: 51 | selected_accounts.append(entry.account) 52 | 53 | selected_nodes = [accapi.root_tree()[x] for x in selected_accounts] 54 | portfolio_data = asset_allocation(selected_nodes, accapi, include_children) 55 | return title, portfolio_data 56 | 57 | 58 | def asset_allocation(nodes, accapi, include_children): 59 | """Compute percentage of assets in each of the given nodes.""" 60 | 61 | date = accapi.end_date() 62 | operating_currency = accapi.get_operating_currencies()[0] 63 | acct_type = ("account", str(str)) 64 | bal_type = ("balance", str(Decimal)) 65 | alloc_type = ("allocation %", str(Decimal)) 66 | rtypes = [acct_type, bal_type, alloc_type] 67 | 68 | rrows = [] 69 | for node in nodes: 70 | row = {'account': node.name} 71 | balance = accapi.cost_or_value(node, date, include_children) 72 | if operating_currency in balance: 73 | row["balance"] = balance[operating_currency] 74 | rrows.append(row) 75 | 76 | portfolio_total = sum(row['balance'] for row in rrows) 77 | for row in rrows: 78 | if "balance" in row: 79 | row["allocation %"] = round((row["balance"] / portfolio_total) * 100, 1) 80 | 81 | return rtypes, rrows, None, None 82 | -------------------------------------------------------------------------------- /fava_investor/modules/assetalloc_class/README.md: -------------------------------------------------------------------------------- 1 | # Asset Allocation 2 | 3 | Understanding the asset allocation of a portfolio is important. This module reports your 4 | current portfolio's asset allocation, based on an arbitrary asset class hierarchy and 5 | depth of your choice. 6 | 7 | ## Installation 8 | A Fava extension, a Beancount command line client, and a library are all included. 9 | To install the Fava plugin, see [fava_investor](https://github.com/redstreet/fava_investor). 10 | 11 | Command line client: 12 | ``` 13 | investor assetalloc-class example.beancount 14 | ``` 15 | The command line client also uses the same Fava configuration shown below. 16 | 17 | ## Configuration 18 | 19 | Price entries are needed in order for this plugin to determine the market value of the 20 | commodities you hold. For accuracy, it helps to have recent price entries for each 21 | commodity you own. 22 | 23 | You probably also want to add this plugin to your Beancount ledger to help: 24 | 25 | ``` 26 | plugin "beancount.plugins.implicit_prices" 27 | ``` 28 | 29 | Accounts with negative balances are skipped with this message: 30 | 31 | ``` 32 | Warning: skipping negative balance 33 | ``` 34 | 35 | These are probably liabilities and are not considered by this plugin. 36 | 37 | 38 | ### Multi-currency portfolios 39 | This module supports multiple currencies. See #32 on how to configure your input 40 | correctly. 41 | 42 | ### Options 43 | Options are declared using a custom `"fava-extension"` directive, which is used both by 44 | the Fava plugin and the CLI, like so: 45 | 46 | ``` 47 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 48 | 'asset_alloc_by_class' : { 49 | 'accounts_patterns': ['Assets:(Investments|Banks)'], 50 | 'skip-tax-adjustment': True, 51 | }}" 52 | ``` 53 | 54 | The full list of configuration options is below: 55 | 56 | #### `accounts_pattern` 57 | 58 | Regex specifying a set of accounts to consider. 59 | 60 | #### `skip_tax_adjustment` 61 | 62 | When set to False, ignore the `asset_allocation_tax_adjustment` metadata declarations. 63 | 64 | ### Metadata Declarations for Commodities 65 | 66 | The percentage of each asset class for each commodity is specified in the commodity 67 | metadata, like so: 68 | 69 | ``` 70 | 2010-01-01 commodity BMUT 71 | asset_allocation_equity_international: 60 72 | asset_allocation_bond: 40 73 | ``` 74 | 75 | 76 | The only requirement is that the metadata field name begins with the prefix 77 | `asset_allocation_`, and has a number for its value that is a percentage, corresponding 78 | to the percentage of the commodity belonging to that asset class. The set of all asset 79 | classes for a commodity should add up to a 100. When they do not, the reporter will pad 80 | the remaining with the 'unknown' class. 81 | 82 | What comes after that prefix in the commodity metadata is arbitrary, which is what 83 | allows you to nest your allocation hierarchy as deep as you would like, separated b 84 | `_`s. 85 | 86 | More examples: 87 | 88 | ``` 89 | 2010-01-01 commodity BOND 90 | asset_allocation_bond_municipal: 80 91 | asset_allocation_bond_treasuries: 20 92 | ``` 93 | 94 | ``` 95 | 2010-01-01 commodity ANOTHERBOND 96 | asset_allocation_bond: 100 97 | ``` 98 | 99 | ``` 100 | 2010-01-01 commodity SP500 101 | asset_allocation_equity_domestic: 100 102 | ``` 103 | 104 | ``` 105 | 2010-01-01 commodity USD 106 | asset_allocation_bond_cash: 100 107 | ``` 108 | 109 | ### Metadata Declarations for Accounts 110 | 111 | Optionally, the percentage by which an entire account should be scaled for tax purposes 112 | is specified by the `asset_allocation_tax_adjustment` metadata in an account's `open` 113 | directive like so: 114 | 115 | ``` 116 | 2010-01-01 open Assets:Investments:Tax-Deferred:Retirement 117 | asset_allocation_tax_adjustment: 55 118 | ``` 119 | 120 | ## Example Output 121 | ``` 122 | $ ./asset_allocation.py example.beancount --dump-balances-tree 123 | ``` 124 | Output: 125 | ``` 126 | asset_type amount percentage 127 | ---------------------- -------- ------------ 128 | Total 158,375 100.0% 129 | equity 124,862 78.8% 130 | equity_international 112,000 70.7% 131 | bond 29,838 18.8% 132 | bond_municipal 1,838 1.2% 133 | realestate 3,675 2.3% 134 | 135 | Account balances: 136 | `-- Assets 137 | `-- Investments 700 BNCT 138 | |-- Tax-Deferred 139 | | `-- Retirement 55 COFE 140 | `-- Taxable 141 | `-- XTrade 190 COFE 142 | ``` 143 | 144 | Asset allocations are displayed hierarchically. Percentages and amounts include the 145 | children. For example, the 'bond' percentage and amount above includes municipal bonds. 146 | 147 | -------------------------------------------------------------------------------- /fava_investor/modules/assetalloc_class/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/fava_investor/modules/assetalloc_class/__init__.py -------------------------------------------------------------------------------- /fava_investor/modules/assetalloc_class/assetalloc_class.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Description: CLI for asset allocation 3 | 4 | import fava_investor.modules.assetalloc_class.libassetalloc as libassetalloc 5 | import beancountinvestorapi as api 6 | from beancount.core import realization 7 | from beancount.core import display_context 8 | import click 9 | import os 10 | import sys 11 | import tabulate 12 | tabulate.PRESERVE_WHITESPACE = True 13 | 14 | 15 | sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..', 'common')) 16 | 17 | 18 | def print_balances_tree(realacc, accapi): 19 | print() 20 | print('Account balances:') 21 | dformat = accapi.options_map['dcontext'].build(alignment=display_context.Align.DOT, 22 | reserved=2) 23 | realization.dump_balances(realacc, dformat, file=sys.stdout) 24 | 25 | 26 | def formatted_tree(root): 27 | rows = [] 28 | for n, level in root.pre_order(0): 29 | name = '_'.join(n.name.split('_')[level-1:]) # remove prefix (equity_domestic --> domestic) 30 | rows.append((' ' * level + name, '{:,.0f}'.format(n.balance_children), 31 | '{:.1f}%'.format(n.percentage_children))) 32 | 33 | return tabulate.tabulate(rows, 34 | headers=['asset_type', 'amount', 'percentage'], 35 | colalign=('left', 'decimal', 'right'), 36 | tablefmt='simple') 37 | 38 | 39 | @click.command() 40 | @click.argument('beancount-file', type=click.Path(exists=True), envvar='BEANCOUNT_FILE') 41 | @click.option('-d', '--dump-balances-tree', help='Show tree', is_flag=True) 42 | def assetalloc_class(beancount_file, dump_balances_tree): 43 | """Beancount Asset Allocation Analyzer. 44 | 45 | The BEANCOUNT_FILE environment variable can optionally be set instead of specifying the file on the 46 | command line. 47 | 48 | The configuration for this module is expected to be supplied as a custom directive like so in your 49 | beancount file: 50 | 51 | \b 52 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 53 | 'asset_alloc_by_class' : { 54 | 'accounts_patterns': ['Assets:(Investments|Banks)'], 55 | 'skip-tax-adjustment': True, 56 | }}" 57 | """ 58 | accapi = api.AccAPI(beancount_file, {}) 59 | config = accapi.get_custom_config('asset_alloc_by_class') 60 | asset_buckets_tree, realacc = libassetalloc.assetalloc(accapi, config) 61 | 62 | click.echo_via_pager(formatted_tree(asset_buckets_tree)) 63 | 64 | if dump_balances_tree: 65 | print_balances_tree(realacc, accapi) 66 | 67 | 68 | if __name__ == '__main__': 69 | assetalloc_class() 70 | -------------------------------------------------------------------------------- /fava_investor/modules/assetalloc_class/example.beancount: -------------------------------------------------------------------------------- 1 | ; Run example by executing: 2 | ; ./asset_allocation.py example.beancount --accounts "Assets:Investments:" --dump 3 | 4 | option "operating_currency" "USD" 5 | 6 | 2010-01-01 open Assets:Investments:Taxable:XTrade 7 | asset_allocation_tax_adjustment: 95 8 | 9 | 2010-01-01 open Assets:Investments:Tax-Deferred:Retirement 10 | asset_allocation_tax_adjustment: 55 11 | 12 | 2010-01-01 open Assets:Bank 13 | 14 | 2010-01-01 commodity BNCT 15 | asset_allocation_equity_international: 80 16 | asset_allocation_bond: 20 17 | 18 | 2010-01-01 commodity COFE 19 | asset_allocation_equity: 70 20 | asset_allocation_bond_municipal: 10 21 | asset_allocation_realestate: 20 22 | 23 | 2011-01-10 * "Buy stock" 24 | Assets:Investments:Taxable:XTrade 200 COFE {75 USD} 25 | Assets:Bank 26 | 27 | 2011-01-10 * "Buy stock" 28 | Assets:Investments:Tax-Deferred:Retirement 100 COFE {75 USD} 29 | Assets:Bank 30 | 31 | ; This will not be considered in our asset allocation since we are filtering by children of 'Assets:Investments' 32 | 2011-01-02 * "Buy stock" 33 | Assets:Investments 700 BNCT {200 USD} 34 | Assets:Bank 35 | 36 | 2011-03-02 price BNCT 200 USD 37 | 2011-03-02 price COFE 75 USD 38 | 39 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 40 | 'asset_alloc_by_class' : { 41 | 'accounts_patterns': ['Assets:Investments'], 42 | } 43 | }" 44 | -------------------------------------------------------------------------------- /fava_investor/modules/assetalloc_class/libassetalloc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Description: Beancount script for asset allocation reporting 3 | 4 | from fava_investor.common.libinvestor import Node 5 | import collections 6 | import re 7 | 8 | # from fava.core.conversion import convert_position 9 | from beancount.core import convert 10 | from beancount.core import inventory 11 | from beancount.core import position 12 | from beancount.core import realization 13 | from beancount.core.number import Decimal 14 | 15 | import os 16 | import sys 17 | sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..', 'common')) 18 | 19 | 20 | class AssetClassNode(Node): 21 | def serialise(self, currency): 22 | """Serialise the node. Make it compatible enough with fava ledger's tree in order to pass this 23 | structure to fava charts """ 24 | 25 | children = [child.serialise(currency) for child in self.children] 26 | return { 27 | "account": self.name, 28 | "balance_children": {currency: self.balance_children}, 29 | "balance": {currency: self.balance}, 30 | "children": children, 31 | "has_txns": False, 32 | } 33 | 34 | def pretty_print(self, indent=0): 35 | fmt = "{}{} {:4.2f} {:4.2f} {:4.2f} {:4.2f} {:4.2f}" 36 | print(fmt.format('-' * indent, self.name, 37 | self.balance, self.balance_children, 38 | self.percentage, self.percentage_children, self.percentage_parent)) 39 | for c in self.children: 40 | c.pretty_print(indent + 1) 41 | 42 | 43 | def compute_child_balances(node, total): 44 | node.balance_children = node.balance + sum(compute_child_balances(c, total) for c in node.children) 45 | node.percentage = (node.balance / total) * 100 46 | node.percentage_children = (node.balance_children / total) * 100 47 | return node.balance_children 48 | 49 | 50 | def compute_parent_balances(node): 51 | if node.parent: 52 | node.percentage_parent = (node.balance_children / node.parent.balance_children) * 100 53 | else: 54 | node.percentage_parent = 100 55 | for c in node.children: 56 | compute_parent_balances(c) 57 | 58 | 59 | def treeify(asset_buckets, accapi): 60 | def ancestors(s): 61 | c = s.count('_') 62 | for i in range(c, -1, -1): 63 | yield s.rsplit('_', i)[0] 64 | 65 | root = AssetClassNode('Total') 66 | root.balance = 0 67 | # The entire asset class tree has to be in a single currency (so they're all comparable). We store this 68 | # one currency in the root node. 69 | root.currency = accapi.get_operating_currencies()[0] 70 | for bucket, balance in asset_buckets.items(): 71 | node = root 72 | for p in ancestors(bucket): 73 | new_node = node.find_child(p) 74 | if not new_node: 75 | new_node = AssetClassNode(p) 76 | new_node.balance = 0 77 | node.add_child(new_node) 78 | node = new_node 79 | node.balance = balance 80 | 81 | total = sum(balance for bucket, balance in asset_buckets.items()) 82 | compute_child_balances(root, total) 83 | compute_parent_balances(root) 84 | return root 85 | 86 | 87 | def bucketize(vbalance, accapi): 88 | f_price_map = accapi.build_price_map() 89 | b_price_map = accapi.build_beancount_price_map() 90 | commodities = accapi.get_commodity_directives() 91 | operating_currencies = accapi.get_operating_currencies() 92 | base_currency = operating_currencies[0] 93 | meta_prefix = 'asset_allocation_' 94 | meta_prefix_len = len(meta_prefix) 95 | end_date = accapi.end_date() 96 | 97 | # Main part: put each commodity's value into asset buckets 98 | asset_buckets = collections.defaultdict(int) 99 | for pos in vbalance.get_positions(): 100 | if pos.units.number < 0: 101 | print("Warning: skipping negative balance:", pos) 102 | continue 103 | 104 | # what we want is the conversion to be done on the end date, or on a date 105 | # closest to it, either earlier or later. convert_position does this via bisect 106 | amount = accapi.convert_position(pos, base_currency, f_price_map, date=end_date) 107 | if amount.currency == pos.units.currency and amount.currency != base_currency: 108 | # Ideally, we would automatically figure out the currency to hop via, based on the cost 109 | # currency of the position. However, with vbalance, cost currency info is not 110 | # available. Hence, we hop via any available operating currency specified by the user. 111 | # This is for supporting multi-currency portfolios 112 | amount = convert.convert_amount(pos.units, base_currency, b_price_map, 113 | via=operating_currencies, date=end_date) 114 | if amount.currency != base_currency: 115 | sys.stderr.write("Error: unable to convert {} to base currency {}" 116 | " (Missing price directive?)\n".format(pos, base_currency)) 117 | sys.exit(1) 118 | 119 | commodity = pos.units.currency 120 | metas = {} if commodities.get(commodity) is None else commodities[commodity].meta 121 | unallocated = Decimal('100') 122 | for meta_key, meta_value in metas.items(): 123 | if meta_key.startswith(meta_prefix): 124 | bucket = meta_key[meta_prefix_len:] 125 | asset_buckets[bucket] += amount.number * (meta_value / 100) 126 | unallocated -= meta_value 127 | if unallocated: 128 | print("Warning: {} asset_allocation_* metadata does not add up to 100%. " 129 | "Padding with 'unknown'.".format(commodity)) 130 | asset_buckets['unknown'] += amount.number * (unallocated / 100) 131 | return asset_buckets 132 | 133 | 134 | def compute_percent(asset_buckets, asset, total_assets): 135 | return (asset_buckets[asset] / total_assets) * 100 136 | 137 | 138 | def compute_percent_subtotal(asset_buckets, asset, total_assets): 139 | return (compute_balance_subtotal(asset_buckets, asset) / total_assets) * 100 140 | 141 | 142 | def compute_balance_subtotal(asset_buckets, asset): 143 | children = [k for k in asset_buckets.keys() if k.startswith(asset) and k != asset] 144 | subtotal = asset_buckets[asset] 145 | for c in children: 146 | subtotal += compute_balance_subtotal(asset_buckets, c) 147 | return subtotal 148 | 149 | 150 | def build_interesting_realacc(accapi, accounts): 151 | def is_included_account(realacc): 152 | for pattern in accounts: 153 | if re.match(pattern, realacc.account): 154 | if realacc.balance == inventory.Inventory(): 155 | return False # Remove empty accounts to "clean up" the tree 156 | return True 157 | return False 158 | 159 | realroot = accapi.realize() 160 | 161 | # first, filter out accounts that are not specified: 162 | realacc = realization.filter(realroot, is_included_account) 163 | 164 | if not realacc: 165 | sys.stderr.write("No included accounts found. (Your --accounts failed to match any account)\n") 166 | sys.exit(1) 167 | 168 | # However, realacc includes all ancestor accounts of specified accounts, and their balances. For example, 169 | # if we specified 'Accounts:Investments:Brokerage', balances due to transactions on 'Accounts:Investments' 170 | # will also be included. We need to filter these out: 171 | for acc in realization.iter_children(realacc): 172 | if not is_included_account(acc): 173 | acc.balance = inventory.Inventory() 174 | return realacc 175 | 176 | 177 | def tax_adjust(realacc, accapi): 178 | def scale_inventory(balance, tax_adj): 179 | """Scale inventory by tax adjustment""" 180 | scaled_balance = inventory.Inventory() 181 | for pos in balance.get_positions(): 182 | # One important assumption is that tax adjustment only ever encounters costs after realization, as 183 | # having a cost spec for the total cost would change the cost per unit. 184 | assert pos.cost is None or isinstance(pos.cost, position.Cost) 185 | 186 | scaled_pos = pos * Decimal(tax_adj / 100) 187 | scaled_balance.add_position(scaled_pos) 188 | return scaled_balance 189 | 190 | account_open_close = accapi.get_account_open_close() 191 | for acc in realization.iter_children(realacc): 192 | if acc.account in account_open_close: 193 | tax_adj = account_open_close[acc.account][0].meta.get('asset_allocation_tax_adjustment', 100) 194 | acc.balance = scale_inventory(acc.balance, tax_adj) 195 | return realacc 196 | 197 | 198 | def assetalloc(accapi, config={}): 199 | realacc = build_interesting_realacc(accapi, config.get('accounts_patterns', ['.*'])) 200 | # print(realization.compute_balance(realacc).reduce(convert.get_units)) 201 | 202 | if config.get('skip_tax_adjustment', False) is False: 203 | tax_adjust(realacc, accapi) 204 | # print(realization.compute_balance(realacc).reduce(convert.get_units)) 205 | 206 | balance = realization.compute_balance(realacc) 207 | asset_buckets = bucketize(balance, accapi) 208 | 209 | return treeify(asset_buckets, accapi), realacc 210 | -------------------------------------------------------------------------------- /fava_investor/modules/assetalloc_class/multicurrency.beancount: -------------------------------------------------------------------------------- 1 | ; Run example by executing: 2 | ; ./asset_allocation.py example.beancount --accounts "Assets:Investments:" --dump 3 | 4 | option "operating_currency" "USD" 5 | option "operating_currency" "GBP" 6 | 7 | 2010-01-01 open Assets:Investments:Taxable:XTrade 8 | 2010-01-01 open Assets:Bank 9 | 10 | 2010-01-01 commodity SPFIVE 11 | asset_allocation_equity_domestic: 100 12 | 13 | 2010-01-01 commodity SPUK 14 | asset_allocation_equity_international: 100 15 | 16 | 2011-01-10 * "Buy stock" 17 | Assets:Investments:Taxable:XTrade 100 SPFIVE {5 USD} 18 | Assets:Bank 19 | 20 | 2011-01-09 price GBP 1.5 USD 21 | 2011-01-10 * "Buy stock" 22 | Assets:Investments:Taxable:XTrade 100 SPUK {5 GBP} 23 | Assets:Bank 24 | 25 | 2011-03-02 price SPFIVE 5 USD 26 | 2011-03-02 price SPUK 5 GBP 27 | 2011-03-02 price GBP 1.5 USD 28 | -------------------------------------------------------------------------------- /fava_investor/modules/assetalloc_class/test_asset_allocation.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import os 5 | from beancount.utils import test_utils 6 | import click.testing 7 | sys.path.append(os.path.join(os.path.dirname(__file__), '.')) 8 | import assetalloc_class 9 | 10 | 11 | class ClickTestCase(test_utils.TestCase): 12 | """Base class for command-line program test cases.""" 13 | 14 | def run_with_args(self, function, *args): 15 | runner = click.testing.CliRunner() 16 | result = runner.invoke(function, args, catch_exceptions=False) 17 | self.assertEqual(result.exit_code, 0) 18 | return result 19 | 20 | 21 | class TestScriptCheck(ClickTestCase): 22 | 23 | @test_utils.docfile 24 | def test_basic_unspecified(self, filename): 25 | """ 26 | option "operating_currency" "USD" 27 | 2010-01-01 open Assets:Investments:Brokerage 28 | 2010-01-01 open Assets:Bank 29 | 30 | 2011-03-02 * "Buy stock" 31 | Assets:Investments:Brokerage 1 BNCT {200 USD} 32 | Assets:Bank 33 | 34 | 2011-03-02 price BNCT 200 USD 35 | """ 36 | result = self.run_with_args(assetalloc_class.assetalloc_class, filename) 37 | expected_output = """ 38 | Warning: skipping negative balance: -200 USD 39 | Warning: BNCT asset_allocation_* metadata does not add up to 100%. Padding with 'unknown'. 40 | asset_type amount percentage 41 | ------------ -------- ------------ 42 | Total 200 100.0% 43 | unknown 200 100.0% 44 | """ 45 | self.assertLines(expected_output, result.stdout) 46 | 47 | @test_utils.docfile 48 | def test_basic_specified(self, filename): 49 | """ 50 | option "operating_currency" "USD" 51 | 2010-01-01 open Assets:Investments:Brokerage 52 | 2010-01-01 open Assets:Bank 53 | 2010-01-01 commodity BNCT 54 | asset_allocation_equity: 60 55 | asset_allocation_bond: 40 56 | 57 | 2011-03-02 * "Buy stock" 58 | Assets:Investments:Brokerage 1 BNCT {200 USD} 59 | Assets:Bank 60 | 61 | 2011-03-02 price BNCT 200 USD 62 | """ 63 | result = self.run_with_args(assetalloc_class.assetalloc_class, filename) 64 | expected_output = """ 65 | Warning: skipping negative balance: -200 USD 66 | asset_type amount percentage 67 | ------------ -------- ------------ 68 | Total 200 100.0% 69 | bond 80 40.0% 70 | equity 120 60.0% 71 | """ 72 | self.assertLines(expected_output, result.stdout) 73 | 74 | @test_utils.docfile 75 | def test_basic_account_filter(self, filename): 76 | """ 77 | option "operating_currency" "USD" 78 | 2010-01-01 open Assets:Investments:Brokerage 79 | 2010-01-01 open Assets:Investments:XTrade 80 | 2010-01-01 open Assets:Bank 81 | 2010-01-01 commodity BNCT 82 | asset_allocation_equity: 60 83 | asset_allocation_bond: 40 84 | 85 | 2011-03-02 * "Buy stock" 86 | Assets:Investments:Brokerage 1 BNCT {200 USD} 87 | Assets:Bank 88 | 89 | 2011-01-02 * "Buy stock" 90 | Assets:Investments:XTrade 2 BNCT {200 USD} 91 | Assets:Bank 92 | 93 | 2011-03-02 price BNCT 200 USD 94 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 95 | 'asset_alloc_by_class' : { 96 | 'accounts_patterns': ['Assets:Investments:Brokerage'], 97 | } 98 | }" 99 | """ 100 | result = self.run_with_args(assetalloc_class.assetalloc_class, filename) 101 | expected_output = """ 102 | asset_type amount percentage 103 | ------------ -------- ------------ 104 | Total 200 100.0% 105 | bond 80 40.0% 106 | equity 120 60.0% 107 | """ 108 | self.assertLines(expected_output, result.stdout) 109 | 110 | @test_utils.docfile 111 | def test_basic_filter_exclude_parent(self, filename): 112 | """ 113 | option "operating_currency" "USD" 114 | 2010-01-01 open Assets:Investments:Brokerage 115 | 2010-01-01 open Assets:Investments:XTrade 116 | 2010-01-01 open Assets:Bank 117 | 2010-01-01 commodity BNCT 118 | asset_allocation_equity: 60 119 | asset_allocation_bond: 40 120 | 121 | 2011-03-02 * "Buy stock" 122 | Assets:Investments:Brokerage 1 BNCT {200 USD} 123 | Assets:Bank 124 | 125 | 2011-01-02 * "Buy stock" 126 | Assets:Investments:XTrade 2 BNCT {200 USD} 127 | Assets:Bank 128 | 129 | 2011-01-02 * "Buy stock" 130 | Assets:Investments 7 BNCT {200 USD} 131 | Assets:Bank 132 | 133 | 2011-03-02 price BNCT 200 USD 134 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 135 | 'asset_alloc_by_class' : { 136 | 'accounts_patterns': ['Assets:Investments:Brokerage'], 137 | } 138 | }" 139 | """ 140 | result = self.run_with_args(assetalloc_class.assetalloc_class, filename) 141 | expected_output = """ 142 | asset_type amount percentage 143 | ------------ -------- ------------ 144 | Total 200 100.0% 145 | bond 80 40.0% 146 | equity 120 60.0% 147 | """ 148 | self.assertLines(expected_output, result.stdout) 149 | 150 | @test_utils.docfile 151 | def test_tree_empty_parent(self, filename): 152 | """ 153 | option "operating_currency" "USD" 154 | 2010-01-01 open Assets:Investments:XTrade 155 | 2010-01-01 open Assets:Bank 156 | 157 | 2010-01-01 commodity BNCT 158 | asset_allocation_equity_international: 100 159 | 160 | 161 | 2011-01-02 * "Buy stock" 162 | Assets:Investments:XTrade 700 BNCT {200 USD} 163 | Assets:Bank 164 | 165 | 2011-03-02 price BNCT 200 USD 166 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 167 | 'asset_alloc_by_class' : { 168 | 'accounts_patterns': ['Assets:Investments'], 169 | } 170 | }" 171 | """ 172 | result = self.run_with_args(assetalloc_class.assetalloc_class, filename) 173 | expected_output = """ 174 | asset_type amount percentage 175 | ----------------- -------- ------------ 176 | Total 140,000 100.0% 177 | equity 140,000 100.0% 178 | international 140,000 100.0% 179 | """ 180 | self.assertLines(expected_output, result.stdout) 181 | 182 | @test_utils.docfile 183 | def test_parent_with_assets(self, filename): 184 | """ 185 | option "operating_currency" "USD" 186 | 2010-01-01 open Assets:Investments:Brokerage 187 | 2010-01-01 open Assets:Bank 188 | 189 | 2010-01-01 commodity BNDLOCAL 190 | asset_allocation_bond_local: 100 191 | 192 | 2010-01-01 commodity BONDS 193 | asset_allocation_bond: 100 194 | 195 | 2011-03-02 * "Buy stock" 196 | Assets:Investments:Brokerage 2 BNDLOCAL {200 USD} 197 | Assets:Bank 198 | 199 | 2011-01-02 * "Buy stock" 200 | Assets:Investments:Brokerage 2 BONDS {200 USD} 201 | Assets:Bank 202 | 203 | 2011-03-02 price BNDLOCAL 200 USD 204 | 2011-03-02 price BONDS 200 USD 205 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 206 | 'asset_alloc_by_class' : { 207 | 'accounts_patterns': ['Assets:Investments'], 208 | } 209 | }" 210 | """ 211 | result = self.run_with_args(assetalloc_class.assetalloc_class, filename) 212 | expected_output = """ 213 | asset_type amount percentage 214 | ------------ -------- ------------ 215 | Total 800 100.0% 216 | bond 800 100.0% 217 | local 400 50.0% 218 | """ 219 | self.assertLines(expected_output, result.stdout) 220 | 221 | @test_utils.docfile 222 | def test_multicurrency(self, filename): 223 | """ 224 | option "operating_currency" "USD" 225 | option "operating_currency" "GBP" 226 | 227 | 2010-01-01 open Assets:Investments:Taxable:XTrade 228 | 2010-01-01 open Assets:Bank 229 | 230 | 2010-01-01 commodity SPFIVE 231 | asset_allocation_equity_domestic: 100 232 | 233 | 2010-01-01 commodity SPUK 234 | asset_allocation_equity_international: 100 235 | 236 | 2011-01-10 * "Buy stock" 237 | Assets:Investments:Taxable:XTrade 100 SPFIVE {5 USD} 238 | Assets:Bank 239 | 240 | 2011-01-09 price GBP 1.5 USD 241 | 2011-01-10 * "Buy stock" 242 | Assets:Investments:Taxable:XTrade 100 SPUK {5 GBP} 243 | Assets:Bank 244 | 245 | 2011-03-02 price SPFIVE 5 USD 246 | 2011-03-02 price SPUK 5 GBP 247 | 2011-03-02 price GBP 1.5 USD 248 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 249 | 'asset_alloc_by_class' : { 250 | 'accounts_patterns': ['Assets:Investments'], 251 | } 252 | }" 253 | """ 254 | result = self.run_with_args(assetalloc_class.assetalloc_class, filename) 255 | expected_output = """ 256 | asset_type amount percentage 257 | ----------------- -------- ------------ 258 | Total 1,250 100.0% 259 | equity 1,250 100.0% 260 | domestic 500 40.0% 261 | international 750 60.0% 262 | """ 263 | self.assertLines(expected_output, result.stdout) 264 | -------------------------------------------------------------------------------- /fava_investor/modules/cashdrag/README.md: -------------------------------------------------------------------------------- 1 | # Cashdrag 2 | 3 | Summarizes amounts and locations (account) of cash across all your accounts, to help you 4 | invest it. 5 | 6 | ## Installation 7 | A Fava extension, a Beancount command line client, and a library are all included. 8 | To install the Fava plugin, see [fava_investor](https://github.com/redstreet/fava_investor). 9 | 10 | Command line client: 11 | ``` 12 | investor cashdrag example.bc 13 | investor cashdrag --help 14 | ``` 15 | The command line client also uses the same Fava configuration shown below. 16 | 17 | ## Configuration 18 | 19 | Configure Cashdrag by including the following lines in your Beancount source. Example: 20 | 21 | ``` 22 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 23 | 'cashdrag': { 24 | 'accounts_pattern': '^Assets:.*', 25 | 'accounts_exclude_pattern': '^Assets:(Cash-In-Wallet.*|Zero-Sum)', 26 | 'metadata_label_cash' : 'asset_allocation_Bond_Cash', 27 | 'min_threshold' : 10 28 | }}" 29 | ``` 30 | 31 | The full list of configuration options is below: 32 | 33 | #### `accounts_pattern` 34 | 35 | Default: '^Assets' 36 | 37 | Regex of accounts to include. 38 | 39 | --- 40 | 41 | #### `accounts_exclude_pattern` 42 | 43 | Default: '' 44 | 45 | Regex of accounts to exclude. Exclusions are applied after `accounts_pattern` is applied. 46 | 47 | --- 48 | 49 | #### `metadata_label_cash` 50 | 51 | Default: 'asset_allocation_Bond_Cash' 52 | 53 | Optional. If specified, consider all currencies that have this metadata set to `100`, to 54 | be cash. 55 | 56 | --- 57 | 58 | #### `min_threshold` 59 | 60 | Default: 0 61 | 62 | Optional. Exclude rows where the converted amount is less than specified amount. 63 | -------------------------------------------------------------------------------- /fava_investor/modules/cashdrag/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/fava_investor/modules/cashdrag/__init__.py -------------------------------------------------------------------------------- /fava_investor/modules/cashdrag/cashdrag.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Beancount cash drag tool.""" 3 | 4 | import click 5 | import fava_investor.modules.cashdrag.libcashdrag as libcashdrag 6 | from fava_investor.common.clicommon import pretty_print_table 7 | import fava_investor.common.beancountinvestorapi as api 8 | 9 | 10 | @click.command() 11 | @click.argument('beancount-file', type=click.Path(exists=True), envvar='BEANCOUNT_FILE') 12 | def cashdrag(beancount_file): 13 | """Cashdrag: Identify cash across all accounts. 14 | 15 | The BEANCOUNT_FILE environment variable can optionally be set instead of specifying the file on the 16 | command line. 17 | 18 | The configuration for this module is expected to be supplied as a custom directive like so in your 19 | beancount file: 20 | 21 | \b 22 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 23 | 'cashdrag': { 24 | 'accounts_pattern': '^Assets:.*', 25 | 'accounts_exclude_pattern': '^Assets:(Cash-In-Wallet.*|Zero-Sum)', 26 | 'metadata_label_cash' : 'asset_allocation_Bond_Cash' 27 | }}" 28 | """ 29 | accapi = api.AccAPI(beancount_file, {}) 30 | config = accapi.get_custom_config('cashdrag') 31 | tables = libcashdrag.find_loose_cash(accapi, config) 32 | 33 | def _gen_output(): 34 | for title, (rtypes, rrows, _, _) in tables: 35 | yield pretty_print_table(title, rtypes, rrows, floatfmt=",.0f") 36 | 37 | click.echo_via_pager(_gen_output()) 38 | 39 | 40 | if __name__ == '__main__': 41 | cashdrag() 42 | -------------------------------------------------------------------------------- /fava_investor/modules/cashdrag/design.md: -------------------------------------------------------------------------------- 1 | ### Cash drag analysis: 2 | - cash as percentage of portfolio, and its locations (accounts) 3 | 4 | ### Config: 5 | - minimum threshold (tricky with multiple currencies) 6 | - list of money market tickers to be considered cash @done 7 | - pick this up from metadata @done 8 | 9 | ### Implementation: 10 | - clean up blank lines @done 11 | - visual treemap 12 | 13 | - analysis: cash as percentage of net worth 14 | - convert all to cash 15 | 16 | -------------------------------------------------------------------------------- /fava_investor/modules/cashdrag/libcashdrag.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python3 2 | 3 | import fava_investor.common.libinvestor as libinvestor 4 | from beancount.core.inventory import Inventory 5 | 6 | 7 | def find_cash_commodities(accapi, options): 8 | """Build list of commodities that are considered cash""" 9 | 10 | meta_label = options.get('metadata_label_cash', 'asset_allocation_Bond_Cash') 11 | cash_commodities = [] 12 | for commodity, declaration in accapi.get_commodity_directives().items(): 13 | if declaration.meta.get(meta_label, 0) == 100: 14 | cash_commodities.append(f'^{commodity}$') 15 | 16 | operating_currencies = accapi.get_operating_currencies() 17 | cash_commodities += map(lambda cur: f'^{cur}$', operating_currencies) 18 | cash_commodities = set(cash_commodities) 19 | commodities_pattern = '(' + '|'.join(cash_commodities) + ')' 20 | return commodities_pattern, operating_currencies[0] 21 | 22 | 23 | def find_loose_cash(accapi, options): 24 | """Find uninvested cash in specified accounts""" 25 | 26 | currencies_pattern, main_currency = find_cash_commodities(accapi, options) 27 | sql = """ 28 | SELECT account AS account, 29 | CONVERT(sum(position), '{main_currency}') AS position 30 | WHERE account ~ '{accounts_pattern}' 31 | AND not account ~ '{accounts_exclude_pattern}' 32 | AND currency ~ '{currencies_pattern}' 33 | GROUP BY account 34 | ORDER BY position DESC 35 | """.format(main_currency=main_currency, 36 | accounts_pattern=options.get('accounts_pattern', '^Assets'), 37 | accounts_exclude_pattern=options.get('accounts_exclude_pattern', '^ $'), # TODO 38 | currencies_pattern=currencies_pattern, 39 | ) 40 | rtypes, rrows = accapi.query_func(sql) 41 | if not rtypes: 42 | return [], {}, [[]] 43 | 44 | rrows = [r for r in rrows if r.position != Inventory()] 45 | threshold = options.get('min_threshold', 0) 46 | if threshold: 47 | rrows = [r for r in rrows if r.position.get_only_position().units.number >= threshold] 48 | 49 | footer = libinvestor.build_table_footer(rtypes, rrows, accapi) 50 | return [('Cash Drag Analysis', (rtypes, rrows, None, footer))] 51 | -------------------------------------------------------------------------------- /fava_investor/modules/minimizegains/README.md: -------------------------------------------------------------------------------- 1 | # Gains Minimizer 2 | _Determine lots to sell to minimize capital gains taxes._ 3 | 4 | ## Introduction 5 | When partially liquidating from taxable accounts, (for example, to make a purchase, or to draw down 6 | from one's retirement savings), capital gains are potentially generated and subject to taxation. 7 | 8 | Careful selection of lots to be sold will allow the tax burden to be minimized in this scenario. 9 | This Gains Minimizer module can help. It displays a table of lots to sell, ordered by their tax 10 | burden. By selling portfolio lots in this order, tax burden is minimized. 11 | 12 | ## Using this module 13 | - most columns in the table are self-explanatory. The non-obvious ones are explained below 14 | - est_tax is the estimated tax on the sale. This is computed using the '[sl]_t_tax_rate' fields in 15 | the configuration below 16 | - est_tax_percent is the percentage of estimated taxes on the proceeds (market_value). This is the 17 | column that this table is sorted by 18 | - cumu_proceeds is the cumulative proceeds (sum of the market_value of all rows upto and including 19 | this one) 20 | - cumu_gains is the cumulative gains (sum of the gains of all rows upto and including this one) 21 | - percent is the ratio of cumu_gains to cumu_proceeds 22 | 23 | To use this table, look down the cumu_proceeds until the first row that exceeds the amount you wish 24 | to liquidate. Liquidate all preceeding rows, including a partial amount of the last row until you 25 | liquidate the amount you desire. 26 | 27 | ## Limitations 28 | Selling in this manner does not account for Asset allocation, which the sales may cause to shift. 29 | If maintaining constant allocation is desired, a different algorithm must be used. In addition, 30 | such an algorithm may have to consider the tax-advantaged portions of the portfolio. 31 | 32 | 33 | ## Example configuration: 34 | ``` 35 | 'minimizegains' : { 'accounts_pattern': 'Assets:Investments:Taxable', 36 | 'account_field': 2, 37 | 'st_tax_rate': 0.30, 38 | 'lt_tax_rate': 0.15 } 39 | ``` 40 | -------------------------------------------------------------------------------- /fava_investor/modules/minimizegains/TODO.md: -------------------------------------------------------------------------------- 1 | 2022-08-28 2 | 3 | ### Summarize on top 4 | - tax rates 5 | 6 | 7 | ### Improvements 8 | - clarify, perhaps with text on top: 9 | - whether proceeds is gross or net 10 | - ensure final amount is same as sum of assets (networth without liability) 11 | 12 | ## Features 13 | 14 | ### Graph 15 | - cumulative tax liability against proceeds 16 | - smooth line from 0 to full 17 | 18 | ### De-emphasize remaining columns 19 | - marginal tax percent is repeated 20 | - either with color or make them into bubble help 21 | - remove unnecessary columns to simplify 22 | 23 | ### Optional "enter amount to liquidate" 24 | - slider or some nice UI 25 | - store in local storage 26 | 27 | 28 | ## Turn into total tax liability tool 29 | - be able to specify tax rates for taxable vs tax advantages accounts 30 | -------------------------------------------------------------------------------- /fava_investor/modules/minimizegains/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/fava_investor/modules/minimizegains/__init__.py -------------------------------------------------------------------------------- /fava_investor/modules/minimizegains/example.beancount: -------------------------------------------------------------------------------- 1 | option "title" "Test" 2 | option "operating_currency" "USD" 3 | option "render_commas" "True" 4 | 5 | 2010-01-01 open Assets:Investments:Taxable:Brokerage 6 | 2010-01-01 open Assets:Bank 7 | 8 | 2010-01-01 commodity BNCT 9 | 2010-01-01 commodity COFE 10 | 11 | 2015-01-01 * "Buy stock" 12 | Assets:Investments:Taxable:Brokerage 100 BNCT {100 USD} 13 | Assets:Bank 14 | 15 | 2016-01-01 * "Buy stock" 16 | Assets:Investments:Taxable:Brokerage 100 COFE {200 USD} 17 | Assets:Bank 18 | 19 | 2018-01-01 price BNCT 150 USD 20 | 2018-01-01 price COFE 201 USD 21 | -------------------------------------------------------------------------------- /fava_investor/modules/minimizegains/libminimizegains.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python3 2 | """ 3 | # Gains Minimizer 4 | _Determine lots to sell to minimize capital gains taxes._ 5 | 6 | See accompanying README.txt 7 | """ 8 | 9 | import collections 10 | from datetime import datetime 11 | from fava_investor.common.libinvestor import val, build_config_table, insert_column, split_currency 12 | from beancount.core.number import Decimal, D 13 | from fava_investor.modules.tlh import libtlh 14 | 15 | 16 | def find_tax_burden(table, amount): 17 | """ 18 | Interpolate tax burden from table for `amount` 19 | 20 | 'table' is the main table output by find_minimized_gains() below. 21 | 22 | Eg table: 23 | cu_proceeds cu_taxes 24 | 15 1 25 | 25 100 26 | 27 | amount = 22. We interpoloate between 15 and 25: 28 | 25-15 = 10 29 | 22-15 = 7 30 | Interpolated tax burden: 1 + ( 7/10 * (100-1) ) 31 | """ 32 | prev = None 33 | for row in table[1][1]: 34 | if row.cu_proceeds > amount: 35 | ratio = (amount - prev.cu_proceeds) / (row.cu_proceeds - prev.cu_proceeds) 36 | cu_taxes = prev.cu_taxes + ((row.cu_taxes - prev.cu_taxes) * ratio) 37 | tax_avg = (cu_taxes / amount) * 100 38 | return amount, cu_taxes, tax_avg, row.tax_marg 39 | prev = row 40 | return None 41 | 42 | 43 | def find_minimized_gains(accapi, options): 44 | account_field = libtlh.get_account_field(options) 45 | accounts_pattern = options.get('accounts_pattern', '') 46 | tax_rate = {'Short': Decimal(options.get('st_tax_rate', 1)), 47 | 'Long': Decimal(options.get('lt_tax_rate', 1))} 48 | 49 | currency = accapi.get_operating_currencies()[0] 50 | 51 | sql = f""" 52 | SELECT 53 | {account_field} as account, 54 | units(sum(position)) as units, 55 | CONVERT(value(sum(position)), '{currency}') as market_value, 56 | cost_date as acq_date, 57 | CONVERT(cost(sum(position)), '{currency}') as basis 58 | WHERE account_sortkey(account) ~ "^[01]" AND 59 | account ~ '{accounts_pattern}' 60 | GROUP BY {account_field}, cost_date, currency, cost_currency, cost_number, account_sortkey(account) 61 | ORDER BY account_sortkey(account), currency, cost_date 62 | """ 63 | rtypes, rrows = accapi.query_func(sql) 64 | if not rtypes: 65 | return [], {}, [[]] 66 | 67 | # Since we GROUP BY cost_date, currency, cost_currency, cost_number, we never expect any of the 68 | # inventories we get to have more than a single position. Thus, we can and should use 69 | # get_only_position() below. We do this grouping because we are interested in seeing every lot (price, 70 | # date) seperately, that can be sold to generate a TLH 71 | 72 | # our output table is slightly different from our query table: 73 | retrow_types = rtypes[:-1] + [('term', str), ('gain', Decimal), 74 | ('est_tax', Decimal), ('est_tax_percent', Decimal)] 75 | retrow_types = insert_column(retrow_types, 'units', Decimal, 'ticker', str) 76 | retrow_types = insert_column(retrow_types, 'market_value', Decimal, 'currency', str) 77 | 78 | # rtypes: 79 | # [('account', ), 80 | # ('units', ), 81 | # ('acq_date', ), 82 | # ('market_value', ), 83 | # ('basis', )] 84 | 85 | RetRow = collections.namedtuple('RetRow', [i[0] for i in retrow_types]) 86 | 87 | to_sell = [] 88 | for row in rrows: 89 | if row.market_value.get_only_position(): 90 | gain = D(val(row.market_value) - val(row.basis)) 91 | term = libtlh.gain_term(row.acq_date, datetime.today().date()) 92 | est_tax = gain * tax_rate[term] 93 | 94 | to_sell.append(RetRow(row.account, *split_currency(row.units), 95 | *split_currency(row.market_value), row.acq_date, term, gain, est_tax, 96 | (est_tax / val(row.market_value)) * 100)) 97 | 98 | to_sell.sort(key=lambda x: x.est_tax_percent) 99 | 100 | # add cumulative column ([:-2] to remove est_tax and est_tax_percent) 101 | retrow_types = [('cu_proceeds', Decimal), ('cu_taxes', Decimal), 102 | ('tax_avg', Decimal), ('tax_marg', Decimal)] + \ 103 | retrow_types[:-2] + [('cu_gains', Decimal)] # noqa: E127 104 | 105 | RetRow = collections.namedtuple('RetRow', [i[0] for i in retrow_types]) 106 | rrows = [] 107 | cumu_proceeds = cumu_gains = cumu_taxes = 0 108 | prev_cumu_proceeds = prev_cumu_taxes = 0 109 | for row in to_sell: 110 | cumu_gains += row.gain 111 | cumu_proceeds += row.market_value 112 | cumu_taxes += row.est_tax 113 | tax_rate_avg = (cumu_taxes / cumu_proceeds) * 100 114 | tax_rate_marginal = ((cumu_taxes - prev_cumu_taxes) / (cumu_proceeds - prev_cumu_proceeds)) * 100 115 | rrows.append(RetRow(round(cumu_proceeds, 0), 116 | round(cumu_taxes, 0), 117 | round(tax_rate_avg, 1), 118 | round(tax_rate_marginal, 2), 119 | *row[:-2], # Remove est_tax and est_tax_percent 120 | round(cumu_gains, 0))) 121 | 122 | prev_cumu_proceeds = cumu_proceeds 123 | prev_cumu_taxes = cumu_taxes 124 | 125 | retrow_types = [r for r in retrow_types if r[0] not in ['est_tax', 'est_tax_percent']] 126 | # rrows, retrow_types = remove_column('gain', rrows, retrow_types) 127 | tables = [build_config_table(options)] 128 | tables.append(('Proceeds, Gains, Taxes', (retrow_types, rrows, None, None))) 129 | return tables 130 | -------------------------------------------------------------------------------- /fava_investor/modules/minimizegains/minimizegains.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Beancount Tool to find lots to sell with lowest gains, to minimize the tax burden.""" 3 | 4 | import fava_investor.modules.minimizegains.libminimizegains as libmg 5 | import fava_investor.common.beancountinvestorapi as api 6 | from fava_investor.common.clicommon import pretty_print_table, write_table_csv 7 | import click 8 | 9 | 10 | @click.command() 11 | @click.argument('beancount-file', type=click.Path(exists=True), envvar='BEANCOUNT_FILE') 12 | @click.option('--brief', help='Summary output', is_flag=True) 13 | @click.option('--csv-output', help='In addition to summary, output to minimizegains.csv', is_flag=True) 14 | @click.option('--amount', help='Compute tax burden for specificed amount. If specified, ' 15 | 'instead of printing out a table, the tax, and average and marginal rate for the ' 16 | 'amount will be printed', default=0) 17 | def minimizegains(beancount_file, brief, csv_output, amount): 18 | """Finds lots to sell with the lowest gains, to minimize the tax burden of selling. 19 | 20 | The BEANCOUNT_FILE environment variable can optionally be set instead of specifying the file on the 21 | command line. 22 | 23 | The configuration for this module is expected to be supplied as a custom directive like so in your 24 | beancount file: 25 | 26 | \b 27 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 28 | 'minimizegains' : { 'accounts_pattern': 'Assets:Investments:Taxable', 29 | 'minimizegains' : { 'accounts_pattern': 'Assets:Investments:Taxable', 30 | 'account_field': 2, 31 | 'st_tax_rate': 0.30, 32 | 'lt_tax_rate': 0.15, } 33 | }}" 34 | 35 | """ 36 | accapi = api.AccAPI(beancount_file, {}) 37 | config = accapi.get_custom_config('minimizegains') 38 | tables = libmg.find_minimized_gains(accapi, config) 39 | 40 | if amount: 41 | proceeds, cu_taxes, tax_avg, tax_marg = libmg.find_tax_burden(tables[1], amount) 42 | print(f"{proceeds}, {cu_taxes:.0f}, {tax_avg:.1f}, {tax_marg:.1f}") 43 | return 44 | 45 | # TODO: 46 | # - use same return return API for all of fava_investor 47 | # - ordered dictionary of title: [retrow_types, table] 48 | # - make output printing and csv a common function 49 | 50 | if csv_output: 51 | write_table_csv('minimizegains.csv', tables[1]) 52 | else: 53 | def _gen_output(): 54 | for title, (rtypes, rrows, _, _) in tables: 55 | yield pretty_print_table(title, rtypes, rrows, floatfmt=",.0f") 56 | 57 | click.echo_via_pager(_gen_output()) 58 | 59 | 60 | if __name__ == '__main__': 61 | minimizegains() 62 | -------------------------------------------------------------------------------- /fava_investor/modules/minimizegains/test_minimizegains.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import beancountinvestorapi as api 4 | import sys 5 | import os 6 | from beancount.utils import test_utils 7 | sys.path.append(os.path.join(os.path.dirname(__file__), '.')) 8 | import libminimizegains as libmg 9 | # To run: pytest 10 | 11 | 12 | class TestScriptCheck(test_utils.TestCase): 13 | def setUp(self): 14 | self.options = {'accounts_pattern': "Assets:Investments:Taxable"} 15 | 16 | @test_utils.docfile 17 | def test_minimizegains_basic(self, f): 18 | """ 19 | option "operating_currency" "USD" 20 | 2010-01-01 open Assets:Investments:Taxable:Brokerage 21 | 2010-01-01 open Assets:Bank 22 | 23 | 2010-01-01 commodity BNCT 24 | 2010-01-01 commodity COFE 25 | 26 | 2015-01-01 * "Buy stock" 27 | Assets:Investments:Taxable:Brokerage 100 BNCT {100 USD} 28 | Assets:Bank 29 | 30 | 2016-01-01 * "Buy stock" 31 | Assets:Investments:Taxable:Brokerage 100 COFE {200 USD} 32 | Assets:Bank 33 | 34 | 2018-01-01 price BNCT 150 USD 35 | 2018-01-01 price COFE 201 USD 36 | """ 37 | accapi = api.AccAPI(f, {}) 38 | ret = libmg.find_minimized_gains(accapi, self.options) 39 | title, (retrow_types, to_sell, _, _) = ret[1] 40 | 41 | self.assertEqual(2, len(to_sell)) 42 | self.assertEqual(20100, to_sell[0].cu_proceeds) 43 | self.assertEqual(5100, to_sell[1].cu_taxes) 44 | -------------------------------------------------------------------------------- /fava_investor/modules/summarizer/README.md: -------------------------------------------------------------------------------- 1 | # Metadata Summarizer 2 | 3 | Define arbitrary tables to summarize and view account metadata, and commodity metadata. 4 | For example, say you want to view the customer service phone numbers for each of your 5 | investment and banking accounts, which you have stored in the account metadata like so 6 | in your Beancount file: 7 | 8 | ``` 9 | 2015-01-01 open Assets:Banks:Checking USD 10 | customer_service_phone: "1-555-123-4567" 11 | ``` 12 | 13 | and so forth for each account. You can view a neat summary table in fava or on the 14 | command line by including these lines: 15 | 16 | ``` 17 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 18 | 'summarizer': [ 19 | { 'title' : 'Customer Service Phone Number', 20 | 'directive_type' : 'accounts', 21 | 'acc_pattern' : '^Assets:(Investments|Banks)', 22 | 'col_labels': [ 'Account', 'Phone_number'], 23 | 'columns' : [ 'account', 'customer_service_phone'], 24 | 'sort_by' : 0, 25 | }]}" 26 | ``` 27 | 28 | Other metadata (eg: transactions or postings) are not supported. 29 | 30 | ## Installation 31 | A Fava extension, a Beancount command line client, and a library are all included. 32 | To install the Fava plugin, see [fava_investor](https://github.com/redstreet/fava_investor). 33 | 34 | Command line client: 35 | ``` 36 | investor summarizer example.beancount 37 | investor summarizer --help # for all options 38 | ``` 39 | 40 | The command line client also uses the same Fava configuration shown below. 41 | 42 | ## Configuration 43 | The full list of configuration options is below: 44 | 45 | #### `title` 46 | 47 | Table title. 48 | 49 | --- 50 | #### `directive_type` 51 | 52 | For each table, this can be `accounts` or `commodities`. Each table can summarize 53 | metadata of either accounts or commodities. 54 | 55 | --- 56 | #### `acc_pattern` 57 | 58 | When the `accounts` directive_type is chosen, the set of accounts to include in the 59 | table, specified as a regex. 60 | 61 | --- 62 | #### `col_labels` 63 | 64 | Column titles. No spaces allowed. This is optional. If not specified, the metadata keys 65 | are used instead. 66 | 67 | --- 68 | #### `columns` 69 | 70 | Metadata keys for each column. 71 | 72 | 73 | --- 74 | #### `sort_by` 75 | 76 | Column number to sort table by. 77 | 78 | --- 79 | #### `meta_prefix` 80 | 81 | Specifying `meta_prefix` (instead of `columns`) for account metadata will display all 82 | metadata beginning with the prefix. 83 | 84 | --- 85 | #### `meta_skip` 86 | 87 | Skip displaying accounts that contain specified metadata keys 88 | 89 | --- 90 | #### `no_footer` 91 | 92 | Do not display footer 93 | 94 | --- 95 | #### `sort_reverse` 96 | 97 | Self explanatory 98 | 99 | --- 100 | The following are special values for 'columns', when 'directive_type' is 'accounts': 101 | - `account`: replace with account name 102 | - `balance`: replace with current balance of the account 103 | 104 | --- 105 | The following are special values for 'columns', when 'directive_type' is 'commodities': 106 | - `ticker`: replace with ticker 107 | - `market_value`: replace with current market value of the commodity held 108 | 109 | Here are two examples of handy commodity summaries to have, to be used in conjunction 110 | with `ticker-util`, which ships with Fava Investor: 111 | 112 | ``` 113 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 114 | 'summarizer': [ 115 | { 'title' : 'Commodities Summary', 116 | 'directive_type' : 'commodities', 117 | 'active_only': True, 118 | 'col_labels': [ 'Ticker', 'Type', 'Equi', 'Description', 'TLH_to', 'ER', 'Market'], 119 | 'columns' : [ 'ticker', 'a__quoteType', 'a__equivalents', 'name', 'tlh_alternates', 'a__annualReportExpenseRatio', 'market_value'], 120 | 'sort_by' : 1, 121 | }, 122 | { 'title' : 'TLH: Substantially Identical Funds and TLH Partners', 123 | 'directive_type' : 'commodities', 124 | 'active_only': True, 125 | 'col_labels': [ 'Ticker', 'Subst_Identicals', 'TLH_Partners'], 126 | 'columns' : [ 'ticker', 'a__substidenticals', 'a__tlh_partners'], 127 | 'sort_by' : 0, 128 | }, 129 | ] 130 | }" 131 | ``` 132 | -------------------------------------------------------------------------------- /fava_investor/modules/summarizer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/fava_investor/modules/summarizer/__init__.py -------------------------------------------------------------------------------- /fava_investor/modules/summarizer/design.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/fava_investor/modules/summarizer/design.md -------------------------------------------------------------------------------- /fava_investor/modules/summarizer/example.beancount: -------------------------------------------------------------------------------- 1 | option "operating_currency" "USD" 2 | 3 | 2012-01-01 open Assets:Investments:ETrade 4 | customer_service_phone: "1-555-234-5678" 5 | 6 | 2012-01-01 open Assets:Investments:Fanguard 7 | customer_service_phone: "1-555-234-1223" 8 | 9 | 2012-01-01 open Assets:Investments:Midelity 10 | customer_service_phone: "1-555-234-1029" 11 | 12 | 2000-01-01 commodity ABCD 13 | name: "ABCD Inc." 14 | 15 | 2012-01-01 open Assets:Investments:Midelity:ABCD 16 | 17 | 2012-01-01 open Income:Dividends 18 | 19 | 2015-01-01 close Assets:Investments:Midelity 20 | 21 | 2014-01-01 * "Transfer" 22 | Assets:Investments:ETrade 23 | Assets:Investments:Midelity -1000 USD 24 | 25 | 2014-01-02 * "Dividends" 26 | Assets:Investments:ETrade 27 | Income:Dividends -50 USD 28 | 29 | 2010-01-01 custom "fava-extension" "plugins.fava.investor.fava_investor" "{ 30 | 'summarizer': [ 31 | { 'title' : 'Customer Service Phone Number', 32 | 'directive_type' : 'accounts', 33 | 'acc_pattern' : '^Assets:(Investments|Banks)', 34 | 'col_labels': [ 'Account', 'Phone_number'], 35 | 'columns' : [ 'account', 'customer_service_phone'], 36 | 'sort_by' : 0, 37 | }, 38 | { 'title' : 'TLH: Substantially Identical Funds and TLH Partners', 39 | 'directive_type' : 'commodities', 40 | 'active_only': True, 41 | 'col_labels': [ 'Ticker', 'Subst_Identicals', 'TLH_Partners'], 42 | 'columns' : [ 'ticker', 'a__substidenticals', 'a__tlh_partners'], 43 | 'sort_by' : 0, 44 | }, 45 | { 'title' : 'Commodities Summary', 46 | 'directive_type' : 'commodities', 47 | 'active_only': False, 48 | 'col_labels': [ 'Ticker', 'Type', 'Equi', 'Description', 'TLH_to', 'ER'], 49 | 'columns' : [ 'ticker', 'a__quoteType', 'a__equivalents', 'name', 'tlh_alternates', 'a__annualReportExpenseRatio'], 50 | 'sort_by' : 1, 51 | }, 52 | ] 53 | }" 54 | 55 | -------------------------------------------------------------------------------- /fava_investor/modules/summarizer/libsummarizer.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python3 2 | """Metadata summarizer library for Beancount. See accompanying README.md for documentation.""" 3 | 4 | import collections 5 | import re 6 | import fava_investor.common.libinvestor as libinvestor 7 | from beancount.core.data import Close 8 | from beancount.core import realization 9 | from beancount.core import convert 10 | from fava_investor.common.libinvestor import build_table_footer 11 | 12 | 13 | # TODO: 14 | # - print balances nicely, sort by them, show what percent is complete 15 | # - for each commodity_leaf account, ensure there is a parent, else print 16 | 17 | p_leaf = re.compile('^[A-Z0-9]*$') 18 | 19 | 20 | def get_active_commodities(accapi): 21 | sql = """ 22 | SELECT 23 | units(sum(position)) as units, 24 | value(sum(position)) as market_value 25 | WHERE account_sortkey(account) ~ "^[01]" 26 | GROUP BY currency, cost_currency 27 | ORDER BY currency, cost_currency 28 | """ 29 | rtypes, rrows = accapi.query_func(sql) 30 | retval = {r.units.get_only_position().units.currency: r.market_value for r in rrows if not r.units.is_empty()} 31 | return retval 32 | 33 | 34 | def order_and_rename(header, options): 35 | """Order the header according to the config, and replace col names with col labels""" 36 | # take advantage of python 3.7+'s insertion order preservation 37 | 38 | def get_col_label(c): 39 | if 'col_labels' in options: 40 | index = options['columns'].index(c) 41 | return options['col_labels'][index] 42 | return c 43 | 44 | retval = {} 45 | for c in options['columns']: 46 | if c in header: 47 | retval[get_col_label(c)] = header[c] 48 | 49 | return retval 50 | 51 | 52 | def is_commodity_leaf(acc, ocs): 53 | splits = acc.rsplit(':', maxsplit=1) 54 | parent = splits[0] 55 | leaf = splits[-1] 56 | is_commodity = p_leaf.match(leaf) 57 | if is_commodity: 58 | return parent in ocs 59 | return False 60 | 61 | 62 | def build_tables(accapi, configs): 63 | tables = [] 64 | for config in configs: 65 | table = build_table(accapi, config) 66 | tables.append(table) 67 | return tables 68 | 69 | 70 | def build_table(accapi, options): 71 | if options['directive_type'] == 'accounts': 72 | rows = active_accounts_metadata(accapi, options) 73 | elif options['directive_type'] == 'commodities': 74 | rows = commodities_metadata(accapi, options) 75 | 76 | all_keys_and_types = {j: type(i[j]) for i in rows for j in list(i)} 77 | header = order_and_rename(all_keys_and_types, options) 78 | 79 | # rename each row's keys to col_labels 80 | if 'col_labels' in options: 81 | for i in range(len(rows)): 82 | rows[i] = order_and_rename(rows[i], options) 83 | 84 | # add all keys to all rows 85 | for i in rows: 86 | for j in header: 87 | if j not in i: 88 | i[j] = '' # TODO: type could be incorrect 89 | 90 | RowTuple = collections.namedtuple('RowTuple', header) 91 | rows = [RowTuple(**i) for i in rows] 92 | rtypes = list(header.items()) 93 | 94 | # sort by the requested. Default to first column 95 | sort_col = options.get('sort_by', 0) 96 | reverse = options.get('sort_reverse', False) 97 | rows.sort(key=lambda x: x[sort_col], reverse=reverse) 98 | 99 | footer = None if 'no_footer' in options else build_table_footer(rtypes, rows, accapi) 100 | return options['title'], (rtypes, rows, None, footer) 101 | # last one is footer 102 | 103 | 104 | def commodities_metadata(accapi, options): 105 | """Build list of commodities""" 106 | 107 | commodities = accapi.get_commodity_directives() 108 | if 'active_only' in options and options['active_only']: 109 | active_commodities = get_active_commodities(accapi) 110 | commodities = {k: v for k, v in commodities.items() if k in active_commodities} 111 | 112 | retval = [] 113 | for co in commodities: 114 | row = {k: v for k, v in commodities[co].meta.items() if k in options['columns']} 115 | if 'ticker' in options['columns']: 116 | row['ticker'] = co 117 | if 'market_value' in options['columns']: 118 | row['market_value'] = active_commodities[co] 119 | retval.append(row) 120 | 121 | return retval 122 | 123 | 124 | def get_metadata(meta, meta_prefix, specified_cols): 125 | ml = len(meta_prefix) 126 | if not specified_cols: # get all metadata that matches meta_prefix 127 | row = {k[ml:]: v for (k, v) in meta.items() if meta_prefix in k} 128 | else: # get metadata that begins with meta_prefix and is in specified_cols 129 | cols_to_get = [meta_prefix + col for col in specified_cols] 130 | row = {k[ml:]: v for (k, v) in meta.items() if k in cols_to_get} 131 | return row 132 | 133 | 134 | def active_accounts_metadata(accapi, options): 135 | """Build metadata table for accounts that are open""" 136 | 137 | # balances = get_balances(accapi) 138 | realacc = accapi.realize() 139 | pm = accapi.build_beancount_price_map() 140 | currency = accapi.get_operating_currencies()[0] 141 | 142 | p_acc_pattern = re.compile(options['acc_pattern']) 143 | meta_prefix = options.get('meta_prefix', '') 144 | specified_cols = options.get('columns', []) 145 | meta_skip = options.get('meta_skip', '') 146 | retval = [] 147 | 148 | # special metadata 149 | add_account = 'account' in options.get('columns', ['account']) 150 | add_balance = 'balance' in options.get('columns', ['balance']) 151 | ocs = accapi.get_account_open_close() 152 | for acc in ocs.keys(): 153 | if p_acc_pattern.match(acc) and not is_commodity_leaf(acc, ocs): 154 | closes = [e for e in ocs[acc] if isinstance(e, Close)] 155 | if not closes: 156 | if meta_skip not in ocs[acc][0].meta: 157 | row = get_metadata(ocs[acc][0].meta, meta_prefix, specified_cols) 158 | if add_account: 159 | row['account'] = acc 160 | if add_balance: 161 | row['balance'] = get_balance(realacc, acc, pm, currency) 162 | retval.append(row) 163 | return retval 164 | 165 | 166 | def get_balance(realacc, account, pm, currency): 167 | subtree = realization.get(realacc, account) 168 | balance = realization.compute_balance(subtree) 169 | vbalance = balance.reduce(convert.get_units) 170 | market_value = vbalance.reduce(convert.convert_position, currency, pm) 171 | val = libinvestor.val(market_value) 172 | return val 173 | # return int(val) 174 | 175 | 176 | def get_balances(accapi): 177 | """Find all balances""" 178 | 179 | currency = accapi.get_operating_currencies()[0] 180 | sql = f"SELECT account, SUM(CONVERT(position, '{currency}'))" 181 | rtypes, rrows = accapi.query_func(sql) 182 | 183 | if not rtypes: 184 | return [], {}, [[]] 185 | # print(rtypes) 186 | # print(rrows) 187 | 188 | balances = {acc: bal for acc, bal in rrows} 189 | # import pdb; pdb.set_trace() 190 | # return rtypes, rrows, balances 191 | return balances 192 | 193 | # footer = libinvestor.build_table_footer(rtypes, rrows, accapi) 194 | # return rtypes, rrows, None, footer 195 | -------------------------------------------------------------------------------- /fava_investor/modules/summarizer/summarizer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """CLI for Metadata Summarizer for Beancount.""" 3 | 4 | import click 5 | import fava_investor.common.beancountinvestorapi as api 6 | import fava_investor.modules.summarizer.libsummarizer as libsummarizer 7 | from fava_investor.common.clicommon import pretty_print_table 8 | 9 | 10 | @click.command() 11 | @click.argument('beancount-file', type=click.Path(exists=True), envvar='BEANCOUNT_FILE') 12 | def summarizer(beancount_file): 13 | """Displays metadata summaries from a config, as tables. 14 | 15 | The BEANCOUNT_FILE environment variable can optionally be set instead of specifying the file on the 16 | command line. 17 | 18 | The configuration for this module is expected to be supplied as a custom directive like so in your 19 | beancount file: 20 | 21 | \b 22 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 23 | 'summarizer': [ 24 | { 'title' : 'Customer Service Phone Number', 25 | 'directive_type' : 'accounts', 26 | 'acc_pattern' : '^Assets:(Investments|Banks)', 27 | 'col_labels': [ 'Account', 'Phone_number'], 28 | 'columns' : [ 'account', 'customer_service_phone'], 29 | 'sort_by' : 0, 30 | }]}" 31 | 32 | """ 33 | accapi = api.AccAPI(beancount_file, {}) 34 | configs = accapi.get_custom_config('summarizer') 35 | tables = libsummarizer.build_tables(accapi, configs) 36 | 37 | def _gen_output(): 38 | for title, (rtypes, rrows, _, _) in tables: 39 | yield pretty_print_table(title, rtypes, rrows, floatfmt=",.0f") 40 | 41 | click.echo_via_pager(_gen_output()) 42 | 43 | 44 | if __name__ == '__main__': 45 | summarizer() 46 | -------------------------------------------------------------------------------- /fava_investor/modules/tlh/README.md: -------------------------------------------------------------------------------- 1 | # Tax Loss Harvester 2 | 3 | Reports on lots that can be tax loss harvested from your Beancount input file. Also 4 | determines which of them would trigger wash sales. 5 | 6 | See this article 7 | **[Tax Loss Harvesting with Beancount](http://reds-rants.netlify.app/personal-finance/tax-loss-harvesting-with-beancount/)** 8 | for more. 9 | 10 | Example: 11 | 12 | ![Screenshot: TLH](tlh.jpg) 13 | 14 | The example above shows a summary of what can be tax-loss harvested currently. This 15 | includes the total harvestable loss, and the sale value required to harvest the loss. 16 | Detailed and summary views of losses by commodity and lots is shown. Losses that would 17 | not be allowable due to wash sales are marked. 18 | 19 | 20 | ## Installation 21 | A Fava extension, a Beancount command line client, and a library are all included. 22 | To install the Fava plugin, see [fava_investor](https://github.com/redstreet/fava_investor). 23 | 24 | Command line client: 25 | ``` 26 | investor tlh example.bc 27 | investor tlh --brief example.bc 28 | ``` 29 | The command line client also uses the same Fava configuration shown below. 30 | 31 | 32 | ## Configuration 33 | 34 | Configure TLH by including the following lines in your Beancount source. Example: 35 | 36 | ``` 37 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 38 | 'tlh' : { 39 | 'account_field': 'account', 40 | 'accounts_pattern': 'Assets:Investments:Taxable', 41 | 'loss_threshold': 50, 42 | 'wash_pattern': 'Assets:Investments', 43 | }, 44 | ... 45 | }" 46 | ``` 47 | 48 | Optionally, include the `tlh_alternates` metadata in your commodity declarations. The 49 | string you provide simply gets summarized into a table in the output (Fava and command 50 | line), serving as an easy reminder for you. For example: 51 | 52 | ``` 53 | 2010-01-01 commodity VTI 54 | tlh_alternates: "VOO" 55 | ``` 56 | --- 57 | 58 | The full list of configuration options is below: 59 | 60 | #### `account_field` 61 | 62 | Default: LEAF(account) 63 | 64 | BQL string that determines what is shown in the account column. If this is set to an 65 | integer, it is replaced with one of the following built-in values: 66 | - `0`: show the entire account name (same as setting it to `'account'`) 67 | - `1`: show only the leaf of the account 68 | - `2`: show only the last but one substring in the account hierarchy. Eg: if the account 69 | is `Assets:Investments:Taxable:XTrade:AAPL`, show simply `XTrade` 70 | 71 | --- 72 | 73 | `accounts_pattern` 74 | 75 | Default: '' 76 | 77 | Regex of the set of accounts to search over for tax loss harvesting opportunities. 78 | This allows you to exclude your tax advantaged and other non-investment accounts. 79 | 80 | --- 81 | 82 | `loss_threshold` 83 | 84 | Default: 1 85 | 86 | Losses below this threshold will be ignored. Useful to filter out minor TLH 87 | opportunities. 88 | 89 | --- 90 | 91 | `wash_pattern` 92 | 93 | Default: '' 94 | 95 | Regex of the set of accounts to search over for possible wash sales. This allows you to 96 | include your tax advantaged and all investment accounts. 97 | 98 | --- 99 | 100 | `a__tlh_partners` 101 | 102 | Use this metadata label to specify a list of "partner" or (aka substitute) funds for 103 | each fund in its commodity declaration. Eg: 104 | 105 | ``` 106 | 2000-01-01 commodity VTI 107 | a__tlh_partners: ITOT,VOO 108 | 109 | ``` 110 | 111 | --- 112 | 113 | `a__equivalents` 114 | 115 | Use this metadata label to specify funds that are equivalents for each fund in its 116 | commodity declaration. For example, VOO, VFINX, and VFIAX are just different share 117 | classes of the same underlying fund. Therefore: 118 | 119 | ``` 120 | 2000-01-01 commodity VOO 121 | a__substidenticals: VFINX,VFIAX 122 | 123 | ``` 124 | 125 | 126 | --- 127 | 128 | `a__substidenticals` 129 | 130 | Use this metadata label to specify substantially identical funds 131 | for each fund in its commodity declaration. For example, VFIAX and FXAIX are offered by 132 | different brokerages, but are considered substantially identical since they both track 133 | the same index (the S&P 500). Therefore: 134 | 135 | ``` 136 | 2000-01-01 commodity VFIAX 137 | a__substidenticals: FXAIX 138 | 139 | ``` 140 | 141 | 142 | ## Limitations 143 | 144 | - Filters in Fava do not work with TLH. Selecting filters on the GUI (eg: a time filter) 145 | will lead to unpredictable results 146 | 147 | - Partial wash sales, or cases where it is not obvious as to how to match the purchases 148 | and sales, are not displayed due to their 149 | [complexity.](https://fairmark.com/investment-taxation/capital-gain/wash/wash-sale-matching-rules/) 150 | 151 | - Booking via specific identification of shares is assumed on all taxable accounts. This 152 | translates to "STRICT" booking in beancount. 153 | 154 | #### Disclaimer 155 | None of the above is or should be construed as financial, tax, or other advice. 156 | -------------------------------------------------------------------------------- /fava_investor/modules/tlh/TODO.txt: -------------------------------------------------------------------------------- 1 | features: 2 | 3 | - top annoyances 4 | ---------------- 5 | - export to csv 6 | - wash sale is not accounted for in summary and in summary's harvestable losses 7 | 8 | - tables: 9 | --------- 10 | - Potential wash sales table: collapse into one row per ticker 11 | 12 | - pricing: 13 | ---------- 14 | - plugin to project MF price using equivalent ETF price (for TLH) 15 | 16 | - fund info: 17 | ------------ 18 | - tickers: mutual funds ETF equivalents 19 | - ticker description 20 | 21 | - notes: 22 | -------- 23 | - customizable notes display 24 | 25 | display main table: 26 | - test cases for wash sales (can't have bought within 30 days; edge cases of 29/30/31 days) 27 | - will grouping by cost_date mean multiple lots with different costs on the same day be rendered 28 | incorrectly? 29 | - assert specid / "STRICT" 30 | 31 | bells and whistles: 32 | - add wash amount to summary 33 | - add wash * to by commodity wash 34 | - use query context (dates? future and past?) 35 | - csv download 36 | - warn if price entries are older than the most recent weekday (approximation of trading day) 37 | 38 | 39 | Command line client: 40 | ---------------------------------------------------------------------------------------- 41 | # TODO: 42 | # - print TLH pairs 43 | # - analysis of TLH pairs: can't be present in both sell and buy columns! 44 | # - print DO-NOT-BUY-UNTIL-WARNING list 45 | 46 | -------------------------------------------------------------------------------- /fava_investor/modules/tlh/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/fava_investor/modules/tlh/__init__.py -------------------------------------------------------------------------------- /fava_investor/modules/tlh/example.beancount: -------------------------------------------------------------------------------- 1 | option "title" "Test" 2 | option "operating_currency" "USD" 3 | option "render_commas" "True" 4 | 5 | 2010-01-01 open Assets:Investments:Taxable:XTrade 6 | 2010-01-01 open Assets:Investments:Tax-Deferred:YTrade 7 | 2010-01-01 open Assets:Bank 8 | 9 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 10 | 'tlh' : { 11 | 'account_field': 'account', 12 | 'accounts_pattern': 'Assets:Investments:Taxable', 13 | 'loss_threshold': 50, 14 | 'wash_pattern': 'Assets:Investments', 15 | } 16 | }" 17 | 18 | 2005-01-01 commodity BNCT 19 | tlh_alternates: "LEDG" 20 | 21 | 2005-01-01 commodity COFE 22 | tlh_alternates: "TEA" 23 | 24 | 2005-01-01 commodity DNRCNT 25 | tlh_alternates: "ORNG" 26 | 27 | 2021-04-01 * "Buy stock" 28 | Assets:Investments:Taxable:XTrade 700 BNCT {200 USD} 29 | Assets:Bank 30 | 31 | 2022-04-02 * "Buy stock" 32 | Assets:Investments:Taxable:XTrade 20 COFE {100 USD} 33 | Assets:Bank 34 | 35 | 2022-04-03 * "Buy stock" 36 | Assets:Investments:Taxable:XTrade 20 UPUP {10 USD} 37 | Assets:Bank 38 | 39 | 2022-04-10 * "Buy stock" 40 | Assets:Investments:Taxable:XTrade 20 DNRCNT {10 USD} 41 | Assets:Bank 42 | 43 | 2022-04-20 * "Buy stock" 44 | Assets:Investments:Tax-Deferred:YTrade 20 DNRCNT {10 USD} 45 | Assets:Bank 46 | 47 | 2022-04-20 * "Buy stock" 48 | Assets:Investments:Taxable:XTrade 1 BNCT {195 USD} 49 | Assets:Bank 50 | 51 | ; 2022-04-21 * "Sell stock" 52 | ; Assets:Investments:Taxable:XTrade -2 BNCT {200 USD} 53 | ; Assets:Bank 54 | 55 | 2022-03-08 price BNCT 150 USD 56 | 2022-03-08 price COFE 95 USD 57 | 2022-03-08 price UPUP 25 USD 58 | 2022-03-08 price DNRCNT 5 USD 59 | 60 | -------------------------------------------------------------------------------- /fava_investor/modules/tlh/libtlh.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python3 2 | """Tax loss harvesting library for Beancount. Determines tax loss harvestable commodities, and potential wash 3 | sales, after account for substantially identical funds.""" 4 | 5 | import collections 6 | import locale 7 | import itertools 8 | from datetime import datetime 9 | from dateutil import relativedelta 10 | from fava_investor.common.libinvestor import val, build_table_footer, insert_column, split_currency 11 | from beancount.core.number import Decimal, D 12 | from beancount.core.inventory import Inventory 13 | 14 | 15 | def get_tables(accapi, options): 16 | retrow_types, to_sell, recent_purchases = find_harvestable_lots(accapi, options) 17 | harvestable_table = retrow_types, to_sell 18 | by_commodity = harvestable_by_commodity(accapi, options, *harvestable_table) 19 | summary = summarize_tlh(harvestable_table, by_commodity) 20 | recents = build_recents(recent_purchases) 21 | 22 | harvestable_table = sort_harvestable_table(harvestable_table, by_commodity) 23 | return harvestable_table, summary, recents, by_commodity 24 | 25 | 26 | def sort_harvestable_table(harvestable_table, by_commodity): 27 | """Sort the main table (harvestable_table) in the order of highest to lowest losses.""" 28 | sort_order = [i.currency for i in by_commodity[1]] 29 | 30 | def order(elem): 31 | return sort_order.index(elem.ticker) 32 | 33 | harvestable_table[1].sort(key=order) 34 | return harvestable_table 35 | 36 | 37 | def gain_term(bought, sold): 38 | diff = relativedelta.relativedelta(sold, bought) 39 | # relativedelta is used to account for leap years, since IRS defines 'long/short' as "> 1 year" 40 | if diff.years > 1 or (diff.years == 1 and (diff.months >= 1 or diff.days >= 1)): 41 | return 'Long' 42 | return 'Short' 43 | 44 | 45 | def get_metavalue(ticker, directives, mlabel): 46 | metadata = {} if directives.get(ticker) is None else directives[ticker].meta 47 | return metadata.get(mlabel, '') # Why a string? 48 | # for multiple values 49 | # values = [metadata[mlabel] for mlabel in mlabels if mlabel in metadata] 50 | # return ','.join(values) 51 | 52 | 53 | def get_account_field(options): 54 | """See accompanying README.md""" 55 | account_field = options.get('account_field', 'LEAF(account)') 56 | try: 57 | if isinstance(account_field, int): 58 | account_field = ['account', 59 | 'LEAF(account)', 60 | 'GREPN("(.*):([^:]*):", account, 2)' # get one-but-leaf account 61 | ][account_field] 62 | except ValueError: 63 | pass 64 | return account_field 65 | 66 | 67 | def find_harvestable_lots(accapi, options): 68 | """Find tax loss harvestable lots. 69 | - This is intended for the US, but may be adaptable to other countries. 70 | - This assumes SpecID (Specific Identification of Shares) is the method used for these accounts 71 | """ 72 | 73 | account_field = get_account_field(options) 74 | accounts_pattern = options.get('accounts_pattern', '') 75 | 76 | sql = f""" 77 | SELECT {account_field} as account, 78 | units(sum(position)) as units, 79 | cost_date as acquisition_date, 80 | value(sum(position)) as market_value, 81 | cost(sum(position)) as basis 82 | WHERE account_sortkey(account) ~ "^[01]" AND 83 | account ~ '{accounts_pattern}' 84 | GROUP BY {account_field}, cost_date, currency, cost_currency, cost_number, account_sortkey(account) 85 | ORDER BY account_sortkey(account), currency, cost_date 86 | """ 87 | rtypes, rrows = accapi.query_func(sql) 88 | if not rtypes: 89 | return [], {}, [[]] 90 | 91 | # Since we GROUP BY cost_date, currency, cost_currency, cost_number, we never expect any of the 92 | # inventories we get to have more than a single position. Thus, we can and should use 93 | # get_only_position() below. We do this grouping because we are interested in seeing every lot (price, 94 | # date) seperately, that can be sold to generate a TLH 95 | 96 | loss_threshold = options.get('loss_threshold', 1) 97 | 98 | # our output table is slightly different from our query table: 99 | retrow_types = rtypes[:-1] + [('loss', Decimal), ('term', str), ('wash', str)] 100 | retrow_types = insert_column(retrow_types, 'units', Decimal, 'ticker', str) 101 | retrow_types = insert_column(retrow_types, 'market_value', Decimal, 'currency', str) 102 | 103 | # rtypes: 104 | # [('account', ), 105 | # ('units', ), 106 | # ('acquisition_date', ), 107 | # ('market_value', ), 108 | # ('basis', )] 109 | 110 | RetRow = collections.namedtuple('RetRow', [i[0] for i in retrow_types]) 111 | 112 | # build our output table: calculate losses, find wash sales 113 | to_sell = [] 114 | recent_purchases = {} 115 | commodities = accapi.get_commodity_directives() 116 | wash_buy_counter = itertools.count() 117 | 118 | for row in rrows: 119 | if row.market_value.get_only_position() and \ 120 | (val(row.market_value) - val(row.basis) < -loss_threshold): 121 | loss = D(val(row.basis) - val(row.market_value)) 122 | 123 | term = gain_term(row.acquisition_date, datetime.today().date()) 124 | 125 | # find wash sales 126 | units, ticker = split_currency(row.units) 127 | recent, wash_id = recent_purchases.get(ticker, (None, None)) 128 | if not recent: 129 | identicals = get_metavalue(ticker, commodities, 'a__substidenticals') 130 | ticksims = [ticker] + identicals.split(',') if identicals else [ticker] 131 | recent = query_recently_bought(ticksims, accapi, options) 132 | wash_id = '' 133 | if len(recent[1]): 134 | wash_id = next(wash_buy_counter) 135 | for t in ticksims: 136 | recent_purchases[t] = (recent, wash_id) 137 | wash = wash_id if len(recent[1]) else '' 138 | 139 | to_sell.append(RetRow(row.account, units, ticker, row.acquisition_date, 140 | *split_currency(row.market_value), loss, term, wash)) 141 | 142 | return retrow_types, to_sell, recent_purchases 143 | 144 | 145 | def harvestable_by_commodity(accapi, options, rtype, rrows): 146 | """Group input by sum(commodity) 147 | """ 148 | 149 | retrow_types = [('currency', str), ('total_loss', Decimal), ('market_value', Decimal), ('alt', str)] 150 | RetRow = collections.namedtuple('RetRow', [i[0] for i in retrow_types]) 151 | 152 | losses = collections.defaultdict(Decimal) 153 | market_value = collections.defaultdict(Decimal) 154 | for row in rrows: 155 | losses[row.ticker] += row.loss 156 | market_value[row.ticker] += row.market_value 157 | 158 | by_commodity = [] 159 | commodities = accapi.get_commodity_directives() 160 | for ticker, loss in sorted(losses.items(), key=lambda x: x[1], reverse=True): 161 | alts = get_metavalue(ticker, commodities, 'a__tlh_partners').replace(',', ', ') 162 | by_commodity.append(RetRow(ticker, loss, market_value[ticker], alts)) 163 | 164 | return retrow_types, by_commodity 165 | 166 | 167 | def build_recents(recent_purchases): 168 | recents = [] 169 | types = [] 170 | for _, ((header, rows), wash_id) in recent_purchases.items(): 171 | if len(rows): 172 | types = header + [('wash', str)] 173 | RetRow = collections.namedtuple('RetRow', [i[0] for i in types]) 174 | rows = [RetRow(*row, wash_id) for row in rows] 175 | recents += rows 176 | 177 | # dedupe recents 178 | recents_dd = [] 179 | [recents_dd.append(r) for r in recents if r not in recents_dd] 180 | return types, recents_dd 181 | 182 | 183 | def gen_ticker_expression(tickers): 184 | """tickers is either a list, or a comma separated string of one or more tickers""" 185 | if isinstance(tickers, str): 186 | tickers = tickers.split(',') 187 | expr = [f'CURRENCY = "{t}" OR' for t in tickers] 188 | expr = ' '.join(expr) 189 | expr = expr[:-3] 190 | return f"({expr})" 191 | 192 | 193 | def query_recently_bought(tickers, accapi, options): 194 | """Looking back 30 days for purchases that would cause wash sales""" 195 | 196 | wash_pattern = options.get('wash_pattern', '') 197 | account_field = get_account_field(options) 198 | wash_pattern_sql = 'AND account ~ "{}"'.format(wash_pattern) if wash_pattern else '' 199 | ticker_expr = gen_ticker_expression(tickers) 200 | sql = ''' 201 | SELECT 202 | {account_field} as account, 203 | date as acquisition_date, 204 | DATE_ADD(date, 31) as earliest_sale, 205 | units(sum(position)) as units, 206 | cost(sum(position)) as basis 207 | WHERE 208 | number > 0 AND 209 | date >= DATE_ADD(TODAY(), -30) AND 210 | {ticker_expr} 211 | {wash_pattern_sql} 212 | GROUP BY {account_field},date,earliest_sale 213 | ORDER BY date DESC 214 | '''.format(**locals()) 215 | rtypes, rrows = accapi.query_func(sql) 216 | return rtypes, rrows 217 | 218 | 219 | def recently_sold_at_loss(accapi, options): 220 | """Looking back 30 days for sales that caused losses. These were likely to have been TLH (but not 221 | necessarily so). This tells us what NOT to buy in order to avoid wash sales.""" 222 | 223 | operating_currencies = accapi.get_operating_currencies_regex() 224 | wash_pattern = options.get('wash_pattern', '') 225 | account_field = get_account_field(options) 226 | wash_pattern_sql = 'AND account ~ "{}"'.format(wash_pattern) if wash_pattern else '' 227 | sql = ''' 228 | SELECT 229 | date as sale_date, 230 | DATE_ADD(date, 30) as until, 231 | currency, 232 | NEG(SUM(COST(position))) as basis, 233 | NEG(SUM(CONVERT(position, cost_currency, date))) as proceeds 234 | WHERE 235 | date >= DATE_ADD(TODAY(), -30) 236 | AND number < 0 237 | AND not currency ~ "{operating_currencies}" 238 | GROUP BY sale_date,until,currency 239 | '''.format(**locals()) 240 | rtypes, rrows = accapi.query_func(sql) 241 | if not rtypes: 242 | return [], [] 243 | 244 | # filter out losses 245 | rtypes = insert_column(rtypes, 'currency', None, 'identicals', str) 246 | rtypes = rtypes + [('loss', Inventory)] 247 | RetRow = collections.namedtuple('RetRow', [i[0] for i in rtypes]) 248 | return_rows = [] 249 | 250 | commodities = accapi.get_commodity_directives() 251 | for row in rrows: 252 | loss = Inventory(row.proceeds) 253 | loss.add_inventory(-(row.basis)) 254 | if loss != Inventory() and val(loss) < 0: 255 | identicals = get_metavalue(row.currency, commodities, 'a__substidenticals').replace(',', ', ') 256 | return_rows.append(RetRow(row.sale_date, row.until, row.currency, identicals, row.basis, 257 | row.proceeds, loss)) 258 | 259 | footer = build_table_footer(rtypes, return_rows, accapi) 260 | return rtypes, return_rows, None, footer 261 | 262 | 263 | def summarize_tlh(harvestable_table, by_commodity): 264 | # Summary 265 | 266 | locale.setlocale(locale.LC_ALL, '') 267 | 268 | to_sell = harvestable_table[1] 269 | summary = {} 270 | summary["Total harvestable loss"] = sum(i.loss for i in to_sell) 271 | summary["Total sale value required"] = sum(i.market_value for i in to_sell) 272 | summary["Commmodities with a loss"] = len(by_commodity[1]) 273 | summary["Number of lots to sell"] = len(to_sell) 274 | unique_txns = set((r.account, r.ticker) for r in to_sell) 275 | summary["Total unique transactions"] = len(unique_txns) 276 | summary = {k: '{:n}'.format(int(v)) for k, v in summary.items()} 277 | return summary 278 | -------------------------------------------------------------------------------- /fava_investor/modules/tlh/test_libtlh.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import beancountinvestorapi as api 4 | import functools 5 | import datetime 6 | import sys 7 | import os 8 | from beancount.utils import test_utils 9 | sys.path.append(os.path.join(os.path.dirname(__file__), '.')) 10 | import libtlh 11 | # To run: pytest 12 | 13 | 14 | @functools.lru_cache(maxsize=1) 15 | def dates(): 16 | def minusdays(today, d): 17 | return (today - datetime.timedelta(days=d)).isoformat() 18 | today = datetime.datetime.now().date() 19 | retval = {'today': today, 20 | 'm1': minusdays(today, 1), 21 | 'm5': minusdays(today, 5), 22 | 'm9': minusdays(today, 9), 23 | 'm10': minusdays(today, 10), 24 | 'm15': minusdays(today, 15), 25 | 'm20': minusdays(today, 20), 26 | 'm100': minusdays(today, 100), 27 | } 28 | return retval 29 | 30 | 31 | def insert_dates(function, **kwargs): 32 | """A decorator that rewrites the function's docstring with dates. Needed here because TLH looks back at 33 | the past 30 days, which means all test cases need to be aware of this period.""" 34 | 35 | @functools.wraps(function) 36 | def new_function(self, filename): 37 | return function(self, filename) 38 | new_function.__doc__ = function.__doc__.format(**(dates())) 39 | return new_function 40 | 41 | 42 | class TestScriptCheck(test_utils.TestCase): 43 | def setUp(self): 44 | self.options = {'accounts_pattern': "Assets:Investments:Taxable", 'wash_pattern': "Assets:Investments"} 45 | 46 | @test_utils.docfile 47 | @insert_dates 48 | def test_no_relevant_accounts(self, f): 49 | """ 50 | 2010-01-01 open Assets:Investments:Brokerage 51 | 2010-01-01 open Assets:Bank 52 | 53 | {m10} * "Buy stock" 54 | Assets:Investments:Brokerage 1 BNCT {{200 USD}} 55 | Assets:Bank 56 | 57 | {m1} price BNCT 100 USD 58 | """ 59 | accapi = api.AccAPI(f, {}) 60 | 61 | retrow_types, to_sell, recent_purchases = libtlh.find_harvestable_lots(accapi, self.options) 62 | 63 | self.assertEqual(0, len(to_sell)) 64 | self.assertEqual(0, len(recent_purchases)) 65 | 66 | @test_utils.docfile 67 | @insert_dates 68 | def test_harvestable_basic(self, f): 69 | """ 70 | 2010-01-01 open Assets:Investments:Taxable:Brokerage 71 | 2010-01-01 open Assets:Bank 72 | 73 | {m100} * "Buy stock" 74 | Assets:Investments:Taxable:Brokerage 1 BNCT {{200 USD}} 75 | Assets:Bank 76 | 77 | {m1} price BNCT 100 USD 78 | """ 79 | accapi = api.AccAPI(f, {}) 80 | 81 | retrow_types, to_sell, recent_purchases = libtlh.find_harvestable_lots(accapi, self.options) 82 | recents = libtlh.build_recents(recent_purchases) 83 | 84 | self.assertEqual(1, len(to_sell)) 85 | self.assertEqual(([], []), recents) 86 | 87 | # Test disabled: recently_sold_at_loss() has these lines, that don't work becaues val() expects Inventory 88 | # to have a single item. I'm guessing favainvestorapi and beancountinvestorapi differ in what gets added 89 | # to loss below, because these lines work fine with favainvestorapi, but fail with beancountinvestorapi. 90 | # needs investigation 91 | # loss = Inventory(row.proceeds) 92 | # loss.add_inventory(-(row.basis)) 93 | # if loss != Inventory() and val(loss) < 0: 94 | # return_rows.append(RetRow(*row, loss)) 95 | # @test_utils.docfile 96 | # @insert_dates 97 | # def test_dontbuy(self, f): 98 | # """ 99 | # option "operating_currency" "USD" 100 | # 2010-01-01 open Assets:Investments:Taxable:Brokerage 101 | # 2010-01-01 open Assets:Bank 102 | 103 | # {m100} * "Buy stock" 104 | # Assets:Investments:Taxable:Brokerage 1 BNCT {{200 USD}} 105 | # Assets:Bank 106 | 107 | # {m10} * "Sell stock" 108 | # Assets:Investments:Taxable:Brokerage -1 BNCT {{200 USD}} @ 100 USD 109 | # Assets:Bank 110 | 111 | # {m1} price BNCT 100 USD 112 | # """ 113 | # accapi = api.AccAPI(f, {}) 114 | 115 | # rtypes, rrows = libtlh.recently_sold_at_loss(accapi, self.options) 116 | 117 | # self.assertEqual(2, len(rrows)) 118 | 119 | @test_utils.docfile 120 | @insert_dates 121 | def test_wash(self, f): 122 | """ 123 | 2010-01-01 open Assets:Investments:Taxable:Brokerage 124 | 2010-01-01 open Assets:Bank 125 | 126 | {m10} * "Buy stock" 127 | Assets:Investments:Taxable:Brokerage 1 BNCT {{200 USD}} 128 | Assets:Bank 129 | 130 | {m1} price BNCT 100 USD 131 | """ 132 | accapi = api.AccAPI(f, {}) 133 | 134 | retrow_types, to_sell, recent_purchases = libtlh.find_harvestable_lots(accapi, self.options) 135 | recents = libtlh.build_recents(recent_purchases) 136 | 137 | self.assertEqual(1, len(to_sell)) 138 | self.assertEqual(1, len(recent_purchases)) 139 | self.assertEqual(1, len(recents[1])) 140 | 141 | @test_utils.docfile 142 | @insert_dates 143 | def test_wash_substantially_identical(self, f): 144 | """ 145 | 2010-01-01 open Assets:Investments:Taxable:Brokerage 146 | 2010-01-01 open Assets:Bank 147 | 148 | 2010-01-01 commodity BNCT 149 | a__substidenticals: "ORNG" 150 | 151 | {m100} * "Buy stock" 152 | Assets:Investments:Taxable:Brokerage 1 BNCT {{200 USD}} 153 | Assets:Bank 154 | 155 | {m10} * "Buy stock" 156 | Assets:Investments:Taxable:Brokerage 1 ORNG {{1 USD}} 157 | Assets:Bank 158 | 159 | {m1} price BNCT 100 USD 160 | """ 161 | accapi = api.AccAPI(f, {}) 162 | 163 | retrow_types, to_sell, recent_purchases = libtlh.find_harvestable_lots(accapi, self.options) 164 | recents = libtlh.build_recents(recent_purchases) 165 | 166 | self.assertEqual(1, len(to_sell)) 167 | self.assertEqual(2, len(recent_purchases)) 168 | self.assertEqual('ORNG', recent_purchases['BNCT'][0][1][0].units.get_only_position().units.currency) 169 | self.assertEqual(1, len(recents[1])) 170 | self.assertEqual('ORNG', recents[1][0].units.get_only_position().units.currency) 171 | -------------------------------------------------------------------------------- /fava_investor/modules/tlh/tlh.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/fava_investor/modules/tlh/tlh.jpg -------------------------------------------------------------------------------- /fava_investor/modules/tlh/tlh.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Beancount Tax Loss Harvester""" 3 | 4 | import fava_investor.modules.tlh.libtlh as libtlh 5 | import fava_investor.common.beancountinvestorapi as api 6 | from fava_investor.common.clicommon import pretty_print_table 7 | import click 8 | 9 | 10 | @click.command() 11 | @click.argument('beancount-file', type=click.Path(exists=True), envvar='BEANCOUNT_FILE') 12 | @click.option('--brief', help='Summary output', is_flag=True) 13 | def tlh(beancount_file, brief): 14 | """Finds opportunities for tax loss harvesting in a beancount file. 15 | 16 | The BEANCOUNT_FILE environment variable can optionally be set instead of specifying the file on the 17 | command line. 18 | 19 | The configuration for this module is expected to be supplied as a custom directive like so in your 20 | beancount file: 21 | 22 | \b 23 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 24 | 'tlh' : { 'accounts_pattern': 'Assets:Investments:Taxable', 25 | 'loss_threshold': 0, 26 | 'wash_pattern': 'Assets:Investments', 27 | 'account_field': 2, 28 | }}" 29 | 30 | """ 31 | accapi = api.AccAPI(beancount_file, {}) 32 | config = accapi.get_custom_config('tlh') 33 | harvestable_table, summary, recents, by_commodity = libtlh.get_tables(accapi, config) 34 | dontbuy = libtlh.recently_sold_at_loss(accapi, config) 35 | 36 | def _gen_output(): 37 | yield click.style("Summary" + '\n', bg='green', fg='white') 38 | for k, v in summary.items(): 39 | yield "{:30}: {:>}\n".format(k, v) 40 | yield '\n' 41 | yield pretty_print_table("Losses by commodity", *by_commodity) 42 | 43 | if not brief: 44 | yield pretty_print_table("Candidates for tax loss harvesting", *harvestable_table) 45 | yield pretty_print_table("What not to sell: recent purchases that would cause wash sales", *recents) 46 | yield pretty_print_table("What not to buy (sales within the last 30 days with losses)", dontbuy[0], dontbuy[1]) 47 | 48 | yield "Note: Turn OFF dividend reinvestment for all these tickers across ALL accounts.\n" 49 | yield "See fava plugin for better formatted and sortable output.\n" 50 | click.echo_via_pager(_gen_output()) 51 | 52 | 53 | if __name__ == '__main__': 54 | tlh() 55 | -------------------------------------------------------------------------------- /fava_investor/modules/tlh/wash_substantially_identical.beancount: -------------------------------------------------------------------------------- 1 | option "title" "Test" 2 | option "operating_currency" "USD" 3 | option "render_commas" "True" 4 | 5 | 2010-01-01 open Assets:Investments:Taxable:XTrade 6 | 2010-01-01 open Assets:Investments:Tax-Deferred:YTrade 7 | 2010-01-01 open Assets:Bank 8 | 9 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 10 | 'tlh' : { 11 | 'account_field': 'account', 12 | 'accounts_pattern': 'Assets:Investments:Taxable', 13 | 'loss_threshold': 50, 14 | 'wash_pattern': 'Assets:Investments', 15 | } 16 | }" 17 | 18 | 2010-01-01 open Assets:Investments:Taxable:Brokerage 19 | 2010-01-01 open Assets:Bank 20 | 21 | 2010-01-01 commodity BNCT 22 | a__substidenticals: "ORNG" 23 | 24 | 2022-01-01 * "Buy stock" 25 | Assets:Investments:Taxable:Brokerage 1 BNCT {{200 USD}} 26 | Assets:Bank 27 | 28 | 2022-10-15 * "Buy stock" 29 | Assets:Investments:Taxable:Brokerage 1 ORNG {{1 USD}} 30 | Assets:Bank 31 | 32 | 2022-10-19 price BNCT 100 USD 33 | -------------------------------------------------------------------------------- /fava_investor/pythonanywhere/commodities.beancount: -------------------------------------------------------------------------------- 1 | ; Generated by: ticker_util.py, at 2022-08-07T00:51:33.253008 2 | 3 | 2022-08-07 commodity GLD 4 | a__annualReportExpenseRatio: "0.4" 5 | a__quoteType: "ETF" 6 | asset_allocation_metals_gold: 100 7 | name: "SPDR Gold Shares" 8 | 9 | 1980-05-12 commodity IRAUSD 10 | export: "IGNORE" 11 | name: "US 401k and IRA Contributions" 12 | 13 | 2022-08-07 commodity ITOT 14 | a__annualReportExpenseRatio: "0.03" 15 | asset_allocation_stock_domestic_totalmarket: 100 16 | a__isin: "US4642871507" 17 | a__quoteType: "ETF" 18 | name: "iShares Core S&P Total U.S. Stock Market ETF" 19 | 20 | 2022-08-07 commodity RGAGX 21 | asset_allocation_cash_reserves: 10 22 | asset_allocation_stock_domestic_largecap: 90 23 | a__isin: "US3998748178" 24 | a__quoteType: "MUTUALFUND" 25 | name: "American Funds The Growth Fund of America Class R-6" 26 | 27 | 1792-01-01 commodity USD 28 | export: "CASH" 29 | name: "US Dollar" 30 | asset_allocation_cash_USD: 100 31 | 32 | 1980-05-12 commodity VACHR 33 | export: "IGNORE" 34 | name: "Employer Vacation Hours" 35 | commodity_skip: "True" 36 | 37 | 2022-08-07 commodity VBMPX 38 | asset_allocation_bond_totalmarket: 100 39 | a__isin: "US9219377853" 40 | a__quoteType: "MUTUALFUND" 41 | name: "Vanguard Total Bond Market Index Fund Institutional Plus Shares" 42 | 43 | 2022-08-07 commodity VEA 44 | a__annualReportExpenseRatio: "0.05" 45 | asset_allocation_stock_international: 100 46 | a__isin: "US9219438580" 47 | a__quoteType: "ETF" 48 | name: "Vanguard Developed Markets Index Fund" 49 | 50 | 2022-08-07 commodity VHT 51 | a__annualReportExpenseRatio: "0.1" 52 | asset_allocation_stock_domestic_sector: 100 53 | a__isin: "US92204A5048" 54 | a__quoteType: "ETF" 55 | name: "Vanguard Health Care Index Fund" 56 | 57 | 2022-08-07 commodity VMMXX 58 | a__quoteType: "MONEYMARKET" 59 | name: "Vanguard Money Market Reserves - Vanguard Cash Reserves Federal Money Market Fund" 60 | -------------------------------------------------------------------------------- /fava_investor/pythonanywhere/fava_config.beancount: -------------------------------------------------------------------------------- 1 | 2010-01-01 custom "fava-extension" "fava_investor" "{ 2 | 'tlh' : { 'accounts_pattern': 'Assets:US', 3 | 'loss_threshold': 0, 4 | 'wash_pattern': 'Assets:US', 5 | 'account_field': 2, 6 | }, 7 | 8 | 'asset_alloc_by_account': [ 9 | { 'title': 'Allocation by Taxability', 10 | 'pattern_type': 'account_name', 11 | 'pattern': 'Assets:US:[^:]*$', 12 | 'include_children': True, 13 | }, 14 | ], 15 | 16 | 'asset_alloc_by_class' : { 17 | 'accounts_patterns': ['Assets:US'], 18 | }, 19 | 20 | 'cashdrag': { 21 | 'accounts_pattern': '^Assets:.*', 22 | 'accounts_exclude_pattern': '^Assets:(Cash-In-Wallet.*|Zero-Sum)', 23 | 'metadata_label_cash' : 'asset_allocation_cash' 24 | }, 25 | 'summarizer': [ 26 | { 'title' : 'Commodities Summary', 27 | 'directive_type' : 'commodities', 28 | 'active_only': True, 29 | 'meta_skip' : 'commodity_skip', 30 | 'col_labels': [ 'Ticker', 'Type', 'Equi', 'Description', 'TLH_to', 'ER', 'Market'], 31 | 'columns' : [ 'ticker', 'a__quoteType', 'a__equivalents', 'name', 'tlh_alternates', 'a__annualReportExpenseRatio', 'market_value'], 32 | 'sort_by' : 1, 33 | }, 34 | ], 35 | 'minimizegains' : { 36 | 'accounts_pattern': 'Assets:US', 37 | 'account_field': 2, 38 | 'st_tax_rate': 0.35, 39 | 'lt_tax_rate': 0.21 40 | } 41 | }" 42 | 43 | -------------------------------------------------------------------------------- /fava_investor/pythonanywhere/favainvestor_pythonanywhere_com_wsgi.py: -------------------------------------------------------------------------------- 1 | """fava wsgi application""" 2 | from __future__ import annotations 3 | 4 | from fava.application import app as application 5 | 6 | application.config["BEANCOUNT_FILES"] = [ 7 | "/home/favainvestor/.local/lib/python3.9/site-packages/fava_investor/pythonanywhere/example.beancount" 8 | ] 9 | -------------------------------------------------------------------------------- /fava_investor/pythonanywhere/scripts: -------------------------------------------------------------------------------- 1 | ticker-util gen-commodities-file --include-undeclared \ 2 | | sed -e 's/a__asset/asset/' -e '/asset_/s/"//g' > commodities.bc 3 | -------------------------------------------------------------------------------- /fava_investor/pythonanywhere/update.bash: -------------------------------------------------------------------------------- 1 | pip3 install git+https://github.com/redstreet/fava_investor 2 | find ~/.local/lib/python3.9/site-packages/fava_investor/pythonanywhere -type f -exec chmod a-w {} \; -print 3 | -------------------------------------------------------------------------------- /fava_investor/templates/Investor.html: -------------------------------------------------------------------------------- 1 | {% import "_query_table.html" as querytable with context %} 2 | 3 | {% set module = request.args.get('module') %} 4 |
5 | {% for key, label in [('aa_class', _('Asset Allocation Classes')), 6 | ('aa_account', _('Asset Allocation Accounts')), 7 | ('cashdrag', _('Cash Drag')), 8 | ('tlh', _('Tax Loss Harvestor')), 9 | ('summarizer', _('Summarizer')), 10 | ('minimizegains', _('Gains Minimizer')) 11 | ] %} 12 |

{% if not (module == key) %}{{ label }}{% else %} {{ label }}{% endif %}

13 | {% endfor %} 14 |
15 | 16 | {% if (module == None) %} 17 | 18 | Welcome! Fava Investor provides reports, analyses, and tools for investments. It 19 | implemented as a collection of modules. Use the tabs on the top to navigate to each 20 | module. 21 | 22 | {% endif %} 23 | 24 | 25 | {% macro table_list_renderer(title, tables) -%} 26 |

{{ title }}

27 | {% if tables|length == 0 %} 28 | Module not configured. See example.beancount for how to configure this module. 29 | {% endif %} 30 | {% for table in tables %} 31 |

{{table[0]}}

32 | {{ querytable.querytable(ledger, None, *table[1]) }} 33 | {% endfor %} 34 | {% endmacro %} 35 | 36 | 37 | {% if (module == 'aa_account') %} 38 | {{ table_list_renderer('Portfolio: Asset Allocation by Accounts', extension.build_aa_by_account()) }} 39 | {% endif %} 40 | 41 | {% if (module == 'cashdrag') %} 42 | {{ table_list_renderer('', extension.build_cashdrag()) }} 43 | {% endif %} 44 | 45 | {% if (module == 'summarizer') %} 46 | {{ table_list_renderer('', extension.build_summarizer()) }} 47 | {% endif %} 48 | 49 | {% if (module == 'minimizegains') %} 50 | {{ table_list_renderer('', extension.build_minimizegains()) }} 51 | {% endif %} 52 | 53 | 54 | 55 | {% if (module == 'tlh') %} 56 |

Tax Loss Harvester

57 | {% set harvests = extension.build_tlh_tables() %} 58 | 59 |
60 |
61 |

Summary

62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | {% for key, value in harvests[1].items() %} 71 | 72 | 73 | 74 | 75 | {% endfor %} 76 | 77 |
{{ _('Summary') }}{{ _('Val') }}
{{ key }}{{ value }}
78 |
79 |
80 |
81 |

Losses by Commodity

82 | {{ querytable.querytable(ledger, None, *harvests[3]) }} 83 |
84 |
85 |
86 | 87 | 88 |

Candidates for tax loss harvesting

89 | {{ querytable.querytable(ledger, None, *harvests[0]) }} 90 | 91 |
92 | 93 | 94 |

Potential wash sales: purchases within the past 30 days

95 | 96 | Below is a list of purchases with the past 30 days. "earliest_sale" is the date on 97 | which a loss of the ticker shown can be harvested without resulting in a wash sale. 98 | 99 | {% set table_empty_msg = None %} 100 | {% if harvests[2][0]|length == 0 %} 101 | {% set table_empty_msg = 'No purchases of the candidates above found within the last 30 days!' %} 102 | {% endif %} 103 | {{ querytable.querytable(ledger, table_empty_msg, *harvests[2]) }} 104 |
105 | 106 |

What not to buy

107 | Below is a list of recent sales with losses. Assuming these losses were harvested, 108 | purchasing these within 30 days of the sale could result in the loss becoming a wash 109 | sale. 110 | {% set lossy_sales = extension.recently_sold_at_loss() %} 111 | {% set table_empty_msg = None %} 112 | {% if lossy_sales[1]|length == 0 %} 113 | {% set table_empty_msg = 'No sales with losses found in the last 30 days!' %} 114 | {% endif %} 115 | {{ querytable.querytable(ledger, table_empty_msg, *lossy_sales) }} 116 | 117 |
118 | None of the above is meant to be financial, legal, tax, or other advice. 119 | 120 | 121 | {% endif %} 122 | 123 | {% set table_hover_text = _('Hold Shift while clicking to expand all children. 124 | Hold Ctrl or Cmd while clicking to expand one level.') %} 125 | 126 | {# TODO: 127 | - add a format_percentage() to fava's template_filters, and use that here 128 | - display 0 decimal places for assets (needed for all of fava_investor) 129 | - fix: asset bucket spacing is too wide 130 | - get currency from libassetalloc instead of looping: {% for currency in ledger.options.operating_currency %} 131 | - remove links from asset class name 132 | #} 133 | 134 | {% macro asset_tree(account_node) %} 135 | 136 |
    137 |
  1. 138 |

    139 | 140 | {% for currency in ledger.options.operating_currency %} 141 | {{ currency }} 142 | {% endfor %} 143 | {{ _('Percentage') }} 144 |

    145 |
  2. 146 | {% for account in ([account_node] if account_node.name else account_node.children) recursive %} 147 | 148 | 149 | 154 | {% for currency in ledger.options.operating_currency %} 155 | 156 | 157 | {{ account.balance|format_currency(currency) }} 158 | 159 | 160 | {{ account.balance_children|format_currency(currency) }} 161 | 162 | 163 | {% endfor %} 164 | 165 | {% set percentage_parent = '{:3.0f}% of {}'.format(account.percentage_parent, account.parent.name) if account.parent else '' %} 166 | 167 | {{ '{:6.2f} %'.format(account.percentage) if account.percentage else '' }} 168 | 169 | 170 | {{ '{:6.2f} %'.format(account.percentage_children) if account.percentage_children else '' }} 171 | 172 | 173 |

    174 | {% if account.children %} 175 |
      176 | {{ loop(account.children|sort(attribute='name')) }} 177 |
    178 | {% endif %} 179 | 180 | {% endfor %} 181 |
182 |
183 | {% endmacro %} 184 | 185 | {% if (module == 'aa_class') %} 186 |

Portfolio: Asset Allocation by Class

187 | 188 | {% set results = extension.build_assetalloc_by_class() %} 189 | {% set chart_data = [{ 190 | 'type': 'hierarchy', 191 | 'label': "Asset Allocation", 192 | 'data': results[0].serialise(results[0]['currency']) 193 | }] 194 | %} 195 | 196 | 197 | {{ asset_tree(results[0]) }} 198 | 199 | {% endif %} 200 | -------------------------------------------------------------------------------- /fava_investor/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/fava_investor/util/__init__.py -------------------------------------------------------------------------------- /fava_investor/util/cachedtickerinfo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import asyncio 3 | import os 4 | import pickle 5 | import datetime 6 | import yfinance as yf 7 | 8 | 9 | class CachedTickerInfo: 10 | def __init__(self, cache_file): 11 | self.cache_file = cache_file 12 | if not os.path.exists(self.cache_file): 13 | with open(self.cache_file, 'wb') as f: 14 | pickle.dump({}, f) 15 | with open(self.cache_file, 'rb') as f: 16 | data = pickle.load(f) 17 | # print(self.get_cache_last_updated()) 18 | self.data = data 19 | 20 | def get_cache_last_updated(self): 21 | cache_file_updated = os.path.getmtime(self.cache_file) 22 | tz = datetime.datetime.now().astimezone().tzinfo 23 | self.cache_last_updated = datetime.datetime.fromtimestamp(cache_file_updated, tz).isoformat() 24 | return self.cache_last_updated 25 | 26 | def lookup_yahoo(self, ticker): 27 | t_obj = yf.Ticker(ticker) 28 | 29 | print("Downloading info for", ticker) 30 | self.data[ticker] = t_obj.info 31 | self.data[ticker]['isin'] = t_obj.isin 32 | if self.data[ticker]['isin'] == '-': 33 | self.data[ticker].pop('isin') 34 | if 'annualReportExpenseRatio' in self.data[ticker]: 35 | er = self.data[ticker]['annualReportExpenseRatio'] 36 | if er: 37 | self.data[ticker]['annualReportExpenseRatio'] = round(er * 100, 2) 38 | 39 | def write_cache(self): 40 | with open(self.cache_file, 'wb') as f: 41 | pickle.dump(self.data, f) 42 | 43 | def remove(self, ticker): 44 | self.data.pop(ticker, None) 45 | self.write_cache() 46 | 47 | def batch_lookup(self, tickers): 48 | async def download(): 49 | loop = asyncio.get_running_loop() 50 | tickers_to_lookup = [t for t in tickers if t not in self.data] 51 | tasks = [loop.run_in_executor(None, self.lookup_yahoo, ticker) for ticker in tickers_to_lookup] 52 | await asyncio.gather(*tasks) 53 | 54 | asyncio.run(download()) 55 | self.write_cache() 56 | -------------------------------------------------------------------------------- /fava_investor/util/experimental/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/fava_investor/util/experimental/__init__.py -------------------------------------------------------------------------------- /fava_investor/util/experimental/scaled_navs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Provide scaled price estimates for mutual funds based on their ETF prices. 3 | 4 | WARNING: it may be dangerous to use this tool financially. You bear all liability for all losses stemming from 5 | usage of this tool. 6 | 7 | Problem: Mutual fund NAVs are updated only once a day, at the end of the day. When needing to make financial 8 | decisions (eg: when tax loss harvesting), it is sometimes useful to estimate that NAV, especially on days when 9 | there are huge swings in the market. 10 | 11 | Idea: NAV estimations can be made based on the ETF share class of the mutual fund, if one exists. 12 | 13 | Note that on volatile days, this can be dangerous, since the estimate can be way off. However, there might be 14 | value to using such scaled estimates even that caveat. 15 | 16 | Solution: Scan a beancount price database, and build a list of mf:etf equivalents. Then, determine the typical 17 | price ratio between these. This is done by building a list of mf:etf price pairs on the same days across time, 18 | and using the median value for the ratio (so extreme and incorrect values are discarded). 19 | 20 | Finally, this ratio is used to estimate today's MF NAV price, based on today's ETF price as supplied in the 21 | price database. 22 | 23 | 24 | Misc notes: 25 | ----------- 26 | 27 | - When multiple Price directives do exist for the same day, the last one to appear in the file will be 28 | selected for inclusion in the Price database. 29 | https://beancount.github.io/docs/beancount_language_syntax.html#prices-on-the-same-day 30 | 31 | """ 32 | 33 | import click 34 | import os 35 | import statistics 36 | import datetime 37 | 38 | from beancount.core import getters 39 | from beancount.core.data import Price 40 | from beancount.core.amount import Amount 41 | from beancount.parser import printer 42 | from fava_investor.util.relatetickers import RelateTickers 43 | 44 | 45 | cf_option = click.option('--cf', '--commodities-file', help="Beancount commodity declarations file", 46 | envvar='BEAN_COMMODITIES_FILE', type=click.Path(exists=True), required=True) 47 | prices_option = click.option('--pf', '--prices-file', help="Beancount prices declarations file", 48 | envvar='BEAN_PRICES_FILE', type=click.Path(exists=True), required=True) 49 | 50 | 51 | class ScaledNAV(RelateTickers): 52 | def __init__(self, cf, prices_file, date=None): 53 | self.cf = cf 54 | self.prices_file = prices_file 55 | self.date = date 56 | entries, _, _ = self.load_file(cf) 57 | 58 | # basic databases 59 | self.db = getters.get_commodity_directives(entries) 60 | 61 | # identicals databases 62 | self.identicals = self.build_commodity_groups(['a__equivalents', 'a__substidenticals']) 63 | 64 | # prices database 65 | entries, _, _ = self.load_file(prices_file) 66 | self.price_entries = [entry for entry in entries if isinstance(entry, Price)] 67 | self.price_entries = sorted(self.price_entries, key=lambda x: x.date) 68 | if not date: 69 | date = datetime.datetime.today().date() 70 | self.latest_prices = {p.currency: p.amount for p in self.price_entries if p.date == date} 71 | self.estimate_mf_navs() 72 | # for k, v in self.latest_prices.items(): 73 | # print(k, v) 74 | 75 | def is_etf(self, ticker): 76 | try: 77 | return self.db[ticker].meta['a__quoteType'] == 'ETF' 78 | except KeyError: 79 | return False 80 | 81 | def is_mf(self, ticker): 82 | try: 83 | return self.db[ticker].meta['a__quoteType'] == 'MUTUALFUND' 84 | except KeyError: 85 | return False 86 | 87 | def mf_to_etf_map(self): 88 | """Map MFs to equivalent ETFs. Assume commodity declarations have 'a__quoteType' set to either 89 | MUTUALFUND or ETF. See ticker-util to do this automatically.""" 90 | 91 | mf_to_etfs = {} 92 | for idents in self.identicals: 93 | etfs = [c for c in idents if self.is_etf(c)] 94 | for etf in etfs: 95 | mfs = [c for c in idents if self.is_mf(c)] 96 | for m in mfs: 97 | mf_to_etfs[m] = etf 98 | return mf_to_etfs 99 | 100 | def estimate_mf_navs(self): 101 | """Estimate what mutual fund NAVs would be based on the current price of the equivalent ETF. Don't use 102 | this unless you know what you are doing!""" 103 | 104 | mf_to_etfs = self.mf_to_etf_map() 105 | scaled_mf = {} 106 | unavailable_etfs = set() 107 | for mf, etf in mf_to_etfs.items(): 108 | ratios = [] 109 | for p in self.price_entries: 110 | if p.currency == mf: 111 | etf_prices = [x for x in self.price_entries if (x.currency == etf and x.date == p.date)] 112 | if etf_prices: 113 | mf_price = p.amount.number 114 | etf_price = etf_prices[0].amount.number 115 | ratio = mf_price / etf_price 116 | # print(" ", p.date, mf_price, etf_price, ratio) 117 | ratios.append((p.date, ratio)) 118 | if ratios: 119 | if etf in self.latest_prices: 120 | # consider only the most recent 10 values, because some ETFs and MFs that are 121 | # not share classes of each other can diverge due to how they pay dividends and 122 | # capgain distributions (eg: VINIX vs VOO) 123 | ratios.sort(key=lambda x: x[0]) 124 | ratios = ratios[-10:] 125 | ratios = [i[1] for i in ratios] 126 | median_ratio = statistics.median(ratios) 127 | scaled_number = round(self.latest_prices[etf].number * median_ratio, 2) 128 | scaled_mf[mf] = (etf, median_ratio, Amount(scaled_number, self.latest_prices[etf].currency)) 129 | else: 130 | unavailable_etfs.add(etf) 131 | 132 | if unavailable_etfs: 133 | print(f"Prices for these ETFs on {self.date} were not found:", 134 | ", ".join(sorted(unavailable_etfs))) 135 | 136 | self.estimated_price_entries = [Price({}, self.date, mf, amt) 137 | for mf, (_, _, amt) in scaled_mf.items()] 138 | 139 | def show_estimates(self): 140 | printer.print_entries(self.estimated_price_entries) 141 | 142 | def update_prices_file(self): 143 | with open(self.prices_file, "a") as fout: 144 | print("\n; Below are *estimated* mutual fund NAVs, generated by: ", end='', file=fout) 145 | print(f"{os.path.basename(__file__)}, at {datetime.datetime.today().isoformat()}", file=fout) 146 | fout.flush() 147 | printer.print_entries(self.estimated_price_entries, file=fout) 148 | print(file=fout) 149 | 150 | 151 | @click.command() 152 | @cf_option 153 | @prices_option 154 | @click.option('--date', help="Date", default=datetime.datetime.today().date()) 155 | @click.option('-w', '--write-to-prices-file', is_flag=True, help='Append estimates to prices file.') 156 | def scaled_navs(cf, pf, date, write_to_prices_file): 157 | """Provide scaled price estimates for mutual funds based on their ETF prices. Experimental. 158 | 159 | \nWARNING: it may be dangerous to use this tool financially. You bear all liability for all losses stemming 160 | from usage of this tool. 161 | 162 | \nThe following environment variables are used: 163 | \n$BEAN_COMMODITIES_FILE: file with beancount commodities declarations. 164 | \n$BEAN_PRICES_FILE: file with beancount prices declarations. 165 | """ 166 | if isinstance(date, str): 167 | date = datetime.datetime.strptime(date, "%Y-%m-%d").date() 168 | s = ScaledNAV(cf, pf, date=date) 169 | s.show_estimates() 170 | if write_to_prices_file: 171 | s.update_prices_file() 172 | print("Above was appended to", pf) 173 | 174 | 175 | if __name__ == '__main__': 176 | scaled_navs() 177 | -------------------------------------------------------------------------------- /fava_investor/util/relatetickers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | from collections import defaultdict 6 | import itertools 7 | 8 | from beancount import loader 9 | from beancount.core import getters 10 | 11 | 12 | class RelateTickers: 13 | def __init__(self, cf): 14 | entries, _, _ = self.load_file(cf) 15 | 16 | # basic databases 17 | self.db = getters.get_commodity_directives(entries) 18 | self.archived = [c for c in self.db if 'archive' in self.db[c].meta] 19 | 20 | # equivalents and identicals databases 21 | self.equis = self.build_commodity_groups(['a__equivalents']) 22 | self.idents = self.build_commodity_groups(['a__equivalents', 'a__substidenticals']) 23 | identscopy = [i.copy() for i in self.idents] 24 | self.idents_preferred = {i.pop(): i for i in identscopy} 25 | 26 | def load_file(self, cf): 27 | if cf is None: 28 | print("File not specified. See help.", file=sys.stderr) 29 | sys.exit(1) 30 | if not os.path.exists(cf): 31 | print(f"File not found: {cf}", file=sys.stderr) 32 | sys.exit(1) 33 | return loader.load_file(cf) 34 | 35 | def non_archived_set(self, s): 36 | removes = [c for c in s if c in self.archived] 37 | return [i for i in s if i not in removes] 38 | 39 | def non_archived_los(self, listofsets): 40 | """Filter out archived commodities from a list of sets.""" 41 | retval = [] 42 | for r in listofsets: 43 | na = self.non_archived_set(r) 44 | if na: 45 | retval.append(na) 46 | return retval 47 | 48 | def substidenticals(self, ticker, equivalents_only=False): 49 | 50 | """Returns a complete list of commodities substantially identical to the given ticker. The 51 | substantially similar set is built from an incomplete beancount commodities declaration 52 | file. 53 | 54 | If the input is a list or a set, returns a list/set for all tickers in the input. 55 | """ 56 | 57 | if isinstance(ticker, list): 58 | retval = [self.substidenticals(t) for t in ticker] 59 | return [j for i in retval for j in i] 60 | 61 | if isinstance(ticker, set): 62 | retval = [self.substidenticals(t) for t in ticker] 63 | return set([j for i in retval for j in i]) 64 | 65 | equivalence_group = self.equis if equivalents_only else self.idents 66 | for group in equivalence_group: 67 | if ticker in group: 68 | return self.pretty_sort([g for g in group if g != ticker]) 69 | return [] 70 | 71 | def representative(self, ticker): 72 | """Consistently returns a ticker that represents a group of substantially identical 73 | tickers. For example, if [AA, BB, CC, DD] are a group of substantially identical tickers, 74 | this method returns 'AA' when called with any ticker in the group (AA, BB, CC, or DD). 75 | 76 | This method also accepts a list or set, and returns a list of representative tickers for 77 | each ticker in the list or set.""" 78 | 79 | db = self.idents_preferred 80 | if isinstance(ticker, list): 81 | return [self.representative(t) for t in ticker] 82 | 83 | if isinstance(ticker, set): 84 | return set([self.representative(t) for t in ticker]) 85 | 86 | if ticker in db: 87 | return ticker 88 | for k in db: 89 | if ticker in db[k]: 90 | return k 91 | return ticker 92 | 93 | def build_commodity_groups(self, metas, only_non_archived=False): 94 | """Find equivalent sets.""" 95 | 96 | retval = [] 97 | 98 | for c in self.db: 99 | equis = set() 100 | for m in metas: 101 | equis.update(self.db[c].meta.get(m, '').split(',')) 102 | if '' in equis: 103 | equis.remove('') 104 | if not equis: 105 | continue 106 | equis.add(c) 107 | added = False 108 | for e in equis: 109 | for r in retval: 110 | if e in r: 111 | r = r.update(equis) 112 | added = True 113 | break 114 | 115 | if not added: 116 | retval.append(set(equis)) 117 | 118 | if only_non_archived: 119 | return self.non_archived_los(retval) 120 | return retval 121 | 122 | def pretty_sort(self, tickers, group=False): 123 | """Sort, and optionally group substantially identical tickers together. 124 | Input: list of tickers, or a comma separated string of tickers 125 | 126 | Note: this is a partial ordering, and will therefore produce different (but valid) 127 | results across different runs for the same input 128 | 129 | """ 130 | 131 | if isinstance(tickers, str): 132 | tickers = tickers.split(',') 133 | tickers.sort(key=len, reverse=True) 134 | tickers.sort(key=lambda x: self.representative(x)) 135 | 136 | if group: 137 | return [tuple(g) for k, g in itertools.groupby(tickers, key=lambda x: self.representative(x))] 138 | else: 139 | return tickers 140 | 141 | def compute_tlh_groups(self, same_type_funds_only=False): 142 | """Given an incomplete specification of TLH partners, and complete specification of 143 | substantially identical and equivalent mutual funds/ETFs/tickers, compute the full set of 144 | TLH partners. 145 | 146 | If same_type_funds_only is True, only include partners of the same type (mutual funds for 147 | mutual funds, ETFs for ETFs, etc.). Type is specified via the a__quoteType commodity 148 | metadata. Eg: 149 | 2005-01-01 commodity VFIAX 150 | a__quoteType: "MUTUALFUND" 151 | 152 | """ 153 | 154 | tlh = defaultdict(set) 155 | 156 | # Step 1. Read tlh partners manually specified in commodity declarations. This is the incompletely 157 | # specification that we want to turn into a complete specification 158 | for c in self.db: 159 | if 'a__tlh_partners' in self.db[c].meta: 160 | partners = self.db[c].meta.get('a__tlh_partners', '').split(',') 161 | tlh[c].update(partners) 162 | # printd(tlh) 163 | 164 | # Step 2. Remove substantially identical tickers by replacing each ticker with a representative for its 165 | # substantially identical group 166 | 167 | tlh = {self.representative(k): self.representative(v) for k, v in tlh.items()} 168 | # printd(tlh) 169 | 170 | # Step 3. Apply the following rule, once (no iteration or convergence needed): 171 | # if we are given: 172 | # A -> (B, C) # Meaning: A's TLH partners are B, C 173 | # where A, B, C are mutually substantially nonidentical 174 | # 175 | # then we infer: 176 | # B -> (A, C) 177 | # C -> (A, B) 178 | 179 | newtlh = tlh.copy() 180 | for k, v in tlh.items(): 181 | for t in v: 182 | tpartners = [k] + [i for i in v if i != t] 183 | if t in newtlh: 184 | newtlh[t].update(tpartners) 185 | else: 186 | newtlh[t] = set(tpartners) 187 | 188 | # printd(newtlh) 189 | tlh = newtlh 190 | 191 | # Step 4. Add substantially identical tickers (first to values, then to keys) 192 | 193 | tlh = {k: v.union(self.substidenticals(v)) for k, v in tlh.items()} 194 | newtlh = tlh.copy() 195 | # printd(tlh) 196 | for k, v in tlh.items(): 197 | for s in self.substidenticals(k): 198 | newtlh[s] = v 199 | # printd(newtlh) 200 | tlh = newtlh 201 | 202 | # Step 5: cleanup. Remove archived tickers from both keys and values 203 | # only include the same type (mutual funds for mutual funds, ETFs for ETFs, etc.) if 204 | # requested 205 | tlh = {k: self.non_archived_set(v) for k, v in tlh.items() if k not in self.archived} 206 | 207 | def fund_type(f): 208 | if f in self.db: 209 | return self.db[f].meta.get('a__quoteType', 'unknown') 210 | return 'unknown' 211 | 212 | if same_type_funds_only: 213 | tlh = {k: [i for i in v if fund_type(i) == fund_type(k)] for k, v in tlh.items()} 214 | 215 | # Step 6: sort into meaningful groups 216 | tlh = {k: self.pretty_sort(v) for k, v in tlh.items()} 217 | 218 | return tlh 219 | -------------------------------------------------------------------------------- /fava_investor/util/test_relatetickers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import os 5 | sys.path.append(os.path.join(os.path.dirname(__file__), '.')) 6 | from beancount.utils import test_utils 7 | from relatetickers import RelateTickers 8 | 9 | 10 | class TestRelateTickers(test_utils.TestCase): 11 | @test_utils.docfile 12 | def test_equivalent_transitive(self, f): 13 | """ 14 | 2005-01-01 commodity VTI 15 | a__equivalents: "VTSAX" 16 | 17 | 2005-01-01 commodity VTSAX 18 | a__equivalents: "VTSMX" 19 | 20 | 2005-01-01 commodity VTSMX 21 | """ 22 | tickerrel = RelateTickers(f) 23 | retval = tickerrel.build_commodity_groups(['a__equivalents']) 24 | 25 | self.assertEqual(1, len(retval)) 26 | self.assertSetEqual(retval[0], set(['VTI', 'VTSAX', 'VTSMX'])) 27 | 28 | @test_utils.docfile 29 | def test_none(self, f): 30 | """ 31 | 2005-01-01 commodity VOO 32 | a__substidenticals: "IVV" 33 | 34 | 2005-01-01 commodity IVV 35 | a__substidenticals: "SPY" 36 | """ 37 | tickerrel = RelateTickers(f) 38 | retval = tickerrel.build_commodity_groups(['a__equivalents']) 39 | 40 | self.assertEqual(0, len(retval)) 41 | 42 | @test_utils.docfile 43 | def test_identicals_only(self, f): 44 | """ 45 | 2005-01-01 commodity VOO 46 | a__substidenticals: "IVV" 47 | 48 | 2005-01-01 commodity IVV 49 | a__substidenticals: "SPY" 50 | """ 51 | tickerrel = RelateTickers(f) 52 | retval = tickerrel.build_commodity_groups(['a__substidenticals']) 53 | 54 | self.assertEqual(1, len(retval)) 55 | self.assertSetEqual(retval[0], set(['IVV', 'SPY', 'VOO'])) 56 | 57 | @test_utils.docfile 58 | def test_identicals(self, f): 59 | """ 60 | 2005-01-01 commodity VOO 61 | a__substidenticals: "IVV" 62 | a__equivalents: "VFIAX" 63 | 64 | 2005-01-01 commodity IVV 65 | a__substidenticals: "SPY" 66 | """ 67 | tickerrel = RelateTickers(f) 68 | retval = tickerrel.idents 69 | 70 | self.assertEqual(1, len(retval)) 71 | self.assertSetEqual(retval[0], set(['IVV', 'SPY', 'VOO', 'VFIAX'])) 72 | 73 | @test_utils.docfile 74 | def test_tlh_groups(self, f): 75 | """ 76 | 2005-01-01 commodity VOO 77 | a__substidenticals: "IVV" 78 | a__equivalents: "VFIAX" 79 | 80 | 2005-01-01 commodity IVV 81 | a__substidenticals: "SPY" 82 | 83 | 2005-01-01 commodity VTI 84 | a__equivalents: "VTSAX" 85 | 86 | 2005-01-01 commodity VTSAX 87 | a__equivalents: "VTSMX" 88 | 89 | 2005-01-01 commodity VLCAX 90 | a__equivalents: "VV" 91 | a__tlh_partners: "VTSAX,FXAIX" 92 | 93 | 2005-01-01 commodity FXAIX 94 | a__substidenticals: "VFIAX" 95 | 96 | """ 97 | tickerrel = RelateTickers(f) 98 | retval = tickerrel.compute_tlh_groups() 99 | 100 | expected_value = {'VLCAX': ['FXAIX', 'VFIAX', 'IVV', 'SPY', 'VOO', 'VTSAX', 'VTSMX', 'VTI'], 101 | 'IVV': ['VLCAX', 'VV', 'VTSAX', 'VTSMX', 'VTI'], 102 | 'VTI': ['FXAIX', 'VFIAX', 'IVV', 'SPY', 'VOO', 'VLCAX', 'VV'], 103 | 'VV': ['FXAIX', 'VFIAX', 'IVV', 'SPY', 'VOO', 'VTSAX', 'VTSMX', 'VTI'], 104 | 'FXAIX': ['VLCAX', 'VV', 'VTSAX', 'VTSMX', 'VTI'], 105 | 'VFIAX': ['VLCAX', 'VV', 'VTSAX', 'VTSMX', 'VTI'], 106 | 'SPY': ['VLCAX', 'VV', 'VTSAX', 'VTSMX', 'VTI'], 107 | 'VOO': ['VLCAX', 'VV', 'VTSAX', 'VTSMX', 'VTI'], 108 | 'VTSAX': ['FXAIX', 'VFIAX', 'IVV', 'SPY', 'VOO', 'VLCAX', 'VV'], 109 | 'VTSMX': ['FXAIX', 'VFIAX', 'IVV', 'SPY', 'VOO', 'VLCAX', 'VV']} 110 | 111 | expected_value = {k: sorted(v) for k, v in expected_value.items()} 112 | retval = {k: sorted(v) for k, v in retval.items()} 113 | self.assertDictEqual(retval, expected_value) 114 | 115 | @test_utils.docfile 116 | def test_tlh_sametype(self, f): 117 | """ 118 | 2005-01-01 commodity VOO 119 | a__substidenticals: "IVV" 120 | a__equivalents: "VFIAX" 121 | a__quoteType: "ETF" 122 | 123 | 2005-01-01 commodity IVV 124 | a__substidenticals: "SPY" 125 | a__quoteType: "ETF" 126 | 127 | 2005-01-01 commodity VV 128 | a__quoteType: "ETF" 129 | 130 | 2005-01-01 commodity SPY 131 | a__quoteType: "ETF" 132 | 133 | 2005-01-01 commodity VTI 134 | a__equivalents: "VTSAX" 135 | a__quoteType: "ETF" 136 | 137 | 2005-01-01 commodity VTSAX 138 | a__equivalents: "VTSMX" 139 | a__quoteType: "MUTUALFUND" 140 | 141 | 2005-01-01 commodity VTSMX 142 | a__quoteType: "MUTUALFUND" 143 | 144 | 2005-01-01 commodity VLCAX 145 | a__equivalents: "VV" 146 | a__tlh_partners: "VTSAX,FXAIX" 147 | a__quoteType: "MUTUALFUND" 148 | 149 | 2005-01-01 commodity FXAIX 150 | a__substidenticals: "VFIAX" 151 | a__quoteType: "MUTUALFUND" 152 | 153 | 2005-01-01 commodity VFIAX 154 | a__quoteType: "MUTUALFUND" 155 | 156 | """ 157 | tickerrel = RelateTickers(f) 158 | retval = tickerrel.compute_tlh_groups(same_type_funds_only=True) 159 | 160 | expected_value = {'VLCAX': ['FXAIX', 'VFIAX', 'VTSAX', 'VTSMX'], 161 | 'IVV': ['VV', 'VTI'], 162 | 'VTI': ['IVV', 'SPY', 'VOO', 'VV'], 163 | 'VV': ['IVV', 'SPY', 'VOO', 'VTI'], 164 | 'FXAIX': ['VLCAX', 'VTSAX', 'VTSMX'], 165 | 'VFIAX': ['VLCAX', 'VTSAX', 'VTSMX'], 166 | 'SPY': ['VV', 'VTI'], 167 | 'VOO': ['VV', 'VTI'], 168 | 'VTSAX': ['FXAIX', 'VFIAX', 'VLCAX'], 169 | 'VTSMX': ['FXAIX', 'VFIAX', 'VLCAX']} 170 | 171 | expected_value = {k: sorted(v) for k, v in expected_value.items()} 172 | retval = {k: sorted(v) for k, v in retval.items()} 173 | self.assertDictEqual(retval, expected_value) 174 | -------------------------------------------------------------------------------- /fava_investor/util/ticker_util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Download and cache basic info about current beancount commodities""" 3 | 4 | import click 5 | from click_aliases import ClickAliasedGroup 6 | import datetime 7 | import os 8 | import sys 9 | 10 | from beancount.core import data 11 | from beancount.parser import printer 12 | from fava_investor.util.relatetickers import RelateTickers 13 | from fava_investor.util.cachedtickerinfo import CachedTickerInfo 14 | 15 | cf_help = """Beancount commodity declarations file. This can alternatively be specified by setting 16 | the BEAN_COMMODITIES_FILE environment variable.""" 17 | cf_option = click.option('--cf', help=cf_help, envvar='BEAN_COMMODITIES_FILE', 18 | type=click.Path(exists=True)) 19 | bean_root = os.getenv('BEAN_ROOT', '~/') 20 | yf_cache = os.path.expanduser(os.sep.join([bean_root, '.ticker_info.yahoo.cache'])) 21 | 22 | 23 | def printd(d): 24 | for k in d: 25 | print(k, d[k]) 26 | print() 27 | 28 | 29 | @click.group(cls=ClickAliasedGroup) 30 | def cli(): 31 | 32 | """In all subcommands, the following environment variables are used: 33 | \n$BEAN_ROOT: root directory for beancount source(s). Downloaded info is cached in this directory 34 | in a file named .ticker_info.yahoo.cache. Default: ~ 35 | \n$BEAN_COMMODITIES_FILE: file with beancount commodities declarations. WARNING: the 'comm' subcommand 36 | will overwrite this file when requested 37 | """ 38 | 39 | pass 40 | 41 | 42 | @cli.group(cls=ClickAliasedGroup) 43 | def relate(): 44 | """Subcommands that find relationships between tickers.""" 45 | pass 46 | 47 | 48 | @cli.command(aliases=['add']) 49 | @click.option('--tickers', default='', help='Comma-separated list of tickers to add') 50 | @click.option('--from-file', is_flag=True, help="Add tickers declared in beancount commodity declarations " 51 | "file (specify the file separately)") 52 | @cf_option 53 | def ticker_add(tickers, from_file, cf): 54 | """Download and add new tickers to database. Accepts a list of comma separated tickers, or alternatively, 55 | adds all tickers declared in the specified beancount file. The latter is useful for the very first time 56 | you run this utility.""" 57 | 58 | if from_file: 59 | tickerrel = RelateTickers(cf) 60 | tickers = tickerrel.db 61 | elif tickers: 62 | tickers = tickers.split(',') 63 | else: 64 | print("Tickers to add not specified.", file=sys.stderr) 65 | return 66 | 67 | ctdata = CachedTickerInfo(yf_cache) 68 | ctdata.batch_lookup(tickers) 69 | ctdata.write_cache() 70 | 71 | 72 | @cli.command(aliases=['remove']) 73 | @click.option('--tickers', default='', help='Comma-separated list of tickers to remove') 74 | def ticker_remove(tickers): 75 | """Remove tickers from the database. Accepts a list of comma separated tickers.""" 76 | tickers = tickers.split(',') 77 | ctdata = CachedTickerInfo(yf_cache) 78 | for t in tickers: 79 | ctdata.remove(t) 80 | 81 | 82 | @cli.command(aliases=['list']) 83 | @click.option('-i', '--info', is_flag=True, help='Show extended information') 84 | @click.option('--available-keys', is_flag=True, help='Show all available keys') 85 | @click.option('-e', '--explore', is_flag=True, help='Open a Python debugger shell to explore tickers interactively') 86 | def ticker_list(info, available_keys, explore): 87 | """List tickers (and optionally, basic info) from the database.""" 88 | ctdata = CachedTickerInfo(yf_cache) 89 | 90 | if info: 91 | interesting = [('Ticker', 'symbol', '{:<6}', 6), 92 | ('Type', 'quoteType', '{:<11}', 11), 93 | ('ISIN', 'isin', '{:>12}', 12), 94 | ('ER', 'annualReportExpenseRatio', '{:4.2f}', 4), 95 | ('Name', 'longName', '{}', 0), 96 | ] 97 | 98 | lines = [] 99 | 100 | # print header line 101 | header_line = ' '.join( 102 | [f'{{:<{width}}}' for _, _, _, width in interesting]) 103 | lines.append(header_line.format(*[h for h, _, _, _ in interesting])) 104 | lines.append(header_line.format( 105 | *['_' * (width if width else 40) for _, _, _, width in interesting])) 106 | 107 | for ticker in sorted(ctdata.data): 108 | info = ctdata.data[ticker] 109 | line = '' 110 | for _, k, fmt, width in interesting: 111 | try: 112 | s = fmt.format(info.get(k, '')) 113 | except (TypeError, ValueError): 114 | s = ' ' * width 115 | line += s + ' ' 116 | lines.append(line) 117 | click.echo_via_pager('\n'.join(lines)) 118 | elif available_keys: 119 | lines = [] 120 | for k, v in ctdata.data.items(): 121 | lines.append(k) 122 | lines.append('\n '.join([i for i in v])) 123 | click.echo_via_pager('\n'.join(lines)) 124 | else: 125 | print(','.join(ctdata.data.keys())) 126 | 127 | if explore: 128 | print("Hint: use ctdata and ctdata.data to explore") 129 | import pdb 130 | pdb.set_trace() 131 | 132 | 133 | def label_transform(label, prefix): 134 | # fava recognizes and displays 'name' 135 | metadata_label_map = {'longName': 'name'} 136 | if label in metadata_label_map: 137 | return metadata_label_map[label] 138 | if 'Position' in label: 139 | # for ['preferredPosition', 'bondPosition', etc.] 140 | return prefix + 'asset_allocation_' + label[:-8] 141 | return prefix + label 142 | 143 | 144 | def value_transform(val, label): 145 | if 'Position' in label: 146 | return str(round(float(val) * 100)) 147 | return str(val) 148 | 149 | 150 | metadata_includes = """quoteType,longName,isin,annualReportExpenseRatio,\ 151 | preferredPosition,bondPosition,convertiblePosition,otherPosition,cashPosition,stockPosition""" 152 | 153 | 154 | @cli.command(aliases=['comm'], context_settings={'show_default': True}) 155 | @cf_option 156 | @click.option('--prefix', help="Metadata label prefix for generated metadata", default='a__') 157 | @click.option('--metadata', help="Metadata to include", default=metadata_includes) 158 | @click.option('--appends', help="Metadata to append to", default="isin") 159 | @click.option('--include-undeclared', is_flag=True, help="Write new commodity entries for tickers in the " 160 | "cached database, but not in the existing Beancount commodity declarations file") 161 | @click.option('--write-file', is_flag=True, help="Overwrite the commodities file. WARNING! This does exactly " 162 | "what it states: it overwrites your file, assuming your commodity declarations source is a " 163 | "separate file (from your beancount sources) that you auto-generate with this utility.") 164 | @click.option('--confirm-overwrite', is_flag=True, help="Specify in conjunction with --write-file to " 165 | "actually overwrite") 166 | @click.option('-st', '--same-type', is_flag=True, help="Include only partners that are of the " 167 | "same type (MF, ETF, etc.)") 168 | def gen_commodities_file(cf, prefix, metadata, appends, include_undeclared, write_file, # noqa: C901 169 | confirm_overwrite, same_type): 170 | """Generate Beancount commodity declarations with metadata from database, and existing declarations.""" 171 | 172 | auto_metadata = metadata.split(',') 173 | auto_metadata_appends = appends.split(',') 174 | 175 | tickerrel = RelateTickers(cf) 176 | commodities = tickerrel.db 177 | full_tlh_db = tickerrel.compute_tlh_groups(same_type) 178 | ctdata = CachedTickerInfo(yf_cache) 179 | 180 | not_in_commodities_file = [c for c in ctdata.data if c not in commodities] 181 | if not_in_commodities_file: 182 | if include_undeclared: 183 | for c in not_in_commodities_file: 184 | commodities[c] = data.Commodity( 185 | {}, datetime.datetime.today().date(), c) 186 | else: 187 | print("Warning: not in ", cf, file=sys.stderr) 188 | print(not_in_commodities_file, file=sys.stderr) 189 | print("Simply declare them in your commodities file, and re-rerun this util to fill in their metadata", 190 | file=sys.stderr) 191 | 192 | # update a_* metadata 193 | for c, metadata in commodities.items(): 194 | if c in ctdata.data: 195 | if tickerrel.substidenticals(c, equivalents_only=True): 196 | metadata.meta[prefix + 'equivalents'] = ','.join(tickerrel.substidenticals(c, equivalents_only=True)) 197 | if tickerrel.substidenticals(c): 198 | metadata.meta[prefix + 'substidenticals'] = ','.join(tickerrel.substidenticals(c)) 199 | if c in full_tlh_db: 200 | metadata.meta[prefix + 'tlh_partners'] = ','.join(full_tlh_db[c]) 201 | for m in auto_metadata: 202 | if m in ctdata.data[c] and ctdata.data[c][m]: 203 | if m in auto_metadata_appends: 204 | mdval = set(metadata.meta.get(prefix + m, '').split(',')) 205 | mdval = set() if mdval == set(['']) else mdval 206 | mdval.add(str(ctdata.data[c][m])) 207 | metadata.meta[prefix + m] = ','.join(sorted(list(mdval))) 208 | else: 209 | label = label_transform(m, prefix) 210 | value = value_transform(ctdata.data[c][m], m) 211 | metadata.meta[label] = value 212 | cv = list(commodities.values()) 213 | cv.sort(key=lambda x: x.currency) 214 | 215 | with open(cf, "w") if write_file and confirm_overwrite else sys.stdout as fout: 216 | print(f"; Generated by: {os.path.basename(__file__)}, at {datetime.datetime.today().isoformat()}", 217 | file=fout) 218 | fout.flush() 219 | printer.print_entries(cv, file=fout) 220 | 221 | if write_file and not confirm_overwrite: 222 | print( 223 | f"Not overwriting {cf} because --confirm-overwrite was not specified") 224 | 225 | # def rewrite_er(): 226 | # ctdata = CachedTickerInfo(yf_cache) 227 | # for ticker in ctdata.data: 228 | # if 'annualReportExpenseRatio' in ctdata.data[ticker] and ctdata.data[ticker]['annualReportExpenseRatio']: 229 | # er = ctdata.data[ticker]['annualReportExpenseRatio'] 230 | # print(ticker, '\t', er) 231 | # # ctdata.data[ticker]['annualReportExpenseRatio'] = round(er/100, 2) 232 | # # ctdata.write_cache() 233 | 234 | 235 | def generate_fund_info(cf=os.getenv('BEAN_COMMODITIES_FILE'), prefix='a__'): 236 | """Generate fund info for importers (from commodity directives in the beancount input file)""" 237 | tickerrel = RelateTickers(cf) 238 | commodities = tickerrel.db 239 | 240 | fund_data = [] 241 | for c in commodities: 242 | cd = commodities[c] 243 | isins = cd.meta.get(prefix + 'isin', '').split(',') 244 | for i in isins: 245 | fund_data.append( 246 | (c, i, cd.meta.get('name', 'Ticker long name unavailable'))) 247 | 248 | money_market = [c for c in commodities if commodities[c].meta.get( 249 | prefix + 'quoteType', '') == 'MONEYMARKET'] 250 | fund_info = {'fund_data': fund_data, 'money_market': money_market} 251 | return fund_info 252 | 253 | 254 | @cli.command(aliases=['show']) 255 | @cf_option 256 | @click.option('--prefix', help="Metadata label prefix for generated metadata", default='a__') 257 | def show_fund_info(cf, prefix): 258 | """Show info that is generated for importers (from commodity directives in the beancount input file)""" 259 | fund_info = generate_fund_info(cf, prefix) 260 | click.echo_via_pager('\n'.join(str(i) for i in fund_info['fund_data'] + ['\nMoney Market:', 261 | str(fund_info['money_market'])])) 262 | 263 | 264 | @relate.command(aliases=['eq']) 265 | @cf_option 266 | def find_equivalents(cf): 267 | """Determine equivalent groups of commodities, from an incomplete specification.""" 268 | 269 | tickerrel = RelateTickers(cf) 270 | retval = tickerrel.build_commodity_groups(['a__equivalents']) 271 | for r in retval: 272 | print(r) 273 | 274 | 275 | @relate.command(aliases=['idents']) 276 | @cf_option 277 | def find_identicals(cf): 278 | """Determine substantially identical groups of commodities from an incomplete specification. 279 | Includes equivalents.""" 280 | 281 | tickerrel = RelateTickers(cf) 282 | # equivalents don't get rewritten into automatic meta values. Identicals do. 283 | retval = tickerrel.build_commodity_groups(['a__equivalents', 'a__substidenticals']) 284 | for r in retval: 285 | print(r) 286 | 287 | 288 | @relate.command(aliases=['archives']) 289 | @cf_option 290 | def list_archived(cf): 291 | """List archived commodities.""" 292 | 293 | tickerrel = RelateTickers(cf) 294 | archived = tickerrel.archived 295 | for r in archived: 296 | print(r) 297 | 298 | 299 | @relate.command(aliases=['tlh']) 300 | @cf_option 301 | @click.option('-st', '--same-type', is_flag=True, help="Include only partners that are of the same type (MF, ETF, etc.)") 302 | def find_tlh_groups(cf, same_type): 303 | """Determine Tax Loss Harvest partner groups.""" 304 | tickerrel = RelateTickers(cf) 305 | full_tlh_db = tickerrel.compute_tlh_groups(same_type) 306 | for t, partners in sorted(full_tlh_db.items()): 307 | print("{:<5}".format(t), partners) 308 | 309 | 310 | if __name__ == '__main__': 311 | cli() 312 | 313 | # TODOs 314 | # - create new commodity entries as needed, when requested 315 | # - cusip info: https://www.quantumonline.com/search.cfm 316 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Automatically generated by https://github.com/damnever/pigar. 2 | 3 | # fava_investor/build/lib/fava_investor/cli/investor.py: 4 4 | # fava_investor/build/lib/fava_investor/common/clicommon.py: 4 5 | # fava_investor/build/lib/fava_investor/modules/assetalloc_class/assetalloc_class.py: 8 6 | # fava_investor/build/lib/fava_investor/modules/assetalloc_class/test_asset_allocation.py: 6 7 | # fava_investor/build/lib/fava_investor/modules/cashdrag/cashdrag.py: 4 8 | # fava_investor/build/lib/fava_investor/modules/summarizer/summarizer.py: 4 9 | # fava_investor/build/lib/fava_investor/modules/tlh/tlh.py: 7 10 | # fava_investor/build/lib/fava_investor/util/experimental/scaled_navs.py: 33 11 | # fava_investor/build/lib/fava_investor/util/ticker_util.py: 4 12 | # fava_investor/fava_investor/cli/investor.py: 4 13 | # fava_investor/fava_investor/common/clicommon.py: 4 14 | # fava_investor/fava_investor/modules/assetalloc_class/assetalloc_class.py: 8 15 | # fava_investor/fava_investor/modules/assetalloc_class/test_asset_allocation.py: 6 16 | # fava_investor/fava_investor/modules/cashdrag/cashdrag.py: 4 17 | # fava_investor/fava_investor/modules/summarizer/summarizer.py: 4 18 | # fava_investor/fava_investor/modules/tlh/tlh.py: 7 19 | # fava_investor/fava_investor/util/experimental/scaled_navs.py: 33 20 | # fava_investor/fava_investor/util/ticker_util.py: 4 21 | Click >= 7.0 22 | 23 | # fava_investor/build/lib/fava_investor/common/beancountinvestorapi.py: 3,4,5,6,7,8,9 24 | # fava_investor/build/lib/fava_investor/common/favainvestorapi.py: 1 25 | # fava_investor/build/lib/fava_investor/common/libinvestor.py: 4,5 26 | # fava_investor/build/lib/fava_investor/modules/assetalloc_account/libaaacc.py: 4 27 | # fava_investor/build/lib/fava_investor/modules/assetalloc_class/assetalloc_class.py: 6,7 28 | # fava_investor/build/lib/fava_investor/modules/assetalloc_class/libassetalloc.py: 8,9,10,11,12 29 | # fava_investor/build/lib/fava_investor/modules/assetalloc_class/test_asset_allocation.py: 5 30 | # fava_investor/build/lib/fava_investor/modules/cashdrag/libcashdrag.py: 4 31 | # fava_investor/build/lib/fava_investor/modules/summarizer/libsummarizer.py: 7,8,9 32 | # fava_investor/build/lib/fava_investor/modules/tlh/libtlh.py: 11,12 33 | # fava_investor/build/lib/fava_investor/modules/tlh/test_libtlh.py: 8 34 | # fava_investor/build/lib/fava_investor/util/experimental/scaled_navs.py: 38,39,40,41 35 | # fava_investor/build/lib/fava_investor/util/relatetickers.py: 8,9 36 | # fava_investor/build/lib/fava_investor/util/ticker_util.py: 10,11 37 | # fava_investor/fava_investor/common/beancountinvestorapi.py: 3,4,5,6,7,8,9 38 | # fava_investor/fava_investor/common/favainvestorapi.py: 1 39 | # fava_investor/fava_investor/common/libinvestor.py: 4,5 40 | # fava_investor/fava_investor/modules/assetalloc_account/libaaacc.py: 4 41 | # fava_investor/fava_investor/modules/assetalloc_class/assetalloc_class.py: 6,7 42 | # fava_investor/fava_investor/modules/assetalloc_class/libassetalloc.py: 8,9,10,11,12 43 | # fava_investor/fava_investor/modules/assetalloc_class/test_asset_allocation.py: 5 44 | # fava_investor/fava_investor/modules/cashdrag/libcashdrag.py: 4 45 | # fava_investor/fava_investor/modules/summarizer/libsummarizer.py: 7,8,9 46 | # fava_investor/fava_investor/modules/tlh/libtlh.py: 11,12 47 | # fava_investor/fava_investor/modules/tlh/test_libtlh.py: 8 48 | # fava_investor/fava_investor/util/experimental/scaled_navs.py: 38,39,40,41 49 | # fava_investor/fava_investor/util/relatetickers.py: 8,9 50 | # fava_investor/fava_investor/util/ticker_util.py: 10,11 51 | beancount >= 2.3.5 52 | 53 | # fava_investor/build/lib/fava_investor/util/ticker_util.py: 5 54 | # fava_investor/fava_investor/util/ticker_util.py: 5 55 | click_aliases >= 1.0.1 56 | 57 | # fava_investor/build/lib/fava_investor/__init__.py: 3,4 58 | # fava_investor/build/lib/fava_investor/common/beancountinvestorapi.py: 29 59 | # fava_investor/build/lib/fava_investor/common/favainvestorapi.py: 2,3 60 | # fava_investor/fava_investor/__init__.py: 3,4 61 | # fava_investor/fava_investor/common/beancountinvestorapi.py: 29 62 | # fava_investor/fava_investor/common/favainvestorapi.py: 2,3 63 | fava>=1.27 64 | 65 | # fava_investor/.eggs/setuptools_scm-7.0.4-py3.8.egg/setuptools_scm/_entrypoints.py: 77 66 | importlib_metadata >= 1.5.0 67 | 68 | # fava_investor/.eggs/setuptools_scm-7.0.4-py3.8.egg/setuptools_scm/_version_cls.py: 5,6 69 | # fava_investor/build/lib/fava_investor/common/favainvestorapi.py: 4 70 | # fava_investor/fava_investor/common/favainvestorapi.py: 4 71 | packaging >= 20.3 72 | 73 | # fava_investor/build/lib/fava_investor/modules/tlh/libtlh.py: 9 74 | # fava_investor/fava_investor/modules/tlh/libtlh.py: 9 75 | python_dateutil >= 2.8.1 76 | 77 | # fava_investor/.eggs/setuptools_scm-7.0.4-py3.8.egg/setuptools_scm/integration.py: 9 78 | # fava_investor/setup.py: 2 79 | setuptools >= 65.5.1 80 | 81 | # fava_investor/build/lib/fava_investor/common/clicommon.py: 5 82 | # fava_investor/build/lib/fava_investor/modules/assetalloc_class/assetalloc_class.py: 11 83 | # fava_investor/fava_investor/common/clicommon.py: 5 84 | # fava_investor/fava_investor/modules/assetalloc_class/assetalloc_class.py: 11 85 | tabulate >= 0.8.9 86 | 87 | # fava_investor/build/lib/fava_investor/util/cachedtickerinfo.py: 6 88 | # fava_investor/fava_investor/util/cachedtickerinfo.py: 6 89 | yfinance >= 0.1.70 90 | -------------------------------------------------------------------------------- /screenshot-assetalloc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/screenshot-assetalloc.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redstreet/fava_investor/2ebbd03aedfe0ecb205ee8ec1ec18bbfd0808f06/screenshot.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from setuptools import find_packages, setup 3 | 4 | with open(path.join(path.dirname(__file__), 'README.md')) as readme: 5 | LONG_DESCRIPTION = readme.read() 6 | 7 | setup( 8 | name='fava_investor', 9 | use_scm_version=True, 10 | setup_requires=['setuptools_scm'], 11 | description='Fava extension and beancount libraries for investing', 12 | long_description=LONG_DESCRIPTION, 13 | long_description_content_type='text/markdown', 14 | url='https://github.com/redstreet/fava_investor', 15 | author='Red S', 16 | author_email='redstreet@users.noreply.github.com', 17 | license='GPL-3.0', 18 | keywords='fava beancount accounting investment', 19 | packages=find_packages(), 20 | include_package_data=True, 21 | install_requires=[ 22 | 'Click >= 7.0', 23 | 'beancount >= 2.3.2', 24 | 'click_aliases >= 1.0.1', 25 | 'fava >= 1.26', 26 | 'packaging >= 20.3', 27 | 'python_dateutil >= 2.8.1', 28 | 'tabulate >= 0.8.9', 29 | 'yfinance >= 0.1.70', 30 | ], 31 | entry_points={ 32 | 'console_scripts': [ 33 | 'ticker-util = fava_investor.util.ticker_util:cli', 34 | 'scaled-navs = fava_investor.util.experimental.scaled_navs:scaled_navs', 35 | 'investor = fava_investor.cli.investor:cli', 36 | ] 37 | }, 38 | zip_safe=False, 39 | classifiers=[ 40 | 'Development Status :: 4 - Beta', 41 | 'Intended Audience :: Financial and Insurance Industry', 42 | 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 43 | 'Natural Language :: English', 44 | 'Programming Language :: Python :: 3 :: Only', 45 | 'Programming Language :: Python :: 3.8', 46 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 47 | 'Topic :: Office/Business :: Financial :: Accounting', 48 | 'Topic :: Office/Business :: Financial :: Investment', 49 | ], 50 | ) 51 | --------------------------------------------------------------------------------