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}