Skip to content

Data Models

Pydantic models for representing financial data.

Assets

fundedness.models.assets.Asset

Bases: BaseModel

A single asset holding.

Source code in fundedness/models/assets.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
class Asset(BaseModel):
    """A single asset holding."""

    name: str = Field(..., description="Descriptive name for the asset")
    value: float = Field(..., ge=0, description="Current market value in dollars")
    account_type: AccountType = Field(
        default=AccountType.TAXABLE,
        description="Tax treatment of the account",
    )
    asset_class: AssetClass = Field(
        default=AssetClass.STOCKS,
        description="Broad asset class category",
    )
    liquidity_class: LiquidityClass = Field(
        default=LiquidityClass.TAXABLE_INDEX,
        description="Liquidity classification",
    )
    concentration_level: ConcentrationLevel = Field(
        default=ConcentrationLevel.DIVERSIFIED,
        description="Concentration/diversification level",
    )
    cost_basis: Optional[float] = Field(
        default=None,
        ge=0,
        description="Cost basis for tax calculations (taxable accounts only)",
    )
    expected_return: Optional[float] = Field(
        default=None,
        description="Override expected return (annual, decimal)",
    )
    volatility: Optional[float] = Field(
        default=None,
        ge=0,
        description="Override volatility (annual standard deviation, decimal)",
    )

    @field_validator("cost_basis")
    @classmethod
    def validate_cost_basis(cls, v: Optional[float], info) -> Optional[float]:
        """Warn if cost basis exceeds value (unrealized loss)."""
        return v

    @property
    def unrealized_gain(self) -> Optional[float]:
        """Calculate unrealized gain/loss if cost basis is known."""
        if self.cost_basis is None:
            return None
        return self.value - self.cost_basis

unrealized_gain property

Calculate unrealized gain/loss if cost basis is known.

validate_cost_basis(v, info) classmethod

Warn if cost basis exceeds value (unrealized loss).

Source code in fundedness/models/assets.py
84
85
86
87
88
@field_validator("cost_basis")
@classmethod
def validate_cost_basis(cls, v: Optional[float], info) -> Optional[float]:
    """Warn if cost basis exceeds value (unrealized loss)."""
    return v

fundedness.models.assets.AccountType

Bases: str, Enum

Tax treatment of an account.

Source code in fundedness/models/assets.py
 9
10
11
12
13
14
15
class AccountType(str, Enum):
    """Tax treatment of an account."""

    TAXABLE = "taxable"
    TAX_DEFERRED = "tax_deferred"  # Traditional IRA, 401(k)
    TAX_EXEMPT = "tax_exempt"  # Roth IRA, Roth 401(k)
    HSA = "hsa"

fundedness.models.assets.LiquidityClass

Bases: str, Enum

Liquidity classification for assets.

Source code in fundedness/models/assets.py
28
29
30
31
32
33
34
35
36
class LiquidityClass(str, Enum):
    """Liquidity classification for assets."""

    CASH = "cash"  # Immediate liquidity
    TAXABLE_INDEX = "taxable_index"  # Public securities in taxable accounts
    RETIREMENT = "retirement"  # Tax-advantaged retirement accounts
    HOME_EQUITY = "home_equity"  # Primary residence equity
    PRIVATE_BUSINESS = "private_business"  # Illiquid business interests
    RESTRICTED = "restricted"  # Restricted stock, vesting schedules

fundedness.models.assets.ConcentrationLevel

Bases: str, Enum

Concentration/diversification level.

Source code in fundedness/models/assets.py
39
40
41
42
43
44
45
class ConcentrationLevel(str, Enum):
    """Concentration/diversification level."""

    DIVERSIFIED = "diversified"  # Broad index funds
    SECTOR = "sector"  # Sector-specific concentration
    SINGLE_STOCK = "single_stock"  # Individual company stock
    STARTUP = "startup"  # Early-stage company equity

Balance Sheet

fundedness.models.assets.BalanceSheet

Bases: BaseModel

Collection of assets representing a household's balance sheet.

Source code in fundedness/models/assets.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
class BalanceSheet(BaseModel):
    """Collection of assets representing a household's balance sheet."""

    assets: list[Asset] = Field(default_factory=list, description="List of asset holdings")

    @property
    def total_value(self) -> float:
        """Total market value of all assets."""
        return sum(asset.value for asset in self.assets)

    @property
    def by_account_type(self) -> dict[AccountType, float]:
        """Total value by account type."""
        result: dict[AccountType, float] = {}
        for asset in self.assets:
            result[asset.account_type] = result.get(asset.account_type, 0) + asset.value
        return result

    @property
    def by_asset_class(self) -> dict[AssetClass, float]:
        """Total value by asset class."""
        result: dict[AssetClass, float] = {}
        for asset in self.assets:
            result[asset.asset_class] = result.get(asset.asset_class, 0) + asset.value
        return result

    @property
    def by_liquidity_class(self) -> dict[LiquidityClass, float]:
        """Total value by liquidity class."""
        result: dict[LiquidityClass, float] = {}
        for asset in self.assets:
            result[asset.liquidity_class] = result.get(asset.liquidity_class, 0) + asset.value
        return result

    def get_stock_allocation(self) -> float:
        """Calculate percentage allocated to stocks."""
        if self.total_value == 0:
            return 0.0
        stock_value = sum(
            asset.value for asset in self.assets if asset.asset_class == AssetClass.STOCKS
        )
        return stock_value / self.total_value

    def get_bond_allocation(self) -> float:
        """Calculate percentage allocated to bonds."""
        if self.total_value == 0:
            return 0.0
        bond_value = sum(
            asset.value for asset in self.assets if asset.asset_class == AssetClass.BONDS
        )
        return bond_value / self.total_value

by_account_type property

Total value by account type.

by_asset_class property

Total value by asset class.

by_liquidity_class property

Total value by liquidity class.

total_value property

Total market value of all assets.

get_bond_allocation()

Calculate percentage allocated to bonds.

Source code in fundedness/models/assets.py
141
142
143
144
145
146
147
148
def get_bond_allocation(self) -> float:
    """Calculate percentage allocated to bonds."""
    if self.total_value == 0:
        return 0.0
    bond_value = sum(
        asset.value for asset in self.assets if asset.asset_class == AssetClass.BONDS
    )
    return bond_value / self.total_value

get_stock_allocation()

Calculate percentage allocated to stocks.

Source code in fundedness/models/assets.py
132
133
134
135
136
137
138
139
def get_stock_allocation(self) -> float:
    """Calculate percentage allocated to stocks."""
    if self.total_value == 0:
        return 0.0
    stock_value = sum(
        asset.value for asset in self.assets if asset.asset_class == AssetClass.STOCKS
    )
    return stock_value / self.total_value

Liabilities

fundedness.models.liabilities.Liability

Bases: BaseModel

A future spending obligation or liability.

Source code in fundedness/models/liabilities.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
class Liability(BaseModel):
    """A future spending obligation or liability."""

    name: str = Field(..., description="Descriptive name for the liability")
    liability_type: LiabilityType = Field(
        default=LiabilityType.ESSENTIAL_SPENDING,
        description="Category of liability",
    )
    annual_amount: float = Field(
        ...,
        ge=0,
        description="Annual spending amount in today's dollars",
    )
    start_year: int = Field(
        default=0,
        ge=0,
        description="Years from now when liability begins (0 = now)",
    )
    end_year: Optional[int] = Field(
        default=None,
        ge=0,
        description="Years from now when liability ends (None = until death)",
    )
    inflation_linkage: InflationLinkage = Field(
        default=InflationLinkage.CPI,
        description="How the liability adjusts for inflation",
    )
    custom_inflation_rate: Optional[float] = Field(
        default=None,
        description="Custom inflation rate if inflation_linkage is CUSTOM (decimal)",
    )
    probability: float = Field(
        default=1.0,
        ge=0,
        le=1,
        description="Probability this liability occurs (1.0 = certain)",
    )
    is_essential: bool = Field(
        default=True,
        description="Whether this is essential (floor) vs discretionary (flex) spending",
    )

    @property
    def duration_years(self) -> Optional[int]:
        """Duration of the liability in years, if end_year is specified."""
        if self.end_year is None:
            return None
        return self.end_year - self.start_year

    def get_inflation_rate(self, base_cpi: float = 0.025) -> float:
        """Get the effective inflation rate for this liability.

        Args:
            base_cpi: Base CPI inflation rate assumption

        Returns:
            Effective annual inflation rate as decimal
        """
        match self.inflation_linkage:
            case InflationLinkage.NONE:
                return 0.0
            case InflationLinkage.CPI:
                return base_cpi
            case InflationLinkage.WAGE:
                return base_cpi + 0.01  # Assume 1% real wage growth
            case InflationLinkage.HEALTHCARE:
                return base_cpi + 0.02  # Assume 2% excess healthcare inflation
            case InflationLinkage.CUSTOM:
                return self.custom_inflation_rate or base_cpi

duration_years property

Duration of the liability in years, if end_year is specified.

get_inflation_rate(base_cpi=0.025)

Get the effective inflation rate for this liability.

Parameters:

Name Type Description Default
base_cpi float

Base CPI inflation rate assumption

0.025

Returns:

Type Description
float

Effective annual inflation rate as decimal

Source code in fundedness/models/liabilities.py
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def get_inflation_rate(self, base_cpi: float = 0.025) -> float:
    """Get the effective inflation rate for this liability.

    Args:
        base_cpi: Base CPI inflation rate assumption

    Returns:
        Effective annual inflation rate as decimal
    """
    match self.inflation_linkage:
        case InflationLinkage.NONE:
            return 0.0
        case InflationLinkage.CPI:
            return base_cpi
        case InflationLinkage.WAGE:
            return base_cpi + 0.01  # Assume 1% real wage growth
        case InflationLinkage.HEALTHCARE:
            return base_cpi + 0.02  # Assume 2% excess healthcare inflation
        case InflationLinkage.CUSTOM:
            return self.custom_inflation_rate or base_cpi

Household

fundedness.models.household.Household

Bases: BaseModel

A household unit for financial planning.

Source code in fundedness/models/household.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
class Household(BaseModel):
    """A household unit for financial planning."""

    name: str = Field(
        default="My Household",
        description="Household name",
    )
    members: list[Person] = Field(
        default_factory=list,
        description="Household members",
    )
    balance_sheet: BalanceSheet = Field(
        default_factory=BalanceSheet,
        description="Household balance sheet",
    )
    liabilities: list[Liability] = Field(
        default_factory=list,
        description="Future spending obligations",
    )
    state: str = Field(
        default="CA",
        description="State of residence (for tax calculations)",
    )
    filing_status: str = Field(
        default="married_filing_jointly",
        description="Tax filing status",
    )

    @property
    def primary_member(self) -> Optional[Person]:
        """Get the primary household member."""
        for member in self.members:
            if member.is_primary:
                return member
        return self.members[0] if self.members else None

    @property
    def planning_horizon(self) -> int:
        """Planning horizon based on longest-lived member."""
        if not self.members:
            return 30  # Default
        return max(member.planning_horizon for member in self.members)

    @property
    def total_assets(self) -> float:
        """Total asset value."""
        return self.balance_sheet.total_value

    @property
    def essential_spending(self) -> float:
        """Total annual essential spending."""
        return sum(
            liability.annual_amount
            for liability in self.liabilities
            if liability.is_essential
        )

    @property
    def discretionary_spending(self) -> float:
        """Total annual discretionary spending."""
        return sum(
            liability.annual_amount
            for liability in self.liabilities
            if not liability.is_essential
        )

    @property
    def total_spending(self) -> float:
        """Total annual spending target."""
        return self.essential_spending + self.discretionary_spending

discretionary_spending property

Total annual discretionary spending.

essential_spending property

Total annual essential spending.

planning_horizon property

Planning horizon based on longest-lived member.

primary_member property

Get the primary household member.

total_assets property

Total asset value.

total_spending property

Total annual spending target.

fundedness.models.household.Person

Bases: BaseModel

An individual person in the household.

Source code in fundedness/models/household.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
class Person(BaseModel):
    """An individual person in the household."""

    name: str = Field(..., description="Person's name")
    age: int = Field(..., ge=0, le=120, description="Current age")
    retirement_age: Optional[int] = Field(
        default=None,
        ge=0,
        le=120,
        description="Expected retirement age (None if already retired)",
    )
    life_expectancy: int = Field(
        default=95,
        ge=0,
        le=120,
        description="Planning life expectancy",
    )
    social_security_age: int = Field(
        default=67,
        ge=62,
        le=70,
        description="Age to claim Social Security",
    )
    social_security_annual: float = Field(
        default=0,
        ge=0,
        description="Expected annual Social Security benefit at claiming age (today's dollars)",
    )
    pension_annual: float = Field(
        default=0,
        ge=0,
        description="Expected annual pension benefit (today's dollars)",
    )
    pension_start_age: Optional[int] = Field(
        default=None,
        ge=0,
        le=120,
        description="Age when pension payments begin",
    )
    is_primary: bool = Field(
        default=True,
        description="Whether this is the primary earner/planner",
    )

    @model_validator(mode="after")
    def validate_ages(self) -> "Person":
        """Validate age relationships."""
        if self.retirement_age is not None and self.retirement_age < self.age:
            # Already past retirement age, treat as retired
            self.retirement_age = None
        if self.life_expectancy < self.age:
            raise ValueError("Life expectancy must be greater than current age")
        return self

    @property
    def years_to_retirement(self) -> int:
        """Years until retirement (0 if already retired)."""
        if self.retirement_age is None:
            return 0
        return max(0, self.retirement_age - self.age)

    @property
    def years_in_retirement(self) -> int:
        """Expected years in retirement."""
        retirement_age = self.retirement_age or self.age
        return max(0, self.life_expectancy - retirement_age)

    @property
    def planning_horizon(self) -> int:
        """Total years in planning horizon."""
        return max(0, self.life_expectancy - self.age)

planning_horizon property

Total years in planning horizon.

years_in_retirement property

Expected years in retirement.

years_to_retirement property

Years until retirement (0 if already retired).

validate_ages()

Validate age relationships.

Source code in fundedness/models/household.py
55
56
57
58
59
60
61
62
63
@model_validator(mode="after")
def validate_ages(self) -> "Person":
    """Validate age relationships."""
    if self.retirement_age is not None and self.retirement_age < self.age:
        # Already past retirement age, treat as retired
        self.retirement_age = None
    if self.life_expectancy < self.age:
        raise ValueError("Life expectancy must be greater than current age")
    return self

Market Model

fundedness.models.market.MarketModel

Bases: BaseModel

Market return and risk assumptions.

Source code in fundedness/models/market.py
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
class MarketModel(BaseModel):
    """Market return and risk assumptions."""

    # Expected returns (annual, real)
    stock_return: float = Field(
        default=0.05,
        description="Expected real return for stocks (decimal)",
    )
    bond_return: float = Field(
        default=0.015,
        description="Expected real return for bonds (decimal)",
    )
    cash_return: float = Field(
        default=0.0,
        description="Expected real return for cash (decimal)",
    )
    real_estate_return: float = Field(
        default=0.03,
        description="Expected real return for real estate (decimal)",
    )

    # Volatility (annual standard deviation)
    stock_volatility: float = Field(
        default=0.16,
        ge=0,
        description="Annual volatility for stocks (decimal)",
    )
    bond_volatility: float = Field(
        default=0.06,
        ge=0,
        description="Annual volatility for bonds (decimal)",
    )
    cash_volatility: float = Field(
        default=0.01,
        ge=0,
        description="Annual volatility for cash (decimal)",
    )
    real_estate_volatility: float = Field(
        default=0.12,
        ge=0,
        description="Annual volatility for real estate (decimal)",
    )

    # Correlations
    stock_bond_correlation: float = Field(
        default=0.0,
        ge=-1,
        le=1,
        description="Correlation between stocks and bonds",
    )
    stock_real_estate_correlation: float = Field(
        default=0.5,
        ge=-1,
        le=1,
        description="Correlation between stocks and real estate",
    )

    # Inflation
    inflation_mean: float = Field(
        default=0.025,
        description="Expected long-term inflation rate (decimal)",
    )
    inflation_volatility: float = Field(
        default=0.015,
        ge=0,
        description="Volatility of inflation (decimal)",
    )

    # Discount rate
    real_discount_rate: float = Field(
        default=0.02,
        description="Real discount rate for liability PV calculations (decimal)",
    )

    # Fat tails
    use_fat_tails: bool = Field(
        default=False,
        description="Use t-distribution for fatter tails",
    )
    degrees_of_freedom: int = Field(
        default=5,
        ge=3,
        description="Degrees of freedom for t-distribution (lower = fatter tails)",
    )

    @field_validator("degrees_of_freedom")
    @classmethod
    def validate_dof(cls, v: int) -> int:
        """Ensure degrees of freedom is reasonable."""
        if v < 3:
            raise ValueError("Degrees of freedom must be at least 3")
        return v

    def get_correlation_matrix(self) -> np.ndarray:
        """Get the correlation matrix for asset classes.

        Returns:
            4x4 correlation matrix for [stocks, bonds, cash, real_estate]
        """
        return np.array([
            [1.0, self.stock_bond_correlation, 0.0, self.stock_real_estate_correlation],
            [self.stock_bond_correlation, 1.0, 0.1, 0.2],
            [0.0, 0.1, 1.0, 0.0],
            [self.stock_real_estate_correlation, 0.2, 0.0, 1.0],
        ])

    def get_covariance_matrix(self) -> np.ndarray:
        """Get the covariance matrix for asset classes.

        Returns:
            4x4 covariance matrix for [stocks, bonds, cash, real_estate]
        """
        volatilities = np.array([
            self.stock_volatility,
            self.bond_volatility,
            self.cash_volatility,
            self.real_estate_volatility,
        ])
        corr = self.get_correlation_matrix()
        # Covariance = outer product of volatilities * correlation
        return np.outer(volatilities, volatilities) * corr

    def get_cholesky_decomposition(self) -> np.ndarray:
        """Get Cholesky decomposition for correlated returns generation.

        Returns:
            Lower triangular Cholesky matrix
        """
        cov = self.get_covariance_matrix()
        return np.linalg.cholesky(cov)

    def expected_portfolio_return(
        self,
        stock_weight: float | np.ndarray,
        bond_weight: float | np.ndarray | None = None,
    ) -> float | np.ndarray:
        """Calculate expected return for a portfolio.

        Args:
            stock_weight: Weight in stocks (0-1), scalar or array
            bond_weight: Weight in bonds (remainder is cash if not specified)

        Returns:
            Expected annual real return (scalar or array matching input)
        """
        if bond_weight is None:
            bond_weight = 1 - stock_weight

        cash_weight = np.maximum(0, 1 - stock_weight - bond_weight)

        return (
            stock_weight * self.stock_return
            + bond_weight * self.bond_return
            + cash_weight * self.cash_return
        )

    def portfolio_volatility(
        self,
        stock_weight: float | np.ndarray,
        bond_weight: float | np.ndarray | None = None,
    ) -> float | np.ndarray:
        """Calculate portfolio volatility.

        Args:
            stock_weight: Weight in stocks (0-1), scalar or array
            bond_weight: Weight in bonds (remainder is cash if not specified)

        Returns:
            Annual portfolio volatility (scalar or array matching input)
        """
        if bond_weight is None:
            bond_weight = 1 - stock_weight

        cash_weight = np.maximum(0, 1 - stock_weight - bond_weight)

        cov = self.get_covariance_matrix()

        # Handle both scalar and array inputs
        if isinstance(stock_weight, np.ndarray):
            # Vectorized computation for array inputs
            # weights shape: (n_samples, 4)
            weights = np.column_stack([stock_weight, bond_weight, cash_weight, np.zeros_like(stock_weight)])
            # cov @ weights.T has shape (4, n_samples)
            # We want sum of weights[i] * (cov @ weights[i]) for each i
            # This is equivalent to diag(weights @ cov @ weights.T)
            portfolio_variance = np.einsum('ij,jk,ik->i', weights, cov, weights)
            return np.sqrt(portfolio_variance)
        else:
            weights = np.array([stock_weight, bond_weight, cash_weight, 0])
            portfolio_variance = weights @ cov @ weights
            return np.sqrt(portfolio_variance)

expected_portfolio_return(stock_weight, bond_weight=None)

Calculate expected return for a portfolio.

Parameters:

Name Type Description Default
stock_weight float | ndarray

Weight in stocks (0-1), scalar or array

required
bond_weight float | ndarray | None

Weight in bonds (remainder is cash if not specified)

None

Returns:

Type Description
float | ndarray

Expected annual real return (scalar or array matching input)

Source code in fundedness/models/market.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
def expected_portfolio_return(
    self,
    stock_weight: float | np.ndarray,
    bond_weight: float | np.ndarray | None = None,
) -> float | np.ndarray:
    """Calculate expected return for a portfolio.

    Args:
        stock_weight: Weight in stocks (0-1), scalar or array
        bond_weight: Weight in bonds (remainder is cash if not specified)

    Returns:
        Expected annual real return (scalar or array matching input)
    """
    if bond_weight is None:
        bond_weight = 1 - stock_weight

    cash_weight = np.maximum(0, 1 - stock_weight - bond_weight)

    return (
        stock_weight * self.stock_return
        + bond_weight * self.bond_return
        + cash_weight * self.cash_return
    )

get_cholesky_decomposition()

Get Cholesky decomposition for correlated returns generation.

Returns:

Type Description
ndarray

Lower triangular Cholesky matrix

Source code in fundedness/models/market.py
131
132
133
134
135
136
137
138
def get_cholesky_decomposition(self) -> np.ndarray:
    """Get Cholesky decomposition for correlated returns generation.

    Returns:
        Lower triangular Cholesky matrix
    """
    cov = self.get_covariance_matrix()
    return np.linalg.cholesky(cov)

get_correlation_matrix()

Get the correlation matrix for asset classes.

Returns:

Type Description
ndarray

4x4 correlation matrix for [stocks, bonds, cash, real_estate]

Source code in fundedness/models/market.py
102
103
104
105
106
107
108
109
110
111
112
113
def get_correlation_matrix(self) -> np.ndarray:
    """Get the correlation matrix for asset classes.

    Returns:
        4x4 correlation matrix for [stocks, bonds, cash, real_estate]
    """
    return np.array([
        [1.0, self.stock_bond_correlation, 0.0, self.stock_real_estate_correlation],
        [self.stock_bond_correlation, 1.0, 0.1, 0.2],
        [0.0, 0.1, 1.0, 0.0],
        [self.stock_real_estate_correlation, 0.2, 0.0, 1.0],
    ])

get_covariance_matrix()

Get the covariance matrix for asset classes.

Returns:

Type Description
ndarray

4x4 covariance matrix for [stocks, bonds, cash, real_estate]

Source code in fundedness/models/market.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
def get_covariance_matrix(self) -> np.ndarray:
    """Get the covariance matrix for asset classes.

    Returns:
        4x4 covariance matrix for [stocks, bonds, cash, real_estate]
    """
    volatilities = np.array([
        self.stock_volatility,
        self.bond_volatility,
        self.cash_volatility,
        self.real_estate_volatility,
    ])
    corr = self.get_correlation_matrix()
    # Covariance = outer product of volatilities * correlation
    return np.outer(volatilities, volatilities) * corr

portfolio_volatility(stock_weight, bond_weight=None)

Calculate portfolio volatility.

Parameters:

Name Type Description Default
stock_weight float | ndarray

Weight in stocks (0-1), scalar or array

required
bond_weight float | ndarray | None

Weight in bonds (remainder is cash if not specified)

None

Returns:

Type Description
float | ndarray

Annual portfolio volatility (scalar or array matching input)

Source code in fundedness/models/market.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
def portfolio_volatility(
    self,
    stock_weight: float | np.ndarray,
    bond_weight: float | np.ndarray | None = None,
) -> float | np.ndarray:
    """Calculate portfolio volatility.

    Args:
        stock_weight: Weight in stocks (0-1), scalar or array
        bond_weight: Weight in bonds (remainder is cash if not specified)

    Returns:
        Annual portfolio volatility (scalar or array matching input)
    """
    if bond_weight is None:
        bond_weight = 1 - stock_weight

    cash_weight = np.maximum(0, 1 - stock_weight - bond_weight)

    cov = self.get_covariance_matrix()

    # Handle both scalar and array inputs
    if isinstance(stock_weight, np.ndarray):
        # Vectorized computation for array inputs
        # weights shape: (n_samples, 4)
        weights = np.column_stack([stock_weight, bond_weight, cash_weight, np.zeros_like(stock_weight)])
        # cov @ weights.T has shape (4, n_samples)
        # We want sum of weights[i] * (cov @ weights[i]) for each i
        # This is equivalent to diag(weights @ cov @ weights.T)
        portfolio_variance = np.einsum('ij,jk,ik->i', weights, cov, weights)
        return np.sqrt(portfolio_variance)
    else:
        weights = np.array([stock_weight, bond_weight, cash_weight, 0])
        portfolio_variance = weights @ cov @ weights
        return np.sqrt(portfolio_variance)

validate_dof(v) classmethod

Ensure degrees of freedom is reasonable.

Source code in fundedness/models/market.py
 94
 95
 96
 97
 98
 99
100
@field_validator("degrees_of_freedom")
@classmethod
def validate_dof(cls, v: int) -> int:
    """Ensure degrees of freedom is reasonable."""
    if v < 3:
        raise ValueError("Degrees of freedom must be at least 3")
    return v

Simulation Config

fundedness.models.simulation.SimulationConfig

Bases: BaseModel

Configuration for Monte Carlo simulations.

Source code in fundedness/models/simulation.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class SimulationConfig(BaseModel):
    """Configuration for Monte Carlo simulations."""

    # Simulation parameters
    n_simulations: int = Field(
        default=10000,
        ge=100,
        le=100000,
        description="Number of Monte Carlo paths",
    )
    n_years: int = Field(
        default=50,
        ge=1,
        le=100,
        description="Simulation horizon in years",
    )
    random_seed: int | None = Field(
        default=None,
        description="Random seed for reproducibility (None = random)",
    )

    # Model components
    market_model: MarketModel = Field(
        default_factory=MarketModel,
        description="Market return and risk assumptions",
    )
    tax_model: TaxModel = Field(
        default_factory=TaxModel,
        description="Tax rate assumptions",
    )
    utility_model: UtilityModel = Field(
        default_factory=UtilityModel,
        description="Utility function parameters",
    )

    # Return generation
    return_model: Literal["lognormal", "t_distribution", "bootstrap"] = Field(
        default="lognormal",
        description="Model for generating returns",
    )

    # Output options
    percentiles: list[int] = Field(
        default=[10, 25, 50, 75, 90],
        description="Percentiles to report (0-100)",
    )
    track_spending: bool = Field(
        default=True,
        description="Track spending paths in simulation",
    )
    track_allocation: bool = Field(
        default=False,
        description="Track allocation changes over time",
    )

    # Performance
    use_vectorized: bool = Field(
        default=True,
        description="Use vectorized numpy operations for speed",
    )
    chunk_size: int = Field(
        default=1000,
        ge=100,
        description="Chunk size for memory-efficient simulation",
    )

    def get_percentile_labels(self) -> list[str]:
        """Get formatted percentile labels."""
        return [f"P{p}" for p in self.percentiles]

get_percentile_labels()

Get formatted percentile labels.

Source code in fundedness/models/simulation.py
78
79
80
def get_percentile_labels(self) -> list[str]:
    """Get formatted percentile labels."""
    return [f"P{p}" for p in self.percentiles]

Tax Model

fundedness.models.tax.TaxModel

Bases: BaseModel

Tax rates and assumptions.

Source code in fundedness/models/tax.py
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
class TaxModel(BaseModel):
    """Tax rates and assumptions."""

    # Federal marginal rates
    federal_ordinary_rate: float = Field(
        default=0.24,
        ge=0,
        le=1,
        description="Federal marginal tax rate on ordinary income",
    )
    federal_ltcg_rate: float = Field(
        default=0.15,
        ge=0,
        le=1,
        description="Federal long-term capital gains rate",
    )
    federal_stcg_rate: float = Field(
        default=0.24,
        ge=0,
        le=1,
        description="Federal short-term capital gains rate (usually = ordinary)",
    )

    # State rates
    state_ordinary_rate: float = Field(
        default=0.093,
        ge=0,
        le=1,
        description="State marginal tax rate on ordinary income",
    )
    state_ltcg_rate: float = Field(
        default=0.093,
        ge=0,
        le=1,
        description="State long-term capital gains rate",
    )

    # Other
    niit_rate: float = Field(
        default=0.038,
        ge=0,
        le=1,
        description="Net Investment Income Tax rate (3.8%)",
    )
    niit_applies: bool = Field(
        default=True,
        description="Whether NIIT applies to this household",
    )

    # Cost basis assumptions
    default_cost_basis_ratio: float = Field(
        default=0.5,
        ge=0,
        le=1,
        description="Default cost basis as fraction of value (for unrealized gains)",
    )

    @property
    def total_ordinary_rate(self) -> float:
        """Combined federal + state ordinary income tax rate."""
        return self.federal_ordinary_rate + self.state_ordinary_rate

    @property
    def total_ltcg_rate(self) -> float:
        """Combined federal + state + NIIT long-term capital gains rate."""
        base = self.federal_ltcg_rate + self.state_ltcg_rate
        if self.niit_applies:
            base += self.niit_rate
        return base

    def get_effective_tax_rate(
        self,
        account_type: AccountType,
        cost_basis_ratio: float | None = None,
    ) -> float:
        """Get the effective tax rate for withdrawals from an account type.

        Args:
            account_type: Type of account
            cost_basis_ratio: Cost basis as fraction of value (for taxable accounts)

        Returns:
            Effective tax rate as decimal (0-1)
        """
        match account_type:
            case AccountType.TAX_EXEMPT:
                # Roth: no tax on withdrawals
                return 0.0

            case AccountType.TAX_DEFERRED:
                # Traditional IRA/401k: taxed as ordinary income
                return self.total_ordinary_rate

            case AccountType.HSA:
                # HSA: no tax if used for medical expenses
                return 0.0

            case AccountType.TAXABLE:
                # Taxable: only gains are taxed
                if cost_basis_ratio is None:
                    cost_basis_ratio = self.default_cost_basis_ratio

                # Gains portion = (1 - cost_basis_ratio)
                gains_portion = 1 - cost_basis_ratio
                return gains_portion * self.total_ltcg_rate

    def get_haircut_by_account_type(self) -> dict[AccountType, float]:
        """Get tax haircut factors by account type.

        Returns:
            Dictionary mapping account type to (1 - tax_rate)
        """
        return {
            AccountType.TAXABLE: 1 - self.get_effective_tax_rate(AccountType.TAXABLE),
            AccountType.TAX_DEFERRED: 1 - self.get_effective_tax_rate(AccountType.TAX_DEFERRED),
            AccountType.TAX_EXEMPT: 1 - self.get_effective_tax_rate(AccountType.TAX_EXEMPT),
            AccountType.HSA: 1 - self.get_effective_tax_rate(AccountType.HSA),
        }

total_ltcg_rate property

Combined federal + state + NIIT long-term capital gains rate.

total_ordinary_rate property

Combined federal + state ordinary income tax rate.

get_effective_tax_rate(account_type, cost_basis_ratio=None)

Get the effective tax rate for withdrawals from an account type.

Parameters:

Name Type Description Default
account_type AccountType

Type of account

required
cost_basis_ratio float | None

Cost basis as fraction of value (for taxable accounts)

None

Returns:

Type Description
float

Effective tax rate as decimal (0-1)

Source code in fundedness/models/tax.py
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def get_effective_tax_rate(
    self,
    account_type: AccountType,
    cost_basis_ratio: float | None = None,
) -> float:
    """Get the effective tax rate for withdrawals from an account type.

    Args:
        account_type: Type of account
        cost_basis_ratio: Cost basis as fraction of value (for taxable accounts)

    Returns:
        Effective tax rate as decimal (0-1)
    """
    match account_type:
        case AccountType.TAX_EXEMPT:
            # Roth: no tax on withdrawals
            return 0.0

        case AccountType.TAX_DEFERRED:
            # Traditional IRA/401k: taxed as ordinary income
            return self.total_ordinary_rate

        case AccountType.HSA:
            # HSA: no tax if used for medical expenses
            return 0.0

        case AccountType.TAXABLE:
            # Taxable: only gains are taxed
            if cost_basis_ratio is None:
                cost_basis_ratio = self.default_cost_basis_ratio

            # Gains portion = (1 - cost_basis_ratio)
            gains_portion = 1 - cost_basis_ratio
            return gains_portion * self.total_ltcg_rate

get_haircut_by_account_type()

Get tax haircut factors by account type.

Returns:

Type Description
dict[AccountType, float]

Dictionary mapping account type to (1 - tax_rate)

Source code in fundedness/models/tax.py
114
115
116
117
118
119
120
121
122
123
124
125
def get_haircut_by_account_type(self) -> dict[AccountType, float]:
    """Get tax haircut factors by account type.

    Returns:
        Dictionary mapping account type to (1 - tax_rate)
    """
    return {
        AccountType.TAXABLE: 1 - self.get_effective_tax_rate(AccountType.TAXABLE),
        AccountType.TAX_DEFERRED: 1 - self.get_effective_tax_rate(AccountType.TAX_DEFERRED),
        AccountType.TAX_EXEMPT: 1 - self.get_effective_tax_rate(AccountType.TAX_EXEMPT),
        AccountType.HSA: 1 - self.get_effective_tax_rate(AccountType.HSA),
    }