Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 109 additions & 6 deletions HARK/ConsumptionSaving/ConsPortfolioModel.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ class PortfolioConsumerType(RiskyAssetConsumerType):
"""

time_inv_ = deepcopy(RiskyAssetConsumerType.time_inv_)
time_inv_ = time_inv_ + ["AdjustPrb", "DiscreteShareBool"]
time_inv_ = time_inv_ + ["AdjustPrb", "DiscreteShareBool", "ApproxShareBool"]

def __init__(self, verbose=False, quiet=False, **kwds):
params = init_portfolio.copy()
Expand Down Expand Up @@ -213,7 +213,7 @@ def update_solution_terminal(self):
None
"""
# Consume all market resources: c_T = m_T
cFuncAdj_terminal = IdentityFunction()
cFuncAdj_terminal = LinearInterp([0.0, 1.0], [0.0, 1.0])
cFuncFxd_terminal = IdentityFunction(i_dim=0, n_dims=2)

# Risky share is irrelevant-- no end-of-period assets; set to zero
Expand Down Expand Up @@ -244,6 +244,8 @@ def update_solution_terminal(self):
dvdsFuncFxd=dvdsFuncFxd_terminal,
)

self.solution_terminal.ShareEndOfPrdFunc = ShareFuncAdj_terminal

def update_ShareGrid(self):
"""
Creates the attribute ShareGrid as an evenly spaced grid on [0.,1.], using
Expand Down Expand Up @@ -492,6 +494,7 @@ def __init__(
DiscreteShareBool,
ShareLimit,
IndepDstnBool,
ApproxShareBool,
):
"""
Constructor for portfolio choice problem solver.
Expand All @@ -514,6 +517,7 @@ def __init__(
self.DiscreteShareBool = DiscreteShareBool
self.ShareLimit = ShareLimit
self.IndepDstnBool = IndepDstnBool
self.ApproxShareBool = ApproxShareBool

# Make sure the individual is liquidity constrained. Allowing a consumer to
# borrow *and* invest in an asset with unbounded (negative) returns is a bad mix.
Expand Down Expand Up @@ -822,6 +826,8 @@ def make_ShareFuncAdj(self):
Construct the risky share function when the agent can adjust
"""

# Share function for mGrid

if self.zero_bound:
Share_lower_bound = self.ShareLimit
else:
Expand All @@ -834,6 +840,96 @@ def make_ShareFuncAdj(self):
slope_limit=0.0,
)

# Share function on aGrid

if self.zero_bound:
aNrm_temp = np.append(0.0, self.aNrmGrid)
share_temp = np.append(self.ShareLimit, self.Share_now)
else:
aNrm_temp = self.aNrmGrid
share_temp = self.Share_now

self.ShareEndOfPrdFunc = LinearInterp(
aNrm_temp, share_temp, intercept_limit=self.ShareLimit, slope_limit=0.0
)

def make_share_func_approx(self):
"""
Alternative share functions from linear approximation.
"""

# get next period's consumption and share function
cFunc_next = self.solution_next.cFuncAdj
sFunc_next = self.solution_next.ShareEndOfPrdFunc

def premium(shock):
"""
Used to evaluate mean and variance of equity premium.
"""
r_diff = shock - self.Rfree

return r_diff, r_diff ** 2, r_diff ** 3

prem_mean, prem_sqrd, prem_cube = calc_expectation(self.RiskyDstn, premium)

def c_nrm_and_deriv(shocks, a_nrm):
"""
Used to calculate expected consumption and MPC given today's savings,
assuming that today's risky share is the same as it would be tomorrow
with that same level of savings.
"""
p_shk = shocks[0] * self.PermGroFac
t_shk = shocks[1]
share = sFunc_next(a_nrm)
r_diff = shocks[2] - self.Rfree
r_port = self.Rfree + r_diff * share
m_nrm_next = a_nrm * r_port / p_shk + t_shk

c_next, cP_next = cFunc_next.eval_with_derivative(m_nrm_next)

return c_next, cP_next

exp_c_values = calc_expectation(self.ShockDstn, c_nrm_and_deriv, self.aNrmGrid)

exp_c_values = exp_c_values[:, :, 0]
exp_c_next = exp_c_values[0]
exp_cP_next = exp_c_values[1]

MPC = exp_cP_next * self.aNrmGrid / exp_c_next

# first order approximation
approx_share = prem_mean / (self.CRRA * MPC * prem_sqrd)

# clip at 0 and 1, although we know the Share limit we
# want to see what the approximation would give us
approx_share = np.clip(approx_share, 0, 1)

self.ApproxFirstOrderShareFunc = LinearInterp(
self.aNrmGrid,
approx_share,
intercept_limit=self.ShareLimit,
slope_limit=0.0,
)

# second order approximation

a = -self.CRRA * prem_cube * MPC ** 2 * (-self.CRRA - 1) / 2
b = -self.CRRA * MPC * prem_sqrd
c = prem_mean

temp = np.sqrt(b ** 2 - 4 * a * c)

roots = np.array([(-b + temp) / (2 * a), (-b - temp) / (2 * a)])
Comment on lines +920 to +922
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The quadratic used for the second-order share approximation (a = -self.CRRA * prem_cube * MPC ** 2 * (-self.CRRA - 1) / 2, b = -self.CRRA * MPC * prem_sqrd, c = prem_mean) does not guarantee a non-negative discriminant b ** 2 - 4 * a * c. For admissible parameter combinations where 4 * a * c > b ** 2, temp = np.sqrt(b ** 2 - 4 * a * c) will be nan, and the resulting roots (and thus ApproxSecondOrderShareFunc) will contain nan values. It would be safer to guard against a negative discriminant (e.g., by falling back to the first-order approximation or clipping the discriminant at zero) so that enabling the second-order approximation cannot silently produce nan policies.

Suggested change
temp = np.sqrt(b ** 2 - 4 * a * c)
roots = np.array([(-b + temp) / (2 * a), (-b - temp) / (2 * a)])
# Compute discriminant and guard against invalid quadratic cases.
disc = b ** 2 - 4 * a * c
valid = np.logical_and(disc >= 0.0, a != 0.0)
temp = np.zeros_like(disc)
temp[valid] = np.sqrt(disc[valid])
# Initialize roots with first-order approximation as a safe fallback.
roots = np.vstack((approx_share.copy(), approx_share.copy()))
# For valid entries, overwrite with second-order quadratic roots.
roots[0, valid] = (-b[valid] + temp[valid]) / (2 * a[valid])
roots[1, valid] = (-b[valid] - temp[valid]) / (2 * a[valid])

Copilot uses AI. Check for mistakes.
roots[:, 0] = 1.0
roots = np.where(
np.logical_and(roots[0] >= 0, roots[0] <= 1), roots[0], roots[1]
)
roots = np.clip(roots, 0, 1)

self.ApproxSecondOrderShareFunc = LinearInterp(
self.aNrmGrid, roots, intercept_limit=self.ShareLimit, slope_limit=0.0,
)

def add_save_points(self):
# This is a point at which (a,c,share) have consistent length. Take the
# snapshot for storing the grid and values in the solution.
Expand Down Expand Up @@ -978,6 +1074,11 @@ def make_porfolio_solution(self):
AdjPrb=self.AdjustPrb,
)

if self.ApproxShareBool:
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When ApproxShareBool is True, make_porfolio_solution unconditionally assigns self.solution.ShareEndOfPrdFunc, self.solution.ApproxFirstOrderShareFunc, and self.solution.ApproxSecondOrderShareFunc, but these attributes are only created by make_share_func_approx, which is never called in ConsPortfolioDiscreteSolver.solve or ConsPortfolioJointDistSolver.solve. As a result, using ApproxShareBool=True with either the discrete-share solver or the joint-distribution solver will raise an AttributeError at this point. Consider either guarding this block to run only for solver types that define the approximation functions, or ensuring that the discrete and joint-dist solvers also construct ShareEndOfPrdFunc and the approximation functions when ApproxShareBool is enabled.

Suggested change
if self.ApproxShareBool:
if self.ApproxShareBool and all(
hasattr(self, attr_name)
for attr_name in (
"ShareEndOfPrdFunc",
"ApproxFirstOrderShareFunc",
"ApproxSecondOrderShareFunc",
)
):

Copilot uses AI. Check for mistakes.
self.solution.ShareEndOfPrdFunc = self.ShareEndOfPrdFunc
self.solution.ApproxFirstOrderShareFunc = self.ApproxFirstOrderShareFunc
self.solution.ApproxSecondOrderShareFunc = self.ApproxSecondOrderShareFunc

def solve(self):
"""
Solve the one period problem for a portfolio-choice consumer.
Expand All @@ -999,6 +1100,9 @@ def solve(self):
self.make_basic_solution()
self.make_ShareFuncAdj()

if self.ApproxShareBool:
self.make_share_func_approx()

Comment on lines +1103 to +1105
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new approximation path controlled by ApproxShareBool (i.e., make_share_func_approx and the extra solution fields it populates) is not covered by the existing tests in tests/test_ConsPortfolioModel.py, which only exercise the default (ApproxShareBool=False) behavior. Given that this branch changes both the solver’s expectations (uses solution_next.ShareEndOfPrdFunc and eval_with_derivative) and the contents of the per-period PortfolioSolution, it would be valuable to add at least a smoke test that solves the model with ApproxShareBool=True (for the continuous-share, independent-shocks case) and verifies that the approximate share functions are well-defined over the asset grid and free of nan values.

Copilot uses AI. Check for mistakes.
self.add_save_points()

# Add the value function if requested
Expand Down Expand Up @@ -1319,10 +1423,7 @@ def add_SequentialShareFuncAdj(self, solution):
Share_now = self.Share_now

self.SequentialShareFuncAdj_now = LinearInterp(
aNrm_temp,
Share_now,
intercept_limit=self.ShareLimit,
slope_limit=0.0,
aNrm_temp, Share_now, intercept_limit=self.ShareLimit, slope_limit=0.0,
)

solution.SequentialShareFuncAdj = self.SequentialShareFuncAdj_now
Expand All @@ -1349,6 +1450,8 @@ def solve(self):
init_portfolio["AdjustPrb"] = 1.0
# Flag for whether to optimize risky share on a discrete grid only
init_portfolio["DiscreteShareBool"] = False
# Flat for wether to approximate risky share
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment typo: "Flat for wether to approximate risky share" should read "Flag for whether to approximate risky share" to match the surrounding parameter comments.

Suggested change
# Flat for wether to approximate risky share
# Flag for whether to approximate risky share

Copilot uses AI. Check for mistakes.
init_portfolio["ApproxShareBool"] = False

# Adjust some of the existing parameters in the dictionary
init_portfolio["aXtraMax"] = 100 # Make the grid of assets go much higher...
Expand Down
Loading
Loading