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):** Used for detecting near-zero denominators
27//!   in `linear_weight` and `quad_polynomial` to prevent division instability.
28//!   This is a machine-precision threshold.
29//!
30//! - **`1e-8`:** Used in `quadratic_interpolation` for detecting exact sample points.
31//!   This is an application-level threshold appropriate for typical financial data.
32
33/// Macro for approximate floating-point equality comparison.
34///
35/// This macro compares two floating-point values with a specified epsilon tolerance,
36/// providing a safe alternative to exact equality checks which can fail due to
37/// floating-point precision issues.
38///
39/// # Usage
40///
41/// ```rust
42/// use nautilus_core::approx_eq;
43///
44/// let a = 0.1 + 0.2;
45/// let b = 0.3;
46/// assert!(approx_eq!(f64, a, b, epsilon = 1e-10));
47/// ```
48#[macro_export]
49macro_rules! approx_eq {
50    ($type:ty, $left:expr, $right:expr, epsilon = $epsilon:expr) => {{
51        let left_val: $type = $left;
52        let right_val: $type = $right;
53        (left_val - right_val).abs() < $epsilon
54    }};
55}
56
57/// Calculates the interpolation weight between `x1` and `x2` for a value `x`.
58///
59/// The returned weight `w` satisfies `y = (1 - w) * y1 + w * y2` when
60/// interpolating ordinates that correspond to abscissas `x1` and `x2`.
61///
62/// # Panics
63///
64/// - If any input is NaN or infinite.
65/// - If `x1` and `x2` are too close (within machine epsilon), which would
66///   cause division by zero or numerical instability.
67#[inline]
68#[must_use]
69pub fn linear_weight(x1: f64, x2: f64, x: f64) -> f64 {
70    const EPSILON: f64 = f64::EPSILON * 2.0; // ~4.44e-16
71
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    let diff = (x2 - x1).abs();
78    assert!(
79        diff >= EPSILON,
80        "`x1` ({x1}) and `x2` ({x2}) are too close for stable interpolation (diff: {diff}, min: {EPSILON})"
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    std::cmp::min(std::cmp::max(pos.saturating_sub(1), 0), 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/// - If any input is NaN or infinite.
127/// - If any two abscissas are too close (within machine epsilon), which would
128///   cause division by zero or numerical instability.
129#[inline]
130#[must_use]
131pub fn quad_polynomial(x: f64, x0: f64, x1: f64, x2: f64, y0: f64, y1: f64, y2: f64) -> f64 {
132    const EPSILON: f64 = f64::EPSILON * 2.0; // ~4.44e-16
133
134    assert!(
135        x.is_finite()
136            && x0.is_finite()
137            && x1.is_finite()
138            && x2.is_finite()
139            && y0.is_finite()
140            && y1.is_finite()
141            && y2.is_finite(),
142        "All inputs must be finite: x={x}, x0={x0}, x1={x1}, x2={x2}, y0={y0}, y1={y1}, y2={y2}"
143    );
144
145    // Protect against coincident x values that would lead to division by zero
146    let diff_01 = (x0 - x1).abs();
147    let diff_02 = (x0 - x2).abs();
148    let diff_12 = (x1 - x2).abs();
149
150    assert!(
151        diff_01 >= EPSILON && diff_02 >= EPSILON && diff_12 >= EPSILON,
152        "Abscissas are too close for stable interpolation: x0={x0}, x1={x1}, x2={x2} (diffs: {diff_01:.2e}, {diff_02:.2e}, {diff_12:.2e}, min: {EPSILON})"
153    );
154
155    y0 * (x - x1) * (x - x2) / ((x0 - x1) * (x0 - x2))
156        + y1 * (x - x0) * (x - x2) / ((x1 - x0) * (x1 - x2))
157        + y2 * (x - x0) * (x - x1) / ((x2 - x0) * (x2 - x1))
158}
159
160/// Performs quadratic interpolation for the point `x` given vectors of abscissas `xs` and ordinates `ys`.
161///
162/// # Panics
163///
164/// Panics if `xs.len() < 3` or `xs.len() != ys.len()`.
165#[must_use]
166pub fn quadratic_interpolation(x: f64, xs: &[f64], ys: &[f64]) -> f64 {
167    let n_elem = xs.len();
168    let epsilon = 1e-8;
169
170    assert!(
171        n_elem >= 3,
172        "Need at least 3 points for quadratic interpolation"
173    );
174    assert_eq!(xs.len(), ys.len(), "xs and ys must have the same length");
175
176    if x <= xs[0] {
177        return ys[0];
178    }
179
180    if x >= xs[n_elem - 1] {
181        return ys[n_elem - 1];
182    }
183
184    let pos = pos_search(x, xs);
185
186    if (xs[pos] - x).abs() < epsilon {
187        return ys[pos];
188    }
189
190    if pos == 0 {
191        return quad_polynomial(x, xs[0], xs[1], xs[2], ys[0], ys[1], ys[2]);
192    }
193
194    if pos == n_elem - 2 {
195        return quad_polynomial(
196            x,
197            xs[n_elem - 3],
198            xs[n_elem - 2],
199            xs[n_elem - 1],
200            ys[n_elem - 3],
201            ys[n_elem - 2],
202            ys[n_elem - 1],
203        );
204    }
205
206    let w = linear_weight(xs[pos], xs[pos + 1], x);
207
208    linear_weighting(
209        quad_polynomial(
210            x,
211            xs[pos - 1],
212            xs[pos],
213            xs[pos + 1],
214            ys[pos - 1],
215            ys[pos],
216            ys[pos + 1],
217        ),
218        quad_polynomial(
219            x,
220            xs[pos],
221            xs[pos + 1],
222            xs[pos + 2],
223            ys[pos],
224            ys[pos + 1],
225            ys[pos + 2],
226        ),
227        w,
228    )
229}
230
231#[cfg(test)]
232mod tests {
233    use rstest::*;
234
235    use super::*;
236
237    #[rstest]
238    #[case(0.0, 10.0, 5.0, 0.5)]
239    #[case(1.0, 3.0, 2.0, 0.5)]
240    #[case(0.0, 1.0, 0.25, 0.25)]
241    #[case(0.0, 1.0, 0.75, 0.75)]
242    fn test_linear_weight_valid_cases(
243        #[case] x1: f64,
244        #[case] x2: f64,
245        #[case] x: f64,
246        #[case] expected: f64,
247    ) {
248        let result = linear_weight(x1, x2, x);
249        assert!(
250            approx_eq!(f64, result, expected, epsilon = 1e-10),
251            "Expected {expected}, was {result}"
252        );
253    }
254
255    #[rstest]
256    #[should_panic(expected = "too close for stable interpolation")]
257    fn test_linear_weight_zero_divisor() {
258        let _ = linear_weight(1.0, 1.0, 0.5);
259    }
260
261    #[rstest]
262    #[should_panic(expected = "too close for stable interpolation")]
263    fn test_linear_weight_near_equal_values() {
264        // Values within machine epsilon should be rejected
265        let _ = linear_weight(1.0, 1.0 + f64::EPSILON, 0.5);
266    }
267
268    #[rstest]
269    fn test_linear_weight_with_small_differences() {
270        // High-resolution data (e.g., nanosecond timestamps as seconds) should work
271        let result = linear_weight(0.0, 1e-12, 5e-13);
272        assert!(result.is_finite());
273        assert!((result - 0.5).abs() < 1e-10); // Should be approximately 0.5
274    }
275
276    #[rstest]
277    fn test_linear_weight_just_above_epsilon() {
278        // Values differing by more than machine epsilon should work
279        let result = linear_weight(1.0, 1.0 + 1e-9, 1.0 + 5e-10);
280        // Should not panic and return a reasonable value
281        assert!(result.is_finite());
282    }
283
284    #[rstest]
285    #[case(1.0, 3.0, 0.5, 2.0)]
286    #[case(10.0, 20.0, 0.25, 12.5)]
287    #[case(0.0, 10.0, 0.0, 0.0)]
288    #[case(0.0, 10.0, 1.0, 10.0)]
289    fn test_linear_weighting(
290        #[case] y1: f64,
291        #[case] y2: f64,
292        #[case] weight: f64,
293        #[case] expected: f64,
294    ) {
295        let result = linear_weighting(y1, y2, weight);
296        assert!(
297            approx_eq!(f64, result, expected, epsilon = 1e-10),
298            "Expected {expected}, was {result}"
299        );
300    }
301
302    #[rstest]
303    #[case(5.0, &[1.0, 2.0, 3.0, 4.0, 6.0, 7.0], 3)]
304    #[case(1.5, &[1.0, 2.0, 3.0, 4.0], 0)]
305    #[case(0.5, &[1.0, 2.0, 3.0, 4.0], 0)]
306    #[case(4.5, &[1.0, 2.0, 3.0, 4.0], 3)]
307    #[case(2.0, &[1.0, 2.0, 3.0, 4.0], 0)]
308    fn test_pos_search(#[case] x: f64, #[case] xs: &[f64], #[case] expected: usize) {
309        let result = pos_search(x, xs);
310        assert_eq!(result, expected);
311    }
312
313    #[rstest]
314    fn test_pos_search_edge_cases() {
315        // Single element array
316        let result = pos_search(5.0, &[10.0]);
317        assert_eq!(result, 0);
318
319        // Value at exact boundary
320        let result = pos_search(3.0, &[1.0, 2.0, 3.0, 4.0]);
321        assert_eq!(result, 1); // Index of largest element < 3.0 is index 1 (value 2.0)
322
323        // Two element array
324        let result = pos_search(1.5, &[1.0, 2.0]);
325        assert_eq!(result, 0);
326    }
327
328    #[rstest]
329    fn test_pos_search_empty_slice() {
330        let empty: &[f64] = &[];
331        assert_eq!(pos_search(42.0, empty), 0);
332    }
333
334    #[rstest]
335    fn test_quad_polynomial_linear_case() {
336        // Test with three collinear points - should behave like linear interpolation
337        let result = quad_polynomial(1.5, 1.0, 2.0, 3.0, 1.0, 2.0, 3.0);
338        assert!(approx_eq!(f64, result, 1.5, epsilon = 1e-10));
339    }
340
341    #[rstest]
342    fn test_quad_polynomial_parabola() {
343        // Test with a simple parabola y = x^2
344        // Points: (0,0), (1,1), (2,4)
345        let result = quad_polynomial(1.5, 0.0, 1.0, 2.0, 0.0, 1.0, 4.0);
346        let expected = 1.5 * 1.5; // Should be 2.25
347        assert!(approx_eq!(f64, result, expected, epsilon = 1e-10));
348    }
349
350    #[rstest]
351    #[should_panic(expected = "too close for stable interpolation")]
352    fn test_quad_polynomial_duplicate_x() {
353        let _ = quad_polynomial(0.5, 1.0, 1.0, 2.0, 0.0, 1.0, 4.0);
354    }
355
356    #[rstest]
357    #[should_panic(expected = "too close for stable interpolation")]
358    fn test_quad_polynomial_near_equal_x_values() {
359        // x0 and x1 differ by only machine epsilon
360        let _ = quad_polynomial(0.5, 1.0, 1.0 + f64::EPSILON, 2.0, 0.0, 1.0, 4.0);
361    }
362
363    #[rstest]
364    fn test_quad_polynomial_with_small_differences() {
365        // High-resolution data should work (e.g., 1e-12 spacing)
366        let result = quad_polynomial(5e-13, 0.0, 1e-12, 2e-12, 0.0, 1.0, 4.0);
367        assert!(result.is_finite());
368    }
369
370    #[rstest]
371    fn test_quad_polynomial_just_above_epsilon() {
372        // Values differing by more than machine epsilon should work
373        let result = quad_polynomial(0.5, 0.0, 1.0 + 1e-9, 2.0, 0.0, 1.0, 4.0);
374        // Should not panic and return a reasonable value
375        assert!(result.is_finite());
376    }
377
378    #[rstest]
379    fn test_quadratic_interpolation_boundary_conditions() {
380        let xs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
381        let ys = vec![1.0, 4.0, 9.0, 16.0, 25.0]; // y = x^2
382
383        // Test below minimum
384        let result = quadratic_interpolation(0.5, &xs, &ys);
385        assert_eq!(result, ys[0]);
386
387        // Test above maximum
388        let result = quadratic_interpolation(6.0, &xs, &ys);
389        assert_eq!(result, ys[4]);
390    }
391
392    #[rstest]
393    fn test_quadratic_interpolation_exact_points() {
394        let xs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
395        let ys = vec![1.0, 4.0, 9.0, 16.0, 25.0];
396
397        // Test exact points
398        for (i, &x) in xs.iter().enumerate() {
399            let result = quadratic_interpolation(x, &xs, &ys);
400            assert!(approx_eq!(f64, result, ys[i], epsilon = 1e-6));
401        }
402    }
403
404    #[rstest]
405    fn test_quadratic_interpolation_intermediate_values() {
406        let xs = vec![1.0, 2.0, 3.0, 4.0, 5.0];
407        let ys = vec![1.0, 4.0, 9.0, 16.0, 25.0]; // y = x^2
408
409        // Test interpolation between points
410        let result = quadratic_interpolation(2.5, &xs, &ys);
411        let expected = 2.5 * 2.5; // Should be close to 6.25
412        assert!((result - expected).abs() < 0.1); // Allow some interpolation error
413    }
414
415    #[rstest]
416    #[should_panic(expected = "Need at least 3 points")]
417    fn test_quadratic_interpolation_insufficient_points() {
418        let xs = vec![1.0, 2.0];
419        let ys = vec![1.0, 4.0];
420        let _ = quadratic_interpolation(1.5, &xs, &ys);
421    }
422
423    #[rstest]
424    #[should_panic(expected = "xs and ys must have the same length")]
425    fn test_quadratic_interpolation_mismatched_lengths() {
426        let xs = vec![1.0, 2.0, 3.0];
427        let ys = vec![1.0, 4.0];
428        let _ = quadratic_interpolation(1.5, &xs, &ys);
429    }
430
431    #[rstest]
432    #[case(f64::NAN, 0.0, 1.0)]
433    #[case(0.0, f64::NAN, 1.0)]
434    #[case(0.0, 1.0, f64::NAN)]
435    #[case(f64::INFINITY, 0.0, 1.0)]
436    #[case(0.0, f64::NEG_INFINITY, 1.0)]
437    #[should_panic(expected = "All inputs must be finite")]
438    fn test_linear_weight_non_finite_panics(#[case] x1: f64, #[case] x2: f64, #[case] x: f64) {
439        let _ = linear_weight(x1, x2, x);
440    }
441
442    #[rstest]
443    #[should_panic(expected = "All inputs must be finite")]
444    fn test_quad_polynomial_nan_panics() {
445        let _ = quad_polynomial(f64::NAN, 0.0, 1.0, 2.0, 0.0, 1.0, 4.0);
446    }
447
448    #[rstest]
449    #[should_panic(expected = "All inputs must be finite")]
450    fn test_quad_polynomial_infinity_panics() {
451        let _ = quad_polynomial(0.5, f64::INFINITY, 1.0, 2.0, 0.0, 1.0, 4.0);
452    }
453}