Skip to main content

fin_stream/lorentz/
mod.rs

1//! Lorentz (special-relativistic) transforms applied to financial time series.
2//!
3//! ## Mathematical basis
4//!
5//! In special relativity, the Lorentz transformation relates the spacetime
6//! coordinates `(t, x)` measured in one inertial frame `S` to the coordinates
7//! `(t', x')` measured in a frame `S'` that moves with velocity `v` along the
8//! x-axis:
9//!
10//! ```text
11//!     t' = gamma * (t  - beta * x / c)
12//!     x' = gamma * (x  - beta * c * t)
13//!
14//!     where  beta  = v / c          (dimensionless velocity, 0 <= beta < 1)
15//!            gamma = 1 / sqrt(1 - beta^2)   (Lorentz factor, >= 1)
16//! ```
17//!
18//! ## Application to market data
19//!
20//! Market microstructure theory frequently treats price discovery as a
21//! diffusion process in two dimensions:
22//!
23//! - **Time axis `t`**: wall-clock time (seconds, or normalized to [0, 1]).
24//! - **Price axis `x`**: log-price or normalized price.
25//!
26//! The Lorentz frame boost is applied as a **feature engineering** step:
27//! it stretches or compresses the price-time plane along hyperbolas, which
28//! are invariant under Lorentz boosts. The motivation is that certain
29//! microstructure signals (momentum, mean-reversion) that appear curved in
30//! the lab frame can appear as straight lines in a boosted frame.
31//!
32//! For a financial series, we set:
33//! - `c = 1` (price changes are bounded by some maximum instantaneous return).
34//! - `beta = drift_rate` (the normalized drift velocity of the price process).
35//!
36//! The transformed coordinates `(t', x')` are then fed as features into a
37//! downstream model.
38//!
39//! ## Guarantees
40//!
41//! - Non-panicking: construction validates `beta` and returns a typed error.
42//! - Deterministic: the same `(t, x, beta)` always produces the same output.
43//! - `beta = 0` is the identity transform (gamma = 1, no distortion).
44
45use crate::error::StreamError;
46
47/// A spacetime event expressed as a (time, position) coordinate pair.
48///
49/// In the financial interpretation:
50/// - `t` is elapsed time since the series start, normalized to a convenient
51///   scale (e.g., seconds, or fraction of the session).
52/// - `x` is normalized log-price or normalized price.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct SpacetimePoint {
55    /// Time coordinate.
56    pub t: f64,
57    /// Spatial (price) coordinate.
58    pub x: f64,
59}
60
61impl SpacetimePoint {
62    /// Construct a new spacetime point.
63    pub fn new(t: f64, x: f64) -> Self {
64        Self { t, x }
65    }
66}
67
68/// Lorentz frame-boost transform for financial time series.
69///
70/// Applies the special-relativistic Lorentz transformation with velocity
71/// parameter `beta = v/c` to `(t, x)` coordinate pairs. The speed of light
72/// is normalized to `c = 1`.
73///
74/// # Construction
75///
76/// ```rust
77/// use fin_stream::lorentz::LorentzTransform;
78///
79/// let lt = LorentzTransform::new(0.5).unwrap(); // beta = 0.5
80/// ```
81///
82/// # Identity
83///
84/// `beta = 0` is the identity: `t' = t`, `x' = x`.
85///
86/// # Singularity avoidance
87///
88/// `beta >= 1` would require dividing by zero in the Lorentz factor and is
89/// rejected at construction time with `LorentzConfigError`.
90#[derive(Debug, Clone, Copy)]
91pub struct LorentzTransform {
92    /// Normalized velocity (v/c). Satisfies `0.0 <= beta < 1.0`.
93    beta: f64,
94    /// Precomputed Lorentz factor: `1 / sqrt(1 - beta^2)`.
95    gamma: f64,
96}
97
98impl LorentzTransform {
99    /// Create a new Lorentz transform with the given velocity parameter.
100    ///
101    /// `beta` must satisfy `0.0 <= beta < 1.0`.
102    ///
103    /// # Errors
104    ///
105    /// Returns `LorentzConfigError` if `beta` is negative, `NaN`, or `>= 1.0`.
106    pub fn new(beta: f64) -> Result<Self, StreamError> {
107        if beta.is_nan() || beta < 0.0 || beta >= 1.0 {
108            return Err(StreamError::LorentzConfigError {
109                reason: format!(
110                    "beta must be in [0.0, 1.0) but got {beta}; \
111                     beta >= 1 produces a division by zero in the Lorentz factor"
112                ),
113            });
114        }
115        let gamma = 1.0 / (1.0 - beta * beta).sqrt();
116        Ok(Self { beta, gamma })
117    }
118
119    /// The configured velocity parameter `beta = v/c`.
120    pub fn beta(&self) -> f64 {
121        self.beta
122    }
123
124    /// The precomputed Lorentz factor `gamma = 1 / sqrt(1 - beta^2)`.
125    ///
126    /// `gamma == 1.0` when `beta == 0` (identity). `gamma` increases
127    /// monotonically towards infinity as `beta` approaches 1.
128    pub fn gamma(&self) -> f64 {
129        self.gamma
130    }
131
132    /// Apply the Lorentz boost to a spacetime point.
133    ///
134    /// Computes:
135    /// ```text
136    ///     t' = gamma * (t - beta * x)
137    ///     x' = gamma * (x - beta * t)
138    /// ```
139    ///
140    /// # Complexity: O(1), no heap allocation.
141    pub fn transform(&self, p: SpacetimePoint) -> SpacetimePoint {
142        let t_prime = self.gamma * (p.t - self.beta * p.x);
143        let x_prime = self.gamma * (p.x - self.beta * p.t);
144        SpacetimePoint {
145            t: t_prime,
146            x: x_prime,
147        }
148    }
149
150    /// Apply the inverse Lorentz boost (boost in the opposite direction).
151    ///
152    /// Computes:
153    /// ```text
154    ///     t' = gamma * (t + beta * x)
155    ///     x' = gamma * (x + beta * t)
156    /// ```
157    ///
158    /// For a point `p`, `inverse_transform(transform(p)) == p` up to floating-
159    /// point rounding.
160    ///
161    /// # Complexity: O(1), no heap allocation.
162    pub fn inverse_transform(&self, p: SpacetimePoint) -> SpacetimePoint {
163        let t_orig = self.gamma * (p.t + self.beta * p.x);
164        let x_orig = self.gamma * (p.x + self.beta * p.t);
165        SpacetimePoint {
166            t: t_orig,
167            x: x_orig,
168        }
169    }
170
171    /// Apply the boost to a batch of points.
172    ///
173    /// Returns a new `Vec` of transformed points. The input slice is not
174    /// modified.
175    ///
176    /// # Complexity: O(n), one heap allocation of size `n`.
177    pub fn transform_batch(&self, points: &[SpacetimePoint]) -> Vec<SpacetimePoint> {
178        points.iter().map(|&p| self.transform(p)).collect()
179    }
180
181    /// Time-dilation: the transformed time coordinate for a point at rest
182    /// (`x = 0`) is `t' = gamma * t`.
183    ///
184    /// This is a convenience method that surfaces the time-dilation formula
185    /// for the common case where only the time axis is of interest.
186    ///
187    /// # Complexity: O(1)
188    pub fn dilate_time(&self, t: f64) -> f64 {
189        self.gamma * t
190    }
191
192    /// Length-contraction: the transformed spatial coordinate for an event at
193    /// the time origin (`t = 0`) is `x' = gamma * x`.
194    ///
195    /// This is a convenience method that surfaces the length-contraction
196    /// formula for the common case where only the price axis is of interest.
197    ///
198    /// # Complexity: O(1)
199    pub fn contract_length(&self, x: f64) -> f64 {
200        self.gamma * x
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    const EPS: f64 = 1e-10;
209
210    fn approx_eq(a: f64, b: f64) -> bool {
211        (a - b).abs() < EPS
212    }
213
214    fn point_approx_eq(a: SpacetimePoint, b: SpacetimePoint) -> bool {
215        approx_eq(a.t, b.t) && approx_eq(a.x, b.x)
216    }
217
218    // ── Construction ─────────────────────────────────────────────────────────
219
220    #[test]
221    fn test_new_valid_beta() {
222        let lt = LorentzTransform::new(0.5).unwrap();
223        assert!((lt.beta() - 0.5).abs() < EPS);
224    }
225
226    #[test]
227    fn test_new_beta_zero() {
228        let lt = LorentzTransform::new(0.0).unwrap();
229        assert_eq!(lt.beta(), 0.0);
230        assert!((lt.gamma() - 1.0).abs() < EPS);
231    }
232
233    #[test]
234    fn test_new_beta_one_returns_error() {
235        let err = LorentzTransform::new(1.0).unwrap_err();
236        assert!(matches!(err, StreamError::LorentzConfigError { .. }));
237    }
238
239    #[test]
240    fn test_new_beta_above_one_returns_error() {
241        let err = LorentzTransform::new(1.5).unwrap_err();
242        assert!(matches!(err, StreamError::LorentzConfigError { .. }));
243    }
244
245    #[test]
246    fn test_new_beta_negative_returns_error() {
247        let err = LorentzTransform::new(-0.1).unwrap_err();
248        assert!(matches!(err, StreamError::LorentzConfigError { .. }));
249    }
250
251    #[test]
252    fn test_new_beta_nan_returns_error() {
253        let err = LorentzTransform::new(f64::NAN).unwrap_err();
254        assert!(matches!(err, StreamError::LorentzConfigError { .. }));
255    }
256
257    // ── beta = 0 is identity ─────────────────────────────────────────────────
258
259    #[test]
260    fn test_beta_zero_is_identity_transform() {
261        let lt = LorentzTransform::new(0.0).unwrap();
262        let p = SpacetimePoint::new(3.0, 4.0);
263        let q = lt.transform(p);
264        assert!(point_approx_eq(p, q), "beta=0 must be identity, got {q:?}");
265    }
266
267    // ── Time dilation ─────────────────────────────────────────────────────────
268
269    /// For a point at x=0, t' = gamma * t (pure time dilation).
270    #[test]
271    fn test_time_dilation_at_x_zero() {
272        let lt = LorentzTransform::new(0.6).unwrap();
273        let p = SpacetimePoint::new(5.0, 0.0);
274        let q = lt.transform(p);
275        let expected_t = lt.gamma() * 5.0;
276        assert!(approx_eq(q.t, expected_t));
277    }
278
279    #[test]
280    fn test_dilate_time_helper() {
281        let lt = LorentzTransform::new(0.6).unwrap();
282        assert!(approx_eq(lt.dilate_time(1.0), lt.gamma()));
283    }
284
285    // ── Length contraction ────────────────────────────────────────────────────
286
287    /// For a point at t=0, x' = gamma * x (pure length contraction).
288    #[test]
289    fn test_length_contraction_at_t_zero() {
290        let lt = LorentzTransform::new(0.6).unwrap();
291        let p = SpacetimePoint::new(0.0, 5.0);
292        let q = lt.transform(p);
293        let expected_x = lt.gamma() * 5.0;
294        assert!(approx_eq(q.x, expected_x));
295    }
296
297    #[test]
298    fn test_contract_length_helper() {
299        let lt = LorentzTransform::new(0.6).unwrap();
300        assert!(approx_eq(lt.contract_length(1.0), lt.gamma()));
301    }
302
303    // ── Known beta values ─────────────────────────────────────────────────────
304
305    /// For beta = 0.6, gamma = 1.25 (classic textbook value).
306    #[test]
307    fn test_known_beta_0_6_gamma_is_1_25() {
308        let lt = LorentzTransform::new(0.6).unwrap();
309        assert!(
310            (lt.gamma() - 1.25).abs() < 1e-9,
311            "gamma should be 1.25, got {}",
312            lt.gamma()
313        );
314    }
315
316    /// For beta = 0.8, gamma = 5/3 approximately 1.6667.
317    #[test]
318    fn test_known_beta_0_8_gamma() {
319        let lt = LorentzTransform::new(0.8).unwrap();
320        let expected_gamma = 5.0 / 3.0;
321        assert!((lt.gamma() - expected_gamma).abs() < 1e-9);
322    }
323
324    /// For beta = 0.5, gamma = 2/sqrt(3) approximately 1.1547.
325    #[test]
326    fn test_known_beta_0_5_gamma() {
327        let lt = LorentzTransform::new(0.5).unwrap();
328        let expected_gamma = 2.0 / 3.0f64.sqrt();
329        assert!((lt.gamma() - expected_gamma).abs() < 1e-9);
330    }
331
332    /// Transform a known point with beta=0.6 and verify the result manually.
333    ///
334    /// Point (t=1, x=0): t' = 1.25 * (1 - 0.6*0) = 1.25; x' = 1.25*(0 - 0.6) = -0.75
335    #[test]
336    fn test_transform_known_point_beta_0_6() {
337        let lt = LorentzTransform::new(0.6).unwrap();
338        let p = SpacetimePoint::new(1.0, 0.0);
339        let q = lt.transform(p);
340        assert!((q.t - 1.25).abs() < 1e-9);
341        assert!((q.x - (-0.75)).abs() < 1e-9);
342    }
343
344    // ── Inverse round-trip ────────────────────────────────────────────────────
345
346    #[test]
347    fn test_inverse_transform_roundtrip() {
348        let lt = LorentzTransform::new(0.7).unwrap();
349        let p = SpacetimePoint::new(3.0, 1.5);
350        let q = lt.transform(p);
351        let r = lt.inverse_transform(q);
352        assert!(
353            point_approx_eq(r, p),
354            "round-trip failed: expected {p:?}, got {r:?}"
355        );
356    }
357
358    // ── Batch transform ───────────────────────────────────────────────────────
359
360    #[test]
361    fn test_transform_batch_length_preserved() {
362        let lt = LorentzTransform::new(0.3).unwrap();
363        let pts = vec![
364            SpacetimePoint::new(0.0, 1.0),
365            SpacetimePoint::new(1.0, 2.0),
366            SpacetimePoint::new(2.0, 3.0),
367        ];
368        let out = lt.transform_batch(&pts);
369        assert_eq!(out.len(), pts.len());
370    }
371
372    #[test]
373    fn test_transform_batch_matches_individual() {
374        let lt = LorentzTransform::new(0.4).unwrap();
375        let pts = vec![SpacetimePoint::new(1.0, 0.5), SpacetimePoint::new(2.0, 1.5)];
376        let batch = lt.transform_batch(&pts);
377        for (i, &p) in pts.iter().enumerate() {
378            let individual = lt.transform(p);
379            assert!(
380                point_approx_eq(batch[i], individual),
381                "batch[{i}] differs from individual transform"
382            );
383        }
384    }
385
386    // ── SpacetimePoint ────────────────────────────────────────────────────────
387
388    #[test]
389    fn test_spacetime_point_fields() {
390        let p = SpacetimePoint::new(1.5, 2.5);
391        assert_eq!(p.t, 1.5);
392        assert_eq!(p.x, 2.5);
393    }
394
395    #[test]
396    fn test_spacetime_point_equality() {
397        let p = SpacetimePoint::new(1.0, 2.0);
398        let q = SpacetimePoint::new(1.0, 2.0);
399        assert_eq!(p, q);
400    }
401}