opendeviationbar_core/timestamp.rs
1//! Universal timestamp normalization utilities
2//!
3//! This module provides centralized timestamp handling to ensure all aggTrade data
4//! uses consistent 16-digit microsecond precision regardless of source format.
5
6/// Universal timestamp normalization threshold
7/// Values below this are treated as 13-digit milliseconds and converted to microseconds
8const MICROSECOND_THRESHOLD: u64 = 10_000_000_000_000;
9
10/// Declared timestamp unit for registry-driven conversion.
11///
12/// Replaces the heuristic `normalize_timestamp()` as the primary conversion path.
13/// The heuristic is preserved as a safety net for legacy code paths.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum TimestampUnit {
16 /// 13-digit millisecond timestamps (pre-2025 Spot, all Futures)
17 Millisecond,
18 /// 16-digit microsecond timestamps (2025+ Spot with timeUnit=MICROSECOND)
19 Microsecond,
20}
21
22impl TimestampUnit {
23 /// Convert a raw timestamp to internal microseconds based on declared unit.
24 #[inline]
25 pub fn to_microseconds(self, raw: i64) -> i64 {
26 match self {
27 TimestampUnit::Millisecond => raw * 1_000,
28 TimestampUnit::Microsecond => raw,
29 }
30 }
31}
32
33/// Normalize any timestamp to 16-digit microseconds
34///
35/// # Arguments
36/// * `raw_timestamp` - Raw timestamp that could be 13-digit millis or 16-digit micros
37///
38/// # Returns
39/// * Normalized timestamp in microseconds (16-digit precision)
40///
41/// # Examples
42/// ```rust
43/// use opendeviationbar_core::normalize_timestamp;
44///
45/// // 13-digit millisecond timestamp -> 16-digit microseconds
46/// assert_eq!(normalize_timestamp(1609459200000), 1609459200000000);
47///
48/// // Already 16-digit microseconds -> unchanged
49/// assert_eq!(normalize_timestamp(1609459200000000), 1609459200000000);
50/// ```
51pub fn normalize_timestamp(raw_timestamp: u64) -> i64 {
52 if raw_timestamp < MICROSECOND_THRESHOLD {
53 // 13-digit milliseconds -> convert to microseconds
54 (raw_timestamp * 1_000) as i64
55 } else {
56 // Already microseconds (16+ digits)
57 raw_timestamp as i64
58 }
59}
60
61/// Validate timestamp is in expected microsecond range
62///
63/// Checks if timestamp falls within reasonable bounds for financial data.
64/// Expanded range (2000-2035) covers historical financial data (2003+)
65/// and cryptocurrency data (2009+) while rejecting obviously invalid timestamps.
66///
67/// # Arguments
68///
69/// * `timestamp` - Timestamp in microseconds (16-digit precision)
70///
71/// # Returns
72///
73/// `true` if timestamp is within valid range, `false` otherwise
74///
75/// # Validation Range (Q16)
76///
77/// - MIN: 2000-01-01 (covers historical data from 2003)
78/// - MAX: 2035-01-01 (future-proof for upcoming data)
79/// - Rejects: Unix epoch (1970), far future (2100+), negative timestamps
80pub fn validate_timestamp(timestamp: i64) -> bool {
81 // Expanded bounds: 2000-01-01 to 2035-01-01 in microseconds (Q16)
82 const MIN_TIMESTAMP: i64 = 946_684_800_000_000; // 2000-01-01 00:00:00 UTC
83 const MAX_TIMESTAMP: i64 = 2_051_222_400_000_000; // 2035-01-01 00:00:00 UTC
84
85 (MIN_TIMESTAMP..=MAX_TIMESTAMP).contains(×tamp)
86}
87
88/// Create a normalized Tick with automatic timestamp conversion
89///
90/// This is the preferred way to create Tick instances to ensure
91/// timestamp consistency across all data sources.
92pub fn create_aggtrade_with_normalized_timestamp(
93 ref_id: i64,
94 price: crate::FixedPoint,
95 volume: crate::FixedPoint,
96 first_sub_id: i64,
97 last_sub_id: i64,
98 raw_timestamp: u64,
99 is_buyer_maker: bool,
100) -> crate::trade::Tick {
101 use crate::trade::Tick;
102
103 Tick {
104 ref_id,
105 price,
106 volume,
107 first_sub_id,
108 last_sub_id,
109 timestamp: normalize_timestamp(raw_timestamp),
110 is_buyer_maker,
111 is_best_match: None, // Not provided in this context
112 best_bid: None,
113 best_ask: None,
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn test_normalize_13_digit_milliseconds() {
123 // Common 13-digit timestamp (Jan 1, 2021 00:00:00 UTC)
124 let millis = 1609459200000u64;
125 let expected = 1609459200000000i64;
126 assert_eq!(normalize_timestamp(millis), expected);
127 }
128
129 #[test]
130 fn test_normalize_16_digit_microseconds() {
131 // Already 16-digit microseconds
132 let micros = 1609459200000000u64;
133 let expected = 1609459200000000i64;
134 assert_eq!(normalize_timestamp(micros), expected);
135 }
136
137 #[test]
138 fn test_threshold_boundary() {
139 // Right at the threshold
140 let threshold_minus_one = MICROSECOND_THRESHOLD - 1;
141 let threshold = MICROSECOND_THRESHOLD;
142
143 // Below threshold: convert
144 assert_eq!(
145 normalize_timestamp(threshold_minus_one),
146 (threshold_minus_one * 1000) as i64
147 );
148
149 // At threshold: no conversion
150 assert_eq!(normalize_timestamp(threshold), threshold as i64);
151 }
152
153 #[test]
154 fn test_validate_timestamp() {
155 // Valid: 2024 timestamp (crypto era)
156 assert!(validate_timestamp(1_704_067_200_000_000)); // 2024-01-01
157
158 // Valid: 2003 timestamp (historical data)
159 assert!(validate_timestamp(1_041_379_200_000_000)); // 2003-01-01
160
161 // Valid: 2000 timestamp (min boundary)
162 assert!(validate_timestamp(946_684_800_000_000)); // 2000-01-01
163
164 // Valid: 2034 timestamp (near max boundary)
165 assert!(validate_timestamp(2_019_686_400_000_000)); // 2034-01-01
166
167 // Invalid: 1999 (before historical data)
168 assert!(!validate_timestamp(915_148_800_000_000)); // 1999-01-01
169
170 // Invalid: Unix epoch era (1970s)
171 assert!(!validate_timestamp(1_000_000_000_000)); // 1970-01-12
172
173 // Invalid: Far future (2050+)
174 assert!(!validate_timestamp(2_524_608_000_000_000)); // 2050-01-01
175 }
176
177 // Issue #96: Additional edge case coverage
178
179 #[test]
180 fn test_validate_timestamp_negative() {
181 assert!(!validate_timestamp(-1));
182 assert!(!validate_timestamp(i64::MIN));
183 }
184
185 #[test]
186 fn test_validate_timestamp_zero() {
187 assert!(!validate_timestamp(0));
188 }
189
190 #[test]
191 fn test_validate_timestamp_exact_boundaries() {
192 // Exact min boundary (2000-01-01): valid
193 assert!(validate_timestamp(946_684_800_000_000));
194 // One microsecond before min: invalid
195 assert!(!validate_timestamp(946_684_800_000_000 - 1));
196 // Exact max boundary (2035-01-01): valid
197 assert!(validate_timestamp(2_051_222_400_000_000));
198 // One microsecond after max: invalid
199 assert!(!validate_timestamp(2_051_222_400_000_000 + 1));
200 }
201
202 #[test]
203 fn test_normalize_timestamp_zero() {
204 // Zero is below threshold → converted (0 * 1000 = 0)
205 assert_eq!(normalize_timestamp(0), 0);
206 }
207
208 #[test]
209 fn test_timestamp_unit_millisecond_conversion() {
210 assert_eq!(
211 TimestampUnit::Millisecond.to_microseconds(1609459200000),
212 1609459200000000
213 );
214 }
215
216 #[test]
217 fn test_timestamp_unit_microsecond_passthrough() {
218 assert_eq!(
219 TimestampUnit::Microsecond.to_microseconds(1609459200000000),
220 1609459200000000
221 );
222 }
223
224 #[test]
225 fn test_create_aggtrade_normalizes_timestamp() {
226 use crate::FixedPoint;
227 let trade = create_aggtrade_with_normalized_timestamp(
228 1,
229 FixedPoint::from_str("100.0").unwrap(),
230 FixedPoint::from_str("1.0").unwrap(),
231 10,
232 10,
233 1609459200000, // 13-digit millis
234 false,
235 );
236 assert_eq!(trade.timestamp, 1609459200000000); // Converted to 16-digit micros
237 }
238}