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