1use super::HybridTimestamp;
7use rkyv::{Archive, Deserialize, Serialize};
8use uuid::Uuid;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Archive, Serialize, Deserialize)]
12#[archive(compare(PartialEq))]
13#[repr(u8)]
14pub enum TimeGranularity {
15 Daily = 0,
17 Weekly = 1,
19 BiWeekly = 2,
21 Monthly = 3,
23 Quarterly = 4,
25 Annual = 5,
27}
28
29impl TimeGranularity {
30 pub fn periods_per_year(&self) -> u32 {
32 match self {
33 TimeGranularity::Daily => 365,
34 TimeGranularity::Weekly => 52,
35 TimeGranularity::BiWeekly => 26,
36 TimeGranularity::Monthly => 12,
37 TimeGranularity::Quarterly => 4,
38 TimeGranularity::Annual => 1,
39 }
40 }
41
42 pub fn seasonal_lag(&self) -> u32 {
44 match self {
45 TimeGranularity::Daily => 7, TimeGranularity::Weekly => 52, TimeGranularity::BiWeekly => 26, TimeGranularity::Monthly => 12, TimeGranularity::Quarterly => 4, TimeGranularity::Annual => 1,
51 }
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Archive, Serialize, Deserialize)]
57#[archive(compare(PartialEq))]
58#[repr(u8)]
59pub enum SeasonalityType {
60 None = 0,
62 Weekly = 1,
64 BiWeekly = 2,
66 Monthly = 3,
68 Quarterly = 4,
70 SemiAnnual = 5,
72 Annual = 6,
74}
75
76impl SeasonalityType {
77 pub fn period_days(&self) -> u32 {
79 match self {
80 SeasonalityType::None => 0,
81 SeasonalityType::Weekly => 7,
82 SeasonalityType::BiWeekly => 14,
83 SeasonalityType::Monthly => 30,
84 SeasonalityType::Quarterly => 91,
85 SeasonalityType::SemiAnnual => 182,
86 SeasonalityType::Annual => 365,
87 }
88 }
89
90 pub fn examples(&self) -> &'static str {
92 match self {
93 SeasonalityType::None => "No regular pattern",
94 SeasonalityType::Weekly => "Payroll, weekly invoicing",
95 SeasonalityType::BiWeekly => "Bi-weekly payroll",
96 SeasonalityType::Monthly => "Rent, subscriptions, utility payments",
97 SeasonalityType::Quarterly => "Tax payments, dividends, quarterly filings",
98 SeasonalityType::SemiAnnual => "Insurance premiums, bond interest",
99 SeasonalityType::Annual => "Year-end adjustments, depreciation",
100 }
101 }
102}
103
104#[derive(Debug, Clone, Archive, Serialize, Deserialize)]
106#[repr(C)]
107pub struct SeasonalPattern {
108 pub id: Uuid,
110 pub account_id: u16,
112 pub seasonality_type: SeasonalityType,
114 pub period_length: u16,
116 pub cycle_count: u16,
118 pub _pad: u16,
120 pub confidence: f32,
122 pub autocorrelation_peak: f32,
124 pub seasonal_amplitude: f32,
126 pub baseline: f32,
128 pub trend_coefficient: f32,
130 pub residual_variance: f32,
132 pub first_observed: HybridTimestamp,
134 pub last_observed: HybridTimestamp,
136 pub flags: SeasonalPatternFlags,
138}
139
140#[derive(Debug, Clone, Copy, Default, Archive, Serialize, Deserialize)]
142#[repr(transparent)]
143pub struct SeasonalPatternFlags(pub u32);
144
145impl SeasonalPatternFlags {
146 pub const IS_STATISTICALLY_SIGNIFICANT: u32 = 1 << 0;
148 pub const HAS_UPWARD_TREND: u32 = 1 << 1;
150 pub const HAS_DOWNWARD_TREND: u32 = 1 << 2;
152 pub const IS_STABLE: u32 = 1 << 3;
154 pub const HAS_STRUCTURAL_BREAK: u32 = 1 << 4;
156}
157
158impl SeasonalPattern {
159 pub fn new(account_id: u16, seasonality_type: SeasonalityType) -> Self {
161 Self {
162 id: Uuid::new_v4(),
163 account_id,
164 seasonality_type,
165 period_length: seasonality_type.period_days() as u16,
166 cycle_count: 0,
167 _pad: 0,
168 confidence: 0.0,
169 autocorrelation_peak: 0.0,
170 seasonal_amplitude: 0.0,
171 baseline: 0.0,
172 trend_coefficient: 0.0,
173 residual_variance: 0.0,
174 first_observed: HybridTimestamp::zero(),
175 last_observed: HybridTimestamp::zero(),
176 flags: SeasonalPatternFlags(0),
177 }
178 }
179
180 pub fn is_strong(&self) -> bool {
182 self.confidence > 0.9
183 }
184
185 pub fn is_significant(&self) -> bool {
187 self.flags.0 & SeasonalPatternFlags::IS_STATISTICALLY_SIGNIFICANT != 0
188 }
189}
190
191#[derive(Debug, Clone, Archive, Serialize, Deserialize)]
194#[repr(C)]
195pub struct BehavioralBaseline {
196 pub id: Uuid,
198 pub account_id: u16,
200 pub period_count: u16,
202 pub _pad: u32,
204
205 pub mean: f64,
208 pub median: f64,
210
211 pub std_dev: f64,
214 pub mad: f64,
216
217 pub q1: f64,
220 pub q3: f64,
222 pub iqr: f64,
224 pub min_value: f64,
226 pub max_value: f64,
228 pub p5: f64,
230 pub p95: f64,
232
233 pub skewness: f64,
236 pub kurtosis: f64,
238
239 pub avg_transaction_count: f64,
242 pub transaction_count_std_dev: f64,
244
245 pub last_updated: HybridTimestamp,
248 pub period_start: HybridTimestamp,
250 pub period_end: HybridTimestamp,
252}
253
254impl BehavioralBaseline {
255 pub fn new(account_id: u16) -> Self {
257 Self {
258 id: Uuid::new_v4(),
259 account_id,
260 period_count: 0,
261 _pad: 0,
262 mean: 0.0,
263 median: 0.0,
264 std_dev: 0.0,
265 mad: 0.0,
266 q1: 0.0,
267 q3: 0.0,
268 iqr: 0.0,
269 min_value: 0.0,
270 max_value: 0.0,
271 p5: 0.0,
272 p95: 0.0,
273 skewness: 0.0,
274 kurtosis: 0.0,
275 avg_transaction_count: 0.0,
276 transaction_count_std_dev: 0.0,
277 last_updated: HybridTimestamp::zero(),
278 period_start: HybridTimestamp::zero(),
279 period_end: HybridTimestamp::zero(),
280 }
281 }
282
283 pub fn z_score(&self, value: f64) -> f64 {
285 if self.std_dev > 0.0 {
286 (value - self.mean) / self.std_dev
287 } else {
288 0.0
289 }
290 }
291
292 pub fn modified_z_score(&self, value: f64) -> f64 {
294 if self.mad > 0.0 {
295 0.6745 * (value - self.median) / self.mad
296 } else {
297 0.0
298 }
299 }
300
301 pub fn is_iqr_outlier(&self, value: f64) -> bool {
303 let lower_fence = self.q1 - 1.5 * self.iqr;
304 let upper_fence = self.q3 + 1.5 * self.iqr;
305 value < lower_fence || value > upper_fence
306 }
307
308 pub fn is_percentile_outlier(&self, value: f64) -> bool {
310 value < self.p5 || value > self.p95
311 }
312
313 pub fn is_anomaly(&self, value: f64) -> (bool, f32) {
315 let mut votes = 0;
316 let mut max_severity = 0.0f32;
317
318 let z = self.z_score(value).abs();
320 if z > 3.0 {
321 votes += 1;
322 max_severity = max_severity.max((z / 5.0) as f32);
323 }
324
325 let mz = self.modified_z_score(value).abs();
327 if mz > 3.5 {
328 votes += 1;
329 max_severity = max_severity.max((mz / 5.0) as f32);
330 }
331
332 if self.is_iqr_outlier(value) {
334 votes += 1;
335 let iqr_deviation = if value < self.q1 {
336 (self.q1 - value) / self.iqr
337 } else {
338 (value - self.q3) / self.iqr
339 };
340 max_severity = max_severity.max(iqr_deviation as f32);
341 }
342
343 if self.is_percentile_outlier(value) {
345 votes += 1;
346 }
347
348 let is_anomaly = votes >= 2;
350 let score = match votes {
351 2 => 0.5,
352 3 => 0.75,
353 4 => 1.0,
354 _ => 0.0,
355 } * max_severity.min(1.0);
356
357 (is_anomaly, score)
358 }
359}
360
361#[derive(Debug, Clone, Archive, Serialize, Deserialize)]
363#[repr(C)]
364pub struct TimeSeriesMetrics {
365 pub id: Uuid,
367 pub account_id: u16,
369 pub period_count: u16,
371 pub granularity: TimeGranularity,
373 pub _pad: [u8; 3],
375
376 pub trend_coefficient: f64,
379 pub trend_intercept: f64,
381 pub trend_r_squared: f64,
383
384 pub volatility: f64,
387 pub coefficient_of_variation: f64,
389
390 pub sma: f64,
393 pub ema: f64,
395
396 pub current_value: f64,
399 pub forecasted_value: f64,
401 pub forecast_ci: f64,
403
404 pub period_start: HybridTimestamp,
407
408 pub flags: TimeSeriesFlags,
411}
412
413#[derive(Debug, Clone, Copy, Default, Archive, Serialize, Deserialize)]
415#[repr(transparent)]
416pub struct TimeSeriesFlags(pub u32);
417
418impl TimeSeriesFlags {
419 pub const HAS_SIGNIFICANT_TREND: u32 = 1 << 0;
421 pub const IS_INCREASING: u32 = 1 << 1;
423 pub const IS_DECREASING: u32 = 1 << 2;
425 pub const IS_HIGH_VOLATILITY: u32 = 1 << 3;
427 pub const IS_STATIONARY: u32 = 1 << 4;
429}
430
431impl TimeSeriesMetrics {
432 pub fn new(account_id: u16, granularity: TimeGranularity) -> Self {
434 Self {
435 id: Uuid::new_v4(),
436 account_id,
437 period_count: 0,
438 granularity,
439 _pad: [0; 3],
440 trend_coefficient: 0.0,
441 trend_intercept: 0.0,
442 trend_r_squared: 0.0,
443 volatility: 0.0,
444 coefficient_of_variation: 0.0,
445 sma: 0.0,
446 ema: 0.0,
447 current_value: 0.0,
448 forecasted_value: 0.0,
449 forecast_ci: 0.0,
450 period_start: HybridTimestamp::zero(),
451 flags: TimeSeriesFlags(0),
452 }
453 }
454
455 pub fn is_increasing(&self) -> bool {
457 self.flags.0 & TimeSeriesFlags::IS_INCREASING != 0
458 }
459
460 pub fn is_decreasing(&self) -> bool {
462 self.flags.0 & TimeSeriesFlags::IS_DECREASING != 0
463 }
464
465 pub fn is_volatile(&self) -> bool {
467 self.flags.0 & TimeSeriesFlags::IS_HIGH_VOLATILITY != 0
468 }
469}
470
471#[derive(Debug, Clone)]
473pub struct TemporalAlert {
474 pub id: Uuid,
476 pub account_id: u16,
478 pub alert_type: TemporalAlertType,
480 pub severity: f32,
482 pub trigger_value: f64,
484 pub expected_value: f64,
486 pub deviation: f64,
488 pub timestamp: HybridTimestamp,
490 pub message: String,
492}
493
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
496pub enum TemporalAlertType {
497 Anomaly,
499 TrendBreak,
501 SeasonalDeviation,
503 DormantActivation,
505 HighVolatility,
507 FrequencyAnomaly,
509}
510
511impl TemporalAlertType {
512 pub fn icon(&self) -> &'static str {
514 match self {
515 TemporalAlertType::Anomaly => "⚠️",
516 TemporalAlertType::TrendBreak => "📈",
517 TemporalAlertType::SeasonalDeviation => "📅",
518 TemporalAlertType::DormantActivation => "💤",
519 TemporalAlertType::HighVolatility => "📊",
520 TemporalAlertType::FrequencyAnomaly => "🔢",
521 }
522 }
523
524 pub fn description(&self) -> &'static str {
526 match self {
527 TemporalAlertType::Anomaly => "Value outside normal range",
528 TemporalAlertType::TrendBreak => "Significant trend change detected",
529 TemporalAlertType::SeasonalDeviation => "Deviation from seasonal pattern",
530 TemporalAlertType::DormantActivation => "Activity on dormant account",
531 TemporalAlertType::HighVolatility => "Unusually high value volatility",
532 TemporalAlertType::FrequencyAnomaly => "Unusual transaction frequency",
533 }
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
542 fn test_baseline_anomaly_detection() {
543 let mut baseline = BehavioralBaseline::new(0);
544 baseline.mean = 100.0;
545 baseline.std_dev = 10.0;
546 baseline.median = 100.0;
547 baseline.mad = 7.5;
548 baseline.q1 = 90.0;
549 baseline.q3 = 110.0;
550 baseline.iqr = 20.0;
551 baseline.p5 = 80.0;
552 baseline.p95 = 120.0;
553
554 let (is_anomaly, _) = baseline.is_anomaly(105.0);
556 assert!(!is_anomaly);
557
558 let (is_anomaly, score) = baseline.is_anomaly(200.0);
560 assert!(is_anomaly);
561 assert!(score > 0.5);
562 }
563
564 #[test]
565 fn test_z_score() {
566 let mut baseline = BehavioralBaseline::new(0);
567 baseline.mean = 100.0;
568 baseline.std_dev = 10.0;
569
570 let z = baseline.z_score(130.0);
571 assert!((z - 3.0).abs() < 0.01);
572 }
573
574 #[test]
575 fn test_seasonality_period() {
576 assert_eq!(SeasonalityType::Monthly.period_days(), 30);
577 assert_eq!(SeasonalityType::Quarterly.period_days(), 91);
578 assert_eq!(SeasonalityType::Annual.period_days(), 365);
579 }
580}