convex_math/solvers/
mod.rs

1//! Root-finding algorithms.
2//!
3//! This module provides numerical solvers for finding roots of equations:
4//!
5//! - [`newton_raphson`]: Fast quadratic convergence when derivative is available
6//! - [`brent`]: Robust method combining bisection, secant, and inverse quadratic
7//! - [`bisection`]: Simple and reliable bracketing method
8//! - [`secant`]: Derivative-free method using finite differences
9//! - [`hybrid`]: Newton-Raphson with Brent fallback for robust convergence
10//!
11//! # Choosing a Solver
12//!
13//! | Solver | Speed | Reliability | Requires |
14//! |--------|-------|-------------|----------|
15//! | Newton-Raphson | Fastest (quadratic) | May diverge | Derivative |
16//! | Brent | Fast (superlinear) | Guaranteed | Bracket |
17//! | Secant | Fast (superlinear) | May diverge | Two guesses |
18//! | Bisection | Slow (linear) | Guaranteed | Bracket |
19//! | Hybrid | Fast | Guaranteed* | Initial guess |
20//!
21//! *When bounds are provided or can be found automatically.
22//!
23//! # Performance
24//!
25//! For typical financial calculations:
26//! - YTM calculation: < 1μs (Newton, ~5-8 iterations)
27//! - Z-spread calculation: < 50μs (Brent with 50 points)
28//!
29//! # Example: YTM Calculation
30//!
31//! ```rust
32//! use convex_math::solvers::{hybrid, SolverConfig};
33//!
34//! // Bond: 5% coupon, 5 years, price 95
35//! let price_fn = |y: f64| {
36//!     let mut pv = 0.0;
37//!     for t in 1..=5 {
38//!         pv += 5.0 / (1.0 + y).powi(t);  // Coupon
39//!     }
40//!     pv += 100.0 / (1.0 + y).powi(5);    // Principal
41//!     pv - 95.0
42//! };
43//!
44//! let d_price_fn = |y: f64| {
45//!     let mut dpv = 0.0;
46//!     for t in 1..=5 {
47//!         dpv -= (t as f64) * 5.0 / (1.0 + y).powi(t + 1);
48//!     }
49//!     dpv -= 5.0 * 100.0 / (1.0 + y).powi(6);
50//!     dpv
51//! };
52//!
53//! let result = hybrid(price_fn, d_price_fn, 0.05, Some((0.0, 0.20)), &SolverConfig::default()).unwrap();
54//! assert!(result.root > 0.05);  // YTM > coupon rate for discount bond
55//! ```
56
57mod bisection;
58mod brent;
59mod hybrid;
60mod newton;
61mod secant;
62
63pub use bisection::bisection;
64pub use brent::brent;
65pub use hybrid::{hybrid, hybrid_numerical};
66pub use newton::{newton_raphson, newton_raphson_numerical};
67pub use secant::secant;
68
69use crate::error::MathResult;
70
71/// Default tolerance for root-finding algorithms.
72pub const DEFAULT_TOLERANCE: f64 = 1e-10;
73
74/// Default maximum iterations for root-finding algorithms.
75pub const DEFAULT_MAX_ITERATIONS: u32 = 100;
76
77/// Configuration for root-finding algorithms.
78#[derive(Debug, Clone, Copy)]
79pub struct SolverConfig {
80    /// Tolerance for convergence.
81    pub tolerance: f64,
82    /// Maximum number of iterations.
83    pub max_iterations: u32,
84}
85
86impl Default for SolverConfig {
87    fn default() -> Self {
88        Self {
89            tolerance: DEFAULT_TOLERANCE,
90            max_iterations: DEFAULT_MAX_ITERATIONS,
91        }
92    }
93}
94
95impl SolverConfig {
96    /// Creates a new solver configuration.
97    #[must_use]
98    pub fn new(tolerance: f64, max_iterations: u32) -> Self {
99        Self {
100            tolerance,
101            max_iterations,
102        }
103    }
104
105    /// Sets the tolerance.
106    #[must_use]
107    pub fn with_tolerance(mut self, tolerance: f64) -> Self {
108        self.tolerance = tolerance;
109        self
110    }
111
112    /// Sets the maximum iterations.
113    #[must_use]
114    pub fn with_max_iterations(mut self, max_iterations: u32) -> Self {
115        self.max_iterations = max_iterations;
116        self
117    }
118}
119
120/// Trait for root-finding algorithms.
121pub trait RootFinder {
122    /// Finds a root of the given function.
123    ///
124    /// # Arguments
125    ///
126    /// * `f` - The function for which to find a root
127    /// * `initial_guess` - Starting point for the search
128    /// * `config` - Solver configuration
129    fn find_root<F>(&self, f: F, initial_guess: f64, config: &SolverConfig) -> MathResult<f64>
130    where
131        F: Fn(f64) -> f64;
132}
133
134/// Trait for root-finding solvers with optional derivative.
135///
136/// This trait provides a unified interface for all solvers, allowing
137/// the caller to optionally provide a derivative function for faster
138/// convergence.
139///
140/// # Example
141///
142/// ```rust
143/// use convex_math::solvers::{Solver, NewtonSolver, SolverConfig};
144///
145/// let solver = NewtonSolver::default();
146/// let f = |x: f64| x * x - 2.0;
147/// let df = |x: f64| 2.0 * x;
148///
149/// let result = solver.solve(f, Some(df), 1.5, None, &SolverConfig::default()).unwrap();
150/// assert!((result.root - std::f64::consts::SQRT_2).abs() < 1e-10);
151/// ```
152pub trait Solver: Send + Sync {
153    /// Solves for a root of the given function.
154    ///
155    /// # Arguments
156    ///
157    /// * `f` - The function for which to find a root
158    /// * `derivative` - Optional derivative function (used if available)
159    /// * `initial_guess` - Starting point for the search
160    /// * `bounds` - Optional bracketing interval (a, b)
161    /// * `config` - Solver configuration
162    ///
163    /// # Returns
164    ///
165    /// The solution result including root, iterations, and residual.
166    fn solve<F, D>(
167        &self,
168        f: F,
169        derivative: Option<D>,
170        initial_guess: f64,
171        bounds: Option<(f64, f64)>,
172        config: &SolverConfig,
173    ) -> MathResult<SolverResult>
174    where
175        F: Fn(f64) -> f64,
176        D: Fn(f64) -> f64;
177
178    /// Returns the name of the solver.
179    fn name(&self) -> &'static str;
180}
181
182/// Newton-Raphson solver implementation.
183#[derive(Debug, Clone, Copy, Default)]
184pub struct NewtonSolver;
185
186impl Solver for NewtonSolver {
187    fn solve<F, D>(
188        &self,
189        f: F,
190        derivative: Option<D>,
191        initial_guess: f64,
192        _bounds: Option<(f64, f64)>,
193        config: &SolverConfig,
194    ) -> MathResult<SolverResult>
195    where
196        F: Fn(f64) -> f64,
197        D: Fn(f64) -> f64,
198    {
199        match derivative {
200            Some(df) => newton_raphson(f, df, initial_guess, config),
201            None => newton_raphson_numerical(f, initial_guess, config),
202        }
203    }
204
205    fn name(&self) -> &'static str {
206        "Newton-Raphson"
207    }
208}
209
210/// Brent's method solver implementation.
211#[derive(Debug, Clone, Copy, Default)]
212pub struct BrentSolver;
213
214impl Solver for BrentSolver {
215    fn solve<F, D>(
216        &self,
217        f: F,
218        _derivative: Option<D>,
219        initial_guess: f64,
220        bounds: Option<(f64, f64)>,
221        config: &SolverConfig,
222    ) -> MathResult<SolverResult>
223    where
224        F: Fn(f64) -> f64,
225        D: Fn(f64) -> f64,
226    {
227        let (a, b) = bounds.unwrap_or((initial_guess - 1.0, initial_guess + 1.0));
228        brent(f, a, b, config)
229    }
230
231    fn name(&self) -> &'static str {
232        "Brent"
233    }
234}
235
236/// Bisection solver implementation.
237#[derive(Debug, Clone, Copy, Default)]
238pub struct BisectionSolver;
239
240impl Solver for BisectionSolver {
241    fn solve<F, D>(
242        &self,
243        f: F,
244        _derivative: Option<D>,
245        initial_guess: f64,
246        bounds: Option<(f64, f64)>,
247        config: &SolverConfig,
248    ) -> MathResult<SolverResult>
249    where
250        F: Fn(f64) -> f64,
251        D: Fn(f64) -> f64,
252    {
253        let (a, b) = bounds.unwrap_or((initial_guess - 1.0, initial_guess + 1.0));
254        bisection(f, a, b, config)
255    }
256
257    fn name(&self) -> &'static str {
258        "Bisection"
259    }
260}
261
262/// Secant method solver implementation.
263#[derive(Debug, Clone, Copy, Default)]
264pub struct SecantSolver;
265
266impl Solver for SecantSolver {
267    fn solve<F, D>(
268        &self,
269        f: F,
270        _derivative: Option<D>,
271        initial_guess: f64,
272        bounds: Option<(f64, f64)>,
273        config: &SolverConfig,
274    ) -> MathResult<SolverResult>
275    where
276        F: Fn(f64) -> f64,
277        D: Fn(f64) -> f64,
278    {
279        let (x0, x1) = bounds.unwrap_or((initial_guess - 0.1, initial_guess + 0.1));
280        secant(f, x0, x1, config)
281    }
282
283    fn name(&self) -> &'static str {
284        "Secant"
285    }
286}
287
288/// Hybrid solver (Newton + Brent fallback).
289#[derive(Debug, Clone, Copy, Default)]
290pub struct HybridSolver;
291
292impl Solver for HybridSolver {
293    fn solve<F, D>(
294        &self,
295        f: F,
296        derivative: Option<D>,
297        initial_guess: f64,
298        bounds: Option<(f64, f64)>,
299        config: &SolverConfig,
300    ) -> MathResult<SolverResult>
301    where
302        F: Fn(f64) -> f64,
303        D: Fn(f64) -> f64,
304    {
305        match derivative {
306            Some(df) => hybrid(f, df, initial_guess, bounds, config),
307            None => hybrid_numerical(f, initial_guess, bounds, config),
308        }
309    }
310
311    fn name(&self) -> &'static str {
312        "Hybrid (Newton + Brent)"
313    }
314}
315
316/// Result of a root-finding iteration.
317#[derive(Debug, Clone, Copy)]
318pub struct SolverResult {
319    /// The root found.
320    pub root: f64,
321    /// Number of iterations used.
322    pub iterations: u32,
323    /// Final residual (function value at root).
324    pub residual: f64,
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use approx::assert_relative_eq;
331
332    #[test]
333    fn test_solver_config() {
334        let config = SolverConfig::default()
335            .with_tolerance(1e-8)
336            .with_max_iterations(50);
337
338        assert!((config.tolerance - 1e-8).abs() < f64::EPSILON);
339        assert_eq!(config.max_iterations, 50);
340    }
341
342    #[test]
343    fn test_solver_trait_newton() {
344        let solver = NewtonSolver;
345        let f = |x: f64| x * x - 2.0;
346        let df = |x: f64| 2.0 * x;
347
348        let result = solver
349            .solve(f, Some(df), 1.5, None, &SolverConfig::default())
350            .unwrap();
351
352        assert_relative_eq!(result.root, std::f64::consts::SQRT_2, epsilon = 1e-10);
353        assert_eq!(solver.name(), "Newton-Raphson");
354    }
355
356    #[test]
357    fn test_solver_trait_brent() {
358        let solver = BrentSolver;
359        let f = |x: f64| x * x - 2.0;
360        let no_deriv: Option<fn(f64) -> f64> = None;
361
362        let result = solver
363            .solve(f, no_deriv, 1.5, Some((1.0, 2.0)), &SolverConfig::default())
364            .unwrap();
365
366        assert_relative_eq!(result.root, std::f64::consts::SQRT_2, epsilon = 1e-10);
367        assert_eq!(solver.name(), "Brent");
368    }
369
370    #[test]
371    fn test_solver_trait_hybrid() {
372        let solver = HybridSolver;
373        let f = |x: f64| x * x - 2.0;
374        let df = |x: f64| 2.0 * x;
375
376        let result = solver
377            .solve(f, Some(df), 1.5, Some((1.0, 2.0)), &SolverConfig::default())
378            .unwrap();
379
380        assert_relative_eq!(result.root, std::f64::consts::SQRT_2, epsilon = 1e-10);
381    }
382
383    // ============ YTM-like Financial Tests ============
384
385    /// Helper to calculate bond price from yield
386    fn bond_price(yield_rate: f64, coupon: f64, face: f64, years: i32, freq: i32) -> f64 {
387        let periods = years * freq;
388        let coupon_per_period = coupon / freq as f64;
389        let discount_rate = yield_rate / freq as f64;
390
391        let mut pv = 0.0;
392        for t in 1..=periods {
393            pv += coupon_per_period / (1.0 + discount_rate).powi(t);
394        }
395        pv += face / (1.0 + discount_rate).powi(periods);
396        pv
397    }
398
399    /// Helper to calculate derivative of bond price w.r.t. yield
400    fn bond_price_derivative(
401        yield_rate: f64,
402        coupon: f64,
403        face: f64,
404        years: i32,
405        freq: i32,
406    ) -> f64 {
407        let periods = years * freq;
408        let coupon_per_period = coupon / freq as f64;
409        let discount_rate = yield_rate / freq as f64;
410
411        let mut dpv = 0.0;
412        for t in 1..=periods {
413            dpv -= (t as f64 / freq as f64) * coupon_per_period / (1.0 + discount_rate).powi(t + 1);
414        }
415        dpv -= (periods as f64 / freq as f64) * face / (1.0 + discount_rate).powi(periods + 1);
416        dpv
417    }
418
419    #[test]
420    fn test_ytm_par_bond() {
421        // A bond trading at par should have YTM = coupon rate
422        let coupon = 5.0;
423        let face = 100.0;
424        let target_price = 100.0; // Par
425        let years = 10;
426        let freq = 2; // Semi-annual
427
428        let f = |y: f64| bond_price(y, coupon, face, years, freq) - target_price;
429        let df = |y: f64| bond_price_derivative(y, coupon, face, years, freq);
430
431        let result = newton_raphson(f, df, 0.05, &SolverConfig::default()).unwrap();
432
433        // YTM should equal coupon rate for par bond
434        assert_relative_eq!(result.root, 0.05, epsilon = 1e-10);
435    }
436
437    #[test]
438    fn test_ytm_discount_bond() {
439        // A bond trading below par should have YTM > coupon rate
440        let coupon = 5.0;
441        let face = 100.0;
442        let target_price = 95.0; // Below par
443        let years = 5;
444        let freq = 2;
445
446        let f = |y: f64| bond_price(y, coupon, face, years, freq) - target_price;
447        let df = |y: f64| bond_price_derivative(y, coupon, face, years, freq);
448
449        let result = hybrid(f, df, 0.05, Some((0.0, 0.20)), &SolverConfig::default()).unwrap();
450
451        // YTM should be higher than coupon rate
452        assert!(result.root > 0.05);
453        // Verify the price matches
454        assert!(f(result.root).abs() < 1e-10);
455    }
456
457    #[test]
458    fn test_ytm_premium_bond() {
459        // A bond trading above par should have YTM < coupon rate
460        let coupon = 7.0;
461        let face = 100.0;
462        let target_price = 105.0; // Above par
463        let years = 5;
464        let freq = 2;
465
466        let f = |y: f64| bond_price(y, coupon, face, years, freq) - target_price;
467        let df = |y: f64| bond_price_derivative(y, coupon, face, years, freq);
468
469        let result = hybrid(f, df, 0.07, Some((0.0, 0.20)), &SolverConfig::default()).unwrap();
470
471        // YTM should be lower than coupon rate
472        assert!(result.root < 0.07);
473        assert!(f(result.root).abs() < 1e-10);
474    }
475
476    #[test]
477    fn test_ytm_all_solvers_agree() {
478        // All solvers should find the same YTM
479        let coupon = 6.0;
480        let face = 100.0;
481        let target_price = 98.0;
482        let years = 7;
483        let freq = 2;
484
485        let f = |y: f64| bond_price(y, coupon, face, years, freq) - target_price;
486        let df = |y: f64| bond_price_derivative(y, coupon, face, years, freq);
487        let config = SolverConfig::default();
488
489        let newton_result = newton_raphson(f, df, 0.06, &config).unwrap();
490        let brent_result = brent(f, 0.0, 0.20, &config).unwrap();
491        let hybrid_result = hybrid(f, df, 0.06, Some((0.0, 0.20)), &config).unwrap();
492        let secant_result = secant(f, 0.05, 0.07, &config).unwrap();
493
494        // All should agree within tolerance
495        assert_relative_eq!(newton_result.root, brent_result.root, epsilon = 1e-8);
496        assert_relative_eq!(newton_result.root, hybrid_result.root, epsilon = 1e-8);
497        assert_relative_eq!(newton_result.root, secant_result.root, epsilon = 1e-8);
498    }
499
500    #[test]
501    fn test_z_spread_like_calculation() {
502        // Simulate Z-spread: find constant spread over zero curve
503        // Simplified: assume flat zero curve at 3%
504        let zero_rate = 0.03;
505        let target_price = 97.0;
506        let coupon = 5.0;
507        let face = 100.0;
508        let years = 5;
509
510        let price_with_spread = |spread: f64| {
511            let mut pv = 0.0;
512            for t in 1..=years {
513                let discount = (-((zero_rate + spread) * t as f64)).exp();
514                pv += coupon * discount;
515            }
516            pv += face * (-((zero_rate + spread) * years as f64)).exp();
517            pv
518        };
519
520        let f = |spread: f64| price_with_spread(spread) - target_price;
521
522        // Z-spread should be positive since bond is below par
523        let result = brent(f, -0.05, 0.10, &SolverConfig::default()).unwrap();
524
525        assert!(result.root > 0.0);
526        assert!(f(result.root).abs() < 1e-10);
527    }
528
529    #[test]
530    fn test_solver_convergence_speed() {
531        // Newton should converge faster than Brent
532        let f = |x: f64| x * x - 2.0;
533        let df = |x: f64| 2.0 * x;
534        let config = SolverConfig::default();
535
536        let newton_result = newton_raphson(f, df, 1.5, &config).unwrap();
537        let brent_result = brent(f, 1.0, 2.0, &config).unwrap();
538
539        // Newton should use fewer iterations
540        assert!(newton_result.iterations <= brent_result.iterations);
541    }
542
543    #[test]
544    fn test_high_yield_bond() {
545        // High yield bond with YTM around 12%
546        let coupon = 8.0;
547        let face = 100.0;
548        let target_price = 85.0; // Deep discount
549        let years = 5;
550        let freq = 2;
551
552        let f = |y: f64| bond_price(y, coupon, face, years, freq) - target_price;
553        let df = |y: f64| bond_price_derivative(y, coupon, face, years, freq);
554
555        let result = hybrid(f, df, 0.10, Some((0.0, 0.30)), &SolverConfig::default()).unwrap();
556
557        // Should find a high yield
558        assert!(result.root > 0.10);
559        assert!(f(result.root).abs() < 1e-10);
560    }
561
562    #[test]
563    fn test_zero_coupon_bond() {
564        // Zero coupon bond - simpler case
565        // Price = Face / (1 + y)^n
566        // At 10% yield over 5 years: Price = 100 / (1.10)^5 = 62.0921...
567        let face = 100.0;
568        let target_price = 62.0921; // Exact 10% yield over 5 years
569        let years = 5;
570
571        let f = |y: f64| face / (1.0 + y).powi(years) - target_price;
572        let df = |y: f64| -(years as f64) * face / (1.0 + y).powi(years + 1);
573
574        let result = newton_raphson(f, df, 0.08, &SolverConfig::default()).unwrap();
575
576        // Should be close to 10%
577        assert_relative_eq!(result.root, 0.10, epsilon = 0.001);
578    }
579}