fundedness 0.2.4__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.
- fundedness/__init__.py +71 -0
- fundedness/allocation/__init__.py +20 -0
- fundedness/allocation/base.py +32 -0
- fundedness/allocation/constant.py +25 -0
- fundedness/allocation/glidepath.py +111 -0
- fundedness/allocation/merton_optimal.py +220 -0
- fundedness/cefr.py +241 -0
- fundedness/liabilities.py +221 -0
- fundedness/liquidity.py +49 -0
- fundedness/merton.py +289 -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 +199 -0
- fundedness/models/simulation.py +80 -0
- fundedness/models/tax.py +125 -0
- fundedness/models/utility.py +154 -0
- fundedness/optimize.py +473 -0
- fundedness/policies.py +204 -0
- fundedness/risk.py +72 -0
- fundedness/simulate.py +595 -0
- fundedness/viz/__init__.py +33 -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/optimal.py +542 -0
- fundedness/viz/survival.py +230 -0
- fundedness/viz/tornado.py +236 -0
- fundedness/viz/waterfall.py +203 -0
- fundedness/withdrawals/__init__.py +27 -0
- fundedness/withdrawals/base.py +116 -0
- fundedness/withdrawals/comparison.py +230 -0
- fundedness/withdrawals/fixed_swr.py +174 -0
- fundedness/withdrawals/guardrails.py +136 -0
- fundedness/withdrawals/merton_optimal.py +286 -0
- fundedness/withdrawals/rmd_style.py +203 -0
- fundedness/withdrawals/vpw.py +136 -0
- fundedness-0.2.4.dist-info/METADATA +300 -0
- fundedness-0.2.4.dist-info/RECORD +43 -0
- fundedness-0.2.4.dist-info/WHEEL +4 -0
- fundedness-0.2.4.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))
|