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/// - If any input is NaN or infinite.
67/// - If `x1` and `x2` are too close relative to their magnitude, which would
68///   cause division by zero or numerical instability.
69#[inline]
70#[must_use]
71pub fn linear_weight(x1: f64, x2: f64, x: f64) -> f64 {
72    assert!(
73        x1.is_finite() && x2.is_finite() && x.is_finite(),
74        "All inputs must be finite: x1={x1}, x2={x2}, x={x}"
75    );
76
77    assert!(
78        !too_close_for_interpolation(x1, x2),
79        "`x1` ({x1}) and `x2` ({x2}) are too close for stable interpolation"
80    );
81    (x - x1) / (x2 - x1)
82}
83
84/// Performs linear interpolation using a weight factor.
85///
86/// Given ordinates `y1` and `y2` and a weight `x1_diff`, computes the
87/// interpolated value using the formula: `y1 + x1_diff * (y2 - y1)`.
88#[inline]
89#[must_use]
90pub fn linear_weighting(y1: f64, y2: f64, x1_diff: f64) -> f64 {
91    x1_diff.mul_add(y2 - y1, y1)
92}
93
94/// Finds the position for interpolation in a sorted array.
95///
96/// Returns the index of the largest element in `xs` that is less than `x`,
97/// clamped to the valid range `[0, xs.len() - 1]`.
98///
99/// # Edge Cases
100///
101/// - For empty arrays, returns 0
102/// - For single-element arrays, always returns index 0, regardless of whether `x > xs[0]`
103/// - For values below the minimum, returns 0
104/// - For values at or above the maximum, returns `xs.len() - 1`
105#[inline]
106#[must_use]
107pub fn pos_search(x: f64, xs: &[f64]) -> usize {
108    if xs.is_empty() {
109        return 0;
110    }
111
112    let n_elem = xs.len();
113    let pos = xs.partition_point(|&val| val < x);
114    pos.saturating_sub(1).min(n_elem - 1)
115}
116
117/// Evaluates the quadratic Lagrange polynomial defined by three points.
118///
119/// Given points `(x0, y0)`, `(x1, y1)`, `(x2, y2)` this returns *P(x)* where
120/// *P* is the unique polynomial of degree ≤ 2 passing through the three
121/// points.
122///
123/// # Panics
124///
125/// - If any input is NaN or infinite.
126/// - If any two abscissas are too close relative to their magnitude, which would
127///   cause division by zero or numerical instability.
128#[inline]
129#[must_use]
130pub fn quad_polynomial(x: f64, x0: f64, x1: f64, x2: f64, y0: f64, y1: f64, y2: f64) -> f64 {
131    assert!(
132        x.is_finite()
133            && x0.is_finite()
134            && x1.is_finite()
135            && x2.is_finite()
136            && y0.is_finite()
137            && y1.is_finite()
138            && y2.is_finite(),
139        "All inputs must be finite: x={x}, x0={x0}, x1={x1}, x2={x2}, y0={y0}, y1={y1}, y2={y2}"
140    );
141
142    // Protect against coincident x values that would lead to division by zero
143    assert!(
144        !too_close_for_interpolation(x0, x1)
145            && !too_close_for_interpolation(x0, x2)
146            && !too_close_for_interpolation(x1, x2),
147        "Abscissas are too close for stable interpolation: x0={x0}, x1={x1}, x2={x2}"
148    );
149
150    y0 * (x - x1) * (x - x2) / ((x0 - x1) * (x0 - x2))
151        + y1 * (x - x0) * (x - x2) / ((x1 - x0) * (x1 - x2))
152        + y2 * (x - x0) * (x - x1) / ((x2 - x0) * (x2 - x1))
153}
154
155/// Performs quadratic interpolation for the point `x` given vectors of abscissas `xs` and ordinates `ys`.
156///
157/// # Panics
158///
159/// Panics if `xs.len() < 3` or `xs.len() != ys.len()`.
160#[must_use]
161pub fn quadratic_interpolation(x: f64, xs: &[f64], ys: &[f64]) -> f64 {
162    let n_elem = xs.len();
163    let epsilon = 1e-8;
164
165    assert!(
166        n_elem >= 3,
167        "Need at least 3 points for quadratic interpolation"
168    );
169    assert_eq!(xs.len(), ys.len(), "xs and ys must have the same length");
170
171    if x <= xs[0] {
172        return ys[0];
173    }
174
175    if x >= xs[n_elem - 1] {
176        return ys[n_elem - 1];
177    }
178
179    let pos = pos_search(x, xs);
180
181    if (xs[pos] - x).abs() < epsilon {
182        return ys[pos];
183    }
184
185    if pos == 0 {
186        return quad_polynomial(x, xs[0], xs[1], xs[2], ys[0], ys[1], ys[2]);
187    }
188
189    if pos == n_elem - 2 {
190        return quad_polynomial(
191            x,
192            xs[n_elem - 3],
193            xs[n_elem - 2],
194            xs[n_elem - 1],
195            ys[n_elem - 3],
196            ys[n_elem - 2],
197            ys[n_elem - 1],
198        );
199    }
200
201    let w = linear_weight(xs[pos], xs[pos + 1], x);
202
203    linear_weighting(
204        quad_polynomial(
205            x,
206            xs[pos - 1],
207            xs[pos],
208            xs[pos + 1],
209            ys[pos - 1],
210            ys[pos],
211            ys[pos + 1],
212        ),
213        quad_polynomial(
214            x,
215            xs[pos],
216            xs[pos + 1],
217            xs[pos + 2],
218            ys[pos],
219            ys[pos + 1],
220            ys[pos + 2],
221        ),
222        w,
223    )
224}
225
226// Returns `true` if `x1` and `x2` are too close, relative to their magnitude,
227// for numerically stable interpolation. The threshold scales with
228// `max(1, |x1|, |x2|)`, giving an absolute floor of `2 * f64::EPSILON` for
229// small values and a relative bound for large values.
230#[inline]
231fn too_close_for_interpolation(x1: f64, x2: f64) -> bool {
232    const EPSILON: f64 = f64::EPSILON * 2.0; // ~4.44e-16
233    let scale = 1.0_f64.max(x1.abs()).max(x2.abs());
234    (x2 - x1).abs() < EPSILON * scale
235}
236
237#[cfg(test)]
238mod tests {
239    use rstest::*;
240
241    use super::*;
242
243    #[rstest]
244    #[case(0.0, 10.0, 5.0, 0.5)]
245    #[case(1.0, 3.0, 2.0, 0.5)]
246    #[case(0.0, 1.0, 0.25, 0.25)]
247    #[case(0.0, 1.0, 0.75, 0.75)]
248    fn test_linear_weight_valid_cases(
249        #[case] x1: f64,
250        #[case] x2: f64,
251        #[case] x: f64,
252        #[case] expected: f64,
253    ) {
254        let result = linear_weight(x1, x2, x);
255        assert!(
256            approx_eq!(f64, result, expected, epsilon = 1e-10),
257            "Expected {expected}, was {result}"
258        );
259    }
260
261    #[rstest]
262    #[should_panic(expected = "too close for stable interpolation")]
263    fn test_linear_weight_zero_divisor() {
264        let _ = linear_weight(1.0, 1.0, 0.5);
265    }
266
267    #[rstest]
268    #[should_panic(expected = "too close for stable interpolation")]
269    fn test_linear_weight_near_equal_values() {
270        // Values within machine epsilon should be rejected
271        let _ = linear_weight(1.0, 1.0 + f64::EPSILON, 0.5);
272    }
273
274    #[rstest]
275    fn test_linear_weight_with_small_differences() {
276        // High-resolution data (e.g., nanosecond timestamps as seconds) should work
277        let result = linear_weight(0.0, 1e-12, 5e-13);
278        assert!(result.is_finite());
279        assert!((result - 0.5).abs() < 1e-10); // Should be approximately 0.5
280    }
281
282    #[rstest]
283    fn test_linear_weight_just_above_epsilon() {
284        // Values differing by more than machine epsilon should work
285        let result = linear_weight(1.0, 1.0 + 1e-9, 1.0 + 5e-10);
286        // Should not panic and return a reasonable value
287        assert!(result.is_finite());
288    }
289
290    #[rstest]
291    #[should_panic(expected = "too close for stable interpolation")]
292    fn test_linear_weight_large_magnitude_near_equal_panics() {
293        // At magnitude 1e16 the f64 resolution is 2.0, so a spacing of 2.0 is
294        // below the scaled threshold (~4.44) and must be rejected
295        let _ = linear_weight(1e16, 1e16 + 2.0, 1e16 + 1.0);
296    }
297
298    #[rstest]
299    fn test_linear_weight_large_magnitude_adequate_spacing() {
300        let result = linear_weight(1e16, 1e16 + 1e10, 1e16 + 5e9);
301        assert!(result.is_finite());
302        assert!((result - 0.5).abs() < 1e-6);
303    }
304
305    #[rstest]
306    #[case(1.0, 3.0, 0.5, 2.0)]
307    #[case(10.0, 20.0, 0.25, 12.5)]
308    #[case(0.0, 10.0, 0.0, 0.0)]
309    #[case(0.0, 10.0, 1.0, 10.0)]
310    fn test_linear_weighting(
311        #[case] y1: f64,
312        #[case] y2: f64,
313        #[case] weight: f64,
314        #[case] expected: f64,
315    ) {
316        let result = linear_weighting(y1, y2, weight);
317        assert!(
318            approx_eq!(f64, result, expected, epsilon = 1e-10),
319            "Expected {expected}, was {result}"
320        );
321    }
322
323    #[rstest]
324    #[case(5.0, &[1.0, 2.0, 3.0, 4.0, 6.0, 7.0], 3)]
325    #[case(1.5, &[1.0, 2.0, 3.0, 4.0], 0)]
326    #[case(0.5, &[1.0, 2.0, 3.0, 4.0], 0)]
327    #[case(4.5, &[1.0, 2.0, 3.0, 4.0], 3)]
328    #[case(2.0, &[1.0, 2.0, 3.0, 4.0], 0)]
329    fn test_pos_search(#[case] x: f64, #[case] xs: &[f64], #[case] expected: usize) {
330        let result = pos_search(x, xs);
331        assert_eq!(result, expected);
332    }
333
334    #[rstest]
335    fn test_pos_search_edge_cases() {
336        // Single element array
337        let result = pos_search(5.0, &[10.0]);
338        assert_eq!(result, 0);
339
340        // Value at exact boundary
341        let result = pos_search(3.0, &[1.0, 2.0, 3.0, 4.0]);
342        assert_eq!(result, 1); // Index of largest element < 3.0 is index 1 (value 2.0)
343
344        // Two element array
345        let result = pos_search(1.5, &[1.0, 2.0]);
346        assert_eq!(result, 0);
347    }
348
349    #[rstest]
350    fn test_pos_search_empty_slice() {
351        let empty: &[f64] = &[];
352        assert_eq!(pos_search(42.0, empty), 0);
353    }
354
355    #[rstest]
356    fn test_quad_polynomial_linear_case() {
357        // Test with three collinear points - should behave like linear interpolation
358        let result = quad_polynomial(1.5, 1.0, 2.0, 3.0, 1.0, 2.0, 3.0);
359        assert!(approx_eq!(f64, result, 1.5, epsilon = 1e-10));
360    }
361
362    #[rstest]
363    fn test_quad_polynomial_parabola() {
364        // Test with a simple parabola y = x^2
365        // Points: (0,0), (1,1), (2,4)
366        let result = quad_polynomial(1.5, 0.0, 1.0, 2.0, 0.0, 1.0, 4.0);
367        let expected = 1.5 * 1.5; // Should be 2.25
368        assert!(approx_eq!(f64, result, expected, epsilon = 1e-10));
369    }
370
371    #[rstest]
372    #[should_panic(expected = "too close for stable interpolation")]
373    fn test_quad_polynomial_duplicate_x() {
374        let _ = quad_polynomial(0.5, 1.0, 1.0, 2.0, 0.0, 1.0, 4.0);
375    }
376
377    #[rstest]
378    #[should_panic(expected = "too close for stable interpolation")]
379    fn test_quad_polynomial_near_equal_x_values() {
380        // x0 and x1 differ by only machine epsilon
381        let _ = quad_polynomial(0.5, 1.0, 1.0 + f64::EPSILON, 2.0, 0.0, 1.0, 4.0);
382    }
383
384    #[rstest]
385    fn test_quad_polynomial_with_small_differences() {
386        // High-resolution data should work (e.g., 1e-12 spacing)
387        let result = quad_polynomial(5e-13, 0.0, 1e-12, 2e-12, 0.0, 1.0, 4.0);
388        assert!(result.is_finite());
389    }
390
391    #[rstest]
392    fn test_quad_polynomial_just_above_epsilon() {
393        // Values differing by more than machine epsilon should work
394        let result = quad_polynomial(0.5, 0.0, 1.0 + 1e-9, 2.0, 0.0, 1.0, 4.0);
395        // Should not panic and return a reasonable value
396        assert!(result.is_finite());
397    }
398
399    #[rstest]
400    #[should_panic(expected = "too close for stable interpolation")]
401    fn test_quad_polynomial_large_magnitude_near_equal_panics() {
402        // x0 and x1 are adjacent representable values at magnitude 1e16
403        let _ = quad_polynomial(1e16, 1e16, 1e16 + 2.0, 2e16, 0.0, 1.0, 4.0);
404    }
405
406    #[rstest]
407    #[expect(
408        clippy::float_cmp,
409        reason = "boundary inputs must return the exact boundary ys value"
410    )]
411    fn test_quadratic_interpolation_boundary_conditions() {
412        let xs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
413        let ys = vec![1.0, 4.0, 9.0, 16.0, 25.0]; // y = x^2
414
415        // Test below minimum
416        let result = quadratic_interpolation(0.5, &xs, &ys);
417        assert_eq!(result, ys[0]);
418
419        // Test above maximum
420        let result = quadratic_interpolation(6.0, &xs, &ys);
421        assert_eq!(result, ys[4]);
422    }
423
424    #[rstest]
425    fn test_quadratic_interpolation_exact_points() {
426        let xs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
427        let ys = vec![1.0, 4.0, 9.0, 16.0, 25.0];
428
429        // Test exact points
430        for (i, &x) in xs.iter().enumerate() {
431            let result = quadratic_interpolation(x, &xs, &ys);
432            assert!(approx_eq!(f64, result, ys[i], epsilon = 1e-6));
433        }
434    }
435
436    #[rstest]
437    fn test_quadratic_interpolation_intermediate_values() {
438        let xs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
439        let ys = vec![1.0, 4.0, 9.0, 16.0, 25.0]; // y = x^2
440
441        // Test interpolation between points
442        let result = quadratic_interpolation(2.5, &xs, &ys);
443        let expected = 2.5 * 2.5; // Should be close to 6.25
444        assert!((result - expected).abs() < 0.1); // Allow some interpolation error
445    }
446
447    #[rstest]
448    #[should_panic(expected = "Need at least 3 points")]
449    fn test_quadratic_interpolation_insufficient_points() {
450        let xs = vec![1.0, 2.0];
451        let ys = vec![1.0, 4.0];
452        let _ = quadratic_interpolation(1.5, &xs, &ys);
453    }
454
455    #[rstest]
456    #[should_panic(expected = "xs and ys must have the same length")]
457    fn test_quadratic_interpolation_mismatched_lengths() {
458        let xs = vec![1.0, 2.0, 3.0];
459        let ys = vec![1.0, 4.0];
460        let _ = quadratic_interpolation(1.5, &xs, &ys);
461    }
462
463    #[rstest]
464    #[case(f64::NAN, 0.0, 1.0)]
465    #[case(0.0, f64::NAN, 1.0)]
466    #[case(0.0, 1.0, f64::NAN)]
467    #[case(f64::INFINITY, 0.0, 1.0)]
468    #[case(0.0, f64::NEG_INFINITY, 1.0)]
469    #[should_panic(expected = "All inputs must be finite")]
470    fn test_linear_weight_non_finite_panics(#[case] x1: f64, #[case] x2: f64, #[case] x: f64) {
471        let _ = linear_weight(x1, x2, x);
472    }
473
474    #[rstest]
475    #[should_panic(expected = "All inputs must be finite")]
476    fn test_quad_polynomial_nan_panics() {
477        let _ = quad_polynomial(f64::NAN, 0.0, 1.0, 2.0, 0.0, 1.0, 4.0);
478    }
479
480    #[rstest]
481    #[should_panic(expected = "All inputs must be finite")]
482    fn test_quad_polynomial_infinity_panics() {
483        let _ = quad_polynomial(0.5, f64::INFINITY, 1.0, 2.0, 0.0, 1.0, 4.0);
484    }
485}