yenwealth 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yenwealth-0.1.0/.github/workflows/continuous_integration.yaml +19 -0
- yenwealth-0.1.0/.gitignore +220 -0
- yenwealth-0.1.0/LICENSE +21 -0
- yenwealth-0.1.0/PKG-INFO +19 -0
- yenwealth-0.1.0/README.md +4 -0
- yenwealth-0.1.0/pyproject.toml +22 -0
- yenwealth-0.1.0/setup.cfg +2 -0
- yenwealth-0.1.0/src/yenwealth/__init__.py +0 -0
- yenwealth-0.1.0/src/yenwealth/constants.py +4 -0
- yenwealth-0.1.0/src/yenwealth/investments.py +502 -0
- yenwealth-0.1.0/src/yenwealth/utilities.py +12 -0
- yenwealth-0.1.0/tests/__init__.py +0 -0
- yenwealth-0.1.0/tests/test_investments.py +516 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: Continuous integration
|
|
2
|
+
on: [push]
|
|
3
|
+
|
|
4
|
+
jobs:
|
|
5
|
+
run_commit_stage:
|
|
6
|
+
runs-on: ubuntu-26.04
|
|
7
|
+
timeout-minutes: 10
|
|
8
|
+
steps:
|
|
9
|
+
- uses: actions/checkout@v7
|
|
10
|
+
- uses: actions/setup-python@v6
|
|
11
|
+
with:
|
|
12
|
+
python-version: '3.14'
|
|
13
|
+
- name: install dependencies
|
|
14
|
+
run: |
|
|
15
|
+
pip install -e ".[dev]"
|
|
16
|
+
- name: run tests
|
|
17
|
+
run: pytest ./tests
|
|
18
|
+
- name: run static code analysis
|
|
19
|
+
run: pycodestyle ./src ./tests
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
219
|
+
|
|
220
|
+
*.code-workspace
|
yenwealth-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jakub Kolodziejczyk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
yenwealth-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: yenwealth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Financial planning tools for individuals living in Japan
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
|
+
Requires-Dist: beartype
|
|
9
|
+
Requires-Dist: pydantic
|
|
10
|
+
Requires-Dist: returns
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pycodestyle; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# yenwealth
|
|
17
|
+
Financial planning tools for individuals living in Japan
|
|
18
|
+
|
|
19
|
+
Work in progress. Please come again later.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "yenwealth"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Financial planning tools for individuals living in Japan"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.14"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"beartype",
|
|
14
|
+
"pydantic",
|
|
15
|
+
"returns"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pycodestyle",
|
|
21
|
+
"pytest",
|
|
22
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module with investment logic
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import collections
|
|
6
|
+
import copy
|
|
7
|
+
import decimal
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
import beartype
|
|
11
|
+
import pydantic
|
|
12
|
+
import returns.maybe
|
|
13
|
+
|
|
14
|
+
from . import constants
|
|
15
|
+
from . import utilities
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
LOGGER = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@beartype.beartype
|
|
22
|
+
class OrdinaryInvestmentAccount:
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
principal: decimal.Decimal,
|
|
27
|
+
gain: decimal.Decimal,
|
|
28
|
+
investment_return_rate: decimal.Decimal,
|
|
29
|
+
capital_gain_tax_rate: decimal.Decimal
|
|
30
|
+
):
|
|
31
|
+
|
|
32
|
+
self.investment_return_rate = investment_return_rate
|
|
33
|
+
self.capital_gain_tax_rate = capital_gain_tax_rate
|
|
34
|
+
self.principal = principal
|
|
35
|
+
self.gain = gain
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def portfolio_value(self) -> decimal.Decimal:
|
|
39
|
+
return self.principal + self.gain
|
|
40
|
+
|
|
41
|
+
def deposit(self, amount: decimal.Decimal):
|
|
42
|
+
|
|
43
|
+
if amount < 0:
|
|
44
|
+
raise ValueError(f"Deposit amount should be non-negative, got {amount}")
|
|
45
|
+
|
|
46
|
+
self.principal += amount
|
|
47
|
+
|
|
48
|
+
def advance_one_year(self):
|
|
49
|
+
|
|
50
|
+
self.gain += self.investment_return_rate * (self.principal + self.gain)
|
|
51
|
+
|
|
52
|
+
def withdraw(self, desired_cash: decimal.Decimal):
|
|
53
|
+
|
|
54
|
+
if desired_cash < 0:
|
|
55
|
+
raise ValueError(f"Withdrawal value should be non-negative, got {desired_cash}")
|
|
56
|
+
|
|
57
|
+
if desired_cash > self.portfolio_value:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Withdrawal value {desired_cash} exceeds portfolio value {self.portfolio_value}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
gain_ratio = self.gain / self.portfolio_value
|
|
63
|
+
|
|
64
|
+
# Calculate gross amount needed to liquidate
|
|
65
|
+
gross_withdrawal = desired_cash / (1 - (gain_ratio * self.capital_gain_tax_rate))
|
|
66
|
+
|
|
67
|
+
if gross_withdrawal > self.portfolio_value:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Gross withdrawal value {gross_withdrawal} needed to realize desired cash "
|
|
70
|
+
f"{desired_cash} exceeds portfolio value {self.portfolio_value}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Proportional splits
|
|
74
|
+
principal_withdrawn = gross_withdrawal * (1 - gain_ratio)
|
|
75
|
+
gain_withdrawn = gross_withdrawal * gain_ratio
|
|
76
|
+
|
|
77
|
+
# Deduct proportionally from remaining balances
|
|
78
|
+
self.principal -= principal_withdrawn
|
|
79
|
+
self.gain -= gain_withdrawn
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def max_cash_withdrawal(self) -> decimal.Decimal:
|
|
83
|
+
|
|
84
|
+
return (self.portfolio_value - (self.capital_gain_tax_rate * self.gain)) \
|
|
85
|
+
.quantize(constants.YEN, decimal.ROUND_DOWN)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@beartype.beartype
|
|
89
|
+
class IdecoInvestmentAccount:
|
|
90
|
+
|
|
91
|
+
def __init__(self, portfolio_value: decimal.Decimal, investment_return_rate: decimal.Decimal):
|
|
92
|
+
|
|
93
|
+
self.investment_return_rate = investment_return_rate
|
|
94
|
+
self.portfolio_value = portfolio_value
|
|
95
|
+
|
|
96
|
+
self.max_annual_contribution = decimal.Decimal("0.276") * constants.MILLION
|
|
97
|
+
self.max_deposit_age = 65
|
|
98
|
+
self.minimum_withdrawal_start_age = 60
|
|
99
|
+
self.maximum_withdrawal_start_age = 75
|
|
100
|
+
|
|
101
|
+
self.max_tax_free_lump_sum = decimal.Decimal("17.8") * constants.MILLION
|
|
102
|
+
|
|
103
|
+
self.has_started_drawing_pension = False
|
|
104
|
+
self.maybe_pension_period_in_years: returns.maybe.Maybe[int] = returns.maybe.Nothing
|
|
105
|
+
self.maybe_pension_schema_start_age: returns.maybe.Maybe[int] = returns.maybe.Nothing
|
|
106
|
+
self.pension_payout_ages: list[int] = []
|
|
107
|
+
|
|
108
|
+
def deposit(self, amount: decimal.Decimal, age: int):
|
|
109
|
+
|
|
110
|
+
if age >= self.max_deposit_age:
|
|
111
|
+
raise ValueError(f"Deposit age {age} exceeds maximum allowed deposit age of {self.max_deposit_age}")
|
|
112
|
+
|
|
113
|
+
if amount < 0:
|
|
114
|
+
raise ValueError(f"Deposit amount should be non-negative, got {amount}")
|
|
115
|
+
|
|
116
|
+
if amount > self.max_annual_contribution:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"Deposit amount {amount} exceeds maximum allowed contribution of {self.max_annual_contribution}")
|
|
119
|
+
|
|
120
|
+
self.portfolio_value += amount
|
|
121
|
+
|
|
122
|
+
def advance_one_year(self):
|
|
123
|
+
|
|
124
|
+
self.portfolio_value = self.portfolio_value * (1 + self.investment_return_rate)
|
|
125
|
+
|
|
126
|
+
def withdraw_tax_free_lump_sum(self) -> decimal.Decimal:
|
|
127
|
+
|
|
128
|
+
amount_withdrawn = min(self.portfolio_value, self.max_tax_free_lump_sum)
|
|
129
|
+
self.portfolio_value -= amount_withdrawn
|
|
130
|
+
return amount_withdrawn
|
|
131
|
+
|
|
132
|
+
def start_pension_scheme(self, start_age: int, period_in_years: int):
|
|
133
|
+
|
|
134
|
+
if start_age < self.minimum_withdrawal_start_age or start_age > self.maximum_withdrawal_start_age:
|
|
135
|
+
raise ValueError(f"Age {start_age} outside of valid withdrawal start age")
|
|
136
|
+
|
|
137
|
+
self.has_started_drawing_pension = True
|
|
138
|
+
self.maybe_pension_period_in_years = returns.maybe.Some(period_in_years)
|
|
139
|
+
self.maybe_pension_schema_start_age = returns.maybe.Some(start_age)
|
|
140
|
+
|
|
141
|
+
def withdraw_pension(self, age: int) -> decimal.Decimal:
|
|
142
|
+
|
|
143
|
+
if self.has_started_drawing_pension is False:
|
|
144
|
+
raise ValueError("pension scheme not initialized")
|
|
145
|
+
|
|
146
|
+
if self.maybe_pension_schema_start_age is returns.maybe.Nothing:
|
|
147
|
+
raise ValueError("pension schema start age not initialized")
|
|
148
|
+
|
|
149
|
+
if self.maybe_pension_period_in_years is returns.maybe.Nothing:
|
|
150
|
+
raise ValueError("pension period not initialized")
|
|
151
|
+
|
|
152
|
+
pension_schema_start_age = self.maybe_pension_schema_start_age.unwrap()
|
|
153
|
+
pension_period_in_years = self.maybe_pension_period_in_years.unwrap()
|
|
154
|
+
|
|
155
|
+
# Check that paid pension history exists for all years between start age and age below this
|
|
156
|
+
if sorted(self.pension_payout_ages) != list(range(pension_schema_start_age, age)):
|
|
157
|
+
raise ValueError("missing pension withdrawal for same ages")
|
|
158
|
+
|
|
159
|
+
if age > pension_schema_start_age + pension_period_in_years:
|
|
160
|
+
raise ValueError("iDeCo pension scheme finished")
|
|
161
|
+
|
|
162
|
+
withdrawal_proportion = 1 / (pension_period_in_years - len(self.pension_payout_ages))
|
|
163
|
+
|
|
164
|
+
withdrawal = self.portfolio_value * decimal.Decimal(withdrawal_proportion)
|
|
165
|
+
self.portfolio_value -= withdrawal
|
|
166
|
+
self.pension_payout_ages.append(age)
|
|
167
|
+
|
|
168
|
+
return withdrawal
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@beartype.beartype
|
|
172
|
+
class OldNisaAccount:
|
|
173
|
+
"""
|
|
174
|
+
Old NISA account that no longer allows deposit, and requires investments to be withdrawn within 20 years
|
|
175
|
+
from when they were made.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(self, year_to_portfolio_map: dict[int, decimal.Decimal], investment_return_rate: decimal.Decimal):
|
|
179
|
+
|
|
180
|
+
self.year_to_portfolio_map = copy.deepcopy(year_to_portfolio_map)
|
|
181
|
+
self.investment_return_rate = investment_return_rate
|
|
182
|
+
|
|
183
|
+
def advance_one_year(self, year: int):
|
|
184
|
+
|
|
185
|
+
for investment_year in self.year_to_portfolio_map.keys():
|
|
186
|
+
|
|
187
|
+
if investment_year + 20 < year:
|
|
188
|
+
raise ValueError(f"nisa investment for year {investment_year} must be liquidated")
|
|
189
|
+
|
|
190
|
+
self.year_to_portfolio_map[investment_year] = \
|
|
191
|
+
self.year_to_portfolio_map[investment_year] * (1 + self.investment_return_rate)
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def portfolio_value(self) -> decimal.Decimal:
|
|
195
|
+
|
|
196
|
+
return decimal.Decimal(sum(self.year_to_portfolio_map.values()))
|
|
197
|
+
|
|
198
|
+
def withdraw_for_year(self, portfolio_year: int, desired_cash: decimal.Decimal):
|
|
199
|
+
"""
|
|
200
|
+
Withdraw from portfolio established on portfolio_year
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
portfolio_value = self.year_to_portfolio_map[portfolio_year]
|
|
204
|
+
|
|
205
|
+
if portfolio_value < desired_cash:
|
|
206
|
+
raise ValueError(f"Portfolio value for {portfolio_year} is not large enough")
|
|
207
|
+
|
|
208
|
+
self.year_to_portfolio_map[portfolio_year] -= desired_cash
|
|
209
|
+
|
|
210
|
+
def withdraw(self, desired_cash: decimal.Decimal):
|
|
211
|
+
"""
|
|
212
|
+
Withdraw from portfolio in order of oldest to newest
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
if desired_cash < 0:
|
|
216
|
+
raise ValueError(f"Withdrawal value should be non-negative, got {desired_cash}")
|
|
217
|
+
|
|
218
|
+
if desired_cash > self.portfolio_value:
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"Withdrawal value {desired_cash} exceeds portfolio value {self.portfolio_value}"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
total_withdrawal = decimal.Decimal("0")
|
|
224
|
+
|
|
225
|
+
for investment_year in sorted(self.year_to_portfolio_map.keys()):
|
|
226
|
+
|
|
227
|
+
withdrawal_from_year = min(desired_cash - total_withdrawal, self.year_to_portfolio_map[investment_year])
|
|
228
|
+
|
|
229
|
+
self.withdraw_for_year(portfolio_year=investment_year, desired_cash=withdrawal_from_year)
|
|
230
|
+
total_withdrawal += withdrawal_from_year
|
|
231
|
+
|
|
232
|
+
if total_withdrawal == desired_cash:
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@beartype.beartype
|
|
237
|
+
class NisaAccount:
|
|
238
|
+
"""
|
|
239
|
+
NISA account
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
def __init__(
|
|
243
|
+
self,
|
|
244
|
+
principal: decimal.Decimal,
|
|
245
|
+
gain: decimal.Decimal,
|
|
246
|
+
investment_return_rate: decimal.Decimal
|
|
247
|
+
):
|
|
248
|
+
|
|
249
|
+
self.annual_deposit_limit = decimal.Decimal("3.6") * constants.MILLION
|
|
250
|
+
self.total_deposit_limit = decimal.Decimal("18") * constants.MILLION
|
|
251
|
+
|
|
252
|
+
if principal > self.total_deposit_limit:
|
|
253
|
+
raise ValueError(f"Principal {principal} exceeds total deposit limit of {self.total_deposit_limit}")
|
|
254
|
+
|
|
255
|
+
self.principal = principal
|
|
256
|
+
self.gain = gain
|
|
257
|
+
self.investment_return_rate = investment_return_rate
|
|
258
|
+
|
|
259
|
+
self.year_to_deposit_map = collections.defaultdict(decimal.Decimal)
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def portfolio_value(self) -> decimal.Decimal:
|
|
263
|
+
return self.principal + self.gain
|
|
264
|
+
|
|
265
|
+
def deposit(self, amount: decimal.Decimal, year: int):
|
|
266
|
+
|
|
267
|
+
if amount < 0:
|
|
268
|
+
raise ValueError(f"Deposit amount should be non-negative, got {amount}")
|
|
269
|
+
|
|
270
|
+
if self.principal + amount > self.total_deposit_limit:
|
|
271
|
+
|
|
272
|
+
message = f"With deposit {amount}, principal would exceed total deposit limit of {self.total_deposit_limit}"
|
|
273
|
+
raise ValueError(message)
|
|
274
|
+
|
|
275
|
+
deposit_for_target_year = self.year_to_deposit_map[year]
|
|
276
|
+
|
|
277
|
+
if deposit_for_target_year + amount > self.annual_deposit_limit:
|
|
278
|
+
message = (
|
|
279
|
+
f"With deposit {amount}, annual deposit limit of {self.annual_deposit_limit} would be exceeded. "
|
|
280
|
+
f"Current deposit for year {year} is {deposit_for_target_year}"
|
|
281
|
+
)
|
|
282
|
+
raise ValueError(message)
|
|
283
|
+
|
|
284
|
+
self.year_to_deposit_map[year] += amount
|
|
285
|
+
self.principal += amount
|
|
286
|
+
|
|
287
|
+
def advance_one_year(self):
|
|
288
|
+
|
|
289
|
+
self.gain += self.investment_return_rate * (self.principal + self.gain)
|
|
290
|
+
|
|
291
|
+
def withdraw(self, desired_amount: decimal.Decimal):
|
|
292
|
+
|
|
293
|
+
if desired_amount > self.portfolio_value:
|
|
294
|
+
raise ValueError(f"Desired amount {desired_amount} exceeds portfolio value {self.portfolio_value}")
|
|
295
|
+
|
|
296
|
+
gain_ratio = self.portfolio_value / self.principal
|
|
297
|
+
|
|
298
|
+
principal_withdrawn = desired_amount / gain_ratio
|
|
299
|
+
|
|
300
|
+
self.principal -= principal_withdrawn
|
|
301
|
+
self.gain -= desired_amount - principal_withdrawn
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class IdecoPolicy(pydantic.BaseModel):
|
|
305
|
+
withdrawal_start_age: int
|
|
306
|
+
withdrawal_period_in_years: int
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class InvestmentPolicy(pydantic.BaseModel):
|
|
310
|
+
ideco: IdecoPolicy
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@beartype.beartype
|
|
314
|
+
class InvestmentManager:
|
|
315
|
+
|
|
316
|
+
def __init__(
|
|
317
|
+
self,
|
|
318
|
+
ordinary_investment_account: OrdinaryInvestmentAccount,
|
|
319
|
+
ideco_investment_account: IdecoInvestmentAccount,
|
|
320
|
+
old_nisa_account: OldNisaAccount,
|
|
321
|
+
nisa_account: NisaAccount,
|
|
322
|
+
investment_policy: InvestmentPolicy,
|
|
323
|
+
start_age: int,
|
|
324
|
+
start_year: int
|
|
325
|
+
):
|
|
326
|
+
"""
|
|
327
|
+
Constructor
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
start_age (int): age of the person for whom investments are managed at simulation start.
|
|
331
|
+
start_year (int): year at which simulation started.
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
self.ordinary_investment_account = ordinary_investment_account
|
|
335
|
+
self.ideco_investment_account = ideco_investment_account
|
|
336
|
+
self.old_nisa_account = old_nisa_account
|
|
337
|
+
self.nisa_account = nisa_account
|
|
338
|
+
self.investment_policy = investment_policy
|
|
339
|
+
self.start_age = start_age
|
|
340
|
+
self.start_year = start_year
|
|
341
|
+
|
|
342
|
+
@property
|
|
343
|
+
def portfolio_value(self) -> decimal.Decimal:
|
|
344
|
+
return \
|
|
345
|
+
self.ordinary_investment_account.portfolio_value + \
|
|
346
|
+
self.ideco_investment_account.portfolio_value + \
|
|
347
|
+
self.old_nisa_account.portfolio_value + \
|
|
348
|
+
self.nisa_account.portfolio_value
|
|
349
|
+
|
|
350
|
+
def advance_one_year(self, year):
|
|
351
|
+
|
|
352
|
+
self.ordinary_investment_account.advance_one_year()
|
|
353
|
+
self.ideco_investment_account.advance_one_year()
|
|
354
|
+
self.old_nisa_account.advance_one_year(year)
|
|
355
|
+
self.nisa_account.advance_one_year()
|
|
356
|
+
|
|
357
|
+
def deposit(self, amount: decimal.Decimal, age: int):
|
|
358
|
+
|
|
359
|
+
if amount < 0:
|
|
360
|
+
raise ValueError(f"Deposit amount should be non-negative, got {amount}")
|
|
361
|
+
|
|
362
|
+
if amount == 0:
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
if age < self.ideco_investment_account.max_deposit_age:
|
|
366
|
+
|
|
367
|
+
amount_deposited_to_ideco = min(amount, self.ideco_investment_account.max_annual_contribution)
|
|
368
|
+
self.ideco_investment_account.deposit(amount=amount_deposited_to_ideco, age=age)
|
|
369
|
+
amount -= amount_deposited_to_ideco
|
|
370
|
+
|
|
371
|
+
LOGGER.debug(
|
|
372
|
+
f"Deposited {utilities.format_million_yen(amount_deposited_to_ideco)} to iDeCo account")
|
|
373
|
+
|
|
374
|
+
if self.nisa_account.principal < self.nisa_account.total_deposit_limit:
|
|
375
|
+
|
|
376
|
+
current_year = self.start_year + age - self.start_age
|
|
377
|
+
|
|
378
|
+
amount_deposited_to_nisa = min(
|
|
379
|
+
self.nisa_account.total_deposit_limit - self.nisa_account.principal,
|
|
380
|
+
self.nisa_account.annual_deposit_limit - self.nisa_account.year_to_deposit_map[current_year],
|
|
381
|
+
amount
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
self.nisa_account.deposit(amount_deposited_to_nisa, current_year)
|
|
385
|
+
amount -= amount_deposited_to_nisa
|
|
386
|
+
|
|
387
|
+
LOGGER.debug(
|
|
388
|
+
f"Deposited {utilities.format_million_yen(amount_deposited_to_nisa)} to NISA account")
|
|
389
|
+
|
|
390
|
+
if amount > 0:
|
|
391
|
+
|
|
392
|
+
self.ordinary_investment_account.deposit(amount)
|
|
393
|
+
LOGGER.debug(
|
|
394
|
+
f"Deposited {utilities.format_million_yen(amount)} to ordinary investment account")
|
|
395
|
+
|
|
396
|
+
def withdraw(self, desired_cash: decimal.Decimal) -> decimal.Decimal:
|
|
397
|
+
|
|
398
|
+
desired_cash = desired_cash.quantize(constants.YEN, decimal.ROUND_UP)
|
|
399
|
+
|
|
400
|
+
if desired_cash < 0:
|
|
401
|
+
raise ValueError(f"Withdrawal value should be non-negative, got {desired_cash}")
|
|
402
|
+
|
|
403
|
+
total_cash_withdrawn = decimal.Decimal("0")
|
|
404
|
+
|
|
405
|
+
# Establish how much to withdraw from ordinary account
|
|
406
|
+
withdrawal_from_ordinary_account = min(
|
|
407
|
+
desired_cash - total_cash_withdrawn,
|
|
408
|
+
self.ordinary_investment_account.max_cash_withdrawal
|
|
409
|
+
).quantize(constants.YEN, rounding=decimal.ROUND_DOWN)
|
|
410
|
+
|
|
411
|
+
if withdrawal_from_ordinary_account > 0:
|
|
412
|
+
|
|
413
|
+
self.ordinary_investment_account.withdraw(withdrawal_from_ordinary_account)
|
|
414
|
+
total_cash_withdrawn += withdrawal_from_ordinary_account
|
|
415
|
+
|
|
416
|
+
withdrawal_from_old_nisa = min(
|
|
417
|
+
desired_cash - total_cash_withdrawn,
|
|
418
|
+
self.old_nisa_account.portfolio_value
|
|
419
|
+
).quantize(constants.YEN, rounding=decimal.ROUND_UP)
|
|
420
|
+
|
|
421
|
+
if withdrawal_from_old_nisa > 0:
|
|
422
|
+
|
|
423
|
+
self.old_nisa_account.withdraw(withdrawal_from_old_nisa)
|
|
424
|
+
total_cash_withdrawn += withdrawal_from_old_nisa
|
|
425
|
+
|
|
426
|
+
# Establish how much to withdraw from NISA
|
|
427
|
+
withdrawal_from_nisa = min(
|
|
428
|
+
desired_cash - total_cash_withdrawn,
|
|
429
|
+
self.nisa_account.portfolio_value
|
|
430
|
+
).quantize(constants.YEN, rounding=decimal.ROUND_UP)
|
|
431
|
+
|
|
432
|
+
if withdrawal_from_nisa > 0:
|
|
433
|
+
|
|
434
|
+
self.nisa_account.withdraw(withdrawal_from_nisa)
|
|
435
|
+
total_cash_withdrawn += withdrawal_from_nisa
|
|
436
|
+
|
|
437
|
+
if total_cash_withdrawn < desired_cash:
|
|
438
|
+
|
|
439
|
+
raise ValueError(f"Not enough funds to withdraw {desired_cash}")
|
|
440
|
+
|
|
441
|
+
return total_cash_withdrawn
|
|
442
|
+
|
|
443
|
+
def optimize_investments(self, age: int):
|
|
444
|
+
"""
|
|
445
|
+
Optimize investments based on the investment policy.
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
current_year = self.start_year + (age - self.start_age)
|
|
449
|
+
|
|
450
|
+
# Check if any old nisa account portfolio has to be liquidated
|
|
451
|
+
if (current_year - 20) in self.old_nisa_account.year_to_portfolio_map.keys():
|
|
452
|
+
|
|
453
|
+
portfolio_value = self.old_nisa_account.year_to_portfolio_map.pop(current_year - 20)
|
|
454
|
+
self.ordinary_investment_account.deposit(portfolio_value)
|
|
455
|
+
|
|
456
|
+
LOGGER.debug(
|
|
457
|
+
f"Moved {utilities.format_million_yen(portfolio_value)} from old NISA account "
|
|
458
|
+
f"for year {current_year - 20} to ordinary investment account"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# Check if we should do lump withdrawal from iDeCo
|
|
462
|
+
if age == self.investment_policy.ideco.withdrawal_start_age:
|
|
463
|
+
|
|
464
|
+
tax_free_lump_sum = self.ideco_investment_account.withdraw_tax_free_lump_sum()
|
|
465
|
+
self.ordinary_investment_account.deposit(tax_free_lump_sum)
|
|
466
|
+
|
|
467
|
+
LOGGER.debug(
|
|
468
|
+
f"Withdrew tax-free lump sum of {utilities.format_million_yen(tax_free_lump_sum)} "
|
|
469
|
+
"from iDeCo account")
|
|
470
|
+
|
|
471
|
+
if self.nisa_account.principal < self.nisa_account.total_deposit_limit:
|
|
472
|
+
|
|
473
|
+
nisa_deposit = min(
|
|
474
|
+
self.nisa_account.total_deposit_limit - self.nisa_account.principal,
|
|
475
|
+
self.nisa_account.annual_deposit_limit,
|
|
476
|
+
self.ordinary_investment_account.max_cash_withdrawal
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
self.ordinary_investment_account.withdraw(nisa_deposit)
|
|
480
|
+
self.nisa_account.deposit(nisa_deposit, self.start_year + age - self.start_year)
|
|
481
|
+
|
|
482
|
+
LOGGER.debug(
|
|
483
|
+
f"Moved {utilities.format_million_yen(nisa_deposit)} "
|
|
484
|
+
"from ordinary investment account to NISA account"
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
def get_portfolio_summary(self) -> dict[str, decimal.Decimal]:
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
"ordinary_investment_account": self.ordinary_investment_account.portfolio_value,
|
|
491
|
+
"ideco_investment_account": self.ideco_investment_account.portfolio_value,
|
|
492
|
+
"old_nisa_account": self.old_nisa_account.portfolio_value,
|
|
493
|
+
"nisa_account": self.nisa_account.portfolio_value,
|
|
494
|
+
"total_portfolio_value": self.portfolio_value
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
def get_formatted_portfolio_summary_description(self) -> str:
|
|
498
|
+
|
|
499
|
+
return "\n".join(
|
|
500
|
+
f"{key}: {value / constants.MILLION:.3f} mln yen"
|
|
501
|
+
for key, value in self.get_portfolio_summary().items()
|
|
502
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import decimal
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import yenwealth.investments
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestOrdinaryInvestmentAccount:
|
|
10
|
+
"""
|
|
11
|
+
Test for OrdinaryInvestmentAccount
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def test_value_of_portfolio_without_investments(self):
|
|
15
|
+
|
|
16
|
+
assert yenwealth.investments.OrdinaryInvestmentAccount(
|
|
17
|
+
principal=decimal.Decimal("0"),
|
|
18
|
+
gain=decimal.Decimal("0"),
|
|
19
|
+
investment_return_rate=decimal.Decimal("0.1"),
|
|
20
|
+
capital_gain_tax_rate=decimal.Decimal("0.2")
|
|
21
|
+
).portfolio_value == decimal.Decimal("0")
|
|
22
|
+
|
|
23
|
+
def test_value_of_portfolio_with_investments(self):
|
|
24
|
+
|
|
25
|
+
investment_account = yenwealth.investments.OrdinaryInvestmentAccount(
|
|
26
|
+
principal=decimal.Decimal("1000"),
|
|
27
|
+
gain=decimal.Decimal("0"),
|
|
28
|
+
investment_return_rate=decimal.Decimal("0.1"),
|
|
29
|
+
capital_gain_tax_rate=decimal.Decimal("0.2"))
|
|
30
|
+
|
|
31
|
+
assert math.isclose(investment_account.principal, decimal.Decimal("1000"), rel_tol=1e-2)
|
|
32
|
+
assert math.isclose(investment_account.gain, decimal.Decimal("0"), rel_tol=1e-2)
|
|
33
|
+
assert math.isclose(investment_account.portfolio_value, decimal.Decimal("1000"), rel_tol=1e-2)
|
|
34
|
+
|
|
35
|
+
investment_account.deposit(decimal.Decimal("10"))
|
|
36
|
+
|
|
37
|
+
assert math.isclose(investment_account.principal, decimal.Decimal("1010"), rel_tol=1e-2)
|
|
38
|
+
assert math.isclose(investment_account.gain, decimal.Decimal("0"), rel_tol=1e-2)
|
|
39
|
+
assert math.isclose(investment_account.portfolio_value, decimal.Decimal("1010"), rel_tol=1e-2)
|
|
40
|
+
|
|
41
|
+
def test_advancement_of_portfolio_value_over_time(self):
|
|
42
|
+
|
|
43
|
+
investment_account = yenwealth.investments.OrdinaryInvestmentAccount(
|
|
44
|
+
principal=decimal.Decimal("1000"),
|
|
45
|
+
gain=decimal.Decimal("0"),
|
|
46
|
+
investment_return_rate=decimal.Decimal("0.1"),
|
|
47
|
+
capital_gain_tax_rate=decimal.Decimal("0.2")
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
investment_account.advance_one_year()
|
|
51
|
+
|
|
52
|
+
assert math.isclose(investment_account.principal, 1000, rel_tol=1e-2)
|
|
53
|
+
assert math.isclose(investment_account.gain, 100, rel_tol=1e-2)
|
|
54
|
+
assert math.isclose(investment_account.portfolio_value, 1100, rel_tol=1e-2)
|
|
55
|
+
|
|
56
|
+
investment_account.advance_one_year()
|
|
57
|
+
|
|
58
|
+
assert math.isclose(investment_account.principal, 1000, rel_tol=1e-2)
|
|
59
|
+
assert math.isclose(investment_account.gain, 210, rel_tol=1e-2)
|
|
60
|
+
assert math.isclose(investment_account.portfolio_value, 1210, rel_tol=1e-2)
|
|
61
|
+
|
|
62
|
+
investment_account.deposit(amount=decimal.Decimal("1000"))
|
|
63
|
+
|
|
64
|
+
assert math.isclose(investment_account.principal, 2000, rel_tol=1e-2)
|
|
65
|
+
assert math.isclose(investment_account.gain, 210, rel_tol=1e-2)
|
|
66
|
+
assert math.isclose(investment_account.portfolio_value, 2210, rel_tol=1e-2)
|
|
67
|
+
|
|
68
|
+
investment_account.advance_one_year()
|
|
69
|
+
|
|
70
|
+
assert math.isclose(investment_account.principal, 2000, rel_tol=1e-2)
|
|
71
|
+
assert math.isclose(investment_account.gain, 431, rel_tol=1e-2)
|
|
72
|
+
assert math.isclose(investment_account.portfolio_value, 2431, rel_tol=1e-2)
|
|
73
|
+
|
|
74
|
+
def test_withdraw_over_portfolio_value(self):
|
|
75
|
+
|
|
76
|
+
investment_account = yenwealth.investments.OrdinaryInvestmentAccount(
|
|
77
|
+
principal=decimal.Decimal("1000"),
|
|
78
|
+
gain=decimal.Decimal("0"),
|
|
79
|
+
investment_return_rate=decimal.Decimal("0.1"),
|
|
80
|
+
capital_gain_tax_rate=decimal.Decimal("0.2"))
|
|
81
|
+
|
|
82
|
+
with pytest.raises(ValueError):
|
|
83
|
+
investment_account.withdraw(desired_cash=decimal.Decimal("2000"))
|
|
84
|
+
|
|
85
|
+
def test_withdraw(self):
|
|
86
|
+
|
|
87
|
+
investment_account = yenwealth.investments.OrdinaryInvestmentAccount(
|
|
88
|
+
principal=decimal.Decimal("1000"),
|
|
89
|
+
gain=decimal.Decimal("0"),
|
|
90
|
+
investment_return_rate=decimal.Decimal("0.1"),
|
|
91
|
+
capital_gain_tax_rate=decimal.Decimal("0.2")
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
investment_account.advance_one_year()
|
|
95
|
+
investment_account.advance_one_year()
|
|
96
|
+
|
|
97
|
+
assert math.isclose(investment_account.principal, decimal.Decimal("1000"), rel_tol=1e-2)
|
|
98
|
+
assert math.isclose(investment_account.gain, decimal.Decimal("210"), rel_tol=1e-2)
|
|
99
|
+
assert math.isclose(investment_account.portfolio_value, decimal.Decimal("1210"), rel_tol=1e-2)
|
|
100
|
+
|
|
101
|
+
investment_account.withdraw(desired_cash=decimal.Decimal("500"))
|
|
102
|
+
|
|
103
|
+
assert math.isclose(investment_account.principal, decimal.Decimal("571.9178"), rel_tol=1e-4)
|
|
104
|
+
assert math.isclose(investment_account.gain, decimal.Decimal("120.1027"), rel_tol=1e-4)
|
|
105
|
+
assert math.isclose(investment_account.portfolio_value, decimal.Decimal("692.0205"), rel_tol=1e-4)
|
|
106
|
+
|
|
107
|
+
def test_withdrawing_zero(self):
|
|
108
|
+
|
|
109
|
+
investment_account = yenwealth.investments.OrdinaryInvestmentAccount(
|
|
110
|
+
principal=decimal.Decimal("1000"),
|
|
111
|
+
gain=decimal.Decimal("0"),
|
|
112
|
+
investment_return_rate=decimal.Decimal("0.1"),
|
|
113
|
+
capital_gain_tax_rate=decimal.Decimal("0.2")
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
investment_account.advance_one_year()
|
|
117
|
+
investment_account.advance_one_year()
|
|
118
|
+
|
|
119
|
+
assert math.isclose(investment_account.principal, decimal.Decimal("1000"), rel_tol=1e-2)
|
|
120
|
+
assert math.isclose(investment_account.gain, decimal.Decimal("210"), rel_tol=1e-2)
|
|
121
|
+
|
|
122
|
+
investment_account.withdraw(desired_cash=decimal.Decimal("0"))
|
|
123
|
+
|
|
124
|
+
assert math.isclose(investment_account.principal, decimal.Decimal("1000"), rel_tol=1e-2)
|
|
125
|
+
assert math.isclose(investment_account.gain, decimal.Decimal("210"), rel_tol=1e-2)
|
|
126
|
+
|
|
127
|
+
def test_withdrawing_negative_amount(self):
|
|
128
|
+
|
|
129
|
+
investment_account = yenwealth.investments.OrdinaryInvestmentAccount(
|
|
130
|
+
principal=decimal.Decimal("1000"),
|
|
131
|
+
gain=decimal.Decimal("0"),
|
|
132
|
+
investment_return_rate=decimal.Decimal("0.1"),
|
|
133
|
+
capital_gain_tax_rate=decimal.Decimal("0.2"))
|
|
134
|
+
|
|
135
|
+
with pytest.raises(ValueError):
|
|
136
|
+
investment_account.withdraw(desired_cash=decimal.Decimal("-100"))
|
|
137
|
+
|
|
138
|
+
def test_max_cash_withdrawal(self):
|
|
139
|
+
|
|
140
|
+
investment_account = yenwealth.investments.OrdinaryInvestmentAccount(
|
|
141
|
+
principal=decimal.Decimal("1000"),
|
|
142
|
+
gain=decimal.Decimal("0"),
|
|
143
|
+
investment_return_rate=decimal.Decimal("0.1"),
|
|
144
|
+
capital_gain_tax_rate=decimal.Decimal("0.2")
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
investment_account.advance_one_year()
|
|
148
|
+
investment_account.advance_one_year()
|
|
149
|
+
|
|
150
|
+
assert math.isclose(investment_account.principal, decimal.Decimal("1000"), rel_tol=1e-2)
|
|
151
|
+
assert math.isclose(investment_account.gain, decimal.Decimal("210"), rel_tol=1e-2)
|
|
152
|
+
|
|
153
|
+
assert investment_account.max_cash_withdrawal == decimal.Decimal("1168")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TestIdecoInvestmentAccount:
|
|
157
|
+
|
|
158
|
+
def test_withdrawing_pensions_without_initializing_it_first(self):
|
|
159
|
+
|
|
160
|
+
ideco = yenwealth.investments.IdecoInvestmentAccount(
|
|
161
|
+
portfolio_value=decimal.Decimal("100"),
|
|
162
|
+
investment_return_rate=decimal.Decimal("0")
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
with pytest.raises(ValueError):
|
|
166
|
+
ideco.withdraw_pension(70)
|
|
167
|
+
|
|
168
|
+
def test_withdrawing_pensions(self):
|
|
169
|
+
|
|
170
|
+
period_in_years = 5
|
|
171
|
+
|
|
172
|
+
ideco = yenwealth.investments.IdecoInvestmentAccount(
|
|
173
|
+
portfolio_value=decimal.Decimal("100"),
|
|
174
|
+
investment_return_rate=decimal.Decimal("0")
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
ideco.start_pension_scheme(
|
|
178
|
+
start_age=70,
|
|
179
|
+
period_in_years=period_in_years
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# First withdrawal
|
|
183
|
+
portfolio_value_before_withdrawal = ideco.portfolio_value
|
|
184
|
+
amount = ideco.withdraw_pension(age=70)
|
|
185
|
+
assert abs(amount - (portfolio_value_before_withdrawal / 5)) < decimal.Decimal("0.001")
|
|
186
|
+
|
|
187
|
+
# Second withdrawal
|
|
188
|
+
portfolio_value_before_withdrawal = ideco.portfolio_value
|
|
189
|
+
amount = ideco.withdraw_pension(age=71)
|
|
190
|
+
assert abs(amount - (portfolio_value_before_withdrawal / 4)) < decimal.Decimal("0.001")
|
|
191
|
+
|
|
192
|
+
# Third withdrawal
|
|
193
|
+
portfolio_value_before_withdrawal = ideco.portfolio_value
|
|
194
|
+
amount = ideco.withdraw_pension(age=72)
|
|
195
|
+
assert abs(amount - (portfolio_value_before_withdrawal / 3)) < decimal.Decimal("0.001")
|
|
196
|
+
|
|
197
|
+
# # Fourth withdrawal
|
|
198
|
+
portfolio_value_before_withdrawal = ideco.portfolio_value
|
|
199
|
+
amount = ideco.withdraw_pension(age=73)
|
|
200
|
+
assert abs(amount - (portfolio_value_before_withdrawal / 2)) < decimal.Decimal("0.001")
|
|
201
|
+
|
|
202
|
+
# Last withdrawal
|
|
203
|
+
portfolio_value_before_withdrawal = ideco.portfolio_value
|
|
204
|
+
amount = ideco.withdraw_pension(age=74)
|
|
205
|
+
assert abs(amount - portfolio_value_before_withdrawal) < decimal.Decimal("0.001")
|
|
206
|
+
|
|
207
|
+
assert ideco.portfolio_value == decimal.Decimal("0")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class TestOldNisaAccount:
|
|
211
|
+
|
|
212
|
+
def test_advance_one_year(self):
|
|
213
|
+
|
|
214
|
+
nisa = yenwealth.investments.OldNisaAccount(
|
|
215
|
+
year_to_portfolio_map={
|
|
216
|
+
2020: decimal.Decimal("100"),
|
|
217
|
+
2021: decimal.Decimal("50")
|
|
218
|
+
},
|
|
219
|
+
investment_return_rate=decimal.Decimal("0.1")
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
nisa.advance_one_year(2025)
|
|
223
|
+
|
|
224
|
+
assert abs(nisa.year_to_portfolio_map[2020] - decimal.Decimal(110)) < decimal.Decimal("0.001")
|
|
225
|
+
assert abs(nisa.year_to_portfolio_map[2021] - decimal.Decimal(55)) < decimal.Decimal("0.001")
|
|
226
|
+
|
|
227
|
+
def test_advance_one_year_over_twenty_years_for_any_investment(self):
|
|
228
|
+
|
|
229
|
+
nisa = yenwealth.investments.OldNisaAccount(
|
|
230
|
+
year_to_portfolio_map={
|
|
231
|
+
2020: decimal.Decimal("100"),
|
|
232
|
+
2021: decimal.Decimal("50")
|
|
233
|
+
},
|
|
234
|
+
investment_return_rate=decimal.Decimal("0.1")
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
with pytest.raises(ValueError):
|
|
238
|
+
nisa.advance_one_year(2041)
|
|
239
|
+
|
|
240
|
+
def test_withdraw_for_invalid_year(self):
|
|
241
|
+
|
|
242
|
+
nisa = yenwealth.investments.OldNisaAccount(
|
|
243
|
+
year_to_portfolio_map={
|
|
244
|
+
2020: decimal.Decimal("100"),
|
|
245
|
+
2021: decimal.Decimal("50")
|
|
246
|
+
},
|
|
247
|
+
investment_return_rate=decimal.Decimal("0.1")
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
with pytest.raises(KeyError):
|
|
251
|
+
nisa.withdraw_for_year(portfolio_year=2022, desired_cash=decimal.Decimal("10"))
|
|
252
|
+
|
|
253
|
+
def test_valid_withdraw_for_year(self):
|
|
254
|
+
|
|
255
|
+
nisa = yenwealth.investments.OldNisaAccount(
|
|
256
|
+
year_to_portfolio_map={
|
|
257
|
+
2020: decimal.Decimal("100"),
|
|
258
|
+
2021: decimal.Decimal("50")
|
|
259
|
+
},
|
|
260
|
+
investment_return_rate=decimal.Decimal("0.1")
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
nisa.withdraw_for_year(portfolio_year=2020, desired_cash=decimal.Decimal("10"))
|
|
264
|
+
|
|
265
|
+
assert nisa.year_to_portfolio_map[2020] == decimal.Decimal("90")
|
|
266
|
+
|
|
267
|
+
def test_withdraw(self):
|
|
268
|
+
|
|
269
|
+
nisa = yenwealth.investments.OldNisaAccount(
|
|
270
|
+
year_to_portfolio_map={
|
|
271
|
+
2020: decimal.Decimal("100"),
|
|
272
|
+
2021: decimal.Decimal("50")
|
|
273
|
+
},
|
|
274
|
+
investment_return_rate=decimal.Decimal("0.1")
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
nisa.withdraw(desired_cash=decimal.Decimal("120"))
|
|
278
|
+
|
|
279
|
+
assert nisa.year_to_portfolio_map[2020] == decimal.Decimal("0")
|
|
280
|
+
assert nisa.year_to_portfolio_map[2021] == decimal.Decimal("30")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class TestNisaAccount:
|
|
284
|
+
|
|
285
|
+
def test_initial_deposit_over_max_limit(self):
|
|
286
|
+
|
|
287
|
+
with pytest.raises(ValueError):
|
|
288
|
+
|
|
289
|
+
yenwealth.investments.NisaAccount(
|
|
290
|
+
principal=decimal.Decimal("20") * yenwealth.constants.MILLION,
|
|
291
|
+
gain=decimal.Decimal("0"),
|
|
292
|
+
investment_return_rate=decimal.Decimal("0")
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def test_deposit_over_annual_limit(self):
|
|
296
|
+
|
|
297
|
+
nisa = yenwealth.investments.NisaAccount(
|
|
298
|
+
principal=decimal.Decimal("0"),
|
|
299
|
+
gain=decimal.Decimal("0"),
|
|
300
|
+
investment_return_rate=decimal.Decimal("0")
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Within limit
|
|
304
|
+
nisa.deposit(amount=decimal.Decimal("2_000_000"), year=2025)
|
|
305
|
+
|
|
306
|
+
with pytest.raises(ValueError):
|
|
307
|
+
|
|
308
|
+
# Over limit for targer year
|
|
309
|
+
nisa.deposit(amount=decimal.Decimal("2_000_000"), year=2025)
|
|
310
|
+
|
|
311
|
+
def test_deposit(self):
|
|
312
|
+
|
|
313
|
+
nisa = yenwealth.investments.NisaAccount(
|
|
314
|
+
principal=decimal.Decimal("0"),
|
|
315
|
+
gain=decimal.Decimal("0"),
|
|
316
|
+
investment_return_rate=decimal.Decimal("0")
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
nisa.deposit(amount=decimal.Decimal("2_000_000"), year=2025)
|
|
320
|
+
|
|
321
|
+
assert nisa.principal == decimal.Decimal("2_000_000")
|
|
322
|
+
assert nisa.year_to_deposit_map[2025] == decimal.Decimal("2_000_000")
|
|
323
|
+
assert nisa.portfolio_value == decimal.Decimal("2_000_000")
|
|
324
|
+
|
|
325
|
+
nisa.deposit(amount=decimal.Decimal("1_000_000"), year=2025)
|
|
326
|
+
|
|
327
|
+
assert nisa.principal == decimal.Decimal("3_000_000")
|
|
328
|
+
assert nisa.year_to_deposit_map[2025] == decimal.Decimal("3_000_000")
|
|
329
|
+
assert nisa.portfolio_value == decimal.Decimal("3_000_000")
|
|
330
|
+
|
|
331
|
+
nisa.deposit(amount=decimal.Decimal("1_000_000"), year=2026)
|
|
332
|
+
|
|
333
|
+
assert nisa.principal == decimal.Decimal("4_000_000")
|
|
334
|
+
assert nisa.year_to_deposit_map[2025] == decimal.Decimal("3_000_000")
|
|
335
|
+
assert nisa.year_to_deposit_map[2026] == decimal.Decimal("1_000_000")
|
|
336
|
+
assert nisa.portfolio_value == decimal.Decimal("4_000_000")
|
|
337
|
+
|
|
338
|
+
def test_advance_one_year(self):
|
|
339
|
+
|
|
340
|
+
nisa = yenwealth.investments.NisaAccount(
|
|
341
|
+
principal=decimal.Decimal("100"),
|
|
342
|
+
gain=decimal.Decimal("0"),
|
|
343
|
+
investment_return_rate=decimal.Decimal("0.1")
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
nisa.advance_one_year()
|
|
347
|
+
|
|
348
|
+
assert nisa.principal == decimal.Decimal("100")
|
|
349
|
+
assert nisa.gain == decimal.Decimal("10")
|
|
350
|
+
assert nisa.portfolio_value == decimal.Decimal("110")
|
|
351
|
+
|
|
352
|
+
nisa.deposit(amount=decimal.Decimal("50"), year=2025)
|
|
353
|
+
|
|
354
|
+
assert nisa.principal == decimal.Decimal("150")
|
|
355
|
+
assert nisa.gain == decimal.Decimal("10")
|
|
356
|
+
assert nisa.portfolio_value == decimal.Decimal("160")
|
|
357
|
+
|
|
358
|
+
nisa.advance_one_year()
|
|
359
|
+
|
|
360
|
+
assert nisa.principal == decimal.Decimal("150")
|
|
361
|
+
assert nisa.gain == decimal.Decimal("26")
|
|
362
|
+
assert nisa.portfolio_value == decimal.Decimal("176")
|
|
363
|
+
|
|
364
|
+
def test_withdrawal_over_portolio_value(self):
|
|
365
|
+
|
|
366
|
+
nisa = yenwealth.investments.NisaAccount(
|
|
367
|
+
principal=decimal.Decimal("100"),
|
|
368
|
+
gain=decimal.Decimal("100"),
|
|
369
|
+
investment_return_rate=decimal.Decimal("0")
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
with pytest.raises(ValueError):
|
|
373
|
+
|
|
374
|
+
nisa.withdraw(decimal.Decimal("210"))
|
|
375
|
+
|
|
376
|
+
def test_withdrawal(self):
|
|
377
|
+
|
|
378
|
+
initial_principal = decimal.Decimal("100")
|
|
379
|
+
initial_portfolio_value = decimal.Decimal("200")
|
|
380
|
+
|
|
381
|
+
nisa = yenwealth.investments.NisaAccount(
|
|
382
|
+
principal=initial_principal,
|
|
383
|
+
gain=initial_portfolio_value - initial_principal,
|
|
384
|
+
investment_return_rate=decimal.Decimal("0")
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
assert nisa.principal == initial_principal
|
|
388
|
+
assert nisa.gain == decimal.Decimal("100")
|
|
389
|
+
assert nisa.portfolio_value == initial_portfolio_value
|
|
390
|
+
|
|
391
|
+
desired_amount = decimal.Decimal("50")
|
|
392
|
+
|
|
393
|
+
nisa.withdraw(desired_amount)
|
|
394
|
+
|
|
395
|
+
assert nisa.portfolio_value == decimal.Decimal("150")
|
|
396
|
+
assert nisa.gain == decimal.Decimal("75")
|
|
397
|
+
assert nisa.principal == decimal.Decimal("75")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class TestInvestmentPolicy:
|
|
401
|
+
|
|
402
|
+
def test_construction_with_valid_data(self):
|
|
403
|
+
|
|
404
|
+
policy = yenwealth.investments.InvestmentPolicy.model_validate(
|
|
405
|
+
{
|
|
406
|
+
"ideco": {
|
|
407
|
+
"withdrawal_start_age": 75,
|
|
408
|
+
"withdrawal_period_in_years": 20
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
assert policy.ideco.withdrawal_start_age == 75
|
|
414
|
+
assert policy.ideco.withdrawal_period_in_years == 20
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class TestInvestmentManager:
|
|
418
|
+
|
|
419
|
+
@pytest.fixture
|
|
420
|
+
def investment_manager(self):
|
|
421
|
+
|
|
422
|
+
# 1. Ordinary Account: 0 principal, 0 gain, 0 tax for simple math
|
|
423
|
+
ordinary = yenwealth.investments.OrdinaryInvestmentAccount(
|
|
424
|
+
principal=decimal.Decimal("0"),
|
|
425
|
+
gain=decimal.Decimal("0"),
|
|
426
|
+
investment_return_rate=decimal.Decimal("0.05"),
|
|
427
|
+
capital_gain_tax_rate=decimal.Decimal("0.20")
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
# 2. iDeCo Account: 0 initial portfolio
|
|
431
|
+
ideco = yenwealth.investments.IdecoInvestmentAccount(
|
|
432
|
+
portfolio_value=decimal.Decimal("0"),
|
|
433
|
+
investment_return_rate=decimal.Decimal("0.05")
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# 3. Old NISA Account: empty year map
|
|
437
|
+
old_nisa = yenwealth.investments.OldNisaAccount(
|
|
438
|
+
year_to_portfolio_map={},
|
|
439
|
+
investment_return_rate=decimal.Decimal("0.05")
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# 4. NISA Account: 0 principal, 0 gain
|
|
443
|
+
nisa = yenwealth.investments.NisaAccount(
|
|
444
|
+
principal=decimal.Decimal("0"),
|
|
445
|
+
gain=decimal.Decimal("0"),
|
|
446
|
+
investment_return_rate=decimal.Decimal("0.05")
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# 5. Investment Policy: Withdrawal starts at age 75 for 20 years
|
|
450
|
+
policy = yenwealth.investments.InvestmentPolicy(
|
|
451
|
+
ideco=yenwealth.investments.IdecoPolicy(
|
|
452
|
+
withdrawal_start_age=75,
|
|
453
|
+
withdrawal_period_in_years=20
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
return yenwealth.investments.InvestmentManager(
|
|
458
|
+
ordinary_investment_account=ordinary,
|
|
459
|
+
ideco_investment_account=ideco,
|
|
460
|
+
old_nisa_account=old_nisa,
|
|
461
|
+
nisa_account=nisa,
|
|
462
|
+
investment_policy=policy,
|
|
463
|
+
start_age=30,
|
|
464
|
+
start_year=2024
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
def test_deposit_priority(self, investment_manager):
|
|
468
|
+
|
|
469
|
+
# Deposit 5,000,000 Yen at age 30
|
|
470
|
+
# Expected order: iDeCo (max 276,000) -> NISA (max 3,600,000) -> Ordinary (rest: 1,124,000)
|
|
471
|
+
investment_manager.deposit(amount=decimal.Decimal("5_000_000"), age=30)
|
|
472
|
+
|
|
473
|
+
assert investment_manager.ideco_investment_account.portfolio_value == decimal.Decimal("276_000")
|
|
474
|
+
assert investment_manager.nisa_account.principal == decimal.Decimal("3_600_000")
|
|
475
|
+
assert investment_manager.ordinary_investment_account.principal == decimal.Decimal("1_124_000")
|
|
476
|
+
|
|
477
|
+
def test_withdraw_priority(self, investment_manager):
|
|
478
|
+
|
|
479
|
+
# Setup initial balances directly
|
|
480
|
+
investment_manager.ordinary_investment_account.principal = decimal.Decimal("1_000_000")
|
|
481
|
+
investment_manager.old_nisa_account.year_to_portfolio_map = {2010: decimal.Decimal("500_000")}
|
|
482
|
+
investment_manager.nisa_account.principal = decimal.Decimal("2_000_000")
|
|
483
|
+
|
|
484
|
+
# Withdraw 2,000,000 Yen
|
|
485
|
+
# Expected order: Ordinary (1,000,000) -> Old NISA (500,000) -> NISA (500,000)
|
|
486
|
+
withdrawn = investment_manager.withdraw(desired_cash=decimal.Decimal("2_000_000"))
|
|
487
|
+
|
|
488
|
+
assert withdrawn == decimal.Decimal("2_000_000")
|
|
489
|
+
assert investment_manager.ordinary_investment_account.portfolio_value == decimal.Decimal("0")
|
|
490
|
+
assert investment_manager.old_nisa_account.portfolio_value == decimal.Decimal("0")
|
|
491
|
+
assert investment_manager.nisa_account.portfolio_value == decimal.Decimal("1_500_000")
|
|
492
|
+
|
|
493
|
+
def test_optimize_investments_old_nisa_liquidation(self, investment_manager):
|
|
494
|
+
|
|
495
|
+
# Current year = start_year (2024) + age (50) - start_age (30) = 2044
|
|
496
|
+
# 20 years prior = 2024. Populate Old NISA portfolio for 2024
|
|
497
|
+
investment_manager.old_nisa_account.year_to_portfolio_map = {2024: decimal.Decimal("1_000_000")}
|
|
498
|
+
|
|
499
|
+
investment_manager.optimize_investments(age=50)
|
|
500
|
+
|
|
501
|
+
# 2024 Old NISA portfolio should be removed and moved into NISA Account
|
|
502
|
+
assert 2024 not in investment_manager.old_nisa_account.year_to_portfolio_map
|
|
503
|
+
assert investment_manager.nisa_account.principal == decimal.Decimal("1_000_000")
|
|
504
|
+
|
|
505
|
+
def test_optimize_investments_ideco_lump_sum_and_nisa_topup(self, investment_manager):
|
|
506
|
+
|
|
507
|
+
investment_manager.ideco_investment_account.portfolio_value = decimal.Decimal("10_000_000")
|
|
508
|
+
|
|
509
|
+
# Set age to policy withdrawal_start_age (75)
|
|
510
|
+
investment_manager.optimize_investments(age=75)
|
|
511
|
+
|
|
512
|
+
# 1. iDeCo 10M transferred to Ordinary Account tax-free
|
|
513
|
+
# 2. Ordinary Account then transfers 3.6M (annual limit) to NISA Account
|
|
514
|
+
assert investment_manager.ideco_investment_account.portfolio_value == decimal.Decimal("0")
|
|
515
|
+
assert investment_manager.nisa_account.principal == decimal.Decimal("3_600_000")
|
|
516
|
+
assert investment_manager.ordinary_investment_account.principal == decimal.Decimal("6_400_000")
|