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.
@@ -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
@@ -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.
@@ -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,4 @@
1
+ # yenwealth
2
+ Financial planning tools for individuals living in Japan
3
+
4
+ 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
+ ]
@@ -0,0 +1,2 @@
1
+ [pycodestyle]
2
+ max-line-length = 120
File without changes
@@ -0,0 +1,4 @@
1
+ import decimal
2
+
3
+ MILLION = 10**6
4
+ YEN = decimal.Decimal("1")
@@ -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
+ )
@@ -0,0 +1,12 @@
1
+ """
2
+ Module with utilities
3
+ """
4
+
5
+ import decimal
6
+
7
+
8
+ def format_million_yen(value: float | int | decimal.Decimal) -> str:
9
+ """
10
+ Format a value in million yen
11
+ """
12
+ return f"{decimal.Decimal(str(value)) / 10**6:.2f} million yen"
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")