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/merton.py ADDED
@@ -0,0 +1,289 @@
1
+ """Merton optimal consumption and portfolio choice formulas.
2
+
3
+ Implements the analytical solutions from Robert Merton's continuous-time
4
+ portfolio optimization framework for retirement planning.
5
+
6
+ References:
7
+ - Merton, R.C. (1969). Lifetime Portfolio Selection under Uncertainty.
8
+ - Haghani, V. & White, J. (2023). The Missing Billionaires. Wiley.
9
+
10
+ Key formulas:
11
+ - Optimal equity allocation: k* = (mu - r) / (gamma * sigma^2)
12
+ - Certainty equivalent return: rce = r + k*(mu - r) - gamma*k^2*sigma^2/2
13
+ - Optimal spending rate: c* = rce - (rce - rtp) / gamma
14
+ """
15
+
16
+ from dataclasses import dataclass
17
+
18
+ import numpy as np
19
+
20
+ from fundedness.models.market import MarketModel
21
+ from fundedness.models.utility import UtilityModel
22
+
23
+
24
+ @dataclass
25
+ class MertonOptimalResult:
26
+ """Results from Merton optimal calculations."""
27
+
28
+ optimal_equity_allocation: float
29
+ certainty_equivalent_return: float
30
+ optimal_spending_rate: float
31
+ wealth_adjusted_allocation: float
32
+ risk_premium: float
33
+ portfolio_volatility: float
34
+
35
+
36
+ def merton_optimal_allocation(
37
+ market_model: MarketModel,
38
+ utility_model: UtilityModel,
39
+ ) -> float:
40
+ """Calculate Merton optimal equity allocation.
41
+
42
+ The Merton formula gives the optimal fraction to invest in risky assets:
43
+ k* = (mu - r) / (gamma * sigma^2)
44
+
45
+ Args:
46
+ market_model: Market return and risk assumptions
47
+ utility_model: Utility parameters including risk aversion
48
+
49
+ Returns:
50
+ Optimal equity allocation as decimal (can exceed 1.0 for leveraged)
51
+ """
52
+ mu = market_model.stock_return
53
+ r = market_model.bond_return
54
+ gamma = utility_model.gamma
55
+ sigma = market_model.stock_volatility
56
+
57
+ if sigma == 0 or gamma == 0:
58
+ return 0.0
59
+
60
+ k_star = (mu - r) / (gamma * sigma**2)
61
+
62
+ return k_star
63
+
64
+
65
+ def certainty_equivalent_return(
66
+ market_model: MarketModel,
67
+ utility_model: UtilityModel,
68
+ equity_allocation: float | None = None,
69
+ ) -> float:
70
+ """Calculate certainty equivalent return for a portfolio.
71
+
72
+ The certainty equivalent return is the guaranteed return that provides
73
+ the same expected utility as the risky portfolio:
74
+ rce = r + k*(mu - r) - gamma*k^2*sigma^2/2
75
+
76
+ Args:
77
+ market_model: Market return and risk assumptions
78
+ utility_model: Utility parameters including risk aversion
79
+ equity_allocation: Equity allocation (uses optimal if None)
80
+
81
+ Returns:
82
+ Certainty equivalent return as decimal
83
+ """
84
+ if equity_allocation is None:
85
+ equity_allocation = merton_optimal_allocation(market_model, utility_model)
86
+
87
+ mu = market_model.stock_return
88
+ r = market_model.bond_return
89
+ gamma = utility_model.gamma
90
+ sigma = market_model.stock_volatility
91
+
92
+ k = equity_allocation
93
+ risk_premium = k * (mu - r)
94
+ risk_penalty = gamma * k**2 * sigma**2 / 2
95
+
96
+ rce = r + risk_premium - risk_penalty
97
+
98
+ return rce
99
+
100
+
101
+ def merton_optimal_spending_rate(
102
+ market_model: MarketModel,
103
+ utility_model: UtilityModel,
104
+ remaining_years: float | None = None,
105
+ ) -> float:
106
+ """Calculate Merton optimal spending rate.
107
+
108
+ The optimal spending rate for an infinite horizon is:
109
+ c* = rce - (rce - rtp) / gamma
110
+
111
+ For finite horizons, the rate is adjusted upward as horizon shortens.
112
+
113
+ Args:
114
+ market_model: Market return and risk assumptions
115
+ utility_model: Utility parameters including risk aversion and time preference
116
+ remaining_years: Years until planning horizon ends (None for infinite)
117
+
118
+ Returns:
119
+ Optimal spending rate as decimal (e.g., 0.03 = 3%)
120
+ """
121
+ rce = certainty_equivalent_return(market_model, utility_model)
122
+ rtp = utility_model.time_preference
123
+ gamma = utility_model.gamma
124
+
125
+ if gamma == 1.0:
126
+ # Log utility special case
127
+ c_star = rtp
128
+ else:
129
+ c_star = rce - (rce - rtp) / gamma
130
+
131
+ # Finite horizon adjustment
132
+ if remaining_years is not None and remaining_years > 0:
133
+ # Use annuity factor to increase spending rate for finite horizon
134
+ # c_finite = c_infinite + 1 / remaining_years (approximate)
135
+ if rce > 0:
136
+ # Annuity present value factor
137
+ pv_factor = (1 - (1 + rce) ** (-remaining_years)) / rce
138
+ if pv_factor > 0:
139
+ annuity_rate = 1 / pv_factor
140
+ c_star = max(c_star, annuity_rate)
141
+ else:
142
+ # With non-positive returns, simple 1/N rule
143
+ c_star = max(c_star, 1 / remaining_years)
144
+
145
+ return max(c_star, 0.0) # Can't have negative spending
146
+
147
+
148
+ def wealth_adjusted_optimal_allocation(
149
+ wealth: float,
150
+ market_model: MarketModel,
151
+ utility_model: UtilityModel,
152
+ min_allocation: float = 0.0,
153
+ max_allocation: float = 1.0,
154
+ ) -> float:
155
+ """Calculate wealth-adjusted optimal equity allocation.
156
+
157
+ Near the subsistence floor, the optimal allocation approaches zero
158
+ because the investor cannot afford to take risk. As wealth rises
159
+ above the floor, allocation approaches the unconstrained Merton optimal.
160
+
161
+ The formula is:
162
+ k_adjusted = k* * (W - F) / W
163
+
164
+ Where W is wealth and F is the subsistence floor.
165
+
166
+ Args:
167
+ wealth: Current portfolio value
168
+ market_model: Market return and risk assumptions
169
+ utility_model: Utility parameters
170
+ min_allocation: Minimum equity allocation (floor)
171
+ max_allocation: Maximum equity allocation (ceiling)
172
+
173
+ Returns:
174
+ Adjusted equity allocation as decimal, bounded by min/max
175
+ """
176
+ k_star = merton_optimal_allocation(market_model, utility_model)
177
+ floor = utility_model.subsistence_floor
178
+
179
+ if wealth <= floor:
180
+ return min_allocation
181
+
182
+ # Scale by distance from floor
183
+ wealth_ratio = (wealth - floor) / wealth
184
+ k_adjusted = k_star * wealth_ratio
185
+
186
+ # Apply bounds
187
+ return np.clip(k_adjusted, min_allocation, max_allocation)
188
+
189
+
190
+ def calculate_merton_optimal(
191
+ wealth: float,
192
+ market_model: MarketModel,
193
+ utility_model: UtilityModel,
194
+ remaining_years: float | None = None,
195
+ ) -> MertonOptimalResult:
196
+ """Calculate all Merton optimal values for given wealth.
197
+
198
+ This is the main entry point for getting all optimal policy parameters.
199
+
200
+ Args:
201
+ wealth: Current portfolio value
202
+ market_model: Market return and risk assumptions
203
+ utility_model: Utility parameters
204
+ remaining_years: Years until planning horizon ends
205
+
206
+ Returns:
207
+ MertonOptimalResult with all optimal values
208
+ """
209
+ k_star = merton_optimal_allocation(market_model, utility_model)
210
+ rce = certainty_equivalent_return(market_model, utility_model)
211
+ c_star = merton_optimal_spending_rate(market_model, utility_model, remaining_years)
212
+ k_adjusted = wealth_adjusted_optimal_allocation(wealth, market_model, utility_model)
213
+
214
+ risk_premium = market_model.stock_return - market_model.bond_return
215
+ portfolio_vol = k_star * market_model.stock_volatility
216
+
217
+ return MertonOptimalResult(
218
+ optimal_equity_allocation=k_star,
219
+ certainty_equivalent_return=rce,
220
+ optimal_spending_rate=c_star,
221
+ wealth_adjusted_allocation=k_adjusted,
222
+ risk_premium=risk_premium,
223
+ portfolio_volatility=portfolio_vol,
224
+ )
225
+
226
+
227
+ def optimal_spending_by_age(
228
+ market_model: MarketModel,
229
+ utility_model: UtilityModel,
230
+ starting_age: int,
231
+ end_age: int = 100,
232
+ ) -> dict[int, float]:
233
+ """Calculate optimal spending rates for each age.
234
+
235
+ Spending rate increases with age as the remaining horizon shortens.
236
+
237
+ Args:
238
+ market_model: Market return and risk assumptions
239
+ utility_model: Utility parameters
240
+ starting_age: Current age
241
+ end_age: Assumed maximum age
242
+
243
+ Returns:
244
+ Dictionary mapping age to optimal spending rate
245
+ """
246
+ rates = {}
247
+ for age in range(starting_age, end_age + 1):
248
+ remaining_years = end_age - age
249
+ if remaining_years <= 0:
250
+ rates[age] = 1.0 # Spend everything at end
251
+ else:
252
+ rates[age] = merton_optimal_spending_rate(
253
+ market_model, utility_model, remaining_years
254
+ )
255
+ return rates
256
+
257
+
258
+ def optimal_allocation_by_wealth(
259
+ market_model: MarketModel,
260
+ utility_model: UtilityModel,
261
+ wealth_levels: np.ndarray,
262
+ min_allocation: float = 0.0,
263
+ max_allocation: float = 1.0,
264
+ ) -> np.ndarray:
265
+ """Calculate optimal allocation for a range of wealth levels.
266
+
267
+ Useful for generating allocation curves showing how equity percentage
268
+ should vary with distance from subsistence floor.
269
+
270
+ Args:
271
+ market_model: Market return and risk assumptions
272
+ utility_model: Utility parameters
273
+ wealth_levels: Array of wealth values to calculate for
274
+ min_allocation: Minimum equity allocation
275
+ max_allocation: Maximum equity allocation
276
+
277
+ Returns:
278
+ Array of optimal allocations corresponding to wealth_levels
279
+ """
280
+ allocations = np.zeros_like(wealth_levels, dtype=float)
281
+ for i, wealth in enumerate(wealth_levels):
282
+ allocations[i] = wealth_adjusted_optimal_allocation(
283
+ wealth=wealth,
284
+ market_model=market_model,
285
+ utility_model=utility_model,
286
+ min_allocation=min_allocation,
287
+ max_allocation=max_allocation,
288
+ )
289
+ return allocations
@@ -0,0 +1,35 @@
1
+ """Pydantic data models for the fundedness package."""
2
+
3
+ from fundedness.models.assets import (
4
+ AccountType,
5
+ Asset,
6
+ AssetClass,
7
+ BalanceSheet,
8
+ ConcentrationLevel,
9
+ LiquidityClass,
10
+ )
11
+ from fundedness.models.household import Household, Person
12
+ from fundedness.models.liabilities import InflationLinkage, Liability, LiabilityType
13
+ from fundedness.models.market import MarketModel
14
+ from fundedness.models.simulation import SimulationConfig
15
+ from fundedness.models.tax import TaxModel
16
+ from fundedness.models.utility import UtilityModel
17
+
18
+ __all__ = [
19
+ "AccountType",
20
+ "Asset",
21
+ "AssetClass",
22
+ "BalanceSheet",
23
+ "ConcentrationLevel",
24
+ "Household",
25
+ "InflationLinkage",
26
+ "Liability",
27
+ "LiabilityClass",
28
+ "LiabilityType",
29
+ "LiquidityClass",
30
+ "MarketModel",
31
+ "Person",
32
+ "SimulationConfig",
33
+ "TaxModel",
34
+ "UtilityModel",
35
+ ]
@@ -0,0 +1,148 @@
1
+ """Asset and balance sheet models."""
2
+
3
+ from enum import Enum
4
+ from typing import Optional
5
+
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+
9
+ class AccountType(str, Enum):
10
+ """Tax treatment of an account."""
11
+
12
+ TAXABLE = "taxable"
13
+ TAX_DEFERRED = "tax_deferred" # Traditional IRA, 401(k)
14
+ TAX_EXEMPT = "tax_exempt" # Roth IRA, Roth 401(k)
15
+ HSA = "hsa"
16
+
17
+
18
+ class AssetClass(str, Enum):
19
+ """Broad asset class categories."""
20
+
21
+ CASH = "cash"
22
+ BONDS = "bonds"
23
+ STOCKS = "stocks"
24
+ REAL_ESTATE = "real_estate"
25
+ ALTERNATIVES = "alternatives"
26
+
27
+
28
+ class LiquidityClass(str, Enum):
29
+ """Liquidity classification for assets."""
30
+
31
+ CASH = "cash" # Immediate liquidity
32
+ TAXABLE_INDEX = "taxable_index" # Public securities in taxable accounts
33
+ RETIREMENT = "retirement" # Tax-advantaged retirement accounts
34
+ HOME_EQUITY = "home_equity" # Primary residence equity
35
+ PRIVATE_BUSINESS = "private_business" # Illiquid business interests
36
+ RESTRICTED = "restricted" # Restricted stock, vesting schedules
37
+
38
+
39
+ class ConcentrationLevel(str, Enum):
40
+ """Concentration/diversification level."""
41
+
42
+ DIVERSIFIED = "diversified" # Broad index funds
43
+ SECTOR = "sector" # Sector-specific concentration
44
+ SINGLE_STOCK = "single_stock" # Individual company stock
45
+ STARTUP = "startup" # Early-stage company equity
46
+
47
+
48
+ class Asset(BaseModel):
49
+ """A single asset holding."""
50
+
51
+ name: str = Field(..., description="Descriptive name for the asset")
52
+ value: float = Field(..., ge=0, description="Current market value in dollars")
53
+ account_type: AccountType = Field(
54
+ default=AccountType.TAXABLE,
55
+ description="Tax treatment of the account",
56
+ )
57
+ asset_class: AssetClass = Field(
58
+ default=AssetClass.STOCKS,
59
+ description="Broad asset class category",
60
+ )
61
+ liquidity_class: LiquidityClass = Field(
62
+ default=LiquidityClass.TAXABLE_INDEX,
63
+ description="Liquidity classification",
64
+ )
65
+ concentration_level: ConcentrationLevel = Field(
66
+ default=ConcentrationLevel.DIVERSIFIED,
67
+ description="Concentration/diversification level",
68
+ )
69
+ cost_basis: Optional[float] = Field(
70
+ default=None,
71
+ ge=0,
72
+ description="Cost basis for tax calculations (taxable accounts only)",
73
+ )
74
+ expected_return: Optional[float] = Field(
75
+ default=None,
76
+ description="Override expected return (annual, decimal)",
77
+ )
78
+ volatility: Optional[float] = Field(
79
+ default=None,
80
+ ge=0,
81
+ description="Override volatility (annual standard deviation, decimal)",
82
+ )
83
+
84
+ @field_validator("cost_basis")
85
+ @classmethod
86
+ def validate_cost_basis(cls, v: Optional[float], info) -> Optional[float]:
87
+ """Warn if cost basis exceeds value (unrealized loss)."""
88
+ return v
89
+
90
+ @property
91
+ def unrealized_gain(self) -> Optional[float]:
92
+ """Calculate unrealized gain/loss if cost basis is known."""
93
+ if self.cost_basis is None:
94
+ return None
95
+ return self.value - self.cost_basis
96
+
97
+
98
+ class BalanceSheet(BaseModel):
99
+ """Collection of assets representing a household's balance sheet."""
100
+
101
+ assets: list[Asset] = Field(default_factory=list, description="List of asset holdings")
102
+
103
+ @property
104
+ def total_value(self) -> float:
105
+ """Total market value of all assets."""
106
+ return sum(asset.value for asset in self.assets)
107
+
108
+ @property
109
+ def by_account_type(self) -> dict[AccountType, float]:
110
+ """Total value by account type."""
111
+ result: dict[AccountType, float] = {}
112
+ for asset in self.assets:
113
+ result[asset.account_type] = result.get(asset.account_type, 0) + asset.value
114
+ return result
115
+
116
+ @property
117
+ def by_asset_class(self) -> dict[AssetClass, float]:
118
+ """Total value by asset class."""
119
+ result: dict[AssetClass, float] = {}
120
+ for asset in self.assets:
121
+ result[asset.asset_class] = result.get(asset.asset_class, 0) + asset.value
122
+ return result
123
+
124
+ @property
125
+ def by_liquidity_class(self) -> dict[LiquidityClass, float]:
126
+ """Total value by liquidity class."""
127
+ result: dict[LiquidityClass, float] = {}
128
+ for asset in self.assets:
129
+ result[asset.liquidity_class] = result.get(asset.liquidity_class, 0) + asset.value
130
+ return result
131
+
132
+ def get_stock_allocation(self) -> float:
133
+ """Calculate percentage allocated to stocks."""
134
+ if self.total_value == 0:
135
+ return 0.0
136
+ stock_value = sum(
137
+ asset.value for asset in self.assets if asset.asset_class == AssetClass.STOCKS
138
+ )
139
+ return stock_value / self.total_value
140
+
141
+ def get_bond_allocation(self) -> float:
142
+ """Calculate percentage allocated to bonds."""
143
+ if self.total_value == 0:
144
+ return 0.0
145
+ bond_value = sum(
146
+ asset.value for asset in self.assets if asset.asset_class == AssetClass.BONDS
147
+ )
148
+ return bond_value / self.total_value
@@ -0,0 +1,153 @@
1
+ """Household and person models."""
2
+
3
+ from typing import Optional
4
+
5
+ from pydantic import BaseModel, Field, model_validator
6
+
7
+ from fundedness.models.assets import BalanceSheet
8
+ from fundedness.models.liabilities import Liability
9
+
10
+
11
+ class Person(BaseModel):
12
+ """An individual person in the household."""
13
+
14
+ name: str = Field(..., description="Person's name")
15
+ age: int = Field(..., ge=0, le=120, description="Current age")
16
+ retirement_age: Optional[int] = Field(
17
+ default=None,
18
+ ge=0,
19
+ le=120,
20
+ description="Expected retirement age (None if already retired)",
21
+ )
22
+ life_expectancy: int = Field(
23
+ default=95,
24
+ ge=0,
25
+ le=120,
26
+ description="Planning life expectancy",
27
+ )
28
+ social_security_age: int = Field(
29
+ default=67,
30
+ ge=62,
31
+ le=70,
32
+ description="Age to claim Social Security",
33
+ )
34
+ social_security_annual: float = Field(
35
+ default=0,
36
+ ge=0,
37
+ description="Expected annual Social Security benefit at claiming age (today's dollars)",
38
+ )
39
+ pension_annual: float = Field(
40
+ default=0,
41
+ ge=0,
42
+ description="Expected annual pension benefit (today's dollars)",
43
+ )
44
+ pension_start_age: Optional[int] = Field(
45
+ default=None,
46
+ ge=0,
47
+ le=120,
48
+ description="Age when pension payments begin",
49
+ )
50
+ is_primary: bool = Field(
51
+ default=True,
52
+ description="Whether this is the primary earner/planner",
53
+ )
54
+
55
+ @model_validator(mode="after")
56
+ def validate_ages(self) -> "Person":
57
+ """Validate age relationships."""
58
+ if self.retirement_age is not None and self.retirement_age < self.age:
59
+ # Already past retirement age, treat as retired
60
+ self.retirement_age = None
61
+ if self.life_expectancy < self.age:
62
+ raise ValueError("Life expectancy must be greater than current age")
63
+ return self
64
+
65
+ @property
66
+ def years_to_retirement(self) -> int:
67
+ """Years until retirement (0 if already retired)."""
68
+ if self.retirement_age is None:
69
+ return 0
70
+ return max(0, self.retirement_age - self.age)
71
+
72
+ @property
73
+ def years_in_retirement(self) -> int:
74
+ """Expected years in retirement."""
75
+ retirement_age = self.retirement_age or self.age
76
+ return max(0, self.life_expectancy - retirement_age)
77
+
78
+ @property
79
+ def planning_horizon(self) -> int:
80
+ """Total years in planning horizon."""
81
+ return max(0, self.life_expectancy - self.age)
82
+
83
+
84
+ class Household(BaseModel):
85
+ """A household unit for financial planning."""
86
+
87
+ name: str = Field(
88
+ default="My Household",
89
+ description="Household name",
90
+ )
91
+ members: list[Person] = Field(
92
+ default_factory=list,
93
+ description="Household members",
94
+ )
95
+ balance_sheet: BalanceSheet = Field(
96
+ default_factory=BalanceSheet,
97
+ description="Household balance sheet",
98
+ )
99
+ liabilities: list[Liability] = Field(
100
+ default_factory=list,
101
+ description="Future spending obligations",
102
+ )
103
+ state: str = Field(
104
+ default="CA",
105
+ description="State of residence (for tax calculations)",
106
+ )
107
+ filing_status: str = Field(
108
+ default="married_filing_jointly",
109
+ description="Tax filing status",
110
+ )
111
+
112
+ @property
113
+ def primary_member(self) -> Optional[Person]:
114
+ """Get the primary household member."""
115
+ for member in self.members:
116
+ if member.is_primary:
117
+ return member
118
+ return self.members[0] if self.members else None
119
+
120
+ @property
121
+ def planning_horizon(self) -> int:
122
+ """Planning horizon based on longest-lived member."""
123
+ if not self.members:
124
+ return 30 # Default
125
+ return max(member.planning_horizon for member in self.members)
126
+
127
+ @property
128
+ def total_assets(self) -> float:
129
+ """Total asset value."""
130
+ return self.balance_sheet.total_value
131
+
132
+ @property
133
+ def essential_spending(self) -> float:
134
+ """Total annual essential spending."""
135
+ return sum(
136
+ liability.annual_amount
137
+ for liability in self.liabilities
138
+ if liability.is_essential
139
+ )
140
+
141
+ @property
142
+ def discretionary_spending(self) -> float:
143
+ """Total annual discretionary spending."""
144
+ return sum(
145
+ liability.annual_amount
146
+ for liability in self.liabilities
147
+ if not liability.is_essential
148
+ )
149
+
150
+ @property
151
+ def total_spending(self) -> float:
152
+ """Total annual spending target."""
153
+ return self.essential_spending + self.discretionary_spending