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
fundedness/liquidity.py
ADDED
|
@@ -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)
|