Skip to main content

solver/models/
merton.rs

1//! Merton's log-utility portfolio problem.
2//!
3//! A validating instance of [`ControlProblem`] with a known closed-form
4//! solution. The state is wealth `x`; the control `u in [0, 1]` is the
5//! fraction of wealth allocated to a risky asset.
6//!
7//! # Dynamics
8//!
9//! ```text
10//! dS/S = mu dt + sigma dW        (risky asset)
11//! r     = risk-free rate          (riskless asset)
12//! dx    = x [ r + u (mu - r) ] dt + x u sigma dW
13//! ```
14//!
15//! # Objective
16//!
17//! Maximize expected log utility of terminal wealth:
18//!
19//! ```text
20//! J(u) = E[ ln x_T ]
21//! ```
22//!
23//! # HJB equation
24//!
25//! ```text
26//! 0 = d_t V + sup_u { x (r + u (mu - r)) V_x
27//!      + 0.5 (x u sigma)^2 V_xx }
28//! ```
29//!
30//! # Exact solution
31//!
32//! The optimal portfolio fraction is constant:
33//!
34//! ```text
35//! u* = (mu - r) / sigma^2
36//! ```
37//!
38//! and the value function is
39//!
40//! ```text
41//! V(t, x) = ln x + [ r + 0.5 (mu - r)^2 / sigma^2 ] (T - t)
42//! ```
43//!
44//! where `T - t` is the remaining horizon.
45use crate::models::control::{ControlProblem, StateDerivatives};
46use crate::numeric::finite_difference::discretization::{DimensionKind, Transport};
47use crate::numeric::finite_difference::pde::PdeProblem;
48
49/// Merton log-utility portfolio problem.
50///
51/// See the module-level documentation for the formulation and exact solution.
52#[derive(Clone, Copy, Debug)]
53pub struct Merton {
54    /// Risk-free rate.
55    pub risk_free_rate: f64,
56    /// Risky-asset drift.
57    pub mu: f64,
58    /// Risky-asset volatility.
59    pub sigma: f64,
60}
61
62impl Merton {
63    /// Creates a Merton problem.
64    ///
65    /// # Panics
66    ///
67    /// Panics if `sigma` is not positive.
68    ///
69    /// # Examples
70    ///
71    /// ```
72    /// use solver::models::merton::Merton;
73    /// let m = Merton::new(0.03, 0.08, 0.2);
74    /// assert!((m.sigma - 0.2).abs() < 1e-12);
75    /// ```
76    pub fn new(risk_free_rate: f64, mu: f64, sigma: f64) -> Self {
77        assert!(sigma > 0.0, "volatility must be positive");
78        Self {
79            risk_free_rate,
80            mu,
81            sigma,
82        }
83    }
84
85    /// Optimal (constant) portfolio fraction.
86    ///
87    /// # Examples
88    ///
89    /// ```
90    /// use solver::models::merton::Merton;
91    /// let m = Merton::new(0.03, 0.08, 0.2);
92    /// assert!((m.exact_policy() - 0.05 / 0.04).abs() < 1e-12);
93    /// ```
94    pub fn exact_policy(&self) -> f64 {
95        (self.mu - self.risk_free_rate) / (self.sigma * self.sigma)
96    }
97
98    /// Closed-form value function `V(t, x)` for remaining horizon `tau = T - t`.
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use solver::models::merton::Merton;
104    /// let m = Merton::new(0.03, 0.08, 0.2);
105    /// let v = m.exact_value(100.0, 1.0);
106    /// assert!(v.is_finite());
107    /// ```
108    pub fn exact_value(&self, wealth: f64, tau: f64) -> f64 {
109        let u = self.exact_policy();
110        let growth = self.risk_free_rate + 0.5 * (self.mu - self.risk_free_rate) * u;
111        wealth.ln() + growth * tau
112    }
113}
114
115impl ControlProblem<1> for Merton {
116    type Control = f64;
117
118    fn optimize(&self, _t: f64, state: &[f64; 1], _derivs: &StateDerivatives<1>) -> Self::Control {
119        // The optimal fraction is independent of wealth and time. Clamp to a
120        // non-negative admissible value so the policy stays well defined even
121        // for extrapolated states.
122        let _ = state;
123        self.exact_policy().max(0.0)
124    }
125
126    fn running_reward(&self, _t: f64, _state: &[f64; 1], _control: &Self::Control) -> f64 {
127        // No running reward: log-utility has a Mayer (terminal-only) objective.
128        0.0
129    }
130
131    fn generator(
132        &self,
133        _t: f64,
134        state: &[f64; 1],
135        control: &Self::Control,
136        derivs: &StateDerivatives<1>,
137    ) -> f64 {
138        let x = state[0];
139        let vx = derivs.grad[0];
140        let vxx = derivs.hessian[0];
141        let u = *control;
142
143        x * (self.risk_free_rate + u * (self.mu - self.risk_free_rate)) * vx
144            + 0.5 * (x * u * self.sigma).powi(2) * vxx
145    }
146
147    fn terminal(&self, state: &[f64; 1]) -> f64 {
148        state[0].ln()
149    }
150
151    fn next_step(&self, _t: f64, state: &[f64; 1], dt: f64, noise: &[f64; 1]) -> [f64; 1] {
152        let x = state[0];
153        let u = self.exact_policy().max(0.0);
154        let drift = x * (self.risk_free_rate + u * (self.mu - self.risk_free_rate)) * dt;
155        let diffusion = x * u * self.sigma * dt.sqrt() * noise[0];
156        [x + drift + diffusion]
157    }
158
159    fn is_diffusion_dimension(&self, _dim: usize) -> bool {
160        true
161    }
162}
163
164impl PdeProblem<1> for Merton {
165    fn dimension_kind(&self, _dim: usize) -> DimensionKind {
166        DimensionKind::Diffusion
167    }
168
169    fn transport(
170        &self,
171        _t: f64,
172        state: &[f64; 1],
173        control: &Self::Control,
174        _derivs: &StateDerivatives<1>,
175    ) -> Transport<1> {
176        // dx = x [r + u (mu - r)] dt + x u sigma dW.
177        let x = state[0];
178        let u = *control;
179        let b = x * (self.risk_free_rate + u * (self.mu - self.risk_free_rate));
180        let d = 0.5 * (x * u * self.sigma).powi(2);
181        let h = x.max(1e-12);
182        Transport::new(
183            [d / (h * h) + b.max(0.0) / h],
184            [d / (h * h) + (-b).max(0.0) / h],
185            0.0,
186        )
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    fn model() -> Merton {
195        Merton::new(0.03, 0.08, 0.2)
196    }
197
198    #[test]
199    fn exact_policy_matches_closed_form() {
200        let m = model();
201        assert!((m.exact_policy() - 0.05 / 0.04).abs() < 1e-12);
202    }
203
204    #[test]
205    fn optimize_returns_exact_policy() {
206        let m = model();
207        let d = StateDerivatives::new([0.0], [0.0]);
208        let u = m.optimize(0.0, &[100.0], &d);
209        assert!((u - m.exact_policy()).abs() < 1e-12);
210    }
211
212    #[test]
213    fn terminal_is_log_wealth() {
214        let m = model();
215        assert!((m.terminal(&[100.0]) - 100.0f64.ln()).abs() < 1e-12);
216    }
217
218    #[test]
219    fn driver_matches_hjb_form_at_exact_derivatives() {
220        // For V = ln x + c tau, V_x = 1/x and V_xx = -1/x^2.
221        let m = model();
222        let x = 100.0;
223        let u = m.exact_policy();
224        let d = StateDerivatives::new([1.0 / x], [-1.0 / (x * x)]);
225
226        let expected =
227            u * (m.mu - m.risk_free_rate) + m.risk_free_rate - 0.5 * u * u * m.sigma * m.sigma;
228        assert!((m.driver(0.0, &[x], &u, &d) - expected).abs() < 1e-12);
229    }
230
231    #[test]
232    fn next_step_preserves_positive_wealth_for_small_dt() {
233        let m = model();
234        let s = m.next_step(0.0, &[100.0], 0.001, &[0.0]);
235        assert!(s[0] > 0.0);
236    }
237
238    #[test]
239    fn dimension_kind_is_diffusion() {
240        let m = model();
241        assert_eq!(PdeProblem::dimension_kind(&m, 0), DimensionKind::Diffusion);
242    }
243}