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}