Skip to main content

solver/models/
american_put.rs

1use super::control::{ControlProblem, StateDerivatives};
2use crate::numeric::finite_difference::discretization::{DimensionKind, Transport};
3use crate::numeric::finite_difference::pde::PdeProblem;
4
5/// American Put Option
6///
7/// Models the optimal exercise strategy for an American Put option.
8/// This is formulated as an optimal stopping problem, but can be solved via HJB
9/// by treating the exercise decision as a control.
10///
11/// Dynamics follow Geometric Brownian Motion for the underlying asset.
12pub struct AmericanPut {
13    /// Risk-free interest rate (r).
14    pub risk_free_rate: f64,
15    /// Volatility of the underlying asset ($\sigma$).
16    pub volatility: f64,
17    /// Strike price (K).
18    pub strike: f64,
19    /// Spatial discretization step size (dx) used for finite difference coefficients.
20    pub dx: f64,
21}
22
23impl ControlProblem<1> for AmericanPut {
24    type Control = ();
25
26    fn optimize(&self, _t: f64, _state: &[f64; 1], _derivs: &StateDerivatives<1>) {}
27
28    fn running_reward(&self, _t: f64, _state: &[f64; 1], _control: &()) -> f64 {
29        // No running reward; the objective is terminal payoff plus the early
30        // exercise obstacle enforced by `apply_constraint`.
31        0.0
32    }
33
34    fn generator(
35        &self,
36        _t: f64,
37        state: &[f64; 1],
38        _control: &(),
39        derivs: &StateDerivatives<1>,
40    ) -> f64 {
41        // Risk-neutral GBM generator L V = r S V_s + 0.5 sigma^2 S^2 V_ss.
42        let s = state[0];
43        self.risk_free_rate * s * derivs.grad[0]
44            + 0.5 * self.volatility.powi(2) * s.powi(2) * derivs.hessian[0]
45    }
46
47    fn terminal(&self, state: &[f64; 1]) -> f64 {
48        let s = state[0];
49        (self.strike - s).max(0.0)
50    }
51
52    fn discount_rate(&self, _state: &[f64; 1]) -> f64 {
53        self.risk_free_rate
54    }
55
56    fn constant_discount_rate(&self) -> Option<f64> {
57        Some(self.risk_free_rate)
58    }
59
60    fn apply_constraint(&self, state: &[f64; 1], value: f64) -> f64 {
61        let s = state[0];
62        let intrinsic = (self.strike - s).max(0.0);
63        value.max(intrinsic)
64    }
65
66    fn next_step(&self, _t: f64, state: &[f64; 1], dt: f64, noise: &[f64; 1]) -> [f64; 1] {
67        // Log-Euler for risk-neutral GBM.
68        let drift = (self.risk_free_rate - 0.5 * self.volatility.powi(2)) * dt;
69        let diffusion = self.volatility * dt.sqrt() * noise[0];
70        [state[0] * (drift + diffusion).exp()]
71    }
72
73    fn is_diffusion_dimension(&self, _dim: usize) -> bool {
74        true
75    }
76}
77
78impl PdeProblem<1> for AmericanPut {
79    fn dimension_kind(&self, _dim: usize) -> DimensionKind {
80        DimensionKind::Diffusion
81    }
82
83    fn transport(
84        &self,
85        _t: f64,
86        state: &[f64; 1],
87        _control: &(),
88        _derivs: &StateDerivatives<1>,
89    ) -> Transport<1> {
90        // L V = r S V_s + 0.5 sigma^2 S^2 V_ss.
91        // Upwind the drift and central-difference the diffusion:
92        //   plus  = D/h^2 + max(b, 0)/h
93        //   minus = D/h^2 + max(-b, 0)/h
94        let s = state[0];
95        let h = self.dx.max(1e-12);
96        let b = self.risk_free_rate * s;
97        let d = 0.5 * self.volatility.powi(2) * s.powi(2);
98        Transport::new(
99            [d / (h * h) + b.max(0.0) / h],
100            [d / (h * h) + (-b).max(0.0) / h],
101            0.0,
102        )
103    }
104}
105
106impl AmericanPut {
107    /// Creates an American put model.
108    ///
109    /// # Examples
110    ///
111    /// ```
112    /// use solver::models::american_put::AmericanPut;
113    /// let put = AmericanPut::new(0.05, 0.2, 100.0, 1.0);
114    /// assert_eq!(put.strike, 100.0);
115    /// ```
116    pub fn new(risk_free_rate: f64, volatility: f64, strike: f64, dx: f64) -> Self {
117        Self {
118            risk_free_rate,
119            volatility,
120            strike,
121            dx,
122        }
123    }
124
125    /// Updates the spatial step size used by the finite-difference transport
126    /// coefficients.
127    ///
128    /// # Examples
129    ///
130    /// ```
131    /// use solver::models::american_put::AmericanPut;
132    /// let mut put = AmericanPut::new(0.05, 0.2, 100.0, 1.0);
133    /// put.set_dx(0.5);
134    /// assert_eq!(put.dx, 0.5);
135    /// ```
136    pub fn set_dx(&mut self, dx: f64) {
137        self.dx = dx;
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::models::control::ControlProblem;
145
146    fn default_model() -> AmericanPut {
147        AmericanPut::new(0.05, 0.2, 100.0, 1.0)
148    }
149
150    #[test]
151    fn terminal_is_payoff() {
152        let m = default_model();
153        assert_eq!(ControlProblem::terminal(&m, &[80.0]), 20.0);
154        assert_eq!(ControlProblem::terminal(&m, &[100.0]), 0.0);
155        assert_eq!(ControlProblem::terminal(&m, &[120.0]), 0.0);
156    }
157
158    #[test]
159    fn constraint_enforces_early_exercise() {
160        let m = default_model();
161        assert_eq!(ControlProblem::apply_constraint(&m, &[80.0], 15.0), 20.0);
162        assert_eq!(ControlProblem::apply_constraint(&m, &[80.0], 25.0), 25.0);
163    }
164
165    #[test]
166    fn discount_rate_is_risk_free() {
167        let m = default_model();
168        assert_eq!(ControlProblem::discount_rate(&m, &[100.0]), 0.05);
169        assert_eq!(ControlProblem::constant_discount_rate(&m), Some(0.05));
170    }
171
172    #[test]
173    fn control_problem_constraint_matches_model() {
174        let m = default_model();
175        let v = ControlProblem::apply_constraint(&m, &[80.0], 15.0);
176        assert_eq!(v, 20.0);
177    }
178
179    #[test]
180    fn control_problem_generator_is_black_scholes_operator() {
181        let m = default_model();
182        let d = StateDerivatives::new([1.0], [0.0]);
183        let g = ControlProblem::generator(&m, 0.0, &[100.0], &(), &d);
184        // L V = r S V_s (diffusion term zero at V_ss = 0).
185        assert!((g - m.risk_free_rate * 100.0).abs() < 1e-12);
186    }
187
188    #[test]
189    fn control_problem_next_step_is_log_euler() {
190        let m = default_model();
191        let s = ControlProblem::next_step(&m, 0.0, &[100.0], 0.0, &[0.0]);
192        assert!((s[0] - 100.0).abs() < 1e-12);
193    }
194
195    #[test]
196    fn dimension_kind_is_diffusion() {
197        let m = default_model();
198        assert_eq!(PdeProblem::dimension_kind(&m, 0), DimensionKind::Diffusion);
199    }
200
201    #[test]
202    fn transport_is_black_scholes_upwind_stencil() {
203        let m = default_model();
204        let s: f64 = 100.0;
205        let d = 0.5 * m.volatility.powi(2) * s.powi(2);
206        let h = m.dx;
207        let t = PdeProblem::transport(&m, 0.0, &[s], &(), &StateDerivatives::new([0.0], [0.0]));
208        assert!((t.plus[0] - (d / (h * h) + m.risk_free_rate * s / h)).abs() < 1e-12);
209        assert!((t.minus[0] - d / (h * h)).abs() < 1e-12);
210        assert_eq!(t.source, 0.0);
211    }
212}