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,49 @@
1
+ """Liquidity factor mappings for CEFR calculations."""
2
+
3
+ from fundedness.models.assets import LiquidityClass
4
+
5
+ # Default liquidity factors by class
6
+ # These represent the fraction of asset value that can be readily accessed
7
+ DEFAULT_LIQUIDITY_FACTORS: dict[LiquidityClass, float] = {
8
+ LiquidityClass.CASH: 1.0, # Immediately liquid
9
+ LiquidityClass.TAXABLE_INDEX: 0.95, # Small trading costs
10
+ LiquidityClass.RETIREMENT: 0.85, # Early withdrawal penalties, RMD constraints
11
+ LiquidityClass.HOME_EQUITY: 0.50, # HELOC access, selling costs
12
+ LiquidityClass.PRIVATE_BUSINESS: 0.30, # Very illiquid, long sale process
13
+ LiquidityClass.RESTRICTED: 0.20, # Vesting constraints, lockups
14
+ }
15
+
16
+
17
+ def get_liquidity_factor(
18
+ liquidity_class: LiquidityClass,
19
+ custom_factors: dict[LiquidityClass, float] | None = None,
20
+ ) -> float:
21
+ """Get the liquidity factor for an asset class.
22
+
23
+ Args:
24
+ liquidity_class: The liquidity classification of the asset
25
+ custom_factors: Optional custom factor overrides
26
+
27
+ Returns:
28
+ Liquidity factor between 0 and 1
29
+ """
30
+ if custom_factors and liquidity_class in custom_factors:
31
+ return custom_factors[liquidity_class]
32
+ return DEFAULT_LIQUIDITY_FACTORS.get(liquidity_class, 1.0)
33
+
34
+
35
+ def get_all_liquidity_factors(
36
+ custom_factors: dict[LiquidityClass, float] | None = None,
37
+ ) -> dict[LiquidityClass, float]:
38
+ """Get all liquidity factors with optional overrides.
39
+
40
+ Args:
41
+ custom_factors: Optional custom factor overrides
42
+
43
+ Returns:
44
+ Dictionary of liquidity class to factor
45
+ """
46
+ factors = DEFAULT_LIQUIDITY_FACTORS.copy()
47
+ if custom_factors:
48
+ factors.update(custom_factors)
49
+ return factors
@@ -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
@@ -0,0 +1,99 @@
1
+ """Liability models for future spending obligations."""
2
+
3
+ from enum import Enum
4
+ from typing import Optional
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class LiabilityType(str, Enum):
10
+ """Type of liability/spending obligation."""
11
+
12
+ ESSENTIAL_SPENDING = "essential_spending" # Non-negotiable living expenses
13
+ DISCRETIONARY_SPENDING = "discretionary_spending" # Flexible lifestyle spending
14
+ LEGACY_GOAL = "legacy_goal" # Bequest target
15
+ MORTGAGE = "mortgage" # Home loan payments
16
+ DEBT = "debt" # Other debt obligations
17
+ HEALTHCARE = "healthcare" # Healthcare/long-term care costs
18
+ TAXES = "taxes" # Future tax obligations
19
+
20
+
21
+ class InflationLinkage(str, Enum):
22
+ """How the liability is linked to inflation."""
23
+
24
+ NONE = "none" # Fixed nominal amount
25
+ CPI = "cpi" # Linked to Consumer Price Index
26
+ WAGE = "wage" # Linked to wage growth
27
+ HEALTHCARE = "healthcare" # Linked to healthcare inflation (typically higher)
28
+ CUSTOM = "custom" # Custom inflation rate
29
+
30
+
31
+ class Liability(BaseModel):
32
+ """A future spending obligation or liability."""
33
+
34
+ name: str = Field(..., description="Descriptive name for the liability")
35
+ liability_type: LiabilityType = Field(
36
+ default=LiabilityType.ESSENTIAL_SPENDING,
37
+ description="Category of liability",
38
+ )
39
+ annual_amount: float = Field(
40
+ ...,
41
+ ge=0,
42
+ description="Annual spending amount in today's dollars",
43
+ )
44
+ start_year: int = Field(
45
+ default=0,
46
+ ge=0,
47
+ description="Years from now when liability begins (0 = now)",
48
+ )
49
+ end_year: Optional[int] = Field(
50
+ default=None,
51
+ ge=0,
52
+ description="Years from now when liability ends (None = until death)",
53
+ )
54
+ inflation_linkage: InflationLinkage = Field(
55
+ default=InflationLinkage.CPI,
56
+ description="How the liability adjusts for inflation",
57
+ )
58
+ custom_inflation_rate: Optional[float] = Field(
59
+ default=None,
60
+ description="Custom inflation rate if inflation_linkage is CUSTOM (decimal)",
61
+ )
62
+ probability: float = Field(
63
+ default=1.0,
64
+ ge=0,
65
+ le=1,
66
+ description="Probability this liability occurs (1.0 = certain)",
67
+ )
68
+ is_essential: bool = Field(
69
+ default=True,
70
+ description="Whether this is essential (floor) vs discretionary (flex) spending",
71
+ )
72
+
73
+ @property
74
+ def duration_years(self) -> Optional[int]:
75
+ """Duration of the liability in years, if end_year is specified."""
76
+ if self.end_year is None:
77
+ return None
78
+ return self.end_year - self.start_year
79
+
80
+ def get_inflation_rate(self, base_cpi: float = 0.025) -> float:
81
+ """Get the effective inflation rate for this liability.
82
+
83
+ Args:
84
+ base_cpi: Base CPI inflation rate assumption
85
+
86
+ Returns:
87
+ Effective annual inflation rate as decimal
88
+ """
89
+ match self.inflation_linkage:
90
+ case InflationLinkage.NONE:
91
+ return 0.0
92
+ case InflationLinkage.CPI:
93
+ return base_cpi
94
+ case InflationLinkage.WAGE:
95
+ return base_cpi + 0.01 # Assume 1% real wage growth
96
+ case InflationLinkage.HEALTHCARE:
97
+ return base_cpi + 0.02 # Assume 2% excess healthcare inflation
98
+ case InflationLinkage.CUSTOM:
99
+ return self.custom_inflation_rate or base_cpi
@@ -0,0 +1,188 @@
1
+ """Market assumptions model."""
2
+
3
+ from typing import Optional
4
+
5
+ import numpy as np
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+
9
+ class MarketModel(BaseModel):
10
+ """Market return and risk assumptions."""
11
+
12
+ # Expected returns (annual, real)
13
+ stock_return: float = Field(
14
+ default=0.05,
15
+ description="Expected real return for stocks (decimal)",
16
+ )
17
+ bond_return: float = Field(
18
+ default=0.015,
19
+ description="Expected real return for bonds (decimal)",
20
+ )
21
+ cash_return: float = Field(
22
+ default=0.0,
23
+ description="Expected real return for cash (decimal)",
24
+ )
25
+ real_estate_return: float = Field(
26
+ default=0.03,
27
+ description="Expected real return for real estate (decimal)",
28
+ )
29
+
30
+ # Volatility (annual standard deviation)
31
+ stock_volatility: float = Field(
32
+ default=0.16,
33
+ ge=0,
34
+ description="Annual volatility for stocks (decimal)",
35
+ )
36
+ bond_volatility: float = Field(
37
+ default=0.06,
38
+ ge=0,
39
+ description="Annual volatility for bonds (decimal)",
40
+ )
41
+ cash_volatility: float = Field(
42
+ default=0.01,
43
+ ge=0,
44
+ description="Annual volatility for cash (decimal)",
45
+ )
46
+ real_estate_volatility: float = Field(
47
+ default=0.12,
48
+ ge=0,
49
+ description="Annual volatility for real estate (decimal)",
50
+ )
51
+
52
+ # Correlations
53
+ stock_bond_correlation: float = Field(
54
+ default=0.0,
55
+ ge=-1,
56
+ le=1,
57
+ description="Correlation between stocks and bonds",
58
+ )
59
+ stock_real_estate_correlation: float = Field(
60
+ default=0.5,
61
+ ge=-1,
62
+ le=1,
63
+ description="Correlation between stocks and real estate",
64
+ )
65
+
66
+ # Inflation
67
+ inflation_mean: float = Field(
68
+ default=0.025,
69
+ description="Expected long-term inflation rate (decimal)",
70
+ )
71
+ inflation_volatility: float = Field(
72
+ default=0.015,
73
+ ge=0,
74
+ description="Volatility of inflation (decimal)",
75
+ )
76
+
77
+ # Discount rate
78
+ real_discount_rate: float = Field(
79
+ default=0.02,
80
+ description="Real discount rate for liability PV calculations (decimal)",
81
+ )
82
+
83
+ # Fat tails
84
+ use_fat_tails: bool = Field(
85
+ default=False,
86
+ description="Use t-distribution for fatter tails",
87
+ )
88
+ degrees_of_freedom: int = Field(
89
+ default=5,
90
+ ge=3,
91
+ description="Degrees of freedom for t-distribution (lower = fatter tails)",
92
+ )
93
+
94
+ @field_validator("degrees_of_freedom")
95
+ @classmethod
96
+ def validate_dof(cls, v: int) -> int:
97
+ """Ensure degrees of freedom is reasonable."""
98
+ if v < 3:
99
+ raise ValueError("Degrees of freedom must be at least 3")
100
+ return v
101
+
102
+ def get_correlation_matrix(self) -> np.ndarray:
103
+ """Get the correlation matrix for asset classes.
104
+
105
+ Returns:
106
+ 4x4 correlation matrix for [stocks, bonds, cash, real_estate]
107
+ """
108
+ return np.array([
109
+ [1.0, self.stock_bond_correlation, 0.0, self.stock_real_estate_correlation],
110
+ [self.stock_bond_correlation, 1.0, 0.1, 0.2],
111
+ [0.0, 0.1, 1.0, 0.0],
112
+ [self.stock_real_estate_correlation, 0.2, 0.0, 1.0],
113
+ ])
114
+
115
+ def get_covariance_matrix(self) -> np.ndarray:
116
+ """Get the covariance matrix for asset classes.
117
+
118
+ Returns:
119
+ 4x4 covariance matrix for [stocks, bonds, cash, real_estate]
120
+ """
121
+ volatilities = np.array([
122
+ self.stock_volatility,
123
+ self.bond_volatility,
124
+ self.cash_volatility,
125
+ self.real_estate_volatility,
126
+ ])
127
+ corr = self.get_correlation_matrix()
128
+ # Covariance = outer product of volatilities * correlation
129
+ return np.outer(volatilities, volatilities) * corr
130
+
131
+ def get_cholesky_decomposition(self) -> np.ndarray:
132
+ """Get Cholesky decomposition for correlated returns generation.
133
+
134
+ Returns:
135
+ Lower triangular Cholesky matrix
136
+ """
137
+ cov = self.get_covariance_matrix()
138
+ return np.linalg.cholesky(cov)
139
+
140
+ def expected_portfolio_return(
141
+ self,
142
+ stock_weight: float,
143
+ bond_weight: Optional[float] = None,
144
+ ) -> float:
145
+ """Calculate expected return for a portfolio.
146
+
147
+ Args:
148
+ stock_weight: Weight in stocks (0-1)
149
+ bond_weight: Weight in bonds (remainder is cash if not specified)
150
+
151
+ Returns:
152
+ Expected annual real return
153
+ """
154
+ if bond_weight is None:
155
+ bond_weight = 1 - stock_weight
156
+
157
+ cash_weight = max(0, 1 - stock_weight - bond_weight)
158
+
159
+ return (
160
+ stock_weight * self.stock_return
161
+ + bond_weight * self.bond_return
162
+ + cash_weight * self.cash_return
163
+ )
164
+
165
+ def portfolio_volatility(
166
+ self,
167
+ stock_weight: float,
168
+ bond_weight: Optional[float] = None,
169
+ ) -> float:
170
+ """Calculate portfolio volatility.
171
+
172
+ Args:
173
+ stock_weight: Weight in stocks (0-1)
174
+ bond_weight: Weight in bonds (remainder is cash if not specified)
175
+
176
+ Returns:
177
+ Annual portfolio volatility
178
+ """
179
+ if bond_weight is None:
180
+ bond_weight = 1 - stock_weight
181
+
182
+ cash_weight = max(0, 1 - stock_weight - bond_weight)
183
+ weights = np.array([stock_weight, bond_weight, cash_weight, 0])
184
+
185
+ cov = self.get_covariance_matrix()
186
+ portfolio_variance = weights @ cov @ weights
187
+
188
+ return np.sqrt(portfolio_variance)