Skip to main content

optionstratlib_core/utils/
numeric.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 27/9/24
5******************************************************************************/
6//! Numeric helpers owned by `core`.
7//!
8//! Tolerance-based `f64` comparison and the logarithmic-return transform
9//! over a `Positive` price series. Both are pure functions over `core`
10//! types with no layer above them in their signatures, so `core` owns
11//! them; `market` and `simulation` are callers, not owners.
12
13use crate::error::DecimalError;
14use crate::model::decimal::d_ln_f64;
15use positive::Positive;
16use rust_decimal::Decimal;
17
18/// Precomputed f64 form of `crate::constants::TOLERANCE` (= 1e-8) so the
19/// hot-path comparison can avoid the runtime fallible `Decimal::to_f64`.
20const TOLERANCE_F64: f64 = 1e-8;
21
22/// Checks for approximate equality between two f64 values within a defined tolerance.
23///
24/// This function compares two floating-point numbers and returns `true` if the absolute
25/// difference between them is less than the predefined `TOLERANCE` constant.  It is useful
26/// for comparing floating-point values that may be subject to small rounding errors.
27///
28/// # Arguments
29///
30/// * `a` - The first f64 value to compare.
31/// * `b` - The second f64 value to compare.
32///
33/// # Returns
34///
35/// `true` if the absolute difference between `a` and `b` is less than `TOLERANCE`, `false` otherwise.
36///
37/// # Example
38///
39/// ```
40/// use optionstratlib_core::utils::numeric::approx_equal;
41///
42/// let x = 1.0;
43/// let y = 1.00000001;
44/// assert!(approx_equal(x, y)); // Returns true
45///
46/// let x = 1.0;
47/// let y = 1.1;
48/// assert!(!approx_equal(x, y)); // Returns false
49/// ```
50#[allow(dead_code)]
51#[must_use]
52pub fn approx_equal(a: f64, b: f64) -> bool {
53    (a - b).abs() < TOLERANCE_F64
54}
55
56/// # Calculate Logarithmic Returns
57///
58/// Computes the logarithmic returns from a series of close prices. Logarithmic returns
59/// are calculated as the natural logarithm of the ratio between consecutive prices.
60///
61/// Logarithmic returns are commonly used in financial analysis because:
62/// - They are additive over time, unlike percentage returns
63/// - They better approximate a normal distribution
64/// - They're suitable for statistical analysis of financial time series
65///
66/// ## Parameters
67///
68/// * `close_prices` - A slice of `Decimal` values representing sequential close prices
69///
70/// ## Returns
71///
72/// * `Result<Vec<Decimal>, DecimalError>` - A vector of logarithmic returns if successful,
73///   or an error if the calculation fails
74///
75/// ## Errors
76///
77/// This function returns a `DecimalError` in the following cases:
78/// - If any price value is zero or negative
79/// - If there's a failure when converting `Decimal` to `f64` for logarithm calculation
80/// - If there's a failure when converting the logarithm result back to `Decimal`
81///
82/// ## Notes
83///
84/// - Returns an empty vector if fewer than 2 price points are provided
85/// - For consecutive prices P₁ and P₂, the log return is ln(P₂/P₁)
86pub fn calculate_log_returns(close_prices: &[Positive]) -> Result<Vec<Decimal>, DecimalError> {
87    if close_prices.len() < 2 {
88        return Ok(Vec::new());
89    }
90
91    let windows = close_prices.windows(2);
92    let mut log_returns = Vec::with_capacity(windows.len());
93
94    for window in windows {
95        let [previous_price, current_price] = window else {
96            // `windows(2)` only ever yields pairs; the arm exists so the
97            // destructuring stays irrefutable without an index.
98            continue;
99        };
100
101        // Calculate price ratio. `Positive / Positive` aborts on a zero
102        // divisor and on a quotient that leaves the representable range, and
103        // `Positive::ln` aborts on a ratio that rounded to zero. A zero price
104        // is an ordinary gap in a feed, not an adversarial input, and this
105        // function's contract already promises an error for it.
106        let ratio = current_price.checked_div(previous_price).map_err(|_| {
107            DecimalError::arithmetic_error(
108                "utils::calculate_log_returns",
109                "price ratio is zero or not representable",
110            )
111        })?;
112        // `f64` logarithm (#857), about 1e-15 relative of the 28-place
113        // series and a tenth of its cost.
114        log_returns.push(d_ln_f64(ratio.to_dec(), "utils::calculate_log_returns")?);
115    }
116
117    Ok(log_returns)
118}
119
120#[cfg(test)]
121mod tests_approx_equal {
122    use super::*;
123
124    #[test]
125    fn test_approx_equal_exact_values() {
126        assert!(approx_equal(1.0, 1.0));
127    }
128
129    #[test]
130    fn test_approx_equal_within_tolerance() {
131        let a = 1.00000001;
132        let b = 1.0;
133        assert!(approx_equal(a, b));
134    }
135
136    #[test]
137    fn test_approx_equal_outside_tolerance() {
138        let a = 1.0001;
139        let b = 1.0;
140        assert!(!approx_equal(a, b));
141    }
142
143    #[test]
144    fn test_approx_equal_negative_values() {
145        let a = -1.00000001;
146        let b = -1.0;
147        assert!(approx_equal(a, b));
148    }
149
150    #[test]
151    fn test_approx_equal_large_values_within_tolerance() {
152        let a = 1000000.000000001;
153        let b = 1000000.0;
154        assert!(approx_equal(a, b));
155    }
156
157    #[test]
158    fn test_approx_equal_large_values_outside_tolerance() {
159        let a = 1000000.1;
160        let b = 1000000.0;
161        assert!(!approx_equal(a, b));
162    }
163
164    #[test]
165    fn test_approx_equal_zero() {
166        let a = 0.0;
167        let b = 0.0;
168        assert!(approx_equal(a, b));
169    }
170
171    #[test]
172    fn test_approx_equal_zero_with_small_value() {
173        let a = 0.000000001;
174        let b = 0.0;
175        assert!(approx_equal(a, b));
176    }
177
178    #[test]
179    fn test_approx_equal_zero_outside_tolerance() {
180        let a = 0.01;
181        let b = 0.0;
182        assert!(!approx_equal(a, b));
183    }
184}
185
186#[cfg(test)]
187mod tests_log_returns {
188    use super::*;
189
190    use approx::assert_relative_eq;
191    use num_traits::ToPrimitive;
192    use positive::pos_or_panic;
193
194    #[test]
195    fn test_empty_input() {
196        let prices: Vec<Positive> = Vec::new();
197        let result = calculate_log_returns(&prices);
198        assert!(result.is_ok());
199        assert_eq!(result.unwrap().len(), 0);
200    }
201
202    #[test]
203    fn test_single_input() {
204        let prices = vec![Positive::HUNDRED];
205        let result = calculate_log_returns(&prices);
206        assert!(result.is_ok());
207        assert_eq!(result.unwrap().len(), 0);
208    }
209
210    #[test]
211    fn test_basic_calculation() {
212        let prices = vec![
213            Positive::HUNDRED,
214            pos_or_panic!(110.0),
215            pos_or_panic!(105.0),
216        ];
217        let result = calculate_log_returns(&prices).unwrap();
218
219        assert_eq!(result.len(), 2);
220
221        // Manually calculate expected values
222        // ln(110.0/100.0) ≈ ln(1.1) ≈ 0.09531
223        // ln(105.0/110.0) ≈ ln(0.9545) ≈ -0.04652
224        assert_relative_eq!(
225            result[0].to_f64().unwrap_or(f64::NAN),
226            0.09531018,
227            epsilon = 0.00001
228        );
229        assert_relative_eq!(
230            result[1].to_f64().unwrap_or(f64::NAN),
231            -0.04652,
232            epsilon = 0.00001
233        );
234    }
235
236    /// A zero price is reported, not aborted on. This function's contract
237    /// has always promised an error for a non-positive price; before #456 the
238    /// `Positive / Positive` divide aborted the caller's process instead.
239    #[test]
240    fn test_zero_price() {
241        let prices = vec![Positive::HUNDRED, Positive::ZERO, pos_or_panic!(105.0)];
242        assert!(calculate_log_returns(&prices).is_err());
243    }
244
245    #[test]
246    #[should_panic]
247    fn test_negative_price() {
248        let prices = vec![
249            Positive::HUNDRED,
250            pos_or_panic!(-50.0),
251            pos_or_panic!(105.0),
252        ];
253        let _ = calculate_log_returns(&prices);
254    }
255
256    #[test]
257    fn test_realistic_stock_prices() {
258        let prices = vec![
259            pos_or_panic!(150.25),
260            pos_or_panic!(151.50),
261            pos_or_panic!(149.75),
262            pos_or_panic!(152.25),
263            pos_or_panic!(153.00),
264        ];
265
266        let result = calculate_log_returns(&prices).unwrap();
267
268        assert_eq!(result.len(), 4);
269
270        assert_relative_eq!(
271            result[0].to_f64().unwrap_or(f64::NAN),
272            0.00829,
273            epsilon = 0.00001
274        );
275        assert_relative_eq!(
276            result[1].to_f64().unwrap_or(f64::NAN),
277            -0.01161,
278            epsilon = 0.00001
279        );
280        assert_relative_eq!(
281            result[2].to_f64().unwrap_or(f64::NAN),
282            0.01656,
283            epsilon = 0.00001
284        );
285        assert_relative_eq!(
286            result[3].to_f64().unwrap_or(f64::NAN),
287            0.00491,
288            epsilon = 0.00001
289        );
290    }
291
292    #[test]
293    fn test_large_price_movements() {
294        // Test with some large price movements (both up and down)
295        let prices = vec![
296            Positive::HUNDRED,    // Starting price
297            pos_or_panic!(200.0), // 100% increase
298            pos_or_panic!(50.0),  // 75% decrease
299            pos_or_panic!(300.0), // 500% increase
300        ];
301
302        let result = calculate_log_returns(&prices).unwrap();
303
304        assert_eq!(result.len(), 3);
305
306        // Expected returns:
307        // ln(200.0/100.0) = ln(2.0) ≈ 0.6931
308        // ln(50.0/200.0) = ln(0.25) ≈ -1.3863
309        // ln(300.0/50.0) = ln(6.0) ≈ 1.7918
310
311        assert_relative_eq!(
312            result[0].to_f64().unwrap_or(f64::NAN),
313            std::f64::consts::LN_2,
314            epsilon = 0.0001
315        );
316        assert_relative_eq!(
317            result[1].to_f64().unwrap_or(f64::NAN),
318            -1.3863,
319            epsilon = 0.0001
320        );
321        assert_relative_eq!(
322            result[2].to_f64().unwrap_or(f64::NAN),
323            1.7918,
324            epsilon = 0.0001
325        );
326    }
327
328    #[test]
329    fn test_no_change_prices() {
330        // Test with prices that don't change
331        let prices = vec![Positive::HUNDRED, Positive::HUNDRED, Positive::HUNDRED];
332        let result = calculate_log_returns(&prices).unwrap();
333
334        assert_eq!(result.len(), 2);
335        // ln(1.0) = 0
336        assert_relative_eq!(
337            result[0].to_f64().unwrap_or(f64::NAN),
338            0.0,
339            epsilon = 0.00001
340        );
341        assert_relative_eq!(
342            result[1].to_f64().unwrap_or(f64::NAN),
343            0.0,
344            epsilon = 0.00001
345        );
346    }
347
348    #[test]
349    fn test_very_small_price_changes() {
350        // Test with very small price changes
351        let prices = vec![
352            pos_or_panic!(100.000),
353            pos_or_panic!(100.001),
354            pos_or_panic!(100.002),
355        ];
356
357        let result = calculate_log_returns(&prices).unwrap();
358
359        assert_eq!(result.len(), 2);
360
361        // Expected returns are very small
362        // ln(100.001/100.000) ≈ ln(1.00001) ≈ 0.00001
363        // ln(100.002/100.001) ≈ ln(1.00001) ≈ 0.00001
364
365        assert!(result[0].to_f64().unwrap_or(f64::NAN) > 0.0);
366        assert!(result[0].to_f64().unwrap_or(f64::NAN) < 0.0001);
367        assert!(result[1].to_f64().unwrap_or(f64::NAN) > 0.0);
368        assert!(result[1].to_f64().unwrap_or(f64::NAN) < 0.0001);
369    }
370}
371
372/// `calculate_log_returns` with the `f64` logarithm (#857) against the
373/// 28-place series it replaced.
374#[cfg(test)]
375mod tests_log_returns_f64 {
376    use super::*;
377    use rust_decimal_macros::dec;
378
379    #[test]
380    fn test_log_returns_within_the_owner_bound() {
381        // Deterministic prices around 100 with moves from 1e-6 to 30 %.
382        let prices: Vec<Positive> = (0..500u32)
383            .map(|i| {
384                let k = Decimal::from((i * 7919) % 1009) - dec!(504);
385                Positive::new_decimal(dec!(100) + k * dec!(0.06)).expect("positive price")
386            })
387            .collect();
388        let fast = calculate_log_returns(&prices).expect("log returns");
389        let mut worst = Decimal::ZERO;
390        for (window, value) in prices.windows(2).zip(&fast) {
391            let [previous, current] = window else {
392                continue;
393            };
394            let exact = crate::model::decimal::d_ln(current.to_dec() / previous.to_dec(), "test")
395                .expect("series ln");
396            let error = if exact.is_zero() {
397                value.abs()
398            } else {
399                (*value - exact).abs() / exact.abs()
400            };
401            assert!(
402                error <= dec!(0.000000000001),
403                "{previous} -> {current}: {error}"
404            );
405            worst = worst.max(error);
406        }
407        assert!(worst < dec!(0.00000000000001), "worst {worst}");
408    }
409}