Skip to main content

solver/models/
merton_jump.rs

1//! Merton's log-utility portfolio problem with a jump in the risky asset.
2//!
3//! A validating instance for the neural jump BSDE: a diffusion-plus-jump
4//! wealth process whose optimal control and value still have closed forms, but
5//! where the jump term genuinely changes the optimal policy (unlike the
6//! no-jump [`Merton`], where the policy is constant in
7//! the jump parameters).
8//!
9//! # Dynamics
10//!
11//! The risky asset follows a geometric jump-diffusion and the wealth `x` is
12//! invested with fraction `u` in the risky asset and the rest at the risk-free
13//! rate `r`:
14//!
15//! ```text
16//! dS/S  = mu dt + sigma dW + (y - 1) dN
17//! dx    = x [ r + u (mu - r) ] dt + x u sigma dW + x u (y - 1) dN
18//! ```
19//!
20//! where `N` is a Poisson process with intensity `lambda` and `y > 0` is a
21//! deterministic multiplicative jump, so a jump scales wealth by
22//! `1 + u (y - 1)`.
23//!
24//! # Objective
25//!
26//! Maximize expected log utility of terminal wealth, `J(u) = E[ ln x_T ]`.
27//!
28//! # HJB equation
29//!
30//! ```text
31//! 0 = d_t V + sup_u { x (r + u (mu - r)) V_x + 0.5 (x u sigma)^2 V_xx
32//!      + lambda [ V(x (1 + u (y - 1))) - V(x) ] }
33//! ```
34//!
35//! # Exact solution
36//!
37//! The value function is `V(t, x) = ln x + B (T - t)` with
38//!
39//! ```text
40//! B = r + u* (mu - r) - 0.5 (u* sigma)^2 + lambda ln(1 + u* (y - 1)),
41//! ```
42//!
43//! and the optimal fraction `u*` solves the quadratic
44//!
45//! ```text
46//! 0 = (mu - r) - sigma^2 u* + lambda (y - 1) / (1 + u* (y - 1)).
47//! ```
48//!
49//! Setting `lambda = 0` or `y = 1` reduces this to the no-jump Merton policy
50//! `u* = (mu - r) / sigma^2`.
51use crate::models::merton::Merton;
52
53/// Merton log-utility portfolio with a deterministic multiplicative jump.
54///
55/// See the module-level documentation for the formulation and exact solution.
56#[derive(Clone, Copy, Debug)]
57pub struct MertonJump {
58    /// Risk-free rate.
59    pub risk_free_rate: f64,
60    /// Risky-asset drift.
61    pub mu: f64,
62    /// Risky-asset volatility.
63    pub sigma: f64,
64    /// Poisson jump intensity.
65    pub jump_rate: f64,
66    /// Deterministic multiplicative jump (wealth scales by `1 + u (y - 1)`).
67    pub jump_multiplier: f64,
68}
69
70impl MertonJump {
71    /// Creates a Merton jump problem.
72    ///
73    /// # Panics
74    ///
75    /// Panics if `sigma` or `jump_multiplier` is not positive, or if
76    /// `jump_rate` is negative.
77    ///
78    /// # Examples
79    ///
80    /// ```
81    /// use solver::models::merton_jump::MertonJump;
82    /// let m = MertonJump::new(0.03, 0.08, 0.3, 1.0, 1.1);
83    /// assert_eq!(m.jump_multiplier, 1.1);
84    /// ```
85    pub fn new(
86        risk_free_rate: f64,
87        mu: f64,
88        sigma: f64,
89        jump_rate: f64,
90        jump_multiplier: f64,
91    ) -> Self {
92        assert!(sigma > 0.0, "volatility must be positive");
93        assert!(jump_rate >= 0.0, "jump rate must be non-negative");
94        assert!(jump_multiplier > 0.0, "jump multiplier must be positive");
95        Self {
96            risk_free_rate,
97            mu,
98            sigma,
99            jump_rate,
100            jump_multiplier,
101        }
102    }
103
104    /// Optimal (constant) portfolio fraction, the admissible root of the
105    /// policy quadratic.
106    ///
107    /// # Examples
108    ///
109    /// ```
110    /// use solver::models::merton_jump::MertonJump;
111    /// let m = MertonJump::new(0.03, 0.08, 0.3, 1.0, 1.1);
112    /// assert!(m.exact_policy() > 0.0);
113    /// ```
114    pub fn exact_policy(&self) -> f64 {
115        let a = self.mu - self.risk_free_rate;
116        let s = self.jump_multiplier - 1.0;
117        let sig2 = self.sigma * self.sigma;
118
119        // A zero jump amplitude degenerates to the no-jump Merton policy.
120        if s.abs() < 1e-14 {
121            return a / sig2;
122        }
123
124        // The first-order condition
125        //   a - sig2 u + lambda s / (1 + u s) = 0
126        // is a quadratic in u after multiplying by (1 + u s).
127        let quad_a = -sig2 * s;
128        let quad_b = a * s - sig2;
129        let quad_c = a + self.jump_rate * s;
130        // The discriminant is (a s + sig2)^2 + 4 lambda sig2 s^2 >= 0.
131        let disc = quad_b * quad_b - 4.0 * quad_a * quad_c;
132        let sqrt_disc = disc.max(0.0).sqrt();
133        let r1 = (-quad_b + sqrt_disc) / (2.0 * quad_a);
134        let r2 = (-quad_b - sqrt_disc) / (2.0 * quad_a);
135
136        // Exactly one root keeps wealth positive after a jump (1 + u s > 0).
137        if 1.0 + r1 * s > 0.0 { r1 } else { r2 }
138    }
139
140    /// Closed-form value function `V(t, x)` for remaining horizon `tau = T - t`.
141    ///
142    /// # Examples
143    ///
144    /// ```
145    /// use solver::models::merton_jump::MertonJump;
146    /// let m = MertonJump::new(0.03, 0.08, 0.3, 1.0, 1.1);
147    /// let v = m.exact_value(100.0, 1.0);
148    /// assert!(v.is_finite());
149    /// ```
150    pub fn exact_value(&self, wealth: f64, tau: f64) -> f64 {
151        let u = self.exact_policy();
152        let a = self.mu - self.risk_free_rate;
153        let s = self.jump_multiplier - 1.0;
154        let growth = self.risk_free_rate + u * a - 0.5 * (u * self.sigma).powi(2)
155            + self.jump_rate * (1.0 + u * s).ln();
156        wealth.ln() + growth * tau
157    }
158}
159
160/// The no-jump reduction of [`MertonJump`] with `jump_rate = 0`.
161impl From<&MertonJump> for Merton {
162    fn from(model: &MertonJump) -> Self {
163        Merton::new(model.risk_free_rate, model.mu, model.sigma)
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn model() -> MertonJump {
172        MertonJump::new(0.03, 0.08, 0.3, 1.0, 1.1)
173    }
174
175    #[test]
176    fn zero_jump_rate_reduces_to_no_jump_policy() {
177        let m = MertonJump::new(0.03, 0.08, 0.3, 0.0, 1.1);
178        let no_jump = Merton::new(0.03, 0.08, 0.3);
179        assert!((m.exact_policy() - no_jump.exact_policy()).abs() < 1e-12);
180    }
181
182    #[test]
183    fn unit_multiplier_reduces_to_no_jump_policy() {
184        let m = MertonJump::new(0.03, 0.08, 0.3, 1.0, 1.0);
185        let no_jump = Merton::new(0.03, 0.08, 0.3);
186        assert!((m.exact_policy() - no_jump.exact_policy()).abs() < 1e-12);
187    }
188
189    #[test]
190    fn policy_satisfies_first_order_condition() {
191        let m = model();
192        let u = m.exact_policy();
193        let a = m.mu - m.risk_free_rate;
194        let s = m.jump_multiplier - 1.0;
195        let residual = a - m.sigma * m.sigma * u + m.jump_rate * s / (1.0 + u * s);
196        assert!(residual.abs() < 1e-10);
197    }
198
199    #[test]
200    fn jump_changes_the_optimal_policy() {
201        let m = model();
202        let no_jump = Merton::new(0.03, 0.08, 0.3);
203        assert!((m.exact_policy() - no_jump.exact_policy()).abs() > 1e-3);
204    }
205
206    #[test]
207    fn value_is_finite_and_positive_growth() {
208        let m = model();
209        let v = m.exact_value(100.0, 1.0);
210        assert!(v.is_finite());
211        assert!(v > 100.0f64.ln());
212    }
213}