Skip to content

Merton Optimal API Reference

This module provides Merton's optimal consumption and portfolio choice formulas.

Core Functions

fundedness.merton.merton_optimal_allocation(market_model, utility_model)

Calculate Merton optimal equity allocation.

The Merton formula gives the optimal fraction to invest in risky assets: k* = (mu - r) / (gamma * sigma^2)

Parameters:

Name Type Description Default
market_model MarketModel

Market return and risk assumptions

required
utility_model UtilityModel

Utility parameters including risk aversion

required

Returns:

Type Description
float

Optimal equity allocation as decimal (can exceed 1.0 for leveraged)

Source code in fundedness/merton.py
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
def merton_optimal_allocation(
    market_model: MarketModel,
    utility_model: UtilityModel,
) -> float:
    """Calculate Merton optimal equity allocation.

    The Merton formula gives the optimal fraction to invest in risky assets:
    k* = (mu - r) / (gamma * sigma^2)

    Args:
        market_model: Market return and risk assumptions
        utility_model: Utility parameters including risk aversion

    Returns:
        Optimal equity allocation as decimal (can exceed 1.0 for leveraged)
    """
    mu = market_model.stock_return
    r = market_model.bond_return
    gamma = utility_model.gamma
    sigma = market_model.stock_volatility

    if sigma == 0 or gamma == 0:
        return 0.0

    k_star = (mu - r) / (gamma * sigma**2)

    return k_star

fundedness.merton.certainty_equivalent_return(market_model, utility_model, equity_allocation=None)

Calculate certainty equivalent return for a portfolio.

The certainty equivalent return is the guaranteed return that provides the same expected utility as the risky portfolio: rce = r + k(mu - r) - gammak^2*sigma^2/2

Parameters:

Name Type Description Default
market_model MarketModel

Market return and risk assumptions

required
utility_model UtilityModel

Utility parameters including risk aversion

required
equity_allocation float | None

Equity allocation (uses optimal if None)

None

Returns:

Type Description
float

Certainty equivalent return as decimal

Source code in fundedness/merton.py
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
def certainty_equivalent_return(
    market_model: MarketModel,
    utility_model: UtilityModel,
    equity_allocation: float | None = None,
) -> float:
    """Calculate certainty equivalent return for a portfolio.

    The certainty equivalent return is the guaranteed return that provides
    the same expected utility as the risky portfolio:
    rce = r + k*(mu - r) - gamma*k^2*sigma^2/2

    Args:
        market_model: Market return and risk assumptions
        utility_model: Utility parameters including risk aversion
        equity_allocation: Equity allocation (uses optimal if None)

    Returns:
        Certainty equivalent return as decimal
    """
    if equity_allocation is None:
        equity_allocation = merton_optimal_allocation(market_model, utility_model)

    mu = market_model.stock_return
    r = market_model.bond_return
    gamma = utility_model.gamma
    sigma = market_model.stock_volatility

    k = equity_allocation
    risk_premium = k * (mu - r)
    risk_penalty = gamma * k**2 * sigma**2 / 2

    rce = r + risk_premium - risk_penalty

    return rce

fundedness.merton.merton_optimal_spending_rate(market_model, utility_model, remaining_years=None)

Calculate Merton optimal spending rate.

The optimal spending rate for an infinite horizon is: c* = rce - (rce - rtp) / gamma

For finite horizons, the rate is adjusted upward as horizon shortens.

Parameters:

Name Type Description Default
market_model MarketModel

Market return and risk assumptions

required
utility_model UtilityModel

Utility parameters including risk aversion and time preference

required
remaining_years float | None

Years until planning horizon ends (None for infinite)

None

Returns:

Type Description
float

Optimal spending rate as decimal (e.g., 0.03 = 3%)

Source code in fundedness/merton.py
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
def merton_optimal_spending_rate(
    market_model: MarketModel,
    utility_model: UtilityModel,
    remaining_years: float | None = None,
) -> float:
    """Calculate Merton optimal spending rate.

    The optimal spending rate for an infinite horizon is:
    c* = rce - (rce - rtp) / gamma

    For finite horizons, the rate is adjusted upward as horizon shortens.

    Args:
        market_model: Market return and risk assumptions
        utility_model: Utility parameters including risk aversion and time preference
        remaining_years: Years until planning horizon ends (None for infinite)

    Returns:
        Optimal spending rate as decimal (e.g., 0.03 = 3%)
    """
    rce = certainty_equivalent_return(market_model, utility_model)
    rtp = utility_model.time_preference
    gamma = utility_model.gamma

    if gamma == 1.0:
        # Log utility special case
        c_star = rtp
    else:
        c_star = rce - (rce - rtp) / gamma

    # Finite horizon adjustment
    if remaining_years is not None and remaining_years > 0:
        # Use annuity factor to increase spending rate for finite horizon
        # c_finite = c_infinite + 1 / remaining_years (approximate)
        if rce > 0:
            # Annuity present value factor
            pv_factor = (1 - (1 + rce) ** (-remaining_years)) / rce
            if pv_factor > 0:
                annuity_rate = 1 / pv_factor
                c_star = max(c_star, annuity_rate)
        else:
            # With non-positive returns, simple 1/N rule
            c_star = max(c_star, 1 / remaining_years)

    return max(c_star, 0.0)  # Can't have negative spending

fundedness.merton.wealth_adjusted_optimal_allocation(wealth, market_model, utility_model, min_allocation=0.0, max_allocation=1.0)

Calculate wealth-adjusted optimal equity allocation.

Near the subsistence floor, the optimal allocation approaches zero because the investor cannot afford to take risk. As wealth rises above the floor, allocation approaches the unconstrained Merton optimal.

The formula is: k_adjusted = k* * (W - F) / W

Where W is wealth and F is the subsistence floor.

Parameters:

Name Type Description Default
wealth float

Current portfolio value

required
market_model MarketModel

Market return and risk assumptions

required
utility_model UtilityModel

Utility parameters

required
min_allocation float

Minimum equity allocation (floor)

0.0
max_allocation float

Maximum equity allocation (ceiling)

1.0

Returns:

Type Description
float

Adjusted equity allocation as decimal, bounded by min/max

Source code in fundedness/merton.py
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
def wealth_adjusted_optimal_allocation(
    wealth: float,
    market_model: MarketModel,
    utility_model: UtilityModel,
    min_allocation: float = 0.0,
    max_allocation: float = 1.0,
) -> float:
    """Calculate wealth-adjusted optimal equity allocation.

    Near the subsistence floor, the optimal allocation approaches zero
    because the investor cannot afford to take risk. As wealth rises
    above the floor, allocation approaches the unconstrained Merton optimal.

    The formula is:
    k_adjusted = k* * (W - F) / W

    Where W is wealth and F is the subsistence floor.

    Args:
        wealth: Current portfolio value
        market_model: Market return and risk assumptions
        utility_model: Utility parameters
        min_allocation: Minimum equity allocation (floor)
        max_allocation: Maximum equity allocation (ceiling)

    Returns:
        Adjusted equity allocation as decimal, bounded by min/max
    """
    k_star = merton_optimal_allocation(market_model, utility_model)
    floor = utility_model.subsistence_floor

    if wealth <= floor:
        return min_allocation

    # Scale by distance from floor
    wealth_ratio = (wealth - floor) / wealth
    k_adjusted = k_star * wealth_ratio

    # Apply bounds
    return np.clip(k_adjusted, min_allocation, max_allocation)

fundedness.merton.calculate_merton_optimal(wealth, market_model, utility_model, remaining_years=None)

Calculate all Merton optimal values for given wealth.

This is the main entry point for getting all optimal policy parameters.

Parameters:

Name Type Description Default
wealth float

Current portfolio value

required
market_model MarketModel

Market return and risk assumptions

required
utility_model UtilityModel

Utility parameters

required
remaining_years float | None

Years until planning horizon ends

None

Returns:

Type Description
MertonOptimalResult

MertonOptimalResult with all optimal values

Source code in fundedness/merton.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
def calculate_merton_optimal(
    wealth: float,
    market_model: MarketModel,
    utility_model: UtilityModel,
    remaining_years: float | None = None,
) -> MertonOptimalResult:
    """Calculate all Merton optimal values for given wealth.

    This is the main entry point for getting all optimal policy parameters.

    Args:
        wealth: Current portfolio value
        market_model: Market return and risk assumptions
        utility_model: Utility parameters
        remaining_years: Years until planning horizon ends

    Returns:
        MertonOptimalResult with all optimal values
    """
    k_star = merton_optimal_allocation(market_model, utility_model)
    rce = certainty_equivalent_return(market_model, utility_model)
    c_star = merton_optimal_spending_rate(market_model, utility_model, remaining_years)
    k_adjusted = wealth_adjusted_optimal_allocation(wealth, market_model, utility_model)

    risk_premium = market_model.stock_return - market_model.bond_return
    portfolio_vol = k_star * market_model.stock_volatility

    return MertonOptimalResult(
        optimal_equity_allocation=k_star,
        certainty_equivalent_return=rce,
        optimal_spending_rate=c_star,
        wealth_adjusted_allocation=k_adjusted,
        risk_premium=risk_premium,
        portfolio_volatility=portfolio_vol,
    )

Helper Functions

fundedness.merton.optimal_spending_by_age(market_model, utility_model, starting_age, end_age=100)

Calculate optimal spending rates for each age.

Spending rate increases with age as the remaining horizon shortens.

Parameters:

Name Type Description Default
market_model MarketModel

Market return and risk assumptions

required
utility_model UtilityModel

Utility parameters

required
starting_age int

Current age

required
end_age int

Assumed maximum age

100

Returns:

Type Description
dict[int, float]

Dictionary mapping age to optimal spending rate

Source code in fundedness/merton.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
def optimal_spending_by_age(
    market_model: MarketModel,
    utility_model: UtilityModel,
    starting_age: int,
    end_age: int = 100,
) -> dict[int, float]:
    """Calculate optimal spending rates for each age.

    Spending rate increases with age as the remaining horizon shortens.

    Args:
        market_model: Market return and risk assumptions
        utility_model: Utility parameters
        starting_age: Current age
        end_age: Assumed maximum age

    Returns:
        Dictionary mapping age to optimal spending rate
    """
    rates = {}
    for age in range(starting_age, end_age + 1):
        remaining_years = end_age - age
        if remaining_years <= 0:
            rates[age] = 1.0  # Spend everything at end
        else:
            rates[age] = merton_optimal_spending_rate(
                market_model, utility_model, remaining_years
            )
    return rates

fundedness.merton.optimal_allocation_by_wealth(market_model, utility_model, wealth_levels, min_allocation=0.0, max_allocation=1.0)

Calculate optimal allocation for a range of wealth levels.

Useful for generating allocation curves showing how equity percentage should vary with distance from subsistence floor.

Parameters:

Name Type Description Default
market_model MarketModel

Market return and risk assumptions

required
utility_model UtilityModel

Utility parameters

required
wealth_levels ndarray

Array of wealth values to calculate for

required
min_allocation float

Minimum equity allocation

0.0
max_allocation float

Maximum equity allocation

1.0

Returns:

Type Description
ndarray

Array of optimal allocations corresponding to wealth_levels

Source code in fundedness/merton.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
def optimal_allocation_by_wealth(
    market_model: MarketModel,
    utility_model: UtilityModel,
    wealth_levels: np.ndarray,
    min_allocation: float = 0.0,
    max_allocation: float = 1.0,
) -> np.ndarray:
    """Calculate optimal allocation for a range of wealth levels.

    Useful for generating allocation curves showing how equity percentage
    should vary with distance from subsistence floor.

    Args:
        market_model: Market return and risk assumptions
        utility_model: Utility parameters
        wealth_levels: Array of wealth values to calculate for
        min_allocation: Minimum equity allocation
        max_allocation: Maximum equity allocation

    Returns:
        Array of optimal allocations corresponding to wealth_levels
    """
    allocations = np.zeros_like(wealth_levels, dtype=float)
    for i, wealth in enumerate(wealth_levels):
        allocations[i] = wealth_adjusted_optimal_allocation(
            wealth=wealth,
            market_model=market_model,
            utility_model=utility_model,
            min_allocation=min_allocation,
            max_allocation=max_allocation,
        )
    return allocations

Data Classes

fundedness.merton.MertonOptimalResult dataclass

Results from Merton optimal calculations.

Source code in fundedness/merton.py
24
25
26
27
28
29
30
31
32
33
@dataclass
class MertonOptimalResult:
    """Results from Merton optimal calculations."""

    optimal_equity_allocation: float
    certainty_equivalent_return: float
    optimal_spending_rate: float
    wealth_adjusted_allocation: float
    risk_premium: float
    portfolio_volatility: float

Spending Policies

fundedness.withdrawals.merton_optimal.MertonOptimalSpendingPolicy dataclass

Bases: BaseWithdrawalPolicy

Spending policy based on Merton optimal consumption theory.

This policy determines spending by applying the Merton optimal spending rate to current wealth, adjusted for the remaining time horizon.

Key characteristics: - Spending rate starts low (~2-3%) and rises with age - Rate depends on risk aversion, time preference, and market assumptions - Adapts to actual wealth (not locked to initial withdrawal amount) - Optional smoothing to reduce year-to-year volatility

Attributes:

Name Type Description
market_model MarketModel

Market return and risk assumptions

utility_model UtilityModel

Utility parameters including risk aversion

starting_age int

Age at retirement/simulation start

end_age int

Assumed maximum age for planning

smoothing_factor float

Blend current with previous spending (0-1, 0=no smoothing)

min_spending_rate float

Minimum spending rate floor

max_spending_rate float

Maximum spending rate ceiling

Source code in fundedness/withdrawals/merton_optimal.py
 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
@dataclass
class MertonOptimalSpendingPolicy(BaseWithdrawalPolicy):
    """Spending policy based on Merton optimal consumption theory.

    This policy determines spending by applying the Merton optimal spending
    rate to current wealth, adjusted for the remaining time horizon.

    Key characteristics:
    - Spending rate starts low (~2-3%) and rises with age
    - Rate depends on risk aversion, time preference, and market assumptions
    - Adapts to actual wealth (not locked to initial withdrawal amount)
    - Optional smoothing to reduce year-to-year volatility

    Attributes:
        market_model: Market return and risk assumptions
        utility_model: Utility parameters including risk aversion
        starting_age: Age at retirement/simulation start
        end_age: Assumed maximum age for planning
        smoothing_factor: Blend current with previous spending (0-1, 0=no smoothing)
        min_spending_rate: Minimum spending rate floor
        max_spending_rate: Maximum spending rate ceiling
    """

    market_model: MarketModel = field(default_factory=MarketModel)
    utility_model: UtilityModel = field(default_factory=UtilityModel)
    starting_age: int = 65
    end_age: int = 100
    smoothing_factor: float = 0.5
    min_spending_rate: float = 0.02
    max_spending_rate: float = 0.15

    @property
    def name(self) -> str:
        return "Merton Optimal"

    @property
    def description(self) -> str:
        gamma = self.utility_model.gamma
        return f"Utility-optimal spending (gamma={gamma})"

    def get_optimal_rate(self, remaining_years: float) -> float:
        """Get the optimal spending rate for given remaining years.

        Args:
            remaining_years: Years until end of planning horizon

        Returns:
            Optimal spending rate as decimal
        """
        rate = merton_optimal_spending_rate(
            market_model=self.market_model,
            utility_model=self.utility_model,
            remaining_years=remaining_years,
        )
        return np.clip(rate, self.min_spending_rate, self.max_spending_rate)

    def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
        """Calculate withdrawal using Merton optimal spending rate.

        Args:
            context: Current state information

        Returns:
            WithdrawalDecision with amount and metadata
        """
        # Determine current age
        if context.age is not None:
            current_age = context.age
        else:
            current_age = self.starting_age + context.year

        remaining_years = max(1, self.end_age - current_age)

        # Get optimal spending rate
        rate = self.get_optimal_rate(remaining_years)

        # Handle vectorized wealth
        if isinstance(context.current_wealth, np.ndarray):
            wealth = context.current_wealth
        else:
            wealth = context.current_wealth

        # Calculate raw spending
        raw_spending = wealth * rate

        # Apply smoothing if we have previous spending
        if self.smoothing_factor > 0 and context.previous_spending is not None:
            # Adjust previous spending for inflation
            prev_real = context.previous_spending / context.inflation_cumulative
            smoothed = (
                self.smoothing_factor * prev_real * context.inflation_cumulative
                + (1 - self.smoothing_factor) * raw_spending
            )
            spending = smoothed
        else:
            spending = raw_spending

        # Apply guardrails
        amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
            spending, context.current_wealth
        )

        return WithdrawalDecision(
            amount=amount,
            is_floor_breach=is_floor_breach,
            is_ceiling_hit=is_ceiling_hit,
            notes=f"Rate: {rate:.1%}, Remaining: {remaining_years}y",
        )

    def get_initial_withdrawal(self, initial_wealth: float) -> float:
        """Calculate first year withdrawal.

        Args:
            initial_wealth: Starting portfolio value

        Returns:
            First year withdrawal amount
        """
        remaining_years = self.end_age - self.starting_age
        rate = self.get_optimal_rate(remaining_years)
        return initial_wealth * rate

    def get_spending(
        self,
        wealth: np.ndarray,
        year: int,
        initial_wealth: float,
    ) -> np.ndarray:
        """Get spending for simulation (vectorized interface).

        This method is used by the Monte Carlo simulation engine.

        Args:
            wealth: Current portfolio values (n_simulations,)
            year: Current simulation year
            initial_wealth: Starting portfolio value

        Returns:
            Spending amounts for each simulation path
        """
        current_age = self.starting_age + year
        remaining_years = max(1, self.end_age - current_age)
        rate = self.get_optimal_rate(remaining_years)

        spending = wealth * rate

        # Ensure non-negative and bounded by wealth
        spending = np.maximum(spending, 0)
        spending = np.minimum(spending, np.maximum(wealth, 0))

        # Apply floor if set
        if self.floor_spending is not None:
            spending = np.maximum(spending, self.floor_spending)
            # But still can't spend more than we have
            spending = np.minimum(spending, np.maximum(wealth, 0))

        return spending

calculate_withdrawal(context)

Calculate withdrawal using Merton optimal spending rate.

Parameters:

Name Type Description Default
context WithdrawalContext

Current state information

required

Returns:

Type Description
WithdrawalDecision

WithdrawalDecision with amount and metadata

Source code in fundedness/withdrawals/merton_optimal.py
 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
def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
    """Calculate withdrawal using Merton optimal spending rate.

    Args:
        context: Current state information

    Returns:
        WithdrawalDecision with amount and metadata
    """
    # Determine current age
    if context.age is not None:
        current_age = context.age
    else:
        current_age = self.starting_age + context.year

    remaining_years = max(1, self.end_age - current_age)

    # Get optimal spending rate
    rate = self.get_optimal_rate(remaining_years)

    # Handle vectorized wealth
    if isinstance(context.current_wealth, np.ndarray):
        wealth = context.current_wealth
    else:
        wealth = context.current_wealth

    # Calculate raw spending
    raw_spending = wealth * rate

    # Apply smoothing if we have previous spending
    if self.smoothing_factor > 0 and context.previous_spending is not None:
        # Adjust previous spending for inflation
        prev_real = context.previous_spending / context.inflation_cumulative
        smoothed = (
            self.smoothing_factor * prev_real * context.inflation_cumulative
            + (1 - self.smoothing_factor) * raw_spending
        )
        spending = smoothed
    else:
        spending = raw_spending

    # Apply guardrails
    amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
        spending, context.current_wealth
    )

    return WithdrawalDecision(
        amount=amount,
        is_floor_breach=is_floor_breach,
        is_ceiling_hit=is_ceiling_hit,
        notes=f"Rate: {rate:.1%}, Remaining: {remaining_years}y",
    )

get_initial_withdrawal(initial_wealth)

Calculate first year withdrawal.

Parameters:

Name Type Description Default
initial_wealth float

Starting portfolio value

required

Returns:

Type Description
float

First year withdrawal amount

Source code in fundedness/withdrawals/merton_optimal.py
129
130
131
132
133
134
135
136
137
138
139
140
def get_initial_withdrawal(self, initial_wealth: float) -> float:
    """Calculate first year withdrawal.

    Args:
        initial_wealth: Starting portfolio value

    Returns:
        First year withdrawal amount
    """
    remaining_years = self.end_age - self.starting_age
    rate = self.get_optimal_rate(remaining_years)
    return initial_wealth * rate

get_optimal_rate(remaining_years)

Get the optimal spending rate for given remaining years.

Parameters:

Name Type Description Default
remaining_years float

Years until end of planning horizon

required

Returns:

Type Description
float

Optimal spending rate as decimal

Source code in fundedness/withdrawals/merton_optimal.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def get_optimal_rate(self, remaining_years: float) -> float:
    """Get the optimal spending rate for given remaining years.

    Args:
        remaining_years: Years until end of planning horizon

    Returns:
        Optimal spending rate as decimal
    """
    rate = merton_optimal_spending_rate(
        market_model=self.market_model,
        utility_model=self.utility_model,
        remaining_years=remaining_years,
    )
    return np.clip(rate, self.min_spending_rate, self.max_spending_rate)

get_spending(wealth, year, initial_wealth)

Get spending for simulation (vectorized interface).

This method is used by the Monte Carlo simulation engine.

Parameters:

Name Type Description Default
wealth ndarray

Current portfolio values (n_simulations,)

required
year int

Current simulation year

required
initial_wealth float

Starting portfolio value

required

Returns:

Type Description
ndarray

Spending amounts for each simulation path

Source code in fundedness/withdrawals/merton_optimal.py
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
def get_spending(
    self,
    wealth: np.ndarray,
    year: int,
    initial_wealth: float,
) -> np.ndarray:
    """Get spending for simulation (vectorized interface).

    This method is used by the Monte Carlo simulation engine.

    Args:
        wealth: Current portfolio values (n_simulations,)
        year: Current simulation year
        initial_wealth: Starting portfolio value

    Returns:
        Spending amounts for each simulation path
    """
    current_age = self.starting_age + year
    remaining_years = max(1, self.end_age - current_age)
    rate = self.get_optimal_rate(remaining_years)

    spending = wealth * rate

    # Ensure non-negative and bounded by wealth
    spending = np.maximum(spending, 0)
    spending = np.minimum(spending, np.maximum(wealth, 0))

    # Apply floor if set
    if self.floor_spending is not None:
        spending = np.maximum(spending, self.floor_spending)
        # But still can't spend more than we have
        spending = np.minimum(spending, np.maximum(wealth, 0))

    return spending

fundedness.withdrawals.merton_optimal.SmoothedMertonPolicy dataclass

Bases: MertonOptimalSpendingPolicy

Merton optimal with aggressive smoothing for stable spending.

This variant applies stronger smoothing to reduce spending volatility, trading off some optimality for a more stable spending experience.

Source code in fundedness/withdrawals/merton_optimal.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
@dataclass
class SmoothedMertonPolicy(MertonOptimalSpendingPolicy):
    """Merton optimal with aggressive smoothing for stable spending.

    This variant applies stronger smoothing to reduce spending volatility,
    trading off some optimality for a more stable spending experience.
    """

    smoothing_factor: float = 0.7
    adaptation_rate: float = 0.1

    @property
    def name(self) -> str:
        return "Smoothed Merton"

    @property
    def description(self) -> str:
        return "Merton optimal with spending smoothing"

    def get_spending(
        self,
        wealth: np.ndarray,
        year: int,
        initial_wealth: float,
    ) -> np.ndarray:
        """Get smoothed spending for simulation.

        Uses exponential smoothing of the optimal spending amount.

        Args:
            wealth: Current portfolio values
            year: Current simulation year
            initial_wealth: Starting portfolio value

        Returns:
            Smoothed spending amounts
        """
        # Get raw Merton optimal spending
        current_age = self.starting_age + year
        remaining_years = max(1, self.end_age - current_age)
        rate = self.get_optimal_rate(remaining_years)

        optimal_spending = wealth * rate

        # For first year or if tracking isn't set up, use optimal directly
        # In practice, smoothing would be applied via simulation state
        spending = optimal_spending

        # Apply floor if set
        if self.floor_spending is not None:
            spending = np.maximum(spending, self.floor_spending)
            spending = np.minimum(spending, np.maximum(wealth, 0))

        return spending

get_spending(wealth, year, initial_wealth)

Get smoothed spending for simulation.

Uses exponential smoothing of the optimal spending amount.

Parameters:

Name Type Description Default
wealth ndarray

Current portfolio values

required
year int

Current simulation year

required
initial_wealth float

Starting portfolio value

required

Returns:

Type Description
ndarray

Smoothed spending amounts

Source code in fundedness/withdrawals/merton_optimal.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
def get_spending(
    self,
    wealth: np.ndarray,
    year: int,
    initial_wealth: float,
) -> np.ndarray:
    """Get smoothed spending for simulation.

    Uses exponential smoothing of the optimal spending amount.

    Args:
        wealth: Current portfolio values
        year: Current simulation year
        initial_wealth: Starting portfolio value

    Returns:
        Smoothed spending amounts
    """
    # Get raw Merton optimal spending
    current_age = self.starting_age + year
    remaining_years = max(1, self.end_age - current_age)
    rate = self.get_optimal_rate(remaining_years)

    optimal_spending = wealth * rate

    # For first year or if tracking isn't set up, use optimal directly
    # In practice, smoothing would be applied via simulation state
    spending = optimal_spending

    # Apply floor if set
    if self.floor_spending is not None:
        spending = np.maximum(spending, self.floor_spending)
        spending = np.minimum(spending, np.maximum(wealth, 0))

    return spending

fundedness.withdrawals.merton_optimal.FloorAdjustedMertonPolicy dataclass

Bases: MertonOptimalSpendingPolicy

Merton optimal that accounts for subsistence floor in spending.

This variant only applies the optimal rate to wealth above the floor-supporting level, ensuring floor spending is always protected.

Source code in fundedness/withdrawals/merton_optimal.py
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
@dataclass
class FloorAdjustedMertonPolicy(MertonOptimalSpendingPolicy):
    """Merton optimal that accounts for subsistence floor in spending.

    This variant only applies the optimal rate to wealth above the
    floor-supporting level, ensuring floor spending is always protected.
    """

    years_of_floor_to_protect: int = 5

    @property
    def name(self) -> str:
        return "Floor-Protected Merton"

    @property
    def description(self) -> str:
        return f"Merton optimal protecting {self.years_of_floor_to_protect}y floor"

    def get_spending(
        self,
        wealth: np.ndarray,
        year: int,
        initial_wealth: float,
    ) -> np.ndarray:
        """Get spending that protects floor for several years.

        Args:
            wealth: Current portfolio values
            year: Current simulation year
            initial_wealth: Starting portfolio value

        Returns:
            Floor-protected spending amounts
        """
        current_age = self.starting_age + year
        remaining_years = max(1, self.end_age - current_age)
        rate = self.get_optimal_rate(remaining_years)

        floor = self.utility_model.subsistence_floor
        protected_wealth = floor * self.years_of_floor_to_protect

        # Only apply rate to wealth above protected level
        excess_wealth = np.maximum(wealth - protected_wealth, 0)
        flex_spending = excess_wealth * rate

        # Total spending = floor + flexible portion
        spending = floor + flex_spending

        # Can't spend more than we have
        spending = np.minimum(spending, np.maximum(wealth, 0))

        return spending

get_spending(wealth, year, initial_wealth)

Get spending that protects floor for several years.

Parameters:

Name Type Description Default
wealth ndarray

Current portfolio values

required
year int

Current simulation year

required
initial_wealth float

Starting portfolio value

required

Returns:

Type Description
ndarray

Floor-protected spending amounts

Source code in fundedness/withdrawals/merton_optimal.py
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def get_spending(
    self,
    wealth: np.ndarray,
    year: int,
    initial_wealth: float,
) -> np.ndarray:
    """Get spending that protects floor for several years.

    Args:
        wealth: Current portfolio values
        year: Current simulation year
        initial_wealth: Starting portfolio value

    Returns:
        Floor-protected spending amounts
    """
    current_age = self.starting_age + year
    remaining_years = max(1, self.end_age - current_age)
    rate = self.get_optimal_rate(remaining_years)

    floor = self.utility_model.subsistence_floor
    protected_wealth = floor * self.years_of_floor_to_protect

    # Only apply rate to wealth above protected level
    excess_wealth = np.maximum(wealth - protected_wealth, 0)
    flex_spending = excess_wealth * rate

    # Total spending = floor + flexible portion
    spending = floor + flex_spending

    # Can't spend more than we have
    spending = np.minimum(spending, np.maximum(wealth, 0))

    return spending

Allocation Policies

fundedness.allocation.merton_optimal.MertonOptimalAllocationPolicy dataclass

Allocation policy based on Merton optimal portfolio theory.

This policy determines equity allocation using the Merton formula, with adjustments for wealth level relative to subsistence floor.

Key characteristics: - Base allocation from Merton: k* = (mu - r) / (gamma * sigma^2) - Allocation decreases as wealth approaches subsistence floor - Configurable bounds to prevent extreme positions

Attributes:

Name Type Description
market_model MarketModel

Market return and risk assumptions

utility_model UtilityModel

Utility parameters including risk aversion

min_equity float

Minimum equity allocation

max_equity float

Maximum equity allocation

use_wealth_adjustment bool

Whether to reduce allocation near floor

Source code in fundedness/allocation/merton_optimal.py
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
@dataclass
class MertonOptimalAllocationPolicy:
    """Allocation policy based on Merton optimal portfolio theory.

    This policy determines equity allocation using the Merton formula,
    with adjustments for wealth level relative to subsistence floor.

    Key characteristics:
    - Base allocation from Merton: k* = (mu - r) / (gamma * sigma^2)
    - Allocation decreases as wealth approaches subsistence floor
    - Configurable bounds to prevent extreme positions

    Attributes:
        market_model: Market return and risk assumptions
        utility_model: Utility parameters including risk aversion
        min_equity: Minimum equity allocation
        max_equity: Maximum equity allocation
        use_wealth_adjustment: Whether to reduce allocation near floor
    """

    market_model: MarketModel = field(default_factory=MarketModel)
    utility_model: UtilityModel = field(default_factory=UtilityModel)
    min_equity: float = 0.0
    max_equity: float = 1.0
    use_wealth_adjustment: bool = True

    @property
    def name(self) -> str:
        k_star = merton_optimal_allocation(self.market_model, self.utility_model)
        return f"Merton Optimal ({k_star:.0%})"

    def get_unconstrained_allocation(self) -> float:
        """Get the unconstrained Merton optimal allocation.

        Returns:
            Optimal equity allocation (may exceed bounds)
        """
        return merton_optimal_allocation(self.market_model, self.utility_model)

    def get_allocation(
        self,
        wealth: float | np.ndarray,
        year: int,
        initial_wealth: float,
    ) -> float | np.ndarray:
        """Get the optimal stock allocation.

        Args:
            wealth: Current portfolio value(s)
            year: Current year in simulation (not used but required by interface)
            initial_wealth: Starting portfolio value (not used but required)

        Returns:
            Stock allocation as decimal (0-1), scalar or array matching wealth
        """
        if not self.use_wealth_adjustment:
            # Use fixed Merton optimal allocation
            k_star = merton_optimal_allocation(self.market_model, self.utility_model)
            return np.clip(k_star, self.min_equity, self.max_equity)

        # Apply wealth-adjusted allocation
        if isinstance(wealth, np.ndarray):
            allocations = np.zeros_like(wealth, dtype=float)
            for i, w in enumerate(wealth):
                allocations[i] = wealth_adjusted_optimal_allocation(
                    wealth=w,
                    market_model=self.market_model,
                    utility_model=self.utility_model,
                    min_allocation=self.min_equity,
                    max_allocation=self.max_equity,
                )
            return allocations
        else:
            return wealth_adjusted_optimal_allocation(
                wealth=wealth,
                market_model=self.market_model,
                utility_model=self.utility_model,
                min_allocation=self.min_equity,
                max_allocation=self.max_equity,
            )

get_allocation(wealth, year, initial_wealth)

Get the optimal stock allocation.

Parameters:

Name Type Description Default
wealth float | ndarray

Current portfolio value(s)

required
year int

Current year in simulation (not used but required by interface)

required
initial_wealth float

Starting portfolio value (not used but required)

required

Returns:

Type Description
float | ndarray

Stock allocation as decimal (0-1), scalar or array matching wealth

Source code in fundedness/allocation/merton_optimal.py
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
def get_allocation(
    self,
    wealth: float | np.ndarray,
    year: int,
    initial_wealth: float,
) -> float | np.ndarray:
    """Get the optimal stock allocation.

    Args:
        wealth: Current portfolio value(s)
        year: Current year in simulation (not used but required by interface)
        initial_wealth: Starting portfolio value (not used but required)

    Returns:
        Stock allocation as decimal (0-1), scalar or array matching wealth
    """
    if not self.use_wealth_adjustment:
        # Use fixed Merton optimal allocation
        k_star = merton_optimal_allocation(self.market_model, self.utility_model)
        return np.clip(k_star, self.min_equity, self.max_equity)

    # Apply wealth-adjusted allocation
    if isinstance(wealth, np.ndarray):
        allocations = np.zeros_like(wealth, dtype=float)
        for i, w in enumerate(wealth):
            allocations[i] = wealth_adjusted_optimal_allocation(
                wealth=w,
                market_model=self.market_model,
                utility_model=self.utility_model,
                min_allocation=self.min_equity,
                max_allocation=self.max_equity,
            )
        return allocations
    else:
        return wealth_adjusted_optimal_allocation(
            wealth=wealth,
            market_model=self.market_model,
            utility_model=self.utility_model,
            min_allocation=self.min_equity,
            max_allocation=self.max_equity,
        )

get_unconstrained_allocation()

Get the unconstrained Merton optimal allocation.

Returns:

Type Description
float

Optimal equity allocation (may exceed bounds)

Source code in fundedness/allocation/merton_optimal.py
46
47
48
49
50
51
52
def get_unconstrained_allocation(self) -> float:
    """Get the unconstrained Merton optimal allocation.

    Returns:
        Optimal equity allocation (may exceed bounds)
    """
    return merton_optimal_allocation(self.market_model, self.utility_model)

fundedness.allocation.merton_optimal.WealthBasedAllocationPolicy dataclass

Allocation that varies with wealth relative to floor.

This is a simplified version that linearly interpolates between a minimum allocation at the floor and maximum at a target wealth.

More intuitive than full Merton but captures the key insight that risk capacity depends on distance from subsistence.

Attributes:

Name Type Description
floor_wealth float

Wealth level at which equity is at minimum

target_wealth float

Wealth level at which equity reaches maximum

min_equity float

Equity allocation at floor

max_equity float

Equity allocation at target and above

Source code in fundedness/allocation/merton_optimal.py
 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
@dataclass
class WealthBasedAllocationPolicy:
    """Allocation that varies with wealth relative to floor.

    This is a simplified version that linearly interpolates between
    a minimum allocation at the floor and maximum at a target wealth.

    More intuitive than full Merton but captures the key insight that
    risk capacity depends on distance from subsistence.

    Attributes:
        floor_wealth: Wealth level at which equity is at minimum
        target_wealth: Wealth level at which equity reaches maximum
        min_equity: Equity allocation at floor
        max_equity: Equity allocation at target and above
    """

    floor_wealth: float = 500_000
    target_wealth: float = 2_000_000
    min_equity: float = 0.2
    max_equity: float = 0.8

    @property
    def name(self) -> str:
        return f"Wealth-Based ({self.min_equity:.0%}-{self.max_equity:.0%})"

    def get_allocation(
        self,
        wealth: float | np.ndarray,
        year: int,
        initial_wealth: float,
    ) -> float | np.ndarray:
        """Get allocation based on current wealth level.

        Args:
            wealth: Current portfolio value(s)
            year: Current year (not used)
            initial_wealth: Starting value (not used)

        Returns:
            Stock allocation interpolated by wealth
        """
        # Linear interpolation between floor and target
        wealth_range = self.target_wealth - self.floor_wealth
        equity_range = self.max_equity - self.min_equity

        if isinstance(wealth, np.ndarray):
            progress = (wealth - self.floor_wealth) / wealth_range
            progress = np.clip(progress, 0, 1)
            return self.min_equity + progress * equity_range
        else:
            progress = (wealth - self.floor_wealth) / wealth_range
            progress = max(0, min(1, progress))
            return self.min_equity + progress * equity_range

get_allocation(wealth, year, initial_wealth)

Get allocation based on current wealth level.

Parameters:

Name Type Description Default
wealth float | ndarray

Current portfolio value(s)

required
year int

Current year (not used)

required
initial_wealth float

Starting value (not used)

required

Returns:

Type Description
float | ndarray

Stock allocation interpolated by wealth

Source code in fundedness/allocation/merton_optimal.py
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
def get_allocation(
    self,
    wealth: float | np.ndarray,
    year: int,
    initial_wealth: float,
) -> float | np.ndarray:
    """Get allocation based on current wealth level.

    Args:
        wealth: Current portfolio value(s)
        year: Current year (not used)
        initial_wealth: Starting value (not used)

    Returns:
        Stock allocation interpolated by wealth
    """
    # Linear interpolation between floor and target
    wealth_range = self.target_wealth - self.floor_wealth
    equity_range = self.max_equity - self.min_equity

    if isinstance(wealth, np.ndarray):
        progress = (wealth - self.floor_wealth) / wealth_range
        progress = np.clip(progress, 0, 1)
        return self.min_equity + progress * equity_range
    else:
        progress = (wealth - self.floor_wealth) / wealth_range
        progress = max(0, min(1, progress))
        return self.min_equity + progress * equity_range

fundedness.allocation.merton_optimal.FloorProtectionAllocationPolicy dataclass

Allocation that increases equity as wealth grows above floor.

Inspired by CPPI (Constant Proportion Portfolio Insurance), this policy allocates equity as a multiple of the "cushion" (wealth above the floor-protection level).

Attributes:

Name Type Description
utility_model UtilityModel

For subsistence floor value

multiplier float

Equity = multiplier * (wealth - floor_reserve) / wealth

floor_years int

Years of floor spending to protect

min_equity float

Minimum equity allocation

max_equity float

Maximum equity allocation

Source code in fundedness/allocation/merton_optimal.py
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
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
@dataclass
class FloorProtectionAllocationPolicy:
    """Allocation that increases equity as wealth grows above floor.

    Inspired by CPPI (Constant Proportion Portfolio Insurance), this
    policy allocates equity as a multiple of the "cushion" (wealth above
    the floor-protection level).

    Attributes:
        utility_model: For subsistence floor value
        multiplier: Equity = multiplier * (wealth - floor_reserve) / wealth
        floor_years: Years of floor spending to protect
        min_equity: Minimum equity allocation
        max_equity: Maximum equity allocation
    """

    utility_model: UtilityModel = field(default_factory=UtilityModel)
    multiplier: float = 3.0
    floor_years: int = 10
    min_equity: float = 0.1
    max_equity: float = 0.9

    @property
    def name(self) -> str:
        return f"Floor Protection (m={self.multiplier})"

    def get_floor_reserve(self) -> float:
        """Get the wealth level that protects floor spending.

        Returns:
            Wealth needed to fund floor spending for floor_years
        """
        return self.utility_model.subsistence_floor * self.floor_years

    def get_allocation(
        self,
        wealth: float | np.ndarray,
        year: int,
        initial_wealth: float,
    ) -> float | np.ndarray:
        """Get allocation based on cushion above floor reserve.

        Args:
            wealth: Current portfolio value(s)
            year: Current year (not used)
            initial_wealth: Starting value (not used)

        Returns:
            Stock allocation based on cushion
        """
        floor_reserve = self.get_floor_reserve()

        if isinstance(wealth, np.ndarray):
            cushion = np.maximum(wealth - floor_reserve, 0)
            # Equity = multiplier * cushion / wealth
            # But avoid division by zero
            allocation = np.where(
                wealth > 0,
                self.multiplier * cushion / wealth,
                0.0,
            )
            return np.clip(allocation, self.min_equity, self.max_equity)
        else:
            if wealth <= 0:
                return self.min_equity
            cushion = max(wealth - floor_reserve, 0)
            allocation = self.multiplier * cushion / wealth
            return max(self.min_equity, min(self.max_equity, allocation))

get_allocation(wealth, year, initial_wealth)

Get allocation based on cushion above floor reserve.

Parameters:

Name Type Description Default
wealth float | ndarray

Current portfolio value(s)

required
year int

Current year (not used)

required
initial_wealth float

Starting value (not used)

required

Returns:

Type Description
float | ndarray

Stock allocation based on cushion

Source code in fundedness/allocation/merton_optimal.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
def get_allocation(
    self,
    wealth: float | np.ndarray,
    year: int,
    initial_wealth: float,
) -> float | np.ndarray:
    """Get allocation based on cushion above floor reserve.

    Args:
        wealth: Current portfolio value(s)
        year: Current year (not used)
        initial_wealth: Starting value (not used)

    Returns:
        Stock allocation based on cushion
    """
    floor_reserve = self.get_floor_reserve()

    if isinstance(wealth, np.ndarray):
        cushion = np.maximum(wealth - floor_reserve, 0)
        # Equity = multiplier * cushion / wealth
        # But avoid division by zero
        allocation = np.where(
            wealth > 0,
            self.multiplier * cushion / wealth,
            0.0,
        )
        return np.clip(allocation, self.min_equity, self.max_equity)
    else:
        if wealth <= 0:
            return self.min_equity
        cushion = max(wealth - floor_reserve, 0)
        allocation = self.multiplier * cushion / wealth
        return max(self.min_equity, min(self.max_equity, allocation))

get_floor_reserve()

Get the wealth level that protects floor spending.

Returns:

Type Description
float

Wealth needed to fund floor spending for floor_years

Source code in fundedness/allocation/merton_optimal.py
179
180
181
182
183
184
185
def get_floor_reserve(self) -> float:
    """Get the wealth level that protects floor spending.

    Returns:
        Wealth needed to fund floor spending for floor_years
    """
    return self.utility_model.subsistence_floor * self.floor_years

Policy Optimization

fundedness.optimize.PolicyParameterSpec dataclass

Specification for an optimizable policy parameter.

Attributes:

Name Type Description
name str

Parameter name (must match policy attribute)

min_value float

Minimum allowed value

max_value float

Maximum allowed value

initial_value float | None

Starting value for optimization

is_integer bool

Whether parameter should be rounded to integer

Source code in fundedness/optimize.py
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
@dataclass
class PolicyParameterSpec:
    """Specification for an optimizable policy parameter.

    Attributes:
        name: Parameter name (must match policy attribute)
        min_value: Minimum allowed value
        max_value: Maximum allowed value
        initial_value: Starting value for optimization
        is_integer: Whether parameter should be rounded to integer
    """

    name: str
    min_value: float
    max_value: float
    initial_value: float | None = None
    is_integer: bool = False

    def get_initial(self) -> float:
        """Get initial value (midpoint if not specified)."""
        if self.initial_value is not None:
            return self.initial_value
        return (self.min_value + self.max_value) / 2

    def clip(self, value: float) -> float:
        """Clip value to bounds and optionally round."""
        clipped = max(self.min_value, min(self.max_value, value))
        if self.is_integer:
            return round(clipped)
        return clipped

clip(value)

Clip value to bounds and optionally round.

Source code in fundedness/optimize.py
43
44
45
46
47
48
def clip(self, value: float) -> float:
    """Clip value to bounds and optionally round."""
    clipped = max(self.min_value, min(self.max_value, value))
    if self.is_integer:
        return round(clipped)
    return clipped

get_initial()

Get initial value (midpoint if not specified).

Source code in fundedness/optimize.py
37
38
39
40
41
def get_initial(self) -> float:
    """Get initial value (midpoint if not specified)."""
    if self.initial_value is not None:
        return self.initial_value
    return (self.min_value + self.max_value) / 2

fundedness.optimize.OptimizationResult dataclass

Results from policy optimization.

Attributes:

Name Type Description
optimal_params dict[str, float]

Dictionary of optimal parameter values

optimal_utility float

Expected lifetime utility at optimum

certainty_equivalent float

Certainty equivalent consumption at optimum

success_rate float

Success rate at optimal parameters

iterations int

Number of optimization iterations

convergence_history list[float]

Utility values during optimization

final_simulation Any

Full simulation result at optimum

Source code in fundedness/optimize.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
@dataclass
class OptimizationResult:
    """Results from policy optimization.

    Attributes:
        optimal_params: Dictionary of optimal parameter values
        optimal_utility: Expected lifetime utility at optimum
        certainty_equivalent: Certainty equivalent consumption at optimum
        success_rate: Success rate at optimal parameters
        iterations: Number of optimization iterations
        convergence_history: Utility values during optimization
        final_simulation: Full simulation result at optimum
    """

    optimal_params: dict[str, float]
    optimal_utility: float
    certainty_equivalent: float
    success_rate: float
    iterations: int
    convergence_history: list[float] = field(default_factory=list)
    final_simulation: Any = None

fundedness.optimize.optimize_spending_policy(policy_class, param_specs, initial_wealth, allocation_policy, config, utility_model, base_params=None, spending_floor=None, method='nelder-mead', max_iterations=50)

Optimize spending policy parameters to maximize utility.

Uses scipy.optimize to search over policy parameters, evaluating each candidate via Monte Carlo simulation.

Parameters:

Name Type Description Default
policy_class type

Spending policy class to optimize

required
param_specs list[PolicyParameterSpec]

Parameters to optimize

required
initial_wealth float

Starting portfolio value

required
allocation_policy Any

Fixed allocation policy to use

required
config SimulationConfig

Simulation configuration

required
utility_model UtilityModel

Utility model for evaluation

required
base_params dict | None

Fixed parameters for the policy

None
spending_floor float | None

Minimum spending floor

None
method str

Optimization method (nelder-mead, powell, etc.)

'nelder-mead'
max_iterations int

Maximum optimization iterations

50

Returns:

Type Description
OptimizationResult

OptimizationResult with optimal parameters and metrics

Source code in fundedness/optimize.py
 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
def optimize_spending_policy(
    policy_class: type,
    param_specs: list[PolicyParameterSpec],
    initial_wealth: float,
    allocation_policy: Any,
    config: SimulationConfig,
    utility_model: UtilityModel,
    base_params: dict | None = None,
    spending_floor: float | None = None,
    method: str = "nelder-mead",
    max_iterations: int = 50,
) -> OptimizationResult:
    """Optimize spending policy parameters to maximize utility.

    Uses scipy.optimize to search over policy parameters, evaluating
    each candidate via Monte Carlo simulation.

    Args:
        policy_class: Spending policy class to optimize
        param_specs: Parameters to optimize
        initial_wealth: Starting portfolio value
        allocation_policy: Fixed allocation policy to use
        config: Simulation configuration
        utility_model: Utility model for evaluation
        base_params: Fixed parameters for the policy
        spending_floor: Minimum spending floor
        method: Optimization method (nelder-mead, powell, etc.)
        max_iterations: Maximum optimization iterations

    Returns:
        OptimizationResult with optimal parameters and metrics
    """
    if base_params is None:
        base_params = {}

    convergence_history = []
    best_utility = -np.inf
    best_params = None
    best_result = None

    def objective(param_values: np.ndarray) -> float:
        """Negative utility (for minimization)."""
        nonlocal best_utility, best_params, best_result

        # Create policy with current parameters
        policy = create_policy_with_params(
            policy_class, base_params, param_specs, param_values
        )

        # Run simulation
        result = run_simulation_with_utility(
            initial_wealth=initial_wealth,
            spending_policy=policy,
            allocation_policy=allocation_policy,
            config=config,
            utility_model=utility_model,
            spending_floor=spending_floor,
        )

        utility = result.expected_lifetime_utility
        convergence_history.append(utility)

        # Track best
        if utility > best_utility:
            best_utility = utility
            best_params = {spec.name: spec.clip(v) for spec, v in zip(param_specs, param_values)}
            best_result = result

        return -utility  # Minimize negative utility

    # Initial values
    x0 = np.array([spec.get_initial() for spec in param_specs])

    # Bounds
    bounds = [(spec.min_value, spec.max_value) for spec in param_specs]

    # Run optimization
    if method.lower() in ("nelder-mead", "powell"):
        result = optimize.minimize(
            objective,
            x0,
            method=method,
            options={"maxiter": max_iterations, "disp": False},
        )
    else:
        result = optimize.minimize(
            objective,
            x0,
            method=method,
            bounds=bounds,
            options={"maxiter": max_iterations, "disp": False},
        )

    return OptimizationResult(
        optimal_params=best_params or {},
        optimal_utility=best_utility,
        certainty_equivalent=best_result.certainty_equivalent_consumption if best_result else 0.0,
        success_rate=best_result.success_rate if best_result else 0.0,
        iterations=result.nit if hasattr(result, "nit") else len(convergence_history),
        convergence_history=convergence_history,
        final_simulation=best_result,
    )

fundedness.optimize.optimize_allocation_policy(policy_class, param_specs, initial_wealth, spending_policy, config, utility_model, base_params=None, spending_floor=None, method='nelder-mead', max_iterations=50)

Optimize allocation policy parameters to maximize utility.

Parameters:

Name Type Description Default
policy_class type

Allocation policy class to optimize

required
param_specs list[PolicyParameterSpec]

Parameters to optimize

required
initial_wealth float

Starting portfolio value

required
spending_policy Any

Fixed spending policy to use

required
config SimulationConfig

Simulation configuration

required
utility_model UtilityModel

Utility model for evaluation

required
base_params dict | None

Fixed parameters for the policy

None
spending_floor float | None

Minimum spending floor

None
method str

Optimization method

'nelder-mead'
max_iterations int

Maximum iterations

50

Returns:

Type Description
OptimizationResult

OptimizationResult with optimal parameters and metrics

Source code in fundedness/optimize.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
def optimize_allocation_policy(
    policy_class: type,
    param_specs: list[PolicyParameterSpec],
    initial_wealth: float,
    spending_policy: Any,
    config: SimulationConfig,
    utility_model: UtilityModel,
    base_params: dict | None = None,
    spending_floor: float | None = None,
    method: str = "nelder-mead",
    max_iterations: int = 50,
) -> OptimizationResult:
    """Optimize allocation policy parameters to maximize utility.

    Args:
        policy_class: Allocation policy class to optimize
        param_specs: Parameters to optimize
        initial_wealth: Starting portfolio value
        spending_policy: Fixed spending policy to use
        config: Simulation configuration
        utility_model: Utility model for evaluation
        base_params: Fixed parameters for the policy
        spending_floor: Minimum spending floor
        method: Optimization method
        max_iterations: Maximum iterations

    Returns:
        OptimizationResult with optimal parameters and metrics
    """
    if base_params is None:
        base_params = {}

    convergence_history = []
    best_utility = -np.inf
    best_params = None
    best_result = None

    def objective(param_values: np.ndarray) -> float:
        nonlocal best_utility, best_params, best_result

        policy = create_policy_with_params(
            policy_class, base_params, param_specs, param_values
        )

        result = run_simulation_with_utility(
            initial_wealth=initial_wealth,
            spending_policy=spending_policy,
            allocation_policy=policy,
            config=config,
            utility_model=utility_model,
            spending_floor=spending_floor,
        )

        utility = result.expected_lifetime_utility
        convergence_history.append(utility)

        if utility > best_utility:
            best_utility = utility
            best_params = {spec.name: spec.clip(v) for spec, v in zip(param_specs, param_values)}
            best_result = result

        return -utility

    x0 = np.array([spec.get_initial() for spec in param_specs])
    bounds = [(spec.min_value, spec.max_value) for spec in param_specs]

    if method.lower() in ("nelder-mead", "powell"):
        result = optimize.minimize(
            objective,
            x0,
            method=method,
            options={"maxiter": max_iterations, "disp": False},
        )
    else:
        result = optimize.minimize(
            objective,
            x0,
            method=method,
            bounds=bounds,
            options={"maxiter": max_iterations, "disp": False},
        )

    return OptimizationResult(
        optimal_params=best_params or {},
        optimal_utility=best_utility,
        certainty_equivalent=best_result.certainty_equivalent_consumption if best_result else 0.0,
        success_rate=best_result.success_rate if best_result else 0.0,
        iterations=result.nit if hasattr(result, "nit") else len(convergence_history),
        convergence_history=convergence_history,
        final_simulation=best_result,
    )

fundedness.optimize.optimize_combined_policy(spending_policy_class, allocation_policy_class, spending_param_specs, allocation_param_specs, initial_wealth, config, utility_model, spending_base_params=None, allocation_base_params=None, spending_floor=None, method='nelder-mead', max_iterations=100)

Jointly optimize spending and allocation policy parameters.

Parameters:

Name Type Description Default
spending_policy_class type

Spending policy class

required
allocation_policy_class type

Allocation policy class

required
spending_param_specs list[PolicyParameterSpec]

Spending parameters to optimize

required
allocation_param_specs list[PolicyParameterSpec]

Allocation parameters to optimize

required
initial_wealth float

Starting portfolio value

required
config SimulationConfig

Simulation configuration

required
utility_model UtilityModel

Utility model for evaluation

required
spending_base_params dict | None

Fixed spending policy parameters

None
allocation_base_params dict | None

Fixed allocation policy parameters

None
spending_floor float | None

Minimum spending floor

None
method str

Optimization method

'nelder-mead'
max_iterations int

Maximum iterations

100

Returns:

Type Description
OptimizationResult

OptimizationResult with optimal parameters for both policies

Source code in fundedness/optimize.py
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
def optimize_combined_policy(
    spending_policy_class: type,
    allocation_policy_class: type,
    spending_param_specs: list[PolicyParameterSpec],
    allocation_param_specs: list[PolicyParameterSpec],
    initial_wealth: float,
    config: SimulationConfig,
    utility_model: UtilityModel,
    spending_base_params: dict | None = None,
    allocation_base_params: dict | None = None,
    spending_floor: float | None = None,
    method: str = "nelder-mead",
    max_iterations: int = 100,
) -> OptimizationResult:
    """Jointly optimize spending and allocation policy parameters.

    Args:
        spending_policy_class: Spending policy class
        allocation_policy_class: Allocation policy class
        spending_param_specs: Spending parameters to optimize
        allocation_param_specs: Allocation parameters to optimize
        initial_wealth: Starting portfolio value
        config: Simulation configuration
        utility_model: Utility model for evaluation
        spending_base_params: Fixed spending policy parameters
        allocation_base_params: Fixed allocation policy parameters
        spending_floor: Minimum spending floor
        method: Optimization method
        max_iterations: Maximum iterations

    Returns:
        OptimizationResult with optimal parameters for both policies
    """
    if spending_base_params is None:
        spending_base_params = {}
    if allocation_base_params is None:
        allocation_base_params = {}

    all_specs = spending_param_specs + allocation_param_specs
    n_spending = len(spending_param_specs)

    convergence_history = []
    best_utility = -np.inf
    best_params = None
    best_result = None

    def objective(param_values: np.ndarray) -> float:
        nonlocal best_utility, best_params, best_result

        spending_values = param_values[:n_spending]
        allocation_values = param_values[n_spending:]

        spending_policy = create_policy_with_params(
            spending_policy_class,
            spending_base_params,
            spending_param_specs,
            spending_values,
        )
        allocation_policy = create_policy_with_params(
            allocation_policy_class,
            allocation_base_params,
            allocation_param_specs,
            allocation_values,
        )

        result = run_simulation_with_utility(
            initial_wealth=initial_wealth,
            spending_policy=spending_policy,
            allocation_policy=allocation_policy,
            config=config,
            utility_model=utility_model,
            spending_floor=spending_floor,
        )

        utility = result.expected_lifetime_utility
        convergence_history.append(utility)

        if utility > best_utility:
            best_utility = utility
            best_params = {}
            for spec, v in zip(spending_param_specs, spending_values):
                best_params[f"spending_{spec.name}"] = spec.clip(v)
            for spec, v in zip(allocation_param_specs, allocation_values):
                best_params[f"allocation_{spec.name}"] = spec.clip(v)
            best_result = result

        return -utility

    x0 = np.array([spec.get_initial() for spec in all_specs])
    bounds = [(spec.min_value, spec.max_value) for spec in all_specs]

    if method.lower() in ("nelder-mead", "powell"):
        result = optimize.minimize(
            objective,
            x0,
            method=method,
            options={"maxiter": max_iterations, "disp": False},
        )
    else:
        result = optimize.minimize(
            objective,
            x0,
            method=method,
            bounds=bounds,
            options={"maxiter": max_iterations, "disp": False},
        )

    return OptimizationResult(
        optimal_params=best_params or {},
        optimal_utility=best_utility,
        certainty_equivalent=best_result.certainty_equivalent_consumption if best_result else 0.0,
        success_rate=best_result.success_rate if best_result else 0.0,
        iterations=result.nit if hasattr(result, "nit") else len(convergence_history),
        convergence_history=convergence_history,
        final_simulation=best_result,
    )