Skip to main content

nautilus_core/
math.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Mathematical functions and interpolation utilities.
17//!
18//! This module provides essential mathematical operations for quantitative trading,
19//! including linear and quadratic interpolation functions commonly used in financial
20//! data processing and analysis.
21//!
22//! # Epsilon Values
23//!
24//! Two epsilon thresholds are used in this module:
25//!
26//! - **`f64::EPSILON * 2.0` (~4.44e-16), scaled by magnitude:** Used for detecting
27//!   near-coincident abscissas in `linear_weight` and `quad_polynomial` to prevent
28//!   division instability. The threshold scales with `max(1, |x1|, |x2|)` so that
29//!   large-magnitude inputs whose difference is below their floating-point
30//!   resolution are also rejected.
31//!
32//! - **`1e-8`:** Used in `quadratic_interpolation` for detecting exact sample points.
33//!   This is an application-level threshold appropriate for typical financial data.
34
35/// Macro for approximate floating-point equality comparison.
36///
37/// This macro compares two floating-point values with a specified epsilon tolerance,
38/// providing a safe alternative to exact equality checks which can fail due to
39/// floating-point precision issues.
40///
41/// # Usage
42///
43/// ```rust
44/// use nautilus_core::approx_eq;
45///
46/// let a = 0.1 + 0.2;
47/// let b = 0.3;
48/// assert!(approx_eq!(f64, a, b, epsilon = 1e-10));
49/// ```
50#[macro_export]
51macro_rules! approx_eq {
52    ($type:ty, $left:expr, $right:expr, epsilon = $epsilon:expr) => {{
53        let left_val: $type = $left;
54        let right_val: $type = $right;
55        (left_val - right_val).abs() < $epsilon
56    }};
57}
58
59/// Calculates the interpolation weight between `x1` and `x2` for a value `x`.
60///
61/// The returned weight `w` satisfies `y = (1 - w) * y1 + w * y2` when
62/// interpolating ordinates that correspond to abscissas `x1` and `x2`.
63///
64/// # Panics
65///
66/// Panics if:
67/// - Any input is NaN or infinite.
68/// - `x1` and `x2` are too close relative to their magnitude, which would
69///   cause division by zero or numerical instability.
70#[inline]
71#[must_use]
72pub fn linear_weight(x1: f64, x2: f64, x: f64) -> f64 {
73    assert!(
74        x1.is_finite() && x2.is_finite() && x.is_finite(),
75        "All inputs must be finite: x1={x1}, x2={x2}, x={x}"
76    );
77
78    assert!(
79        !too_close_for_interpolation(x1, x2),
80        "`x1` ({x1}) and `x2` ({x2}) are too close for stable interpolation"
81    );
82    (x - x1) / (x2 - x1)
83}
84
85/// Performs linear interpolation using a weight factor.
86///
87/// Given ordinates `y1` and `y2` and a weight `x1_diff`, computes the
88/// interpolated value using the formula: `y1 + x1_diff * (y2 - y1)`.
89#[inline]
90#[must_use]
91pub fn linear_weighting(y1: f64, y2: f64, x1_diff: f64) -> f64 {
92    x1_diff.mul_add(y2 - y1, y1)
93}
94
95/// Finds the position for interpolation in a sorted array.
96///
97/// Returns the index of the largest element in `xs` that is less than `x`,
98/// clamped to the valid range `[0, xs.len() - 1]`.
99///
100/// # Edge Cases
101///
102/// - For empty arrays, returns 0
103/// - For single-element arrays, always returns index 0, regardless of whether `x > xs[0]`
104/// - For values below the minimum, returns 0
105/// - For values at or above the maximum, returns `xs.len() - 1`
106#[inline]
107#[must_use]
108pub fn pos_search(x: f64, xs: &[f64]) -> usize {
109    if xs.is_empty() {
110        return 0;
111    }
112
113    let n_elem = xs.len();
114    let pos = xs.partition_point(|&val| val < x);
115    pos.saturating_sub(1).min(n_elem - 1)
116}
117
118/// Evaluates the quadratic Lagrange polynomial defined by three points.
119///
120/// Given points `(x0, y0)`, `(x1, y1)`, `(x2, y2)` this returns *P(x)* where
121/// *P* is the unique polynomial of degree ≤ 2 passing through the three
122/// points.
123///
124/// # Panics
125///
126/// Panics if:
127/// - Any input is NaN or infinite.
128/// - Any two abscissas are too close relative to their magnitude, which would
129///   cause division by zero or numerical instability.
130#[inline]
131#[must_use]
132pub fn quad_polynomial(x: f64, x0: f64, x1: f64, x2: f64, y0: f64, y1: f64, y2: f64) -> f64 {
133    assert!(
134        x.is_finite()
135            && x0.is_finite()
136            && x1.is_finite()
137            && x2.is_finite()
138            && y0.is_finite()
139            && y1.is_finite()
140            && y2.is_finite(),
141        "All inputs must be finite: x={x}, x0={x0}, x1={x1}, x2={x2}, y0={y0}, y1={y1}, y2={y2}"
142    );
143
144    // Protect against coincident x values that would lead to division by zero
145    assert!(
146        !too_close_for_interpolation(x0, x1)
147            && !too_close_for_interpolation(x0, x2)
148            && !too_close_for_interpolation(x1, x2),
149        "Abscissas are too close for stable interpolation: x0={x0}, x1={x1}, x2={x2}"
150    );
151
152    y0 * (x - x1) * (x - x2) / ((x0 - x1) * (x0 - x2))
153        + y1 * (x - x0) * (x - x2) / ((x1 - x0) * (x1 - x2))
154        + y2 * (x - x0) * (x - x1) / ((x2 - x0) * (x2 - x1))
155}
156
157/// Performs quadratic interpolation for the point `x` given vectors of abscissas `xs` and ordinates `ys`.
158///
159/// # Panics
160///
161/// Panics if `xs.len() < 3` or `xs.len() != ys.len()`.
162#[must_use]
163pub fn quadratic_interpolation(x: f64, xs: &[f64], ys: &[f64]) -> f64 {
164    let n_elem = xs.len();
165    let epsilon = 1e-8;
166
167    assert!(
168        n_elem >= 3,
169        "Need at least 3 points for quadratic interpolation"
170    );
171    assert_eq!(xs.len(), ys.len(), "xs and ys must have the same length");
172
173    if x <= xs[0] {
174        return ys[0];
175    }
176
177    if x >= xs[n_elem - 1] {
178        return ys[n_elem - 1];
179    }
180
181    let pos = pos_search(x, xs);
182
183    if (xs[pos] - x).abs() < epsilon {
184        return ys[pos];
185    }
186
187    if pos == 0 {
188        return quad_polynomial(x, xs[0], xs[1], xs[2], ys[0], ys[1], ys[2]);
189    }
190
191    if pos == n_elem - 2 {
192        return quad_polynomial(
193            x,
194            xs[n_elem - 3],
195            xs[n_elem - 2],
196            xs[n_elem - 1],
197            ys[n_elem - 3],
198            ys[n_elem - 2],
199            ys[n_elem - 1],
200        );
201    }
202
203    let w = linear_weight(xs[pos], xs[pos + 1], x);
204
205    linear_weighting(
206        quad_polynomial(
207            x,
208            xs[pos - 1],
209            xs[pos],
210            xs[pos + 1],
211            ys[pos - 1],
212            ys[pos],
213            ys[pos + 1],
214        ),
215        quad_polynomial(
216            x,
217            xs[pos],
218            xs[pos + 1],
219            xs[pos + 2],
220            ys[pos],
221            ys[pos + 1],
222            ys[pos + 2],
223        ),
224        w,
225    )
226}
227
228/// Returns `true` if `x1` and `x2` are too close, relative to their magnitude,
229/// for numerically stable interpolation.
230///
231/// The threshold scales with `max(1, |x1|, |x2|)`, giving an absolute floor of
232/// `2 * f64::EPSILON` for small values and a relative bound for large values.
233/// [`linear_weight`] and [`quad_polynomial`] panic when this returns `true` for
234/// their abscissas.
235#[inline]
236#[must_use]
237pub fn too_close_for_interpolation(x1: f64, x2: f64) -> bool {
238    const EPSILON: f64 = f64::EPSILON * 2.0; // ~4.44e-16
239    let scale = 1.0_f64.max(x1.abs()).max(x2.abs());
240    (x2 - x1).abs() < EPSILON * scale
241}
242
243#[cfg(test)]
244mod tests {
245    use rstest::*;
246
247    use super::*;
248
249    #[rstest]
250    #[case(0.0, 10.0, 5.0, 0.5)]
251    #[case(1.0, 3.0, 2.0, 0.5)]
252    #[case(0.0, 1.0, 0.25, 0.25)]
253    #[case(0.0, 1.0, 0.75, 0.75)]
254    fn test_linear_weight_valid_cases(
255        #[case] x1: f64,
256        #[case] x2: f64,
257        #[case] x: f64,
258        #[case] expected: f64,
259    ) {
260        let result = linear_weight(x1, x2, x);
261        assert!(
262            approx_eq!(f64, result, expected, epsilon = 1e-10),
263            "Expected {expected}, was {result}"
264        );
265    }
266
267    #[rstest]
268    #[should_panic(expected = "too close for stable interpolation")]
269    fn test_linear_weight_zero_divisor() {
270        let _ = linear_weight(1.0, 1.0, 0.5);
271    }
272
273    #[rstest]
274    #[should_panic(expected = "too close for stable interpolation")]
275    fn test_linear_weight_near_equal_values() {
276        // Values within machine epsilon should be rejected
277        let _ = linear_weight(1.0, 1.0 + f64::EPSILON, 0.5);
278    }
279
280    #[rstest]
281    fn test_linear_weight_with_small_differences() {
282        // High-resolution data (e.g., nanosecond timestamps as seconds) should work
283        let result = linear_weight(0.0, 1e-12, 5e-13);
284        assert!(result.is_finite());
285        assert!((result - 0.5).abs() < 1e-10); // Should be approximately 0.5
286    }
287
288    #[rstest]
289    fn test_linear_weight_just_above_epsilon() {
290        // Values differing by more than machine epsilon should work
291        let result = linear_weight(1.0, 1.0 + 1e-9, 1.0 + 5e-10);
292        // Should not panic and return a reasonable value
293        assert!(result.is_finite());
294    }
295
296    #[rstest]
297    #[should_panic(expected = "too close for stable interpolation")]
298    fn test_linear_weight_large_magnitude_near_equal_panics() {
299        // At magnitude 1e16 the f64 resolution is 2.0, so a spacing of 2.0 is
300        // below the scaled threshold (~4.44) and must be rejected
301        let _ = linear_weight(1e16, 1e16 + 2.0, 1e16 + 1.0);
302    }
303
304    #[rstest]
305    fn test_linear_weight_large_magnitude_adequate_spacing() {
306        let result = linear_weight(1e16, 1e16 + 1e10, 1e16 + 5e9);
307        assert!(result.is_finite());
308        assert!((result - 0.5).abs() < 1e-6);
309    }
310
311    #[rstest]
312    #[case(1.0, 3.0, 0.5, 2.0)]
313    #[case(10.0, 20.0, 0.25, 12.5)]
314    #[case(0.0, 10.0, 0.0, 0.0)]
315    #[case(0.0, 10.0, 1.0, 10.0)]
316    fn test_linear_weighting(
317        #[case] y1: f64,
318        #[case] y2: f64,
319        #[case] weight: f64,
320        #[case] expected: f64,
321    ) {
322        let result = linear_weighting(y1, y2, weight);
323        assert!(
324            approx_eq!(f64, result, expected, epsilon = 1e-10),
325            "Expected {expected}, was {result}"
326        );
327    }
328
329    #[rstest]
330    #[case(5.0, &[1.0, 2.0, 3.0, 4.0, 6.0, 7.0], 3)]
331    #[case(1.5, &[1.0, 2.0, 3.0, 4.0], 0)]
332    #[case(0.5, &[1.0, 2.0, 3.0, 4.0], 0)]
333    #[case(4.5, &[1.0, 2.0, 3.0, 4.0], 3)]
334    #[case(2.0, &[1.0, 2.0, 3.0, 4.0], 0)]
335    fn test_pos_search(#[case] x: f64, #[case] xs: &[f64], #[case] expected: usize) {
336        let result = pos_search(x, xs);
337        assert_eq!(result, expected);
338    }
339
340    #[rstest]
341    fn test_pos_search_edge_cases() {
342        // Single element array
343        let result = pos_search(5.0, &[10.0]);
344        assert_eq!(result, 0);
345
346        // Value at exact boundary
347        let result = pos_search(3.0, &[1.0, 2.0, 3.0, 4.0]);
348        assert_eq!(result, 1); // Index of largest element < 3.0 is index 1 (value 2.0)
349
350        // Two element array
351        let result = pos_search(1.5, &[1.0, 2.0]);
352        assert_eq!(result, 0);
353    }
354
355    #[rstest]
356    fn test_pos_search_empty_slice() {
357        let empty: &[f64] = &[];
358        assert_eq!(pos_search(42.0, empty), 0);
359    }
360
361    #[rstest]
362    fn test_quad_polynomial_linear_case() {
363        // Test with three collinear points - should behave like linear interpolation
364        let result = quad_polynomial(1.5, 1.0, 2.0, 3.0, 1.0, 2.0, 3.0);
365        assert!(approx_eq!(f64, result, 1.5, epsilon = 1e-10));
366    }
367
368    #[rstest]
369    fn test_quad_polynomial_parabola() {
370        // Test with a simple parabola y = x^2
371        // Points: (0,0), (1,1), (2,4)
372        let result = quad_polynomial(1.5, 0.0, 1.0, 2.0, 0.0, 1.0, 4.0);
373        let expected = 1.5 * 1.5; // Should be 2.25
374        assert!(approx_eq!(f64, result, expected, epsilon = 1e-10));
375    }
376
377    #[rstest]
378    #[should_panic(expected = "too close for stable interpolation")]
379    fn test_quad_polynomial_duplicate_x() {
380        let _ = quad_polynomial(0.5, 1.0, 1.0, 2.0, 0.0, 1.0, 4.0);
381    }
382
383    #[rstest]
384    #[should_panic(expected = "too close for stable interpolation")]
385    fn test_quad_polynomial_near_equal_x_values() {
386        // x0 and x1 differ by only machine epsilon
387        let _ = quad_polynomial(0.5, 1.0, 1.0 + f64::EPSILON, 2.0, 0.0, 1.0, 4.0);
388    }
389
390    #[rstest]
391    fn test_quad_polynomial_with_small_differences() {
392        // High-resolution data should work (e.g., 1e-12 spacing)
393        let result = quad_polynomial(5e-13, 0.0, 1e-12, 2e-12, 0.0, 1.0, 4.0);
394        assert!(result.is_finite());
395    }
396
397    #[rstest]
398    fn test_quad_polynomial_just_above_epsilon() {
399        // Values differing by more than machine epsilon should work
400        let result = quad_polynomial(0.5, 0.0, 1.0 + 1e-9, 2.0, 0.0, 1.0, 4.0);
401        // Should not panic and return a reasonable value
402        assert!(result.is_finite());
403    }
404
405    #[rstest]
406    #[should_panic(expected = "too close for stable interpolation")]
407    fn test_quad_polynomial_large_magnitude_near_equal_panics() {
408        // x0 and x1 are adjacent representable values at magnitude 1e16
409        let _ = quad_polynomial(1e16, 1e16, 1e16 + 2.0, 2e16, 0.0, 1.0, 4.0);
410    }
411
412    #[rstest]
413    // `allow` not `expect`: nightly clippy does not fire `float_cmp` inside `assert_eq!`
414    #[allow(
415        clippy::float_cmp,
416        reason = "boundary inputs must return the exact boundary ys value"
417    )]
418    fn test_quadratic_interpolation_boundary_conditions() {
419        let xs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
420        let ys = vec![1.0, 4.0, 9.0, 16.0, 25.0]; // y = x^2
421
422        // Test below minimum
423        let result = quadratic_interpolation(0.5, &xs, &ys);
424        assert_eq!(result, ys[0]);
425
426        // Test above maximum
427        let result = quadratic_interpolation(6.0, &xs, &ys);
428        assert_eq!(result, ys[4]);
429    }
430
431    #[rstest]
432    fn test_quadratic_interpolation_exact_points() {
433        let xs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
434        let ys = vec![1.0, 4.0, 9.0, 16.0, 25.0];
435
436        // Test exact points
437        for (i, &x) in xs.iter().enumerate() {
438            let result = quadratic_interpolation(x, &xs, &ys);
439            assert!(approx_eq!(f64, result, ys[i], epsilon = 1e-6));
440        }
441    }
442
443    #[rstest]
444    fn test_quadratic_interpolation_intermediate_values() {
445        let xs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
446        let ys = vec![1.0, 4.0, 9.0, 16.0, 25.0]; // y = x^2
447
448        // Test interpolation between points
449        let result = quadratic_interpolation(2.5, &xs, &ys);
450        let expected = 2.5 * 2.5; // Should be close to 6.25
451        assert!((result - expected).abs() < 0.1); // Allow some interpolation error
452    }
453
454    #[rstest]
455    #[should_panic(expected = "Need at least 3 points")]
456    fn test_quadratic_interpolation_insufficient_points() {
457        let xs = vec![1.0, 2.0];
458        let ys = vec![1.0, 4.0];
459        let _ = quadratic_interpolation(1.5, &xs, &ys);
460    }
461
462    #[rstest]
463    #[should_panic(expected = "xs and ys must have the same length")]
464    fn test_quadratic_interpolation_mismatched_lengths() {
465        let xs = vec![1.0, 2.0, 3.0];
466        let ys = vec![1.0, 4.0];
467        let _ = quadratic_interpolation(1.5, &xs, &ys);
468    }
469
470    #[rstest]
471    #[case(f64::NAN, 0.0, 1.0)]
472    #[case(0.0, f64::NAN, 1.0)]
473    #[case(0.0, 1.0, f64::NAN)]
474    #[case(f64::INFINITY, 0.0, 1.0)]
475    #[case(0.0, f64::NEG_INFINITY, 1.0)]
476    #[should_panic(expected = "All inputs must be finite")]
477    fn test_linear_weight_non_finite_panics(#[case] x1: f64, #[case] x2: f64, #[case] x: f64) {
478        let _ = linear_weight(x1, x2, x);
479    }
480
481    #[rstest]
482    #[should_panic(expected = "All inputs must be finite")]
483    fn test_quad_polynomial_nan_panics() {
484        let _ = quad_polynomial(f64::NAN, 0.0, 1.0, 2.0, 0.0, 1.0, 4.0);
485    }
486
487    #[rstest]
488    #[should_panic(expected = "All inputs must be finite")]
489    fn test_quad_polynomial_infinity_panics() {
490        let _ = quad_polynomial(0.5, f64::INFINITY, 1.0, 2.0, 0.0, 1.0, 4.0);
491    }
492}