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 { t: t_prime, x: x_prime }
145    }
146
147    /// Apply the inverse Lorentz boost (boost in the opposite direction).
148    ///
149    /// Computes:
150    /// ```text
151    ///     t' = gamma * (t + beta * x)
152    ///     x' = gamma * (x + beta * t)
153    /// ```
154    ///
155    /// For a point `p`, `inverse_transform(transform(p)) == p` up to floating-
156    /// point rounding.
157    ///
158    /// # Complexity: O(1), no heap allocation.
159    pub fn inverse_transform(&self, p: SpacetimePoint) -> SpacetimePoint {
160        let t_orig = self.gamma * (p.t + self.beta * p.x);
161        let x_orig = self.gamma * (p.x + self.beta * p.t);
162        SpacetimePoint { t: t_orig, x: x_orig }
163    }
164
165    /// Apply the boost to a batch of points.
166    ///
167    /// Returns a new `Vec` of transformed points. The input slice is not
168    /// modified.
169    ///
170    /// # Complexity: O(n), one heap allocation of size `n`.
171    pub fn transform_batch(&self, points: &[SpacetimePoint]) -> Vec<SpacetimePoint> {
172        points.iter().map(|&p| self.transform(p)).collect()
173    }
174
175    /// Time-dilation: the transformed time coordinate for a point at rest
176    /// (`x = 0`) is `t' = gamma * t`.
177    ///
178    /// This is a convenience method that surfaces the time-dilation formula
179    /// for the common case where only the time axis is of interest.
180    ///
181    /// # Complexity: O(1)
182    pub fn dilate_time(&self, t: f64) -> f64 {
183        self.gamma * t
184    }
185
186    /// Length-contraction: the transformed spatial coordinate for an event at
187    /// the time origin (`t = 0`) is `x' = gamma * x`.
188    ///
189    /// This is a convenience method that surfaces the length-contraction
190    /// formula for the common case where only the price axis is of interest.
191    ///
192    /// # Complexity: O(1)
193    pub fn contract_length(&self, x: f64) -> f64 {
194        self.gamma * x
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    const EPS: f64 = 1e-10;
203
204    fn approx_eq(a: f64, b: f64) -> bool {
205        (a - b).abs() < EPS
206    }
207
208    fn point_approx_eq(a: SpacetimePoint, b: SpacetimePoint) -> bool {
209        approx_eq(a.t, b.t) && approx_eq(a.x, b.x)
210    }
211
212    // ── Construction ─────────────────────────────────────────────────────────
213
214    #[test]
215    fn test_new_valid_beta() {
216        let lt = LorentzTransform::new(0.5).unwrap();
217        assert!((lt.beta() - 0.5).abs() < EPS);
218    }
219
220    #[test]
221    fn test_new_beta_zero() {
222        let lt = LorentzTransform::new(0.0).unwrap();
223        assert_eq!(lt.beta(), 0.0);
224        assert!((lt.gamma() - 1.0).abs() < EPS);
225    }
226
227    #[test]
228    fn test_new_beta_one_returns_error() {
229        let err = LorentzTransform::new(1.0).unwrap_err();
230        assert!(matches!(err, StreamError::LorentzConfigError { .. }));
231    }
232
233    #[test]
234    fn test_new_beta_above_one_returns_error() {
235        let err = LorentzTransform::new(1.5).unwrap_err();
236        assert!(matches!(err, StreamError::LorentzConfigError { .. }));
237    }
238
239    #[test]
240    fn test_new_beta_negative_returns_error() {
241        let err = LorentzTransform::new(-0.1).unwrap_err();
242        assert!(matches!(err, StreamError::LorentzConfigError { .. }));
243    }
244
245    #[test]
246    fn test_new_beta_nan_returns_error() {
247        let err = LorentzTransform::new(f64::NAN).unwrap_err();
248        assert!(matches!(err, StreamError::LorentzConfigError { .. }));
249    }
250
251    // ── beta = 0 is identity ─────────────────────────────────────────────────
252
253    #[test]
254    fn test_beta_zero_is_identity_transform() {
255        let lt = LorentzTransform::new(0.0).unwrap();
256        let p = SpacetimePoint::new(3.0, 4.0);
257        let q = lt.transform(p);
258        assert!(point_approx_eq(p, q), "beta=0 must be identity, got {q:?}");
259    }
260
261    // ── Time dilation ─────────────────────────────────────────────────────────
262
263    /// For a point at x=0, t' = gamma * t (pure time dilation).
264    #[test]
265    fn test_time_dilation_at_x_zero() {
266        let lt = LorentzTransform::new(0.6).unwrap();
267        let p = SpacetimePoint::new(5.0, 0.0);
268        let q = lt.transform(p);
269        let expected_t = lt.gamma() * 5.0;
270        assert!(approx_eq(q.t, expected_t));
271    }
272
273    #[test]
274    fn test_dilate_time_helper() {
275        let lt = LorentzTransform::new(0.6).unwrap();
276        assert!(approx_eq(lt.dilate_time(1.0), lt.gamma()));
277    }
278
279    // ── Length contraction ────────────────────────────────────────────────────
280
281    /// For a point at t=0, x' = gamma * x (pure length contraction).
282    #[test]
283    fn test_length_contraction_at_t_zero() {
284        let lt = LorentzTransform::new(0.6).unwrap();
285        let p = SpacetimePoint::new(0.0, 5.0);
286        let q = lt.transform(p);
287        let expected_x = lt.gamma() * 5.0;
288        assert!(approx_eq(q.x, expected_x));
289    }
290
291    #[test]
292    fn test_contract_length_helper() {
293        let lt = LorentzTransform::new(0.6).unwrap();
294        assert!(approx_eq(lt.contract_length(1.0), lt.gamma()));
295    }
296
297    // ── Known beta values ─────────────────────────────────────────────────────
298
299    /// For beta = 0.6, gamma = 1.25 (classic textbook value).
300    #[test]
301    fn test_known_beta_0_6_gamma_is_1_25() {
302        let lt = LorentzTransform::new(0.6).unwrap();
303        assert!((lt.gamma() - 1.25).abs() < 1e-9, "gamma should be 1.25, got {}", lt.gamma());
304    }
305
306    /// For beta = 0.8, gamma = 5/3 approximately 1.6667.
307    #[test]
308    fn test_known_beta_0_8_gamma() {
309        let lt = LorentzTransform::new(0.8).unwrap();
310        let expected_gamma = 5.0 / 3.0;
311        assert!((lt.gamma() - expected_gamma).abs() < 1e-9);
312    }
313
314    /// For beta = 0.5, gamma = 2/sqrt(3) approximately 1.1547.
315    #[test]
316    fn test_known_beta_0_5_gamma() {
317        let lt = LorentzTransform::new(0.5).unwrap();
318        let expected_gamma = 2.0 / 3.0f64.sqrt();
319        assert!((lt.gamma() - expected_gamma).abs() < 1e-9);
320    }
321
322    /// Transform a known point with beta=0.6 and verify the result manually.
323    ///
324    /// Point (t=1, x=0): t' = 1.25 * (1 - 0.6*0) = 1.25; x' = 1.25*(0 - 0.6) = -0.75
325    #[test]
326    fn test_transform_known_point_beta_0_6() {
327        let lt = LorentzTransform::new(0.6).unwrap();
328        let p = SpacetimePoint::new(1.0, 0.0);
329        let q = lt.transform(p);
330        assert!((q.t - 1.25).abs() < 1e-9);
331        assert!((q.x - (-0.75)).abs() < 1e-9);
332    }
333
334    // ── Inverse round-trip ────────────────────────────────────────────────────
335
336    #[test]
337    fn test_inverse_transform_roundtrip() {
338        let lt = LorentzTransform::new(0.7).unwrap();
339        let p = SpacetimePoint::new(3.0, 1.5);
340        let q = lt.transform(p);
341        let r = lt.inverse_transform(q);
342        assert!(point_approx_eq(r, p), "round-trip failed: expected {p:?}, got {r:?}");
343    }
344
345    // ── Batch transform ───────────────────────────────────────────────────────
346
347    #[test]
348    fn test_transform_batch_length_preserved() {
349        let lt = LorentzTransform::new(0.3).unwrap();
350        let pts = vec![
351            SpacetimePoint::new(0.0, 1.0),
352            SpacetimePoint::new(1.0, 2.0),
353            SpacetimePoint::new(2.0, 3.0),
354        ];
355        let out = lt.transform_batch(&pts);
356        assert_eq!(out.len(), pts.len());
357    }
358
359    #[test]
360    fn test_transform_batch_matches_individual() {
361        let lt = LorentzTransform::new(0.4).unwrap();
362        let pts = vec![
363            SpacetimePoint::new(1.0, 0.5),
364            SpacetimePoint::new(2.0, 1.5),
365        ];
366        let batch = lt.transform_batch(&pts);
367        for (i, &p) in pts.iter().enumerate() {
368            let individual = lt.transform(p);
369            assert!(
370                point_approx_eq(batch[i], individual),
371                "batch[{i}] differs from individual transform"
372            );
373        }
374    }
375
376    // ── SpacetimePoint ────────────────────────────────────────────────────────
377
378    #[test]
379    fn test_spacetime_point_fields() {
380        let p = SpacetimePoint::new(1.5, 2.5);
381        assert_eq!(p.t, 1.5);
382        assert_eq!(p.x, 2.5);
383    }
384
385    #[test]
386    fn test_spacetime_point_equality() {
387        let p = SpacetimePoint::new(1.0, 2.0);
388        let q = SpacetimePoint::new(1.0, 2.0);
389        assert_eq!(p, q);
390    }
391}