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.

@@ -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]
@@ -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