fundedness 0.1.0__py3-none-any.whl
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.
Potentially problematic release.
This version of fundedness might be problematic. Click here for more details.
- fundedness/__init__.py +38 -0
- fundedness/allocation/__init__.py +12 -0
- fundedness/allocation/base.py +32 -0
- fundedness/allocation/constant.py +25 -0
- fundedness/allocation/glidepath.py +111 -0
- fundedness/cefr.py +241 -0
- fundedness/liabilities.py +221 -0
- fundedness/liquidity.py +49 -0
- fundedness/models/__init__.py +35 -0
- fundedness/models/assets.py +148 -0
- fundedness/models/household.py +153 -0
- fundedness/models/liabilities.py +99 -0
- fundedness/models/market.py +188 -0
- fundedness/models/simulation.py +80 -0
- fundedness/models/tax.py +125 -0
- fundedness/models/utility.py +154 -0
- fundedness/policies.py +204 -0
- fundedness/risk.py +72 -0
- fundedness/simulate.py +401 -0
- fundedness/viz/__init__.py +19 -0
- fundedness/viz/colors.py +110 -0
- fundedness/viz/comparison.py +294 -0
- fundedness/viz/fan_chart.py +193 -0
- fundedness/viz/histogram.py +225 -0
- fundedness/viz/survival.py +230 -0
- fundedness/viz/tornado.py +236 -0
- fundedness/viz/waterfall.py +203 -0
- fundedness/withdrawals/__init__.py +19 -0
- fundedness/withdrawals/base.py +116 -0
- fundedness/withdrawals/comparison.py +230 -0
- fundedness/withdrawals/fixed_swr.py +113 -0
- fundedness/withdrawals/guardrails.py +136 -0
- fundedness/withdrawals/rmd_style.py +203 -0
- fundedness/withdrawals/vpw.py +136 -0
- fundedness-0.1.0.dist-info/METADATA +233 -0
- fundedness-0.1.0.dist-info/RECORD +38 -0
- fundedness-0.1.0.dist-info/WHEEL +4 -0
- fundedness-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""RMD-style withdrawal strategy."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from fundedness.withdrawals.base import (
|
|
8
|
+
BaseWithdrawalPolicy,
|
|
9
|
+
WithdrawalContext,
|
|
10
|
+
WithdrawalDecision,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# IRS Uniform Lifetime Table (2024)
|
|
15
|
+
# Maps age to distribution period (divisor)
|
|
16
|
+
RMD_TABLE = {
|
|
17
|
+
72: 27.4, 73: 26.5, 74: 25.5, 75: 24.6, 76: 23.7,
|
|
18
|
+
77: 22.9, 78: 22.0, 79: 21.1, 80: 20.2, 81: 19.4,
|
|
19
|
+
82: 18.5, 83: 17.7, 84: 16.8, 85: 16.0, 86: 15.2,
|
|
20
|
+
87: 14.4, 88: 13.7, 89: 12.9, 90: 12.2, 91: 11.5,
|
|
21
|
+
92: 10.8, 93: 10.1, 94: 9.5, 95: 8.9, 96: 8.4,
|
|
22
|
+
97: 7.8, 98: 7.3, 99: 6.8, 100: 6.4, 101: 6.0,
|
|
23
|
+
102: 5.6, 103: 5.2, 104: 4.9, 105: 4.6, 106: 4.3,
|
|
24
|
+
107: 4.1, 108: 3.9, 109: 3.7, 110: 3.5, 111: 3.4,
|
|
25
|
+
112: 3.3, 113: 3.1, 114: 3.0, 115: 2.9, 116: 2.8,
|
|
26
|
+
117: 2.7, 118: 2.5, 119: 2.3, 120: 2.0,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_rmd_divisor(age: int) -> float:
|
|
31
|
+
"""Get RMD distribution period for given age.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
age: Current age
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Distribution period (divisor)
|
|
38
|
+
"""
|
|
39
|
+
if age < 72:
|
|
40
|
+
# Extrapolate backwards (not actual RMD, but useful for strategy)
|
|
41
|
+
return 27.4 + (72 - age) * 1.0 # Approximate slope
|
|
42
|
+
elif age > 120:
|
|
43
|
+
return 2.0
|
|
44
|
+
else:
|
|
45
|
+
return RMD_TABLE.get(age, 2.0)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class RMDStylePolicy(BaseWithdrawalPolicy):
|
|
50
|
+
"""RMD-style withdrawal strategy.
|
|
51
|
+
|
|
52
|
+
Uses IRS Required Minimum Distribution table to determine withdrawals.
|
|
53
|
+
Withdrawal = Portfolio Value / Distribution Period
|
|
54
|
+
|
|
55
|
+
This approach automatically increases withdrawal rate as you age,
|
|
56
|
+
similar to how RMDs work for tax-deferred accounts.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
starting_age: int = 65
|
|
60
|
+
multiplier: float = 1.0 # Scale factor (1.0 = exact RMD, 1.5 = 150% of RMD)
|
|
61
|
+
start_before_72: bool = True # Apply RMD-style before actual RMD age
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def name(self) -> str:
|
|
65
|
+
mult_str = f" × {self.multiplier}" if self.multiplier != 1.0 else ""
|
|
66
|
+
return f"RMD-Style{mult_str}"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def description(self) -> str:
|
|
70
|
+
return (
|
|
71
|
+
"Withdraw based on IRS RMD table divisors. "
|
|
72
|
+
"Withdrawal rate automatically increases with age."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def get_initial_withdrawal(self, initial_wealth: float) -> float:
|
|
76
|
+
"""Calculate first year withdrawal."""
|
|
77
|
+
divisor = get_rmd_divisor(self.starting_age)
|
|
78
|
+
return (initial_wealth / divisor) * self.multiplier
|
|
79
|
+
|
|
80
|
+
def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
|
|
81
|
+
"""Calculate RMD-style withdrawal.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
context: Current state including age or year
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
WithdrawalDecision based on RMD table
|
|
88
|
+
"""
|
|
89
|
+
# Determine current age
|
|
90
|
+
if context.age is not None:
|
|
91
|
+
current_age = context.age
|
|
92
|
+
else:
|
|
93
|
+
current_age = self.starting_age + context.year
|
|
94
|
+
|
|
95
|
+
# Get divisor
|
|
96
|
+
divisor = get_rmd_divisor(current_age)
|
|
97
|
+
|
|
98
|
+
# Calculate withdrawal
|
|
99
|
+
if isinstance(context.current_wealth, np.ndarray):
|
|
100
|
+
amount = (context.current_wealth / divisor) * self.multiplier
|
|
101
|
+
else:
|
|
102
|
+
amount = (context.current_wealth / divisor) * self.multiplier
|
|
103
|
+
|
|
104
|
+
# Apply guardrails
|
|
105
|
+
amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
|
|
106
|
+
amount, context.current_wealth
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
withdrawal_rate = 1 / divisor * self.multiplier
|
|
110
|
+
|
|
111
|
+
return WithdrawalDecision(
|
|
112
|
+
amount=amount,
|
|
113
|
+
is_floor_breach=is_floor_breach,
|
|
114
|
+
is_ceiling_hit=is_ceiling_hit,
|
|
115
|
+
notes=f"Age {current_age}, divisor: {divisor:.1f}, rate: {withdrawal_rate:.2%}",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class AmortizationPolicy(BaseWithdrawalPolicy):
|
|
121
|
+
"""Amortization-based withdrawal strategy.
|
|
122
|
+
|
|
123
|
+
Treats the portfolio like a mortgage in reverse - calculates the level
|
|
124
|
+
payment that would exhaust the portfolio over the planning horizon
|
|
125
|
+
given expected returns.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
starting_age: int = 65
|
|
129
|
+
planning_age: int = 95 # Age to plan to
|
|
130
|
+
expected_return: float = 0.04 # Expected real return
|
|
131
|
+
recalculate_annually: bool = True # Recalculate each year
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def name(self) -> str:
|
|
135
|
+
return "Amortization"
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def description(self) -> str:
|
|
139
|
+
return (
|
|
140
|
+
f"Calculate level payment to exhaust portfolio by age {self.planning_age} "
|
|
141
|
+
f"assuming {self.expected_return:.1%} real return."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def _calculate_pmt(self, wealth: float, years_remaining: int) -> float:
|
|
145
|
+
"""Calculate amortization payment.
|
|
146
|
+
|
|
147
|
+
PMT = PV * r / (1 - (1+r)^-n)
|
|
148
|
+
"""
|
|
149
|
+
if years_remaining <= 0:
|
|
150
|
+
return wealth # Spend it all
|
|
151
|
+
|
|
152
|
+
r = self.expected_return
|
|
153
|
+
n = years_remaining
|
|
154
|
+
|
|
155
|
+
if r == 0:
|
|
156
|
+
return wealth / n
|
|
157
|
+
|
|
158
|
+
# Standard amortization formula
|
|
159
|
+
pmt = wealth * r / (1 - (1 + r) ** (-n))
|
|
160
|
+
return pmt
|
|
161
|
+
|
|
162
|
+
def get_initial_withdrawal(self, initial_wealth: float) -> float:
|
|
163
|
+
"""Calculate first year withdrawal."""
|
|
164
|
+
years = self.planning_age - self.starting_age
|
|
165
|
+
return self._calculate_pmt(initial_wealth, years)
|
|
166
|
+
|
|
167
|
+
def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
|
|
168
|
+
"""Calculate amortization-based withdrawal.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
context: Current state
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
WithdrawalDecision based on amortization formula
|
|
175
|
+
"""
|
|
176
|
+
# Determine current age and years remaining
|
|
177
|
+
if context.age is not None:
|
|
178
|
+
current_age = context.age
|
|
179
|
+
else:
|
|
180
|
+
current_age = self.starting_age + context.year
|
|
181
|
+
|
|
182
|
+
years_remaining = max(1, self.planning_age - current_age)
|
|
183
|
+
|
|
184
|
+
# Calculate payment
|
|
185
|
+
if isinstance(context.current_wealth, np.ndarray):
|
|
186
|
+
amount = np.array([
|
|
187
|
+
self._calculate_pmt(w, years_remaining)
|
|
188
|
+
for w in context.current_wealth
|
|
189
|
+
])
|
|
190
|
+
else:
|
|
191
|
+
amount = self._calculate_pmt(context.current_wealth, years_remaining)
|
|
192
|
+
|
|
193
|
+
# Apply guardrails
|
|
194
|
+
amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
|
|
195
|
+
amount, context.current_wealth
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return WithdrawalDecision(
|
|
199
|
+
amount=amount,
|
|
200
|
+
is_floor_breach=is_floor_breach,
|
|
201
|
+
is_ceiling_hit=is_ceiling_hit,
|
|
202
|
+
notes=f"Years remaining: {years_remaining}",
|
|
203
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Variable Percentage Withdrawal (VPW) strategy."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from fundedness.withdrawals.base import (
|
|
8
|
+
BaseWithdrawalPolicy,
|
|
9
|
+
WithdrawalContext,
|
|
10
|
+
WithdrawalDecision,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# VPW percentage table based on age and asset allocation
|
|
15
|
+
# Source: Bogleheads VPW methodology
|
|
16
|
+
# These are the percentages of portfolio to withdraw at each age
|
|
17
|
+
VPW_TABLE = {
|
|
18
|
+
# Age: {stock_pct: withdrawal_pct}
|
|
19
|
+
# Simplified table - in practice would interpolate
|
|
20
|
+
60: {0: 0.037, 25: 0.039, 50: 0.042, 75: 0.046, 100: 0.051},
|
|
21
|
+
65: {0: 0.041, 25: 0.044, 50: 0.047, 75: 0.052, 100: 0.058},
|
|
22
|
+
70: {0: 0.047, 25: 0.050, 50: 0.054, 75: 0.060, 100: 0.068},
|
|
23
|
+
75: {0: 0.054, 25: 0.058, 50: 0.064, 75: 0.071, 100: 0.081},
|
|
24
|
+
80: {0: 0.064, 25: 0.069, 50: 0.076, 75: 0.086, 100: 0.099},
|
|
25
|
+
85: {0: 0.078, 25: 0.084, 50: 0.093, 75: 0.106, 100: 0.124},
|
|
26
|
+
90: {0: 0.097, 25: 0.106, 50: 0.118, 75: 0.135, 100: 0.160},
|
|
27
|
+
95: {0: 0.127, 25: 0.139, 50: 0.156, 75: 0.180, 100: 0.214},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_vpw_rate(age: int, stock_allocation: int = 50) -> float:
|
|
32
|
+
"""Get VPW withdrawal rate for given age and allocation.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
age: Current age
|
|
36
|
+
stock_allocation: Stock allocation as integer percentage (0-100)
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Withdrawal rate as decimal
|
|
40
|
+
"""
|
|
41
|
+
# Find bracketing ages
|
|
42
|
+
ages = sorted(VPW_TABLE.keys())
|
|
43
|
+
|
|
44
|
+
if age <= ages[0]:
|
|
45
|
+
age_key = ages[0]
|
|
46
|
+
elif age >= ages[-1]:
|
|
47
|
+
age_key = ages[-1]
|
|
48
|
+
else:
|
|
49
|
+
# Find closest age
|
|
50
|
+
age_key = min(ages, key=lambda x: abs(x - age))
|
|
51
|
+
|
|
52
|
+
# Find closest allocation
|
|
53
|
+
allocations = sorted(VPW_TABLE[age_key].keys())
|
|
54
|
+
if stock_allocation <= allocations[0]:
|
|
55
|
+
alloc_key = allocations[0]
|
|
56
|
+
elif stock_allocation >= allocations[-1]:
|
|
57
|
+
alloc_key = allocations[-1]
|
|
58
|
+
else:
|
|
59
|
+
alloc_key = min(allocations, key=lambda x: abs(x - stock_allocation))
|
|
60
|
+
|
|
61
|
+
return VPW_TABLE[age_key][alloc_key]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class VPWPolicy(BaseWithdrawalPolicy):
|
|
66
|
+
"""Variable Percentage Withdrawal (VPW) strategy.
|
|
67
|
+
|
|
68
|
+
Withdrawal rate varies based on age and remaining life expectancy.
|
|
69
|
+
Uses actuarial tables to determine appropriate withdrawal percentage.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
starting_age: int = 65
|
|
73
|
+
stock_allocation: int = 50 # As integer percentage
|
|
74
|
+
smoothing_factor: float = 0.0 # 0 = pure VPW, 1 = fully smoothed
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def name(self) -> str:
|
|
78
|
+
return "VPW"
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def description(self) -> str:
|
|
82
|
+
return (
|
|
83
|
+
"Variable Percentage Withdrawal based on age and life expectancy. "
|
|
84
|
+
"Withdrawal rate increases as you age."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def get_initial_withdrawal(self, initial_wealth: float) -> float:
|
|
88
|
+
"""Calculate first year withdrawal."""
|
|
89
|
+
rate = get_vpw_rate(self.starting_age, self.stock_allocation)
|
|
90
|
+
return initial_wealth * rate
|
|
91
|
+
|
|
92
|
+
def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
|
|
93
|
+
"""Calculate VPW withdrawal.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
context: Current state including age or year
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
WithdrawalDecision based on VPW table
|
|
100
|
+
"""
|
|
101
|
+
# Determine current age
|
|
102
|
+
if context.age is not None:
|
|
103
|
+
current_age = context.age
|
|
104
|
+
else:
|
|
105
|
+
current_age = self.starting_age + context.year
|
|
106
|
+
|
|
107
|
+
# Get VPW rate for current age
|
|
108
|
+
vpw_rate = get_vpw_rate(current_age, self.stock_allocation)
|
|
109
|
+
|
|
110
|
+
# Calculate base withdrawal
|
|
111
|
+
if isinstance(context.current_wealth, np.ndarray):
|
|
112
|
+
base_amount = context.current_wealth * vpw_rate
|
|
113
|
+
else:
|
|
114
|
+
base_amount = context.current_wealth * vpw_rate
|
|
115
|
+
|
|
116
|
+
# Apply smoothing if requested
|
|
117
|
+
if self.smoothing_factor > 0 and context.previous_spending is not None:
|
|
118
|
+
smoothed = (
|
|
119
|
+
self.smoothing_factor * context.previous_spending
|
|
120
|
+
+ (1 - self.smoothing_factor) * base_amount
|
|
121
|
+
)
|
|
122
|
+
amount = smoothed
|
|
123
|
+
else:
|
|
124
|
+
amount = base_amount
|
|
125
|
+
|
|
126
|
+
# Apply guardrails
|
|
127
|
+
amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
|
|
128
|
+
amount, context.current_wealth
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return WithdrawalDecision(
|
|
132
|
+
amount=amount,
|
|
133
|
+
is_floor_breach=is_floor_breach,
|
|
134
|
+
is_ceiling_hit=is_ceiling_hit,
|
|
135
|
+
notes=f"Age {current_age}, VPW rate: {vpw_rate:.2%}",
|
|
136
|
+
)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fundedness
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python financial planning toolkit with CEFR calculations, Monte Carlo simulations, and beautiful visualizations
|
|
5
|
+
Project-URL: Homepage, https://github.com/engineerinvestor/financial-health-calculator
|
|
6
|
+
Project-URL: Documentation, https://engineerinvestor.github.io/financial-health-calculator/
|
|
7
|
+
Project-URL: Repository, https://github.com/engineerinvestor/financial-health-calculator
|
|
8
|
+
Author-email: Engineer Investor <egr.investor@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: CEFR,Monte Carlo,finance,planning,retirement,withdrawal
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: numpy>=1.24.0
|
|
22
|
+
Requires-Dist: pandas>=2.0.0
|
|
23
|
+
Requires-Dist: plotly>=5.18.0
|
|
24
|
+
Requires-Dist: pydantic>=2.0.0
|
|
25
|
+
Requires-Dist: scipy>=1.11.0
|
|
26
|
+
Provides-Extra: all
|
|
27
|
+
Requires-Dist: fastapi>=0.108.0; extra == 'all'
|
|
28
|
+
Requires-Dist: hypothesis>=6.92.0; extra == 'all'
|
|
29
|
+
Requires-Dist: mkdocs-material>=9.5.0; extra == 'all'
|
|
30
|
+
Requires-Dist: mkdocs>=1.5.0; extra == 'all'
|
|
31
|
+
Requires-Dist: mkdocstrings[python]>=0.24.0; extra == 'all'
|
|
32
|
+
Requires-Dist: mypy>=1.8.0; extra == 'all'
|
|
33
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == 'all'
|
|
34
|
+
Requires-Dist: pytest>=7.4.0; extra == 'all'
|
|
35
|
+
Requires-Dist: ruff>=0.1.9; extra == 'all'
|
|
36
|
+
Requires-Dist: streamlit>=1.29.0; extra == 'all'
|
|
37
|
+
Requires-Dist: uvicorn[standard]>=0.25.0; extra == 'all'
|
|
38
|
+
Provides-Extra: api
|
|
39
|
+
Requires-Dist: fastapi>=0.108.0; extra == 'api'
|
|
40
|
+
Requires-Dist: uvicorn[standard]>=0.25.0; extra == 'api'
|
|
41
|
+
Provides-Extra: dev
|
|
42
|
+
Requires-Dist: hypothesis>=6.92.0; extra == 'dev'
|
|
43
|
+
Requires-Dist: mypy>=1.8.0; extra == 'dev'
|
|
44
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
|
|
45
|
+
Requires-Dist: pytest>=7.4.0; extra == 'dev'
|
|
46
|
+
Requires-Dist: ruff>=0.1.9; extra == 'dev'
|
|
47
|
+
Provides-Extra: docs
|
|
48
|
+
Requires-Dist: mkdocs-material>=9.5.0; extra == 'docs'
|
|
49
|
+
Requires-Dist: mkdocs>=1.5.0; extra == 'docs'
|
|
50
|
+
Requires-Dist: mkdocstrings[python]>=0.24.0; extra == 'docs'
|
|
51
|
+
Provides-Extra: streamlit
|
|
52
|
+
Requires-Dist: streamlit>=1.29.0; extra == 'streamlit'
|
|
53
|
+
Description-Content-Type: text/markdown
|
|
54
|
+
|
|
55
|
+
# Financial Health Calculator
|
|
56
|
+
|
|
57
|
+
A comprehensive Python financial planning toolkit with CEFR calculations, Monte Carlo simulations, and beautiful Plotly visualizations.
|
|
58
|
+
|
|
59
|
+
[](https://colab.research.google.com/github/engineerinvestor/financial-health-calculator/blob/main/examples/01_cefr_basics.ipynb)
|
|
60
|
+
|
|
61
|
+
## Features
|
|
62
|
+
|
|
63
|
+
- **CEFR (Certainty-Equivalent Funded Ratio)**: A fundedness metric that accounts for taxes, liquidity, and concentration risk
|
|
64
|
+
- **Monte Carlo Simulations**: Project retirement outcomes with configurable market assumptions
|
|
65
|
+
- **Withdrawal Strategy Lab**: Compare strategies including fixed SWR, guardrails, VPW, and RMD-style
|
|
66
|
+
- **Beautiful Visualizations**: Interactive Plotly charts with fan charts, waterfalls, and survival curves
|
|
67
|
+
- **REST API**: FastAPI backend for programmatic access
|
|
68
|
+
- **Streamlit App**: User-friendly web interface
|
|
69
|
+
|
|
70
|
+
## Quick Start
|
|
71
|
+
|
|
72
|
+
### Installation
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install git+https://github.com/engineerinvestor/financial-health-calculator.git
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
For development with all extras:
|
|
79
|
+
```bash
|
|
80
|
+
pip install "git+https://github.com/engineerinvestor/financial-health-calculator.git#egg=fundedness[all]"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Basic Usage
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from fundedness import Asset, BalanceSheet, Liability, compute_cefr
|
|
87
|
+
from fundedness.models.assets import AccountType, LiquidityClass, ConcentrationLevel
|
|
88
|
+
|
|
89
|
+
# Define your assets
|
|
90
|
+
assets = [
|
|
91
|
+
Asset(
|
|
92
|
+
name="401(k)",
|
|
93
|
+
value=500_000,
|
|
94
|
+
account_type=AccountType.TAX_DEFERRED,
|
|
95
|
+
liquidity_class=LiquidityClass.RETIREMENT,
|
|
96
|
+
concentration_level=ConcentrationLevel.DIVERSIFIED,
|
|
97
|
+
),
|
|
98
|
+
Asset(
|
|
99
|
+
name="Roth IRA",
|
|
100
|
+
value=200_000,
|
|
101
|
+
account_type=AccountType.TAX_EXEMPT,
|
|
102
|
+
liquidity_class=LiquidityClass.RETIREMENT,
|
|
103
|
+
concentration_level=ConcentrationLevel.DIVERSIFIED,
|
|
104
|
+
),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
# Define your spending
|
|
108
|
+
liabilities = [
|
|
109
|
+
Liability(name="Living Expenses", annual_amount=50_000, is_essential=True),
|
|
110
|
+
Liability(name="Travel", annual_amount=20_000, is_essential=False),
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
# Calculate CEFR
|
|
114
|
+
result = compute_cefr(
|
|
115
|
+
balance_sheet=BalanceSheet(assets=assets),
|
|
116
|
+
liabilities=liabilities,
|
|
117
|
+
planning_horizon=30,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
print(f"CEFR: {result.cefr:.2f}")
|
|
121
|
+
print(f"Funded: {result.is_funded}")
|
|
122
|
+
print(result.get_interpretation())
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Tutorials
|
|
126
|
+
|
|
127
|
+
- [CEFR Basics](https://colab.research.google.com/github/engineerinvestor/financial-health-calculator/blob/main/examples/01_cefr_basics.ipynb) - Introduction to the CEFR metric
|
|
128
|
+
- [Time Distribution Analysis](https://colab.research.google.com/github/engineerinvestor/financial-health-calculator/blob/main/examples/02_time_distribution.ipynb) - Monte Carlo simulations
|
|
129
|
+
- [Withdrawal Strategy Comparison](https://colab.research.google.com/github/engineerinvestor/financial-health-calculator/blob/main/examples/03_withdrawal_comparison.ipynb) - Compare different approaches
|
|
130
|
+
|
|
131
|
+
## Running the Apps
|
|
132
|
+
|
|
133
|
+
### Streamlit Web App
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
streamlit run streamlit_app/app.py
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### FastAPI REST API
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
uvicorn api.main:app --reload
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
API documentation available at `http://localhost:8000/docs`
|
|
146
|
+
|
|
147
|
+
## Key Concepts
|
|
148
|
+
|
|
149
|
+
### CEFR (Certainty-Equivalent Funded Ratio)
|
|
150
|
+
|
|
151
|
+
CEFR measures how well-funded your retirement is after accounting for:
|
|
152
|
+
|
|
153
|
+
- **Tax Haircuts**: What you'll owe when withdrawing from different account types
|
|
154
|
+
- **Liquidity Haircuts**: How easily you can access your assets
|
|
155
|
+
- **Reliability Haircuts**: Risk from concentrated positions
|
|
156
|
+
|
|
157
|
+
**Formula:**
|
|
158
|
+
```
|
|
159
|
+
CEFR = Σ(Asset × (1-τ) × λ × ρ) / PV(Liabilities)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Where τ = tax rate, λ = liquidity factor, ρ = reliability factor
|
|
163
|
+
|
|
164
|
+
**Interpretation:**
|
|
165
|
+
- CEFR ≥ 2.0: Excellent - Very well-funded
|
|
166
|
+
- CEFR 1.5-2.0: Strong - Well-funded with margin
|
|
167
|
+
- CEFR 1.0-1.5: Adequate - Fully funded
|
|
168
|
+
- CEFR < 1.0: Underfunded - Action needed
|
|
169
|
+
|
|
170
|
+
### Withdrawal Strategies
|
|
171
|
+
|
|
172
|
+
| Strategy | Description | Best For |
|
|
173
|
+
|----------|-------------|----------|
|
|
174
|
+
| Fixed SWR | 4% of initial portfolio, adjusted for inflation | Predictability |
|
|
175
|
+
| % of Portfolio | Fixed % of current value | Market adaptation |
|
|
176
|
+
| Guardrails | Adjustable with floor/ceiling | Balance |
|
|
177
|
+
| VPW | Age-based variable percentage | Maximizing spending |
|
|
178
|
+
| RMD-Style | IRS distribution table based | Tax efficiency |
|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
### Setup
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
git clone https://github.com/engineerinvestor/financial-health-calculator.git
|
|
186
|
+
cd financial-health-calculator
|
|
187
|
+
pip install -e ".[dev]"
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Running Tests
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
pytest
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Code Quality
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
ruff check .
|
|
200
|
+
mypy fundedness
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Project Structure
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
financial-health-calculator/
|
|
207
|
+
├── fundedness/ # Core Python package
|
|
208
|
+
│ ├── models/ # Pydantic data models
|
|
209
|
+
│ ├── viz/ # Plotly visualizations
|
|
210
|
+
│ ├── withdrawals/ # Withdrawal strategies
|
|
211
|
+
│ ├── allocation/ # Asset allocation strategies
|
|
212
|
+
│ ├── cefr.py # CEFR calculation
|
|
213
|
+
│ ├── simulate.py # Monte Carlo engine
|
|
214
|
+
│ └── policies.py # Spending/allocation policies
|
|
215
|
+
├── api/ # FastAPI REST API
|
|
216
|
+
├── streamlit_app/ # Streamlit web application
|
|
217
|
+
├── examples/ # Jupyter notebooks
|
|
218
|
+
└── tests/ # pytest tests
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Contact
|
|
222
|
+
|
|
223
|
+
- Twitter: [@egr_investor](https://x.com/egr_investor)
|
|
224
|
+
- GitHub: [engineerinvestor](https://github.com/engineerinvestor)
|
|
225
|
+
- Email: egr.investor@gmail.com
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT License
|
|
230
|
+
|
|
231
|
+
## Disclaimer
|
|
232
|
+
|
|
233
|
+
This tool is for educational purposes only and does not constitute financial advice. Consult a qualified financial advisor for personalized recommendations.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
fundedness/__init__.py,sha256=P8LNYSUUlk4-8Wa34FlhCyA_echWenZvg1hk7ccdK58,735
|
|
2
|
+
fundedness/cefr.py,sha256=hCImRrjz-DCPJsuF3HShDZK5tw1vslakh-DrNBHsh2k,7728
|
|
3
|
+
fundedness/liabilities.py,sha256=uKvEV6JQnQdrd7b6VsCc2qf9S2DJdK0XhEX9qwca2Ws,6662
|
|
4
|
+
fundedness/liquidity.py,sha256=ncZF70AfgoVoYZnozpA_CHemfaHmI4KKCe3vITp5ljM,1713
|
|
5
|
+
fundedness/policies.py,sha256=pyIYz86Z3sMYAW2rNhPLnXQ3PQCDCa-oxf9NhWyawy8,5854
|
|
6
|
+
fundedness/risk.py,sha256=Lpls6Q2wVq5SX4p9ZQwCtTM73AcqrGCm3hE2QcgKuq0,2750
|
|
7
|
+
fundedness/simulate.py,sha256=JOZeFx6oyOY2nyzejMgwPKnbNvzQZfYdslN-CwnxfwM,13769
|
|
8
|
+
fundedness/allocation/__init__.py,sha256=6cYoAK3xbC6WphbAQ33XtHILCOY59NfXebpDXNeND18,408
|
|
9
|
+
fundedness/allocation/base.py,sha256=kDypu2t86fsn2DyL5an1fPjbPbQWNmqydz3axWUgjLI,763
|
|
10
|
+
fundedness/allocation/constant.py,sha256=6QcUJ0aBQcJcRJtOMhs7uUu13wJtgcPU7ToQzQZhcgw,531
|
|
11
|
+
fundedness/allocation/glidepath.py,sha256=-q6Jb5WpRuZjJ68akT5WgeRIx3QSIDtlNih0_Iz-_cc,3440
|
|
12
|
+
fundedness/models/__init__.py,sha256=EyW6tGUI9T6QMbIfzsG3igqg5--DtZHSTAuMX6foOiA,874
|
|
13
|
+
fundedness/models/assets.py,sha256=ZKgxl4YaqdRVfDErL9weRhh4St_azo8ruN4g15T3IsQ,5013
|
|
14
|
+
fundedness/models/household.py,sha256=t64FjvO0uQJemddLtCPpK3NNYdZCXU6KjA1VZshaqEk,4671
|
|
15
|
+
fundedness/models/liabilities.py,sha256=iqGl5RU841k1clGOwnFParUvxzCJclq2JzNJoNPywuo,3416
|
|
16
|
+
fundedness/models/market.py,sha256=c3jskREsB7m_mjzuBwJ7oo6hlvLmR423t0JT8_a50eg,5551
|
|
17
|
+
fundedness/models/simulation.py,sha256=0jtIJWISDL-TlKVjr6KxcRfAj5Mrbp-VNyA4Bu7IUfU,2224
|
|
18
|
+
fundedness/models/tax.py,sha256=0XhoBNZqfRqF1_acakrkYgMS5L6Pknq-TloQQBrFw1U,3859
|
|
19
|
+
fundedness/models/utility.py,sha256=0AoJdccTb2hKBrTz5LExm8SrSsaWhhBf7y2NNaUzWQ0,4678
|
|
20
|
+
fundedness/viz/__init__.py,sha256=PRP4PtkBr8PlO4Rz3AUPjj6dKL6168_0ogM8qSqoi-Q,687
|
|
21
|
+
fundedness/viz/colors.py,sha256=kbYyCLvLARoIAMztHjnJZDxMGF9-an5z4obORa6JFEU,3204
|
|
22
|
+
fundedness/viz/comparison.py,sha256=DoQW6xr0r2ko55qyli4ue8ndj__opun97brbbHSnKVA,8530
|
|
23
|
+
fundedness/viz/fan_chart.py,sha256=GqdrOKdW12K7Y8sFQJ853QbYG3ASxvEzfY9cC-EKK70,6102
|
|
24
|
+
fundedness/viz/histogram.py,sha256=i56g330-S84WxUU8GGya40yNUQbnAI2D0vzUbFy51DI,6784
|
|
25
|
+
fundedness/viz/survival.py,sha256=xtaAHkJ3jlTUCYnLrTvwWlOlfLao6NC5cRxT5XKWrxM,6515
|
|
26
|
+
fundedness/viz/tornado.py,sha256=1dZzAfKTDCcyyfbDUBE39f44e6k48yviF8kTRee-pwQ,6684
|
|
27
|
+
fundedness/viz/waterfall.py,sha256=6LD0SbZDUzJrA0Zh1Y1qDx0I4BM5ZN_f2eVCXNK5HRc,5586
|
|
28
|
+
fundedness/withdrawals/__init__.py,sha256=v5wKqet3lNMr9UDMZC4d3UPOrRPbzQ6VvdP-ADAiBrg,646
|
|
29
|
+
fundedness/withdrawals/base.py,sha256=WXBuo-tJtp4V2Ottp2PC2YtyK3mGVJ9Cgq21zGUWObg,3472
|
|
30
|
+
fundedness/withdrawals/comparison.py,sha256=eiobGMHmeQpGnr0_HcY_EKrv5_Nu7EaVf5Y9DdDNAyg,7833
|
|
31
|
+
fundedness/withdrawals/fixed_swr.py,sha256=jwbVZHJuVzaSfDCpo-Zp6wdJmI-RzHvg46s1PFxhfA4,3639
|
|
32
|
+
fundedness/withdrawals/guardrails.py,sha256=rZKVojKbl9LLJLi9LlK1I2JXb537MN_AbZfn4hIU8is,5468
|
|
33
|
+
fundedness/withdrawals/rmd_style.py,sha256=E5FfrUYxFJwULKxzxbtZFLoLspLzUUomTQRUWLGLDpg,6373
|
|
34
|
+
fundedness/withdrawals/vpw.py,sha256=aajHLAkHfsxh33uGRgzv0ozVuZREG-EmARJgWOU6mis,4389
|
|
35
|
+
fundedness-0.1.0.dist-info/METADATA,sha256=MLUiTj3UTO7RUaUhgbHk3ObQb173X44gpP6N9iK8tyM,8092
|
|
36
|
+
fundedness-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
37
|
+
fundedness-0.1.0.dist-info/entry_points.txt,sha256=Oh-Hg08i044YHuSHViCdfoD8CenIGcKrVuUVysvN9sY,51
|
|
38
|
+
fundedness-0.1.0.dist-info/RECORD,,
|