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}