fundedness 0.2.2__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.

Files changed (43) hide show
  1. fundedness/__init__.py +71 -0
  2. fundedness/allocation/__init__.py +20 -0
  3. fundedness/allocation/base.py +32 -0
  4. fundedness/allocation/constant.py +25 -0
  5. fundedness/allocation/glidepath.py +111 -0
  6. fundedness/allocation/merton_optimal.py +220 -0
  7. fundedness/cefr.py +241 -0
  8. fundedness/liabilities.py +221 -0
  9. fundedness/liquidity.py +49 -0
  10. fundedness/merton.py +289 -0
  11. fundedness/models/__init__.py +35 -0
  12. fundedness/models/assets.py +148 -0
  13. fundedness/models/household.py +153 -0
  14. fundedness/models/liabilities.py +99 -0
  15. fundedness/models/market.py +188 -0
  16. fundedness/models/simulation.py +80 -0
  17. fundedness/models/tax.py +125 -0
  18. fundedness/models/utility.py +154 -0
  19. fundedness/optimize.py +473 -0
  20. fundedness/policies.py +204 -0
  21. fundedness/risk.py +72 -0
  22. fundedness/simulate.py +559 -0
  23. fundedness/viz/__init__.py +33 -0
  24. fundedness/viz/colors.py +110 -0
  25. fundedness/viz/comparison.py +294 -0
  26. fundedness/viz/fan_chart.py +193 -0
  27. fundedness/viz/histogram.py +225 -0
  28. fundedness/viz/optimal.py +542 -0
  29. fundedness/viz/survival.py +230 -0
  30. fundedness/viz/tornado.py +236 -0
  31. fundedness/viz/waterfall.py +203 -0
  32. fundedness/withdrawals/__init__.py +27 -0
  33. fundedness/withdrawals/base.py +116 -0
  34. fundedness/withdrawals/comparison.py +230 -0
  35. fundedness/withdrawals/fixed_swr.py +174 -0
  36. fundedness/withdrawals/guardrails.py +136 -0
  37. fundedness/withdrawals/merton_optimal.py +286 -0
  38. fundedness/withdrawals/rmd_style.py +203 -0
  39. fundedness/withdrawals/vpw.py +136 -0
  40. fundedness-0.2.2.dist-info/METADATA +299 -0
  41. fundedness-0.2.2.dist-info/RECORD +43 -0
  42. fundedness-0.2.2.dist-info/WHEEL +4 -0
  43. fundedness-0.2.2.dist-info/entry_points.txt +2 -0
fundedness/__init__.py ADDED
@@ -0,0 +1,71 @@
1
+ """Fundedness: A Python financial planning toolkit.
2
+
3
+ This package provides tools for:
4
+ - CEFR (Certainty-Equivalent Funded Ratio) calculations
5
+ - Monte Carlo retirement simulations
6
+ - Withdrawal strategy comparison
7
+ - Utility-optimal spending and allocation (Merton framework)
8
+ - Beautiful Plotly visualizations
9
+ """
10
+
11
+ __version__ = "0.2.2"
12
+
13
+ from fundedness.cefr import CEFRResult, compute_cefr
14
+ from fundedness.merton import (
15
+ MertonOptimalResult,
16
+ calculate_merton_optimal,
17
+ certainty_equivalent_return,
18
+ merton_optimal_allocation,
19
+ merton_optimal_spending_rate,
20
+ optimal_allocation_by_wealth,
21
+ optimal_spending_by_age,
22
+ wealth_adjusted_optimal_allocation,
23
+ )
24
+ from fundedness.models import (
25
+ Asset,
26
+ BalanceSheet,
27
+ Household,
28
+ Liability,
29
+ MarketModel,
30
+ Person,
31
+ SimulationConfig,
32
+ TaxModel,
33
+ UtilityModel,
34
+ )
35
+ from fundedness.simulate import (
36
+ SimulationResult,
37
+ run_simulation,
38
+ run_simulation_with_policy,
39
+ run_simulation_with_utility,
40
+ )
41
+
42
+ __all__ = [
43
+ "__version__",
44
+ # CEFR
45
+ "compute_cefr",
46
+ "CEFRResult",
47
+ # Merton optimal
48
+ "calculate_merton_optimal",
49
+ "certainty_equivalent_return",
50
+ "merton_optimal_allocation",
51
+ "merton_optimal_spending_rate",
52
+ "MertonOptimalResult",
53
+ "optimal_allocation_by_wealth",
54
+ "optimal_spending_by_age",
55
+ "wealth_adjusted_optimal_allocation",
56
+ # Simulation
57
+ "run_simulation",
58
+ "run_simulation_with_policy",
59
+ "run_simulation_with_utility",
60
+ "SimulationResult",
61
+ # Models
62
+ "Asset",
63
+ "BalanceSheet",
64
+ "Household",
65
+ "Liability",
66
+ "MarketModel",
67
+ "Person",
68
+ "SimulationConfig",
69
+ "TaxModel",
70
+ "UtilityModel",
71
+ ]
@@ -0,0 +1,20 @@
1
+ """Asset allocation strategy implementations."""
2
+
3
+ from fundedness.allocation.base import AllocationPolicy
4
+ from fundedness.allocation.constant import ConstantAllocationPolicy
5
+ from fundedness.allocation.glidepath import AgeBasedGlidepathPolicy, RisingEquityGlidepathPolicy
6
+ from fundedness.allocation.merton_optimal import (
7
+ FloorProtectionAllocationPolicy,
8
+ MertonOptimalAllocationPolicy,
9
+ WealthBasedAllocationPolicy,
10
+ )
11
+
12
+ __all__ = [
13
+ "AllocationPolicy",
14
+ "AgeBasedGlidepathPolicy",
15
+ "ConstantAllocationPolicy",
16
+ "FloorProtectionAllocationPolicy",
17
+ "MertonOptimalAllocationPolicy",
18
+ "RisingEquityGlidepathPolicy",
19
+ "WealthBasedAllocationPolicy",
20
+ ]
@@ -0,0 +1,32 @@
1
+ """Base class for allocation policies."""
2
+
3
+ from typing import Protocol
4
+
5
+ import numpy as np
6
+
7
+
8
+ class AllocationPolicy(Protocol):
9
+ """Protocol defining the interface for allocation strategies."""
10
+
11
+ @property
12
+ def name(self) -> str:
13
+ """Human-readable name for the strategy."""
14
+ ...
15
+
16
+ def get_allocation(
17
+ self,
18
+ wealth: float | np.ndarray,
19
+ year: int,
20
+ initial_wealth: float,
21
+ ) -> float | np.ndarray:
22
+ """Get the stock allocation for the given context.
23
+
24
+ Args:
25
+ wealth: Current portfolio value(s)
26
+ year: Current year in simulation
27
+ initial_wealth: Starting portfolio value
28
+
29
+ Returns:
30
+ Stock allocation as decimal (0-1)
31
+ """
32
+ ...
@@ -0,0 +1,25 @@
1
+ """Constant allocation policy."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+
8
+ @dataclass
9
+ class ConstantAllocationPolicy:
10
+ """Maintain a constant stock/bond allocation."""
11
+
12
+ stock_weight: float = 0.6
13
+
14
+ @property
15
+ def name(self) -> str:
16
+ return f"{self.stock_weight:.0%} Stocks"
17
+
18
+ def get_allocation(
19
+ self,
20
+ wealth: float | np.ndarray,
21
+ year: int,
22
+ initial_wealth: float,
23
+ ) -> float:
24
+ """Return constant stock allocation."""
25
+ return self.stock_weight
@@ -0,0 +1,111 @@
1
+ """Glidepath allocation policies."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+
8
+ @dataclass
9
+ class AgeBasedGlidepathPolicy:
10
+ """Traditional declining equity glidepath based on age.
11
+
12
+ Classic approach: reduce equity allocation as you age to reduce
13
+ sequence-of-returns risk.
14
+ """
15
+
16
+ initial_stock_weight: float = 0.7
17
+ final_stock_weight: float = 0.3
18
+ years_to_final: int = 30
19
+
20
+ @property
21
+ def name(self) -> str:
22
+ return f"Glidepath ({self.initial_stock_weight:.0%} → {self.final_stock_weight:.0%})"
23
+
24
+ def get_allocation(
25
+ self,
26
+ wealth: float | np.ndarray,
27
+ year: int,
28
+ initial_wealth: float,
29
+ ) -> float:
30
+ """Calculate stock allocation based on years into retirement."""
31
+ progress = min(year / self.years_to_final, 1.0)
32
+ stock_weight = self.initial_stock_weight - progress * (
33
+ self.initial_stock_weight - self.final_stock_weight
34
+ )
35
+ return stock_weight
36
+
37
+
38
+ @dataclass
39
+ class RisingEquityGlidepathPolicy:
40
+ """Rising equity glidepath (bonds-first approach).
41
+
42
+ Start conservative, increase equity over time as sequence-of-returns
43
+ risk decreases and remaining lifespan shortens.
44
+
45
+ Based on research by Wade Pfau and Michael Kitces showing that
46
+ rising equity glidepaths can improve outcomes in some scenarios.
47
+ """
48
+
49
+ initial_stock_weight: float = 0.3
50
+ final_stock_weight: float = 0.7
51
+ years_to_final: int = 20
52
+
53
+ @property
54
+ def name(self) -> str:
55
+ return f"Rising Equity ({self.initial_stock_weight:.0%} → {self.final_stock_weight:.0%})"
56
+
57
+ def get_allocation(
58
+ self,
59
+ wealth: float | np.ndarray,
60
+ year: int,
61
+ initial_wealth: float,
62
+ ) -> float:
63
+ """Calculate stock allocation - increasing over time."""
64
+ progress = min(year / self.years_to_final, 1.0)
65
+ stock_weight = self.initial_stock_weight + progress * (
66
+ self.final_stock_weight - self.initial_stock_weight
67
+ )
68
+ return stock_weight
69
+
70
+
71
+ @dataclass
72
+ class VShapedGlidepathPolicy:
73
+ """V-shaped glidepath: reduce then increase equity.
74
+
75
+ Start moderate, reduce equity in early retirement (highest sequence risk),
76
+ then increase equity as the portfolio stabilizes and remaining horizon shortens.
77
+ """
78
+
79
+ initial_stock_weight: float = 0.5
80
+ minimum_stock_weight: float = 0.3
81
+ final_stock_weight: float = 0.6
82
+ years_to_minimum: int = 10
83
+ years_to_final: int = 30
84
+
85
+ @property
86
+ def name(self) -> str:
87
+ return "V-Shaped Glidepath"
88
+
89
+ def get_allocation(
90
+ self,
91
+ wealth: float | np.ndarray,
92
+ year: int,
93
+ initial_wealth: float,
94
+ ) -> float:
95
+ """Calculate V-shaped stock allocation."""
96
+ if year <= self.years_to_minimum:
97
+ # Declining phase
98
+ progress = year / self.years_to_minimum
99
+ stock_weight = self.initial_stock_weight - progress * (
100
+ self.initial_stock_weight - self.minimum_stock_weight
101
+ )
102
+ else:
103
+ # Rising phase
104
+ years_in_rising = year - self.years_to_minimum
105
+ years_remaining = self.years_to_final - self.years_to_minimum
106
+ progress = min(years_in_rising / years_remaining, 1.0)
107
+ stock_weight = self.minimum_stock_weight + progress * (
108
+ self.final_stock_weight - self.minimum_stock_weight
109
+ )
110
+
111
+ return stock_weight
@@ -0,0 +1,220 @@
1
+ """Merton optimal allocation policy based on utility maximization."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ import numpy as np
6
+
7
+ from fundedness.merton import (
8
+ merton_optimal_allocation,
9
+ wealth_adjusted_optimal_allocation,
10
+ )
11
+ from fundedness.models.market import MarketModel
12
+ from fundedness.models.utility import UtilityModel
13
+
14
+
15
+ @dataclass
16
+ class MertonOptimalAllocationPolicy:
17
+ """Allocation policy based on Merton optimal portfolio theory.
18
+
19
+ This policy determines equity allocation using the Merton formula,
20
+ with adjustments for wealth level relative to subsistence floor.
21
+
22
+ Key characteristics:
23
+ - Base allocation from Merton: k* = (mu - r) / (gamma * sigma^2)
24
+ - Allocation decreases as wealth approaches subsistence floor
25
+ - Configurable bounds to prevent extreme positions
26
+
27
+ Attributes:
28
+ market_model: Market return and risk assumptions
29
+ utility_model: Utility parameters including risk aversion
30
+ min_equity: Minimum equity allocation
31
+ max_equity: Maximum equity allocation
32
+ use_wealth_adjustment: Whether to reduce allocation near floor
33
+ """
34
+
35
+ market_model: MarketModel = field(default_factory=MarketModel)
36
+ utility_model: UtilityModel = field(default_factory=UtilityModel)
37
+ min_equity: float = 0.0
38
+ max_equity: float = 1.0
39
+ use_wealth_adjustment: bool = True
40
+
41
+ @property
42
+ def name(self) -> str:
43
+ k_star = merton_optimal_allocation(self.market_model, self.utility_model)
44
+ return f"Merton Optimal ({k_star:.0%})"
45
+
46
+ def get_unconstrained_allocation(self) -> float:
47
+ """Get the unconstrained Merton optimal allocation.
48
+
49
+ Returns:
50
+ Optimal equity allocation (may exceed bounds)
51
+ """
52
+ return merton_optimal_allocation(self.market_model, self.utility_model)
53
+
54
+ def get_allocation(
55
+ self,
56
+ wealth: float | np.ndarray,
57
+ year: int,
58
+ initial_wealth: float,
59
+ ) -> float | np.ndarray:
60
+ """Get the optimal stock allocation.
61
+
62
+ Args:
63
+ wealth: Current portfolio value(s)
64
+ year: Current year in simulation (not used but required by interface)
65
+ initial_wealth: Starting portfolio value (not used but required)
66
+
67
+ Returns:
68
+ Stock allocation as decimal (0-1), scalar or array matching wealth
69
+ """
70
+ if not self.use_wealth_adjustment:
71
+ # Use fixed Merton optimal allocation
72
+ k_star = merton_optimal_allocation(self.market_model, self.utility_model)
73
+ return np.clip(k_star, self.min_equity, self.max_equity)
74
+
75
+ # Apply wealth-adjusted allocation
76
+ if isinstance(wealth, np.ndarray):
77
+ allocations = np.zeros_like(wealth, dtype=float)
78
+ for i, w in enumerate(wealth):
79
+ allocations[i] = wealth_adjusted_optimal_allocation(
80
+ wealth=w,
81
+ market_model=self.market_model,
82
+ utility_model=self.utility_model,
83
+ min_allocation=self.min_equity,
84
+ max_allocation=self.max_equity,
85
+ )
86
+ return allocations
87
+ else:
88
+ return wealth_adjusted_optimal_allocation(
89
+ wealth=wealth,
90
+ market_model=self.market_model,
91
+ utility_model=self.utility_model,
92
+ min_allocation=self.min_equity,
93
+ max_allocation=self.max_equity,
94
+ )
95
+
96
+
97
+ @dataclass
98
+ class WealthBasedAllocationPolicy:
99
+ """Allocation that varies with wealth relative to floor.
100
+
101
+ This is a simplified version that linearly interpolates between
102
+ a minimum allocation at the floor and maximum at a target wealth.
103
+
104
+ More intuitive than full Merton but captures the key insight that
105
+ risk capacity depends on distance from subsistence.
106
+
107
+ Attributes:
108
+ floor_wealth: Wealth level at which equity is at minimum
109
+ target_wealth: Wealth level at which equity reaches maximum
110
+ min_equity: Equity allocation at floor
111
+ max_equity: Equity allocation at target and above
112
+ """
113
+
114
+ floor_wealth: float = 500_000
115
+ target_wealth: float = 2_000_000
116
+ min_equity: float = 0.2
117
+ max_equity: float = 0.8
118
+
119
+ @property
120
+ def name(self) -> str:
121
+ return f"Wealth-Based ({self.min_equity:.0%}-{self.max_equity:.0%})"
122
+
123
+ def get_allocation(
124
+ self,
125
+ wealth: float | np.ndarray,
126
+ year: int,
127
+ initial_wealth: float,
128
+ ) -> float | np.ndarray:
129
+ """Get allocation based on current wealth level.
130
+
131
+ Args:
132
+ wealth: Current portfolio value(s)
133
+ year: Current year (not used)
134
+ initial_wealth: Starting value (not used)
135
+
136
+ Returns:
137
+ Stock allocation interpolated by wealth
138
+ """
139
+ # Linear interpolation between floor and target
140
+ wealth_range = self.target_wealth - self.floor_wealth
141
+ equity_range = self.max_equity - self.min_equity
142
+
143
+ if isinstance(wealth, np.ndarray):
144
+ progress = (wealth - self.floor_wealth) / wealth_range
145
+ progress = np.clip(progress, 0, 1)
146
+ return self.min_equity + progress * equity_range
147
+ else:
148
+ progress = (wealth - self.floor_wealth) / wealth_range
149
+ progress = max(0, min(1, progress))
150
+ return self.min_equity + progress * equity_range
151
+
152
+
153
+ @dataclass
154
+ class FloorProtectionAllocationPolicy:
155
+ """Allocation that increases equity as wealth grows above floor.
156
+
157
+ Inspired by CPPI (Constant Proportion Portfolio Insurance), this
158
+ policy allocates equity as a multiple of the "cushion" (wealth above
159
+ the floor-protection level).
160
+
161
+ Attributes:
162
+ utility_model: For subsistence floor value
163
+ multiplier: Equity = multiplier * (wealth - floor_reserve) / wealth
164
+ floor_years: Years of floor spending to protect
165
+ min_equity: Minimum equity allocation
166
+ max_equity: Maximum equity allocation
167
+ """
168
+
169
+ utility_model: UtilityModel = field(default_factory=UtilityModel)
170
+ multiplier: float = 3.0
171
+ floor_years: int = 10
172
+ min_equity: float = 0.1
173
+ max_equity: float = 0.9
174
+
175
+ @property
176
+ def name(self) -> str:
177
+ return f"Floor Protection (m={self.multiplier})"
178
+
179
+ def get_floor_reserve(self) -> float:
180
+ """Get the wealth level that protects floor spending.
181
+
182
+ Returns:
183
+ Wealth needed to fund floor spending for floor_years
184
+ """
185
+ return self.utility_model.subsistence_floor * self.floor_years
186
+
187
+ def get_allocation(
188
+ self,
189
+ wealth: float | np.ndarray,
190
+ year: int,
191
+ initial_wealth: float,
192
+ ) -> float | np.ndarray:
193
+ """Get allocation based on cushion above floor reserve.
194
+
195
+ Args:
196
+ wealth: Current portfolio value(s)
197
+ year: Current year (not used)
198
+ initial_wealth: Starting value (not used)
199
+
200
+ Returns:
201
+ Stock allocation based on cushion
202
+ """
203
+ floor_reserve = self.get_floor_reserve()
204
+
205
+ if isinstance(wealth, np.ndarray):
206
+ cushion = np.maximum(wealth - floor_reserve, 0)
207
+ # Equity = multiplier * cushion / wealth
208
+ # But avoid division by zero
209
+ allocation = np.where(
210
+ wealth > 0,
211
+ self.multiplier * cushion / wealth,
212
+ 0.0,
213
+ )
214
+ return np.clip(allocation, self.min_equity, self.max_equity)
215
+ else:
216
+ if wealth <= 0:
217
+ return self.min_equity
218
+ cushion = max(wealth - floor_reserve, 0)
219
+ allocation = self.multiplier * cushion / wealth
220
+ return max(self.min_equity, min(self.max_equity, allocation))