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 .unwrap();
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 && (below.is_none() || k > below.unwrap()) {
149 below = Some(k);
150 }
151 if k >= days_to_end && (above.is_none() || k < above.unwrap()) {
152 above = Some(k);
153 }
154 }
155
156 match (below, above) {
157 (Some(b), Some(a)) if b != a => {
158 let b_val = profile.get(&b).unwrap_or(&1.0);
159 let a_val = profile.get(&a).unwrap_or(&1.0);
160 let t = (days_to_end - b) as f64 / (a - b) as f64;
161 b_val + (a_val - b_val) * t
162 }
163 (Some(b), _) => *profile.get(&b).unwrap_or(&1.0),
164 (_, Some(a)) => *profile.get(&a).unwrap_or(&1.0),
165 _ => 1.0,
166 }
167 }
168 InterpolationMethod::Step => {
169 let prev = keys.iter().filter(|&&k| k <= days_to_end).max();
170 prev.and_then(|k| profile.get(k).copied()).unwrap_or(1.0)
171 }
172 }
173 }
174
175 PeriodEndModel::ExtendedCrunch {
176 start_day,
177 sustained_high_days,
178 peak_multiplier,
179 ramp_up_days,
180 } => {
181 if days_to_end < *start_day || days_to_end > 0 {
182 return 1.0;
183 }
184
185 let ramp_end = start_day + ramp_up_days;
186 let sustain_end = ramp_end + sustained_high_days;
187
188 if days_to_end < ramp_end {
189 let ramp_position = (days_to_end - start_day) as f64 / *ramp_up_days as f64;
191 1.0 + (peak_multiplier - 1.0) * ramp_position
192 } else if days_to_end < sustain_end {
193 *peak_multiplier
195 } else {
196 let wind_down_days = (-sustain_end) as f64;
198 let position = (days_to_end - sustain_end) as f64 / wind_down_days;
199 1.0 + (peak_multiplier - 1.0) * (1.0 - position * 0.3)
200 }
201 }
202 }
203 }
204
205 pub fn flat(multiplier: f64) -> Self {
207 Self::FlatMultiplier { multiplier }
208 }
209
210 pub fn exponential_accounting() -> Self {
212 Self::ExponentialAcceleration {
213 start_day: -10,
214 base_multiplier: 1.0,
215 peak_multiplier: 3.5,
216 decay_rate: 0.3,
217 }
218 }
219
220 pub fn custom_profile(profile: HashMap<i32, f64>) -> Self {
222 Self::DailyProfile {
223 profile,
224 interpolation: InterpolationMethod::Linear,
225 }
226 }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct PeriodEndConfig {
232 #[serde(default = "default_true")]
234 pub enabled: bool,
235 #[serde(default)]
237 pub model: PeriodEndModel,
238 #[serde(default = "default_one")]
240 pub additional_multiplier: f64,
241}
242
243fn default_true() -> bool {
244 true
245}
246
247fn default_one() -> f64 {
248 1.0
249}
250
251impl Default for PeriodEndConfig {
252 fn default() -> Self {
253 Self {
254 enabled: true,
255 model: PeriodEndModel::default(),
256 additional_multiplier: 1.0,
257 }
258 }
259}
260
261impl PeriodEndConfig {
262 pub fn disabled() -> Self {
264 Self {
265 enabled: false,
266 model: PeriodEndModel::default(),
267 additional_multiplier: 1.0,
268 }
269 }
270
271 pub fn get_multiplier(&self, days_to_end: i32) -> f64 {
273 if !self.enabled {
274 return 1.0;
275 }
276 self.model.get_multiplier(days_to_end) * self.additional_multiplier
277 }
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct PeriodEndDynamics {
283 #[serde(default)]
285 pub month_end: PeriodEndConfig,
286 #[serde(default)]
288 pub quarter_end: PeriodEndConfig,
289 #[serde(default)]
291 pub year_end: PeriodEndConfig,
292}
293
294impl Default for PeriodEndDynamics {
295 fn default() -> Self {
296 Self {
297 month_end: PeriodEndConfig {
298 enabled: true,
299 model: PeriodEndModel::ExponentialAcceleration {
300 start_day: -10,
301 base_multiplier: 1.0,
302 peak_multiplier: 2.5,
303 decay_rate: 0.25,
304 },
305 additional_multiplier: 1.0,
306 },
307 quarter_end: PeriodEndConfig {
308 enabled: true,
309 model: PeriodEndModel::ExponentialAcceleration {
310 start_day: -12,
311 base_multiplier: 1.0,
312 peak_multiplier: 4.0,
313 decay_rate: 0.3,
314 },
315 additional_multiplier: 1.0,
316 },
317 year_end: PeriodEndConfig {
318 enabled: true,
319 model: PeriodEndModel::ExtendedCrunch {
320 start_day: -15,
321 sustained_high_days: 7,
322 peak_multiplier: 6.0,
323 ramp_up_days: 3,
324 },
325 additional_multiplier: 1.0,
326 },
327 }
328 }
329}
330
331impl PeriodEndDynamics {
332 pub fn new(
334 month_end: PeriodEndConfig,
335 quarter_end: PeriodEndConfig,
336 year_end: PeriodEndConfig,
337 ) -> Self {
338 Self {
339 month_end,
340 quarter_end,
341 year_end,
342 }
343 }
344
345 pub fn disabled() -> Self {
347 Self {
348 month_end: PeriodEndConfig::disabled(),
349 quarter_end: PeriodEndConfig::disabled(),
350 year_end: PeriodEndConfig::disabled(),
351 }
352 }
353
354 pub fn flat(month: f64, quarter: f64, year: f64) -> Self {
356 Self {
357 month_end: PeriodEndConfig {
358 enabled: true,
359 model: PeriodEndModel::flat(month),
360 additional_multiplier: 1.0,
361 },
362 quarter_end: PeriodEndConfig {
363 enabled: true,
364 model: PeriodEndModel::flat(quarter),
365 additional_multiplier: 1.0,
366 },
367 year_end: PeriodEndConfig {
368 enabled: true,
369 model: PeriodEndModel::flat(year),
370 additional_multiplier: 1.0,
371 },
372 }
373 }
374
375 pub fn get_multiplier(&self, date: NaiveDate, period_end: NaiveDate) -> f64 {
380 let days_to_end = (date - period_end).num_days() as i32;
381
382 let is_year_end = period_end.month() == 12;
384 let is_quarter_end = matches!(period_end.month(), 3 | 6 | 9 | 12);
385
386 if is_year_end && self.year_end.enabled {
388 self.year_end.get_multiplier(days_to_end)
389 } else if is_quarter_end && self.quarter_end.enabled {
390 self.quarter_end.get_multiplier(days_to_end)
391 } else if self.month_end.enabled {
392 self.month_end.get_multiplier(days_to_end)
393 } else {
394 1.0
395 }
396 }
397
398 pub fn get_multiplier_for_date(&self, date: NaiveDate) -> f64 {
400 let period_end = Self::last_day_of_month(date);
401 self.get_multiplier(date, period_end)
402 }
403
404 fn last_day_of_month(date: NaiveDate) -> NaiveDate {
406 let year = date.year();
407 let month = date.month();
408
409 if month == 12 {
410 NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap() - Duration::days(1)
411 } else {
412 NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap() - Duration::days(1)
413 }
414 }
415
416 pub fn is_in_period_end(&self, date: NaiveDate) -> bool {
418 let period_end = Self::last_day_of_month(date);
419 let days_to_end = (date - period_end).num_days() as i32;
420
421 let in_month_end = self.month_end.enabled && (-10..=0).contains(&days_to_end);
423 let in_quarter_end = self.quarter_end.enabled
424 && matches!(period_end.month(), 3 | 6 | 9 | 12)
425 && (-12..=0).contains(&days_to_end);
426 let in_year_end =
427 self.year_end.enabled && period_end.month() == 12 && (-15..=0).contains(&days_to_end);
428
429 in_month_end || in_quarter_end || in_year_end
430 }
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize, Default)]
435pub struct PeriodEndSchemaConfig {
436 #[serde(default)]
438 pub model: Option<String>,
439
440 #[serde(default)]
442 pub month_end: Option<PeriodEndModelConfig>,
443
444 #[serde(default)]
446 pub quarter_end: Option<PeriodEndModelConfig>,
447
448 #[serde(default)]
450 pub year_end: Option<PeriodEndModelConfig>,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct PeriodEndModelConfig {
456 #[serde(default)]
458 pub inherit_from: Option<String>,
459
460 #[serde(default)]
462 pub additional_multiplier: Option<f64>,
463
464 #[serde(default)]
466 pub start_day: Option<i32>,
467
468 #[serde(default)]
470 pub base_multiplier: Option<f64>,
471
472 #[serde(default)]
474 pub peak_multiplier: Option<f64>,
475
476 #[serde(default)]
478 pub decay_rate: Option<f64>,
479
480 #[serde(default)]
482 pub sustained_high_days: Option<i32>,
483}
484
485impl PeriodEndSchemaConfig {
486 pub fn to_dynamics(&self) -> PeriodEndDynamics {
488 let mut dynamics = PeriodEndDynamics::default();
489
490 if let Some(model_type) = &self.model {
492 match model_type.as_str() {
493 "flat" => {
494 dynamics = PeriodEndDynamics::flat(2.5, 4.0, 6.0);
495 }
496 "exponential" | "exponential_acceleration" => {
497 }
499 "extended_crunch" => {
500 dynamics.month_end.model = PeriodEndModel::ExtendedCrunch {
501 start_day: -10,
502 sustained_high_days: 3,
503 peak_multiplier: 2.5,
504 ramp_up_days: 2,
505 };
506 dynamics.quarter_end.model = PeriodEndModel::ExtendedCrunch {
507 start_day: -12,
508 sustained_high_days: 4,
509 peak_multiplier: 4.0,
510 ramp_up_days: 3,
511 };
512 }
513 _ => {}
514 }
515 }
516
517 if let Some(config) = &self.month_end {
519 Self::apply_config(&mut dynamics.month_end, config, None);
520 }
521
522 if let Some(config) = &self.quarter_end {
523 let inherit = config.inherit_from.as_ref().map(|_| &dynamics.month_end);
524 Self::apply_config(&mut dynamics.quarter_end, config, inherit);
525 }
526
527 if let Some(config) = &self.year_end {
528 let inherit = config.inherit_from.as_ref().map(|from| {
529 if from == "quarter_end" {
530 &dynamics.quarter_end
531 } else {
532 &dynamics.month_end
533 }
534 });
535 Self::apply_config(&mut dynamics.year_end, config, inherit);
536 }
537
538 dynamics
539 }
540
541 fn apply_config(
542 target: &mut PeriodEndConfig,
543 config: &PeriodEndModelConfig,
544 inherit: Option<&PeriodEndConfig>,
545 ) {
546 if let Some(inherited) = inherit {
548 target.model = inherited.model.clone();
549 target.additional_multiplier = inherited.additional_multiplier;
550 }
551
552 if let Some(mult) = config.additional_multiplier {
554 target.additional_multiplier = mult;
555 }
556
557 match &mut target.model {
559 PeriodEndModel::ExponentialAcceleration {
560 start_day,
561 base_multiplier,
562 peak_multiplier,
563 decay_rate,
564 } => {
565 if let Some(sd) = config.start_day {
566 *start_day = sd;
567 }
568 if let Some(bm) = config.base_multiplier {
569 *base_multiplier = bm;
570 }
571 if let Some(pm) = config.peak_multiplier {
572 *peak_multiplier = pm;
573 }
574 if let Some(dr) = config.decay_rate {
575 *decay_rate = dr;
576 }
577 }
578 PeriodEndModel::ExtendedCrunch {
579 start_day,
580 sustained_high_days,
581 peak_multiplier,
582 ..
583 } => {
584 if let Some(sd) = config.start_day {
585 *start_day = sd;
586 }
587 if let Some(shd) = config.sustained_high_days {
588 *sustained_high_days = shd;
589 }
590 if let Some(pm) = config.peak_multiplier {
591 *peak_multiplier = pm;
592 }
593 }
594 PeriodEndModel::FlatMultiplier { multiplier } => {
595 if let Some(pm) = config.peak_multiplier {
596 *multiplier = pm;
597 }
598 }
599 _ => {}
600 }
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607
608 #[test]
609 fn test_flat_multiplier() {
610 let model = PeriodEndModel::flat(3.0);
611
612 assert!((model.get_multiplier(0) - 3.0).abs() < 0.01);
614 assert!((model.get_multiplier(-3) - 3.0).abs() < 0.01);
615 assert!((model.get_multiplier(-5) - 3.0).abs() < 0.01);
616
617 assert!((model.get_multiplier(-6) - 1.0).abs() < 0.01);
619 assert!((model.get_multiplier(-10) - 1.0).abs() < 0.01);
620 }
621
622 #[test]
623 fn test_exponential_acceleration() {
624 let model = PeriodEndModel::ExponentialAcceleration {
625 start_day: -10,
626 base_multiplier: 1.0,
627 peak_multiplier: 3.5,
628 decay_rate: 0.3,
629 };
630
631 let at_start = model.get_multiplier(-10);
633 assert!((1.0..1.3).contains(&at_start));
634
635 let at_peak = model.get_multiplier(0);
637 assert!((at_peak - 3.5).abs() < 0.01);
638
639 let mid = model.get_multiplier(-5);
641 assert!(mid > 1.5 && mid < 3.0);
642
643 assert!((model.get_multiplier(-15) - 1.0).abs() < 0.01);
645 assert!((model.get_multiplier(1) - 1.0).abs() < 0.01);
646 }
647
648 #[test]
649 fn test_daily_profile_linear() {
650 let mut profile = HashMap::new();
651 profile.insert(-10, 1.0);
652 profile.insert(-5, 2.0);
653 profile.insert(0, 4.0);
654
655 let model = PeriodEndModel::DailyProfile {
656 profile,
657 interpolation: InterpolationMethod::Linear,
658 };
659
660 assert!((model.get_multiplier(-10) - 1.0).abs() < 0.01);
662 assert!((model.get_multiplier(-5) - 2.0).abs() < 0.01);
663 assert!((model.get_multiplier(0) - 4.0).abs() < 0.01);
664
665 let interp = model.get_multiplier(-7);
667 assert!(interp > 1.3 && interp < 1.7);
668 }
669
670 #[test]
671 fn test_extended_crunch() {
672 let model = PeriodEndModel::ExtendedCrunch {
673 start_day: -10,
674 sustained_high_days: 5,
675 peak_multiplier: 4.0,
676 ramp_up_days: 3,
677 };
678
679 assert!((model.get_multiplier(-15) - 1.0).abs() < 0.01);
681
682 let at_start = model.get_multiplier(-10);
684 assert!((1.0..2.0).contains(&at_start));
685
686 let in_sustained = model.get_multiplier(-5);
688 assert!((in_sustained - 4.0).abs() < 0.01);
689 }
690
691 #[test]
692 fn test_period_end_dynamics() {
693 let dynamics = PeriodEndDynamics::default();
694
695 let june_25 = NaiveDate::from_ymd_opt(2024, 6, 25).unwrap();
697 let mult = dynamics.get_multiplier_for_date(june_25);
698 assert!(mult > 1.0); let march_28 = NaiveDate::from_ymd_opt(2024, 3, 28).unwrap();
702 let q_mult = dynamics.get_multiplier_for_date(march_28);
703 assert!(q_mult > mult); let dec_20 = NaiveDate::from_ymd_opt(2024, 12, 20).unwrap();
707 let y_mult = dynamics.get_multiplier_for_date(dec_20);
708 assert!(y_mult > q_mult); }
710
711 #[test]
712 fn test_period_end_config_disabled() {
713 let config = PeriodEndConfig::disabled();
714 assert!((config.get_multiplier(0) - 1.0).abs() < 0.01);
715 assert!((config.get_multiplier(-5) - 1.0).abs() < 0.01);
716 }
717
718 #[test]
719 fn test_is_in_period_end() {
720 let dynamics = PeriodEndDynamics::default();
721
722 let mid_month = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
724 assert!(!dynamics.is_in_period_end(mid_month));
725
726 let last_day = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
728 assert!(dynamics.is_in_period_end(last_day));
729
730 let five_before = NaiveDate::from_ymd_opt(2024, 6, 25).unwrap();
732 assert!(dynamics.is_in_period_end(five_before));
733 }
734
735 #[test]
736 fn test_schema_config_conversion() {
737 let schema = PeriodEndSchemaConfig {
738 model: Some("exponential".to_string()),
739 month_end: Some(PeriodEndModelConfig {
740 inherit_from: None,
741 additional_multiplier: None,
742 start_day: Some(-8),
743 base_multiplier: None,
744 peak_multiplier: Some(3.0),
745 decay_rate: None,
746 sustained_high_days: None,
747 }),
748 quarter_end: Some(PeriodEndModelConfig {
749 inherit_from: Some("month_end".to_string()),
750 additional_multiplier: Some(1.5),
751 start_day: None,
752 base_multiplier: None,
753 peak_multiplier: None,
754 decay_rate: None,
755 sustained_high_days: None,
756 }),
757 year_end: None,
758 };
759
760 let dynamics = schema.to_dynamics();
761
762 if let PeriodEndModel::ExponentialAcceleration {
764 peak_multiplier, ..
765 } = &dynamics.month_end.model
766 {
767 assert!((*peak_multiplier - 3.0).abs() < 0.01);
768 }
769
770 assert!((dynamics.quarter_end.additional_multiplier - 1.5).abs() < 0.01);
772 }
773}