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,80 @@
|
|
|
1
|
+
"""Simulation configuration model."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from fundedness.models.market import MarketModel
|
|
8
|
+
from fundedness.models.tax import TaxModel
|
|
9
|
+
from fundedness.models.utility import UtilityModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SimulationConfig(BaseModel):
|
|
13
|
+
"""Configuration for Monte Carlo simulations."""
|
|
14
|
+
|
|
15
|
+
# Simulation parameters
|
|
16
|
+
n_simulations: int = Field(
|
|
17
|
+
default=10000,
|
|
18
|
+
ge=100,
|
|
19
|
+
le=100000,
|
|
20
|
+
description="Number of Monte Carlo paths",
|
|
21
|
+
)
|
|
22
|
+
n_years: int = Field(
|
|
23
|
+
default=50,
|
|
24
|
+
ge=1,
|
|
25
|
+
le=100,
|
|
26
|
+
description="Simulation horizon in years",
|
|
27
|
+
)
|
|
28
|
+
random_seed: int | None = Field(
|
|
29
|
+
default=None,
|
|
30
|
+
description="Random seed for reproducibility (None = random)",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Model components
|
|
34
|
+
market_model: MarketModel = Field(
|
|
35
|
+
default_factory=MarketModel,
|
|
36
|
+
description="Market return and risk assumptions",
|
|
37
|
+
)
|
|
38
|
+
tax_model: TaxModel = Field(
|
|
39
|
+
default_factory=TaxModel,
|
|
40
|
+
description="Tax rate assumptions",
|
|
41
|
+
)
|
|
42
|
+
utility_model: UtilityModel = Field(
|
|
43
|
+
default_factory=UtilityModel,
|
|
44
|
+
description="Utility function parameters",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Return generation
|
|
48
|
+
return_model: Literal["lognormal", "t_distribution", "bootstrap"] = Field(
|
|
49
|
+
default="lognormal",
|
|
50
|
+
description="Model for generating returns",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Output options
|
|
54
|
+
percentiles: list[int] = Field(
|
|
55
|
+
default=[10, 25, 50, 75, 90],
|
|
56
|
+
description="Percentiles to report (0-100)",
|
|
57
|
+
)
|
|
58
|
+
track_spending: bool = Field(
|
|
59
|
+
default=True,
|
|
60
|
+
description="Track spending paths in simulation",
|
|
61
|
+
)
|
|
62
|
+
track_allocation: bool = Field(
|
|
63
|
+
default=False,
|
|
64
|
+
description="Track allocation changes over time",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Performance
|
|
68
|
+
use_vectorized: bool = Field(
|
|
69
|
+
default=True,
|
|
70
|
+
description="Use vectorized numpy operations for speed",
|
|
71
|
+
)
|
|
72
|
+
chunk_size: int = Field(
|
|
73
|
+
default=1000,
|
|
74
|
+
ge=100,
|
|
75
|
+
description="Chunk size for memory-efficient simulation",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def get_percentile_labels(self) -> list[str]:
|
|
79
|
+
"""Get formatted percentile labels."""
|
|
80
|
+
return [f"P{p}" for p in self.percentiles]
|
fundedness/models/tax.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Tax model for after-tax calculations."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from fundedness.models.assets import AccountType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TaxModel(BaseModel):
|
|
9
|
+
"""Tax rates and assumptions."""
|
|
10
|
+
|
|
11
|
+
# Federal marginal rates
|
|
12
|
+
federal_ordinary_rate: float = Field(
|
|
13
|
+
default=0.24,
|
|
14
|
+
ge=0,
|
|
15
|
+
le=1,
|
|
16
|
+
description="Federal marginal tax rate on ordinary income",
|
|
17
|
+
)
|
|
18
|
+
federal_ltcg_rate: float = Field(
|
|
19
|
+
default=0.15,
|
|
20
|
+
ge=0,
|
|
21
|
+
le=1,
|
|
22
|
+
description="Federal long-term capital gains rate",
|
|
23
|
+
)
|
|
24
|
+
federal_stcg_rate: float = Field(
|
|
25
|
+
default=0.24,
|
|
26
|
+
ge=0,
|
|
27
|
+
le=1,
|
|
28
|
+
description="Federal short-term capital gains rate (usually = ordinary)",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# State rates
|
|
32
|
+
state_ordinary_rate: float = Field(
|
|
33
|
+
default=0.093,
|
|
34
|
+
ge=0,
|
|
35
|
+
le=1,
|
|
36
|
+
description="State marginal tax rate on ordinary income",
|
|
37
|
+
)
|
|
38
|
+
state_ltcg_rate: float = Field(
|
|
39
|
+
default=0.093,
|
|
40
|
+
ge=0,
|
|
41
|
+
le=1,
|
|
42
|
+
description="State long-term capital gains rate",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Other
|
|
46
|
+
niit_rate: float = Field(
|
|
47
|
+
default=0.038,
|
|
48
|
+
ge=0,
|
|
49
|
+
le=1,
|
|
50
|
+
description="Net Investment Income Tax rate (3.8%)",
|
|
51
|
+
)
|
|
52
|
+
niit_applies: bool = Field(
|
|
53
|
+
default=True,
|
|
54
|
+
description="Whether NIIT applies to this household",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Cost basis assumptions
|
|
58
|
+
default_cost_basis_ratio: float = Field(
|
|
59
|
+
default=0.5,
|
|
60
|
+
ge=0,
|
|
61
|
+
le=1,
|
|
62
|
+
description="Default cost basis as fraction of value (for unrealized gains)",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def total_ordinary_rate(self) -> float:
|
|
67
|
+
"""Combined federal + state ordinary income tax rate."""
|
|
68
|
+
return self.federal_ordinary_rate + self.state_ordinary_rate
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def total_ltcg_rate(self) -> float:
|
|
72
|
+
"""Combined federal + state + NIIT long-term capital gains rate."""
|
|
73
|
+
base = self.federal_ltcg_rate + self.state_ltcg_rate
|
|
74
|
+
if self.niit_applies:
|
|
75
|
+
base += self.niit_rate
|
|
76
|
+
return base
|
|
77
|
+
|
|
78
|
+
def get_effective_tax_rate(
|
|
79
|
+
self,
|
|
80
|
+
account_type: AccountType,
|
|
81
|
+
cost_basis_ratio: float | None = None,
|
|
82
|
+
) -> float:
|
|
83
|
+
"""Get the effective tax rate for withdrawals from an account type.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
account_type: Type of account
|
|
87
|
+
cost_basis_ratio: Cost basis as fraction of value (for taxable accounts)
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Effective tax rate as decimal (0-1)
|
|
91
|
+
"""
|
|
92
|
+
match account_type:
|
|
93
|
+
case AccountType.TAX_EXEMPT:
|
|
94
|
+
# Roth: no tax on withdrawals
|
|
95
|
+
return 0.0
|
|
96
|
+
|
|
97
|
+
case AccountType.TAX_DEFERRED:
|
|
98
|
+
# Traditional IRA/401k: taxed as ordinary income
|
|
99
|
+
return self.total_ordinary_rate
|
|
100
|
+
|
|
101
|
+
case AccountType.HSA:
|
|
102
|
+
# HSA: no tax if used for medical expenses
|
|
103
|
+
return 0.0
|
|
104
|
+
|
|
105
|
+
case AccountType.TAXABLE:
|
|
106
|
+
# Taxable: only gains are taxed
|
|
107
|
+
if cost_basis_ratio is None:
|
|
108
|
+
cost_basis_ratio = self.default_cost_basis_ratio
|
|
109
|
+
|
|
110
|
+
# Gains portion = (1 - cost_basis_ratio)
|
|
111
|
+
gains_portion = 1 - cost_basis_ratio
|
|
112
|
+
return gains_portion * self.total_ltcg_rate
|
|
113
|
+
|
|
114
|
+
def get_haircut_by_account_type(self) -> dict[AccountType, float]:
|
|
115
|
+
"""Get tax haircut factors by account type.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Dictionary mapping account type to (1 - tax_rate)
|
|
119
|
+
"""
|
|
120
|
+
return {
|
|
121
|
+
AccountType.TAXABLE: 1 - self.get_effective_tax_rate(AccountType.TAXABLE),
|
|
122
|
+
AccountType.TAX_DEFERRED: 1 - self.get_effective_tax_rate(AccountType.TAX_DEFERRED),
|
|
123
|
+
AccountType.TAX_EXEMPT: 1 - self.get_effective_tax_rate(AccountType.TAX_EXEMPT),
|
|
124
|
+
AccountType.HSA: 1 - self.get_effective_tax_rate(AccountType.HSA),
|
|
125
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Utility model for lifetime utility optimization."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class UtilityModel(BaseModel):
|
|
8
|
+
"""CRRA utility model with subsistence floor."""
|
|
9
|
+
|
|
10
|
+
gamma: float = Field(
|
|
11
|
+
default=3.0,
|
|
12
|
+
gt=0,
|
|
13
|
+
description="Coefficient of relative risk aversion (CRRA parameter)",
|
|
14
|
+
)
|
|
15
|
+
subsistence_floor: float = Field(
|
|
16
|
+
default=30000,
|
|
17
|
+
ge=0,
|
|
18
|
+
description="Minimum annual spending floor in dollars",
|
|
19
|
+
)
|
|
20
|
+
bequest_weight: float = Field(
|
|
21
|
+
default=0.0,
|
|
22
|
+
ge=0,
|
|
23
|
+
le=1,
|
|
24
|
+
description="Weight on bequest utility (0 = no bequest motive)",
|
|
25
|
+
)
|
|
26
|
+
time_preference: float = Field(
|
|
27
|
+
default=0.02,
|
|
28
|
+
ge=0,
|
|
29
|
+
description="Pure rate of time preference (discount rate for utility)",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def utility(self, consumption: float) -> float:
|
|
33
|
+
"""Calculate CRRA utility of consumption.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
consumption: Annual consumption in dollars
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Utility value (can be negative)
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ValueError: If consumption is below subsistence floor
|
|
43
|
+
"""
|
|
44
|
+
excess = consumption - self.subsistence_floor
|
|
45
|
+
|
|
46
|
+
if excess <= 0:
|
|
47
|
+
# Below floor: large negative utility
|
|
48
|
+
return -1e10
|
|
49
|
+
|
|
50
|
+
if self.gamma == 1.0:
|
|
51
|
+
# Log utility special case
|
|
52
|
+
return np.log(excess)
|
|
53
|
+
|
|
54
|
+
return (excess ** (1 - self.gamma)) / (1 - self.gamma)
|
|
55
|
+
|
|
56
|
+
def marginal_utility(self, consumption: float) -> float:
|
|
57
|
+
"""Calculate marginal utility of consumption.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
consumption: Annual consumption in dollars
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Marginal utility value
|
|
64
|
+
"""
|
|
65
|
+
excess = consumption - self.subsistence_floor
|
|
66
|
+
|
|
67
|
+
if excess <= 0:
|
|
68
|
+
return 1e10 # Very high marginal utility when below floor
|
|
69
|
+
|
|
70
|
+
return excess ** (-self.gamma)
|
|
71
|
+
|
|
72
|
+
def inverse_marginal_utility(self, mu: float) -> float:
|
|
73
|
+
"""Calculate consumption from marginal utility (for optimization).
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
mu: Marginal utility value
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Consumption that produces this marginal utility
|
|
80
|
+
"""
|
|
81
|
+
excess = mu ** (-1 / self.gamma)
|
|
82
|
+
return excess + self.subsistence_floor
|
|
83
|
+
|
|
84
|
+
def certainty_equivalent(
|
|
85
|
+
self,
|
|
86
|
+
consumption_samples: np.ndarray,
|
|
87
|
+
) -> float:
|
|
88
|
+
"""Calculate certainty equivalent consumption.
|
|
89
|
+
|
|
90
|
+
The certainty equivalent is the guaranteed consumption that
|
|
91
|
+
provides the same expected utility as the risky consumption stream.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
consumption_samples: Array of consumption outcomes
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Certainty equivalent consumption value
|
|
98
|
+
"""
|
|
99
|
+
# Calculate expected utility
|
|
100
|
+
utilities = np.array([self.utility(c) for c in consumption_samples])
|
|
101
|
+
expected_utility = np.mean(utilities)
|
|
102
|
+
|
|
103
|
+
# Invert to find certainty equivalent
|
|
104
|
+
if self.gamma == 1.0:
|
|
105
|
+
return np.exp(expected_utility) + self.subsistence_floor
|
|
106
|
+
|
|
107
|
+
# For CRRA: CE = (EU * (1-gamma))^(1/(1-gamma)) + floor
|
|
108
|
+
ce_excess = (expected_utility * (1 - self.gamma)) ** (1 / (1 - self.gamma))
|
|
109
|
+
return ce_excess + self.subsistence_floor
|
|
110
|
+
|
|
111
|
+
def lifetime_utility(
|
|
112
|
+
self,
|
|
113
|
+
consumption_path: np.ndarray,
|
|
114
|
+
survival_probabilities: np.ndarray | None = None,
|
|
115
|
+
) -> float:
|
|
116
|
+
"""Calculate discounted lifetime utility.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
consumption_path: Array of annual consumption values
|
|
120
|
+
survival_probabilities: Probability of being alive at each year (optional)
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Discounted expected lifetime utility
|
|
124
|
+
"""
|
|
125
|
+
n_years = len(consumption_path)
|
|
126
|
+
|
|
127
|
+
if survival_probabilities is None:
|
|
128
|
+
survival_probabilities = np.ones(n_years)
|
|
129
|
+
|
|
130
|
+
total_utility = 0.0
|
|
131
|
+
for t, (consumption, survival_prob) in enumerate(
|
|
132
|
+
zip(consumption_path, survival_probabilities)
|
|
133
|
+
):
|
|
134
|
+
discount = (1 + self.time_preference) ** (-t)
|
|
135
|
+
total_utility += discount * survival_prob * self.utility(consumption)
|
|
136
|
+
|
|
137
|
+
return total_utility
|
|
138
|
+
|
|
139
|
+
def risk_tolerance(self, wealth: float) -> float:
|
|
140
|
+
"""Calculate risk tolerance at a given wealth level.
|
|
141
|
+
|
|
142
|
+
Risk tolerance = 1 / (gamma * wealth) for CRRA utility.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
wealth: Current wealth level
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Risk tolerance as decimal
|
|
149
|
+
"""
|
|
150
|
+
if wealth <= self.subsistence_floor:
|
|
151
|
+
return 0.0 # No risk tolerance below floor
|
|
152
|
+
|
|
153
|
+
excess_wealth = wealth - self.subsistence_floor
|
|
154
|
+
return 1 / (self.gamma * excess_wealth) * excess_wealth
|
fundedness/policies.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Spending and allocation policy implementations."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class FixedRealSpending:
|
|
10
|
+
"""Fixed real (inflation-adjusted) spending policy."""
|
|
11
|
+
|
|
12
|
+
annual_spending: float
|
|
13
|
+
inflation_rate: float = 0.025
|
|
14
|
+
|
|
15
|
+
def get_spending(
|
|
16
|
+
self,
|
|
17
|
+
wealth: np.ndarray,
|
|
18
|
+
year: int,
|
|
19
|
+
initial_wealth: float,
|
|
20
|
+
) -> np.ndarray:
|
|
21
|
+
"""Get spending, capped at available wealth."""
|
|
22
|
+
nominal_spending = self.annual_spending * (1 + self.inflation_rate) ** year
|
|
23
|
+
return np.minimum(nominal_spending, np.maximum(wealth, 0))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class PercentOfPortfolio:
|
|
28
|
+
"""Spend a fixed percentage of current portfolio value."""
|
|
29
|
+
|
|
30
|
+
percentage: float = 0.04 # 4% rule
|
|
31
|
+
floor: float | None = None
|
|
32
|
+
ceiling: float | None = None
|
|
33
|
+
|
|
34
|
+
def get_spending(
|
|
35
|
+
self,
|
|
36
|
+
wealth: np.ndarray,
|
|
37
|
+
year: int,
|
|
38
|
+
initial_wealth: float,
|
|
39
|
+
) -> np.ndarray:
|
|
40
|
+
"""Get spending as percentage of current wealth."""
|
|
41
|
+
spending = wealth * self.percentage
|
|
42
|
+
|
|
43
|
+
if self.floor is not None:
|
|
44
|
+
spending = np.maximum(spending, self.floor)
|
|
45
|
+
|
|
46
|
+
if self.ceiling is not None:
|
|
47
|
+
spending = np.minimum(spending, self.ceiling)
|
|
48
|
+
|
|
49
|
+
return np.minimum(spending, np.maximum(wealth, 0))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ConstantAllocation:
|
|
54
|
+
"""Constant stock/bond allocation."""
|
|
55
|
+
|
|
56
|
+
stock_weight: float = 0.6
|
|
57
|
+
|
|
58
|
+
def get_allocation(
|
|
59
|
+
self,
|
|
60
|
+
wealth: np.ndarray,
|
|
61
|
+
year: int,
|
|
62
|
+
initial_wealth: float,
|
|
63
|
+
) -> float:
|
|
64
|
+
"""Return constant stock allocation."""
|
|
65
|
+
return self.stock_weight
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class AgeBasedGlidepath:
|
|
70
|
+
"""Age-based declining equity glidepath.
|
|
71
|
+
|
|
72
|
+
Classic rule: stock_weight = 100 - age (or similar)
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
initial_stock_weight: float = 0.8
|
|
76
|
+
final_stock_weight: float = 0.3
|
|
77
|
+
years_to_final: int = 30
|
|
78
|
+
starting_age: int = 65
|
|
79
|
+
|
|
80
|
+
def get_allocation(
|
|
81
|
+
self,
|
|
82
|
+
wealth: np.ndarray,
|
|
83
|
+
year: int,
|
|
84
|
+
initial_wealth: float,
|
|
85
|
+
) -> float:
|
|
86
|
+
"""Calculate stock allocation based on years into retirement."""
|
|
87
|
+
progress = min(year / self.years_to_final, 1.0)
|
|
88
|
+
stock_weight = self.initial_stock_weight - progress * (
|
|
89
|
+
self.initial_stock_weight - self.final_stock_weight
|
|
90
|
+
)
|
|
91
|
+
return stock_weight
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class RisingEquityGlidepath:
|
|
96
|
+
"""Rising equity glidepath (bonds-first spending).
|
|
97
|
+
|
|
98
|
+
Start conservative, increase equity over time as sequence risk decreases.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
initial_stock_weight: float = 0.3
|
|
102
|
+
final_stock_weight: float = 0.7
|
|
103
|
+
years_to_final: int = 20
|
|
104
|
+
|
|
105
|
+
def get_allocation(
|
|
106
|
+
self,
|
|
107
|
+
wealth: np.ndarray,
|
|
108
|
+
year: int,
|
|
109
|
+
initial_wealth: float,
|
|
110
|
+
) -> float:
|
|
111
|
+
"""Calculate stock allocation - increasing over time."""
|
|
112
|
+
progress = min(year / self.years_to_final, 1.0)
|
|
113
|
+
stock_weight = self.initial_stock_weight + progress * (
|
|
114
|
+
self.final_stock_weight - self.initial_stock_weight
|
|
115
|
+
)
|
|
116
|
+
return stock_weight
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class FundednessBasedAllocation:
|
|
121
|
+
"""Adjust allocation based on current fundedness level.
|
|
122
|
+
|
|
123
|
+
Higher fundedness = can take more risk
|
|
124
|
+
Lower fundedness = reduce risk to protect floor
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
target_fundedness: float = 1.2 # Target CEFR
|
|
128
|
+
max_stock_weight: float = 0.8
|
|
129
|
+
min_stock_weight: float = 0.2
|
|
130
|
+
liability_pv: float = 1_000_000 # PV of future spending
|
|
131
|
+
|
|
132
|
+
def get_allocation(
|
|
133
|
+
self,
|
|
134
|
+
wealth: np.ndarray,
|
|
135
|
+
year: int,
|
|
136
|
+
initial_wealth: float,
|
|
137
|
+
) -> np.ndarray:
|
|
138
|
+
"""Calculate allocation based on current fundedness."""
|
|
139
|
+
# Simple fundedness estimate (wealth / liability PV)
|
|
140
|
+
# In practice, would recalculate full CEFR
|
|
141
|
+
fundedness = wealth / self.liability_pv
|
|
142
|
+
|
|
143
|
+
# Linear interpolation based on fundedness
|
|
144
|
+
# At target fundedness, use moderate allocation
|
|
145
|
+
# Above target, can increase stocks
|
|
146
|
+
# Below target, reduce stocks
|
|
147
|
+
|
|
148
|
+
relative_fundedness = fundedness / self.target_fundedness
|
|
149
|
+
|
|
150
|
+
# Map to allocation range
|
|
151
|
+
stock_weight = self.min_stock_weight + (self.max_stock_weight - self.min_stock_weight) * (
|
|
152
|
+
np.clip(relative_fundedness, 0.5, 1.5) - 0.5
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return np.clip(stock_weight, self.min_stock_weight, self.max_stock_weight)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class FloorCeilingSpending:
|
|
160
|
+
"""Spending policy with floor and ceiling guardrails.
|
|
161
|
+
|
|
162
|
+
Attempts to maintain target spending but:
|
|
163
|
+
- Never spends below floor (essential spending)
|
|
164
|
+
- Never spends above ceiling (luxury cap)
|
|
165
|
+
- Adjusts based on portfolio performance
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
target_spending: float
|
|
169
|
+
floor_spending: float
|
|
170
|
+
ceiling_spending: float
|
|
171
|
+
adjustment_rate: float = 0.05 # How fast to adjust toward target
|
|
172
|
+
|
|
173
|
+
def __post_init__(self):
|
|
174
|
+
self._previous_spending = None
|
|
175
|
+
|
|
176
|
+
def get_spending(
|
|
177
|
+
self,
|
|
178
|
+
wealth: np.ndarray,
|
|
179
|
+
year: int,
|
|
180
|
+
initial_wealth: float,
|
|
181
|
+
) -> np.ndarray:
|
|
182
|
+
"""Calculate spending with guardrails."""
|
|
183
|
+
if self._previous_spending is None:
|
|
184
|
+
self._previous_spending = np.full_like(wealth, self.target_spending)
|
|
185
|
+
|
|
186
|
+
# Calculate sustainable spending estimate (simplified)
|
|
187
|
+
sustainable_rate = 0.04 # Simple 4% estimate
|
|
188
|
+
sustainable_spending = wealth * sustainable_rate
|
|
189
|
+
|
|
190
|
+
# Target is previous spending (smoothing)
|
|
191
|
+
target = self._previous_spending
|
|
192
|
+
|
|
193
|
+
# Adjust target toward sustainable level
|
|
194
|
+
target = target + self.adjustment_rate * (sustainable_spending - target)
|
|
195
|
+
|
|
196
|
+
# Apply floor and ceiling
|
|
197
|
+
spending = np.clip(target, self.floor_spending, self.ceiling_spending)
|
|
198
|
+
|
|
199
|
+
# Can't spend more than wealth
|
|
200
|
+
spending = np.minimum(spending, np.maximum(wealth, 0))
|
|
201
|
+
|
|
202
|
+
self._previous_spending = spending
|
|
203
|
+
|
|
204
|
+
return spending
|
fundedness/risk.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Reliability/risk factor mappings for CEFR calculations."""
|
|
2
|
+
|
|
3
|
+
from fundedness.models.assets import AssetClass, ConcentrationLevel
|
|
4
|
+
|
|
5
|
+
# Default reliability factors by concentration level
|
|
6
|
+
# These represent the certainty-equivalent haircut for concentration risk
|
|
7
|
+
DEFAULT_RELIABILITY_FACTORS: dict[ConcentrationLevel, float] = {
|
|
8
|
+
ConcentrationLevel.DIVERSIFIED: 0.85, # Broad market index
|
|
9
|
+
ConcentrationLevel.SECTOR: 0.70, # Sector concentration
|
|
10
|
+
ConcentrationLevel.SINGLE_STOCK: 0.60, # Individual company
|
|
11
|
+
ConcentrationLevel.STARTUP: 0.30, # Early-stage, high uncertainty
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
# Additional reliability adjustments by asset class
|
|
15
|
+
ASSET_CLASS_RELIABILITY: dict[AssetClass, float] = {
|
|
16
|
+
AssetClass.CASH: 1.0, # No reliability haircut for cash
|
|
17
|
+
AssetClass.BONDS: 0.95, # Slight credit/duration risk
|
|
18
|
+
AssetClass.STOCKS: 1.0, # Base reliability (modified by concentration)
|
|
19
|
+
AssetClass.REAL_ESTATE: 0.90, # Valuation uncertainty
|
|
20
|
+
AssetClass.ALTERNATIVES: 0.80, # Higher uncertainty
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_reliability_factor(
|
|
25
|
+
concentration_level: ConcentrationLevel,
|
|
26
|
+
asset_class: AssetClass | None = None,
|
|
27
|
+
custom_factors: dict[ConcentrationLevel, float] | None = None,
|
|
28
|
+
) -> float:
|
|
29
|
+
"""Get the reliability factor for an asset.
|
|
30
|
+
|
|
31
|
+
The reliability factor combines concentration risk with asset class risk.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
concentration_level: The concentration level of the asset
|
|
35
|
+
asset_class: Optional asset class for additional adjustment
|
|
36
|
+
custom_factors: Optional custom concentration factor overrides
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Reliability factor between 0 and 1
|
|
40
|
+
"""
|
|
41
|
+
# Get concentration-based factor
|
|
42
|
+
if custom_factors and concentration_level in custom_factors:
|
|
43
|
+
concentration_factor = custom_factors[concentration_level]
|
|
44
|
+
else:
|
|
45
|
+
concentration_factor = DEFAULT_RELIABILITY_FACTORS.get(concentration_level, 1.0)
|
|
46
|
+
|
|
47
|
+
# Apply asset class adjustment if provided
|
|
48
|
+
if asset_class is not None:
|
|
49
|
+
asset_adjustment = ASSET_CLASS_RELIABILITY.get(asset_class, 1.0)
|
|
50
|
+
# Cash and bonds don't need concentration haircut
|
|
51
|
+
if asset_class in (AssetClass.CASH, AssetClass.BONDS):
|
|
52
|
+
return asset_adjustment
|
|
53
|
+
return concentration_factor * asset_adjustment
|
|
54
|
+
|
|
55
|
+
return concentration_factor
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_all_reliability_factors(
|
|
59
|
+
custom_factors: dict[ConcentrationLevel, float] | None = None,
|
|
60
|
+
) -> dict[ConcentrationLevel, float]:
|
|
61
|
+
"""Get all reliability factors with optional overrides.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
custom_factors: Optional custom factor overrides
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Dictionary of concentration level to factor
|
|
68
|
+
"""
|
|
69
|
+
factors = DEFAULT_RELIABILITY_FACTORS.copy()
|
|
70
|
+
if custom_factors:
|
|
71
|
+
factors.update(custom_factors)
|
|
72
|
+
return factors
|