1use chrono::{Datelike, Duration, NaiveDate};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(tag = "type", rename_all = "snake_case")]
16pub enum PeriodEndModel {
17 FlatMultiplier {
19 multiplier: f64,
21 },
22
23 ExponentialAcceleration {
25 start_day: i32,
27 base_multiplier: f64,
29 peak_multiplier: f64,
31 decay_rate: f64,
33 },
34
35 DailyProfile {
37 profile: HashMap<i32, f64>,
39 #[serde(default)]
41 interpolation: InterpolationMethod,
42 },
43
44 ExtendedCrunch {
46 start_day: i32,
48 sustained_high_days: i32,
50 peak_multiplier: f64,
52 #[serde(default = "default_ramp_days")]
54 ramp_up_days: i32,
55 },
56}
57
58fn default_ramp_days() -> i32 {
59 3
60}
61
62#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum InterpolationMethod {
66 #[default]
68 Nearest,
69 Linear,
71 Step,
73}
74
75impl Default for PeriodEndModel {
76 fn default() -> Self {
77 Self::ExponentialAcceleration {
78 start_day: -10,
79 base_multiplier: 1.0,
80 peak_multiplier: 3.5,
81 decay_rate: 0.3,
82 }
83 }
84}
85
86impl PeriodEndModel {
87 pub fn get_multiplier(&self, days_to_end: i32) -> f64 {
91 match self {
92 PeriodEndModel::FlatMultiplier { multiplier } => {
93 if (-5..=0).contains(&days_to_end) {
94 *multiplier
95 } else {
96 1.0
97 }
98 }
99
100 PeriodEndModel::ExponentialAcceleration {
101 start_day,
102 base_multiplier,
103 peak_multiplier,
104 decay_rate,
105 } => {
106 if days_to_end < *start_day || days_to_end > 0 {
107 return 1.0;
108 }
109
110 let total_days = (-start_day) as f64;
112 let position = (days_to_end - start_day) as f64 / total_days;
113
114 let exp_factor = (decay_rate * position).exp();
116 let exp_max = decay_rate.exp();
117 let normalized = (exp_factor - 1.0) / (exp_max - 1.0);
118
119 base_multiplier + (peak_multiplier - base_multiplier) * normalized
120 }
121
122 PeriodEndModel::DailyProfile {
123 profile,
124 interpolation,
125 } => {
126 if let Some(&mult) = profile.get(&days_to_end) {
127 return mult;
128 }
129
130 let keys: Vec<i32> = profile.keys().copied().collect();
132 if keys.is_empty() {
133 return 1.0;
134 }
135
136 match interpolation {
137 InterpolationMethod::Nearest => {
138 let nearest = keys
139 .iter()
140 .min_by_key(|&&k| (k - days_to_end).abs())
141 .expect("valid date components");
142 *profile.get(nearest).unwrap_or(&1.0)
143 }
144 InterpolationMethod::Linear => {
145 let mut below = None;
146 let mut above = None;
147 for &k in &keys {
148 if k <= days_to_end
149 && (below.is_none() || k > below.expect("valid date components"))
150 {
151 below = Some(k);
152 }
153 if k >= days_to_end
154 && (above.is_none() || k < above.expect("valid date components"))
155 {
156 above = Some(k);
157 }
158 }
159
160 match (below, above) {
161 (Some(b), Some(a)) if b != a => {
162 let b_val = profile.get(&b).unwrap_or(&1.0);
163 let a_val = profile.get(&a).unwrap_or(&1.0);
164 let t = (days_to_end - b) as f64 / (a - b) as f64;
165 b_val + (a_val - b_val) * t
166 }
167 (Some(b), _) => *profile.get(&b).unwrap_or(&1.0),
168 (_, Some(a)) => *profile.get(&a).unwrap_or(&1.0),
169 _ => 1.0,
170 }
171 }
172 InterpolationMethod::Step => {
173 let prev = keys.iter().filter(|&&k| k <= days_to_end).max();
174 prev.and_then(|k| profile.get(k).copied()).unwrap_or(1.0)
175 }
176 }
177 }
178
179 PeriodEndModel::ExtendedCrunch {
180 start_day,
181 sustained_high_days,
182 peak_multiplier,
183 ramp_up_days,
184 } => {
185 if days_to_end < *start_day || days_to_end > 0 {
186 return 1.0;
187 }
188
189 let ramp_end = start_day + ramp_up_days;
190 let sustain_end = ramp_end + sustained_high_days;
191
192 if days_to_end < ramp_end {
193 let ramp_position = (days_to_end - start_day) as f64 / *ramp_up_days as f64;
195 1.0 + (peak_multiplier - 1.0) * ramp_position
196 } else if days_to_end < sustain_end {
197 *peak_multiplier
199 } else {
200 let wind_down_days = (-sustain_end) as f64;
202 let position = (days_to_end - sustain_end) as f64 / wind_down_days;
203 1.0 + (peak_multiplier - 1.0) * (1.0 - position * 0.3)
204 }
205 }
206 }
207 }
208
209 pub fn flat(multiplier: f64) -> Self {
211 Self::FlatMultiplier { multiplier }
212 }
213
214 pub fn exponential_accounting() -> Self {
216 Self::ExponentialAcceleration {
217 start_day: -10,
218 base_multiplier: 1.0,
219 peak_multiplier: 3.5,
220 decay_rate: 0.3,
221 }
222 }
223
224 pub fn custom_profile(profile: HashMap<i32, f64>) -> Self {
226 Self::DailyProfile {
227 profile,
228 interpolation: InterpolationMethod::Linear,
229 }
230 }
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct PeriodEndConfig {
236 #[serde(default = "default_true")]
238 pub enabled: bool,
239 #[serde(default)]
241 pub model: PeriodEndModel,
242 #[serde(default = "default_one")]
244 pub additional_multiplier: f64,
245}
246
247fn default_true() -> bool {
248 true
249}
250
251fn default_one() -> f64 {
252 1.0
253}
254
255impl Default for PeriodEndConfig {
256 fn default() -> Self {
257 Self {
258 enabled: true,
259 model: PeriodEndModel::default(),
260 additional_multiplier: 1.0,
261 }
262 }
263}
264
265impl PeriodEndConfig {
266 pub fn disabled() -> Self {
268 Self {
269 enabled: false,
270 model: PeriodEndModel::default(),
271 additional_multiplier: 1.0,
272 }
273 }
274
275 pub fn get_multiplier(&self, days_to_end: i32) -> f64 {
277 if !self.enabled {
278 return 1.0;
279 }
280 self.model.get_multiplier(days_to_end) * self.additional_multiplier
281 }
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct PeriodEndDynamics {
287 #[serde(default)]
289 pub month_end: PeriodEndConfig,
290 #[serde(default)]
292 pub quarter_end: PeriodEndConfig,
293 #[serde(default)]
295 pub year_end: PeriodEndConfig,
296}
297
298impl Default for PeriodEndDynamics {
299 fn default() -> Self {
300 Self {
301 month_end: PeriodEndConfig {
302 enabled: true,
303 model: PeriodEndModel::ExponentialAcceleration {
304 start_day: -10,
305 base_multiplier: 1.0,
306 peak_multiplier: 2.5,
307 decay_rate: 0.25,
308 },
309 additional_multiplier: 1.0,
310 },
311 quarter_end: PeriodEndConfig {
312 enabled: true,
313 model: PeriodEndModel::ExponentialAcceleration {
314 start_day: -12,
315 base_multiplier: 1.0,
316 peak_multiplier: 4.0,
317 decay_rate: 0.3,
318 },
319 additional_multiplier: 1.0,
320 },
321 year_end: PeriodEndConfig {
322 enabled: true,
323 model: PeriodEndModel::ExtendedCrunch {
324 start_day: -15,
325 sustained_high_days: 7,
326 peak_multiplier: 6.0,
327 ramp_up_days: 3,
328 },
329 additional_multiplier: 1.0,
330 },
331 }
332 }
333}
334
335impl PeriodEndDynamics {
336 pub fn new(
338 month_end: PeriodEndConfig,
339 quarter_end: PeriodEndConfig,
340 year_end: PeriodEndConfig,
341 ) -> Self {
342 Self {
343 month_end,
344 quarter_end,
345 year_end,
346 }
347 }
348
349 pub fn disabled() -> Self {
351 Self {
352 month_end: PeriodEndConfig::disabled(),
353 quarter_end: PeriodEndConfig::disabled(),
354 year_end: PeriodEndConfig::disabled(),
355 }
356 }
357
358 pub fn flat(month: f64, quarter: f64, year: f64) -> Self {
360 Self {
361 month_end: PeriodEndConfig {
362 enabled: true,
363 model: PeriodEndModel::flat(month),
364 additional_multiplier: 1.0,
365 },
366 quarter_end: PeriodEndConfig {
367 enabled: true,
368 model: PeriodEndModel::flat(quarter),
369 additional_multiplier: 1.0,
370 },
371 year_end: PeriodEndConfig {
372 enabled: true,
373 model: PeriodEndModel::flat(year),
374 additional_multiplier: 1.0,
375 },
376 }
377 }
378
379 pub fn get_multiplier(&self, date: NaiveDate, period_end: NaiveDate) -> f64 {
384 let days_to_end = (date - period_end).num_days() as i32;
385
386 let is_year_end = period_end.month() == 12;
388 let is_quarter_end = matches!(period_end.month(), 3 | 6 | 9 | 12);
389
390 if is_year_end && self.year_end.enabled {
392 self.year_end.get_multiplier(days_to_end)
393 } else if is_quarter_end && self.quarter_end.enabled {
394 self.quarter_end.get_multiplier(days_to_end)
395 } else if self.month_end.enabled {
396 self.month_end.get_multiplier(days_to_end)
397 } else {
398 1.0
399 }
400 }
401
402 pub fn get_multiplier_for_date(&self, date: NaiveDate) -> f64 {
404 let period_end = Self::last_day_of_month(date);
405 self.get_multiplier(date, period_end)
406 }
407
408 fn last_day_of_month(date: NaiveDate) -> NaiveDate {
410 let year = date.year();
411 let month = date.month();
412
413 if month == 12 {
414 NaiveDate::from_ymd_opt(year + 1, 1, 1).expect("valid date components")
415 - Duration::days(1)
416 } else {
417 NaiveDate::from_ymd_opt(year, month + 1, 1).expect("valid date components")
418 - Duration::days(1)
419 }
420 }
421
422 pub fn is_in_period_end(&self, date: NaiveDate) -> bool {
424 let period_end = Self::last_day_of_month(date);
425 let days_to_end = (date - period_end).num_days() as i32;
426
427 let in_month_end = self.month_end.enabled && (-10..=0).contains(&days_to_end);
429 let in_quarter_end = self.quarter_end.enabled
430 && matches!(period_end.month(), 3 | 6 | 9 | 12)
431 && (-12..=0).contains(&days_to_end);
432 let in_year_end =
433 self.year_end.enabled && period_end.month() == 12 && (-15..=0).contains(&days_to_end);
434
435 in_month_end || in_quarter_end || in_year_end
436 }
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize, Default)]
441pub struct PeriodEndSchemaConfig {
442 #[serde(default)]
444 pub model: Option<String>,
445
446 #[serde(default)]
448 pub month_end: Option<PeriodEndModelConfig>,
449
450 #[serde(default)]
452 pub quarter_end: Option<PeriodEndModelConfig>,
453
454 #[serde(default)]
456 pub year_end: Option<PeriodEndModelConfig>,
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct PeriodEndModelConfig {
462 #[serde(default)]
464 pub inherit_from: Option<String>,
465
466 #[serde(default)]
468 pub additional_multiplier: Option<f64>,
469
470 #[serde(default)]
472 pub start_day: Option<i32>,
473
474 #[serde(default)]
476 pub base_multiplier: Option<f64>,
477
478 #[serde(default)]
480 pub peak_multiplier: Option<f64>,
481
482 #[serde(default)]
484 pub decay_rate: Option<f64>,
485
486 #[serde(default)]
488 pub sustained_high_days: Option<i32>,
489}
490
491impl PeriodEndSchemaConfig {
492 pub fn to_dynamics(&self) -> PeriodEndDynamics {
494 let mut dynamics = PeriodEndDynamics::default();
495
496 if let Some(model_type) = &self.model {
498 match model_type.as_str() {
499 "flat" => {
500 dynamics = PeriodEndDynamics::flat(2.5, 4.0, 6.0);
501 }
502 "exponential" | "exponential_acceleration" => {
503 }
505 "extended_crunch" => {
506 dynamics.month_end.model = PeriodEndModel::ExtendedCrunch {
507 start_day: -10,
508 sustained_high_days: 3,
509 peak_multiplier: 2.5,
510 ramp_up_days: 2,
511 };
512 dynamics.quarter_end.model = PeriodEndModel::ExtendedCrunch {
513 start_day: -12,
514 sustained_high_days: 4,
515 peak_multiplier: 4.0,
516 ramp_up_days: 3,
517 };
518 }
519 _ => {}
520 }
521 }
522
523 if let Some(config) = &self.month_end {
525 Self::apply_config(&mut dynamics.month_end, config, None);
526 }
527
528 if let Some(config) = &self.quarter_end {
529 let inherit = config.inherit_from.as_ref().map(|_| &dynamics.month_end);
530 Self::apply_config(&mut dynamics.quarter_end, config, inherit);
531 }
532
533 if let Some(config) = &self.year_end {
534 let inherit = config.inherit_from.as_ref().map(|from| {
535 if from == "quarter_end" {
536 &dynamics.quarter_end
537 } else {
538 &dynamics.month_end
539 }
540 });
541 Self::apply_config(&mut dynamics.year_end, config, inherit);
542 }
543
544 dynamics
545 }
546
547 fn apply_config(
548 target: &mut PeriodEndConfig,
549 config: &PeriodEndModelConfig,
550 inherit: Option<&PeriodEndConfig>,
551 ) {
552 if let Some(inherited) = inherit {
554 target.model = inherited.model.clone();
555 target.additional_multiplier = inherited.additional_multiplier;
556 }
557
558 if let Some(mult) = config.additional_multiplier {
560 target.additional_multiplier = mult;
561 }
562
563 match &mut target.model {
565 PeriodEndModel::ExponentialAcceleration {
566 start_day,
567 base_multiplier,
568 peak_multiplier,
569 decay_rate,
570 } => {
571 if let Some(sd) = config.start_day {
572 *start_day = sd;
573 }
574 if let Some(bm) = config.base_multiplier {
575 *base_multiplier = bm;
576 }
577 if let Some(pm) = config.peak_multiplier {
578 *peak_multiplier = pm;
579 }
580 if let Some(dr) = config.decay_rate {
581 *decay_rate = dr;
582 }
583 }
584 PeriodEndModel::ExtendedCrunch {
585 start_day,
586 sustained_high_days,
587 peak_multiplier,
588 ..
589 } => {
590 if let Some(sd) = config.start_day {
591 *start_day = sd;
592 }
593 if let Some(shd) = config.sustained_high_days {
594 *sustained_high_days = shd;
595 }
596 if let Some(pm) = config.peak_multiplier {
597 *peak_multiplier = pm;
598 }
599 }
600 PeriodEndModel::FlatMultiplier { multiplier } => {
601 if let Some(pm) = config.peak_multiplier {
602 *multiplier = pm;
603 }
604 }
605 _ => {}
606 }
607 }
608}
609
610#[cfg(test)]
611#[allow(clippy::unwrap_used)]
612mod tests {
613 use super::*;
614
615 #[test]
616 fn test_flat_multiplier() {
617 let model = PeriodEndModel::flat(3.0);
618
619 assert!((model.get_multiplier(0) - 3.0).abs() < 0.01);
621 assert!((model.get_multiplier(-3) - 3.0).abs() < 0.01);
622 assert!((model.get_multiplier(-5) - 3.0).abs() < 0.01);
623
624 assert!((model.get_multiplier(-6) - 1.0).abs() < 0.01);
626 assert!((model.get_multiplier(-10) - 1.0).abs() < 0.01);
627 }
628
629 #[test]
630 fn test_exponential_acceleration() {
631 let model = PeriodEndModel::ExponentialAcceleration {
632 start_day: -10,
633 base_multiplier: 1.0,
634 peak_multiplier: 3.5,
635 decay_rate: 0.3,
636 };
637
638 let at_start = model.get_multiplier(-10);
640 assert!((1.0..1.3).contains(&at_start));
641
642 let at_peak = model.get_multiplier(0);
644 assert!((at_peak - 3.5).abs() < 0.01);
645
646 let mid = model.get_multiplier(-5);
648 assert!(mid > 1.5 && mid < 3.0);
649
650 assert!((model.get_multiplier(-15) - 1.0).abs() < 0.01);
652 assert!((model.get_multiplier(1) - 1.0).abs() < 0.01);
653 }
654
655 #[test]
656 fn test_daily_profile_linear() {
657 let mut profile = HashMap::new();
658 profile.insert(-10, 1.0);
659 profile.insert(-5, 2.0);
660 profile.insert(0, 4.0);
661
662 let model = PeriodEndModel::DailyProfile {
663 profile,
664 interpolation: InterpolationMethod::Linear,
665 };
666
667 assert!((model.get_multiplier(-10) - 1.0).abs() < 0.01);
669 assert!((model.get_multiplier(-5) - 2.0).abs() < 0.01);
670 assert!((model.get_multiplier(0) - 4.0).abs() < 0.01);
671
672 let interp = model.get_multiplier(-7);
674 assert!(interp > 1.3 && interp < 1.7);
675 }
676
677 #[test]
678 fn test_extended_crunch() {
679 let model = PeriodEndModel::ExtendedCrunch {
680 start_day: -10,
681 sustained_high_days: 5,
682 peak_multiplier: 4.0,
683 ramp_up_days: 3,
684 };
685
686 assert!((model.get_multiplier(-15) - 1.0).abs() < 0.01);
688
689 let at_start = model.get_multiplier(-10);
691 assert!((1.0..2.0).contains(&at_start));
692
693 let in_sustained = model.get_multiplier(-5);
695 assert!((in_sustained - 4.0).abs() < 0.01);
696 }
697
698 #[test]
699 fn test_period_end_dynamics() {
700 let dynamics = PeriodEndDynamics::default();
701
702 let june_25 = NaiveDate::from_ymd_opt(2024, 6, 25).unwrap();
704 let mult = dynamics.get_multiplier_for_date(june_25);
705 assert!(mult > 1.0); let march_28 = NaiveDate::from_ymd_opt(2024, 3, 28).unwrap();
709 let q_mult = dynamics.get_multiplier_for_date(march_28);
710 assert!(q_mult > mult); let dec_20 = NaiveDate::from_ymd_opt(2024, 12, 20).unwrap();
714 let y_mult = dynamics.get_multiplier_for_date(dec_20);
715 assert!(y_mult > q_mult); }
717
718 #[test]
719 fn test_period_end_config_disabled() {
720 let config = PeriodEndConfig::disabled();
721 assert!((config.get_multiplier(0) - 1.0).abs() < 0.01);
722 assert!((config.get_multiplier(-5) - 1.0).abs() < 0.01);
723 }
724
725 #[test]
726 fn test_is_in_period_end() {
727 let dynamics = PeriodEndDynamics::default();
728
729 let mid_month = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
731 assert!(!dynamics.is_in_period_end(mid_month));
732
733 let last_day = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
735 assert!(dynamics.is_in_period_end(last_day));
736
737 let five_before = NaiveDate::from_ymd_opt(2024, 6, 25).unwrap();
739 assert!(dynamics.is_in_period_end(five_before));
740 }
741
742 #[test]
743 fn test_schema_config_conversion() {
744 let schema = PeriodEndSchemaConfig {
745 model: Some("exponential".to_string()),
746 month_end: Some(PeriodEndModelConfig {
747 inherit_from: None,
748 additional_multiplier: None,
749 start_day: Some(-8),
750 base_multiplier: None,
751 peak_multiplier: Some(3.0),
752 decay_rate: None,
753 sustained_high_days: None,
754 }),
755 quarter_end: Some(PeriodEndModelConfig {
756 inherit_from: Some("month_end".to_string()),
757 additional_multiplier: Some(1.5),
758 start_day: None,
759 base_multiplier: None,
760 peak_multiplier: None,
761 decay_rate: None,
762 sustained_high_days: None,
763 }),
764 year_end: None,
765 };
766
767 let dynamics = schema.to_dynamics();
768
769 if let PeriodEndModel::ExponentialAcceleration {
771 peak_multiplier, ..
772 } = &dynamics.month_end.model
773 {
774 assert!((*peak_multiplier - 3.0).abs() < 0.01);
775 }
776
777 assert!((dynamics.quarter_end.additional_multiplier - 1.5).abs() < 0.01);
779 }
780}