Skip to main content

datasynth_core/distributions/
period_end.rs

1//! Period-end dynamics and decay curves for realistic volume modeling.
2//!
3//! Implements various models for period-end volume spikes including:
4//! - Flat multipliers (legacy behavior)
5//! - Exponential acceleration curves
6//! - Custom daily profiles
7//! - Extended crunch periods
8
9use chrono::{Datelike, Duration, NaiveDate};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// Model for period-end volume patterns.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(tag = "type", rename_all = "snake_case")]
16pub enum PeriodEndModel {
17    /// Simple flat multiplier (legacy behavior).
18    FlatMultiplier {
19        /// Volume multiplier during period-end
20        multiplier: f64,
21    },
22
23    /// Exponential acceleration curve approaching period end.
24    ExponentialAcceleration {
25        /// Days before period end to start acceleration (negative, e.g., -10)
26        start_day: i32,
27        /// Multiplier at the start of acceleration
28        base_multiplier: f64,
29        /// Peak multiplier on the last day
30        peak_multiplier: f64,
31        /// Decay rate (higher = steeper curve, typically 0.1-0.5)
32        decay_rate: f64,
33    },
34
35    /// Custom daily profile with explicit multipliers.
36    DailyProfile {
37        /// Map of days-to-close -> multiplier (e.g., -5 -> 1.5, -1 -> 3.0)
38        profile: HashMap<i32, f64>,
39        /// Interpolation method for days not in profile
40        #[serde(default)]
41        interpolation: InterpolationMethod,
42    },
43
44    /// Extended crunch period with sustained high volume.
45    ExtendedCrunch {
46        /// Days before period end to start (negative, e.g., -10)
47        start_day: i32,
48        /// Number of days at sustained high volume
49        sustained_high_days: i32,
50        /// Peak multiplier during crunch
51        peak_multiplier: f64,
52        /// Ramp-up rate (days from start to peak)
53        #[serde(default = "default_ramp_days")]
54        ramp_up_days: i32,
55    },
56}
57
58fn default_ramp_days() -> i32 {
59    3
60}
61
62/// Interpolation method for daily profiles.
63#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum InterpolationMethod {
66    /// Use the nearest defined value
67    #[default]
68    Nearest,
69    /// Linear interpolation between defined values
70    Linear,
71    /// Step function (use previous defined value)
72    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    /// Calculate the multiplier for a given number of days to period end.
88    ///
89    /// `days_to_end` is negative or zero (0 = last day, -1 = day before, etc.)
90    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                // Normalize position: 0.0 at start_day, 1.0 at day 0
111                let total_days = (-start_day) as f64;
112                let position = (days_to_end - start_day) as f64 / total_days;
113
114                // Exponential growth: base + (peak - base) * (e^(rate*pos) - 1) / (e^rate - 1)
115                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                // Handle days not in profile
131                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                    // Ramp-up phase
194                    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                    // Sustained high phase
198                    *peak_multiplier
199                } else {
200                    // Gradual decrease (optional wind-down)
201                    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    /// Create a flat multiplier model.
210    pub fn flat(multiplier: f64) -> Self {
211        Self::FlatMultiplier { multiplier }
212    }
213
214    /// Create an exponential acceleration model with typical accounting close parameters.
215    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    /// Create a custom daily profile.
225    pub fn custom_profile(profile: HashMap<i32, f64>) -> Self {
226        Self::DailyProfile {
227            profile,
228            interpolation: InterpolationMethod::Linear,
229        }
230    }
231}
232
233/// Configuration for a specific period end type (month, quarter, year).
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct PeriodEndConfig {
236    /// Whether this period-end effect is enabled
237    #[serde(default = "default_true")]
238    pub enabled: bool,
239    /// The model to use for this period end
240    #[serde(default)]
241    pub model: PeriodEndModel,
242    /// Additional multiplier applied on top of the model
243    #[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    /// Create a disabled config.
267    pub fn disabled() -> Self {
268        Self {
269            enabled: false,
270            model: PeriodEndModel::default(),
271            additional_multiplier: 1.0,
272        }
273    }
274
275    /// Get the multiplier for days to end.
276    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/// Dynamics for all period-end types.
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct PeriodEndDynamics {
287    /// Month-end configuration
288    #[serde(default)]
289    pub month_end: PeriodEndConfig,
290    /// Quarter-end configuration
291    #[serde(default)]
292    pub quarter_end: PeriodEndConfig,
293    /// Year-end configuration
294    #[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    /// Create with specific configurations for each period type.
337    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    /// Create dynamics with all period ends disabled.
350    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    /// Create with flat multipliers (legacy behavior).
359    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    /// Get the multiplier for a specific date.
380    ///
381    /// Determines which period-end type applies and calculates the appropriate multiplier.
382    /// Priority: year-end > quarter-end > month-end
383    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        // Determine which period-end type this is
387        let is_year_end = period_end.month() == 12;
388        let is_quarter_end = matches!(period_end.month(), 3 | 6 | 9 | 12);
389
390        // Use the most specific applicable config
391        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    /// Get the multiplier for a date, automatically determining the period end.
403    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    /// Get the last day of the month for a date.
409    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    /// Check if a date is within a period-end window.
423    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        // Check if within any active window
428        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/// Configuration struct for YAML/JSON deserialization.
440#[derive(Debug, Clone, Serialize, Deserialize, Default)]
441pub struct PeriodEndSchemaConfig {
442    /// Overall model type
443    #[serde(default)]
444    pub model: Option<String>,
445
446    /// Month-end configuration
447    #[serde(default)]
448    pub month_end: Option<PeriodEndModelConfig>,
449
450    /// Quarter-end configuration
451    #[serde(default)]
452    pub quarter_end: Option<PeriodEndModelConfig>,
453
454    /// Year-end configuration
455    #[serde(default)]
456    pub year_end: Option<PeriodEndModelConfig>,
457}
458
459/// Schema config for a period-end model.
460#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct PeriodEndModelConfig {
462    /// Inherit from another config (e.g., "month_end")
463    #[serde(default)]
464    pub inherit_from: Option<String>,
465
466    /// Additional multiplier on top of inherited or base model
467    #[serde(default)]
468    pub additional_multiplier: Option<f64>,
469
470    /// Days before period end to start acceleration
471    #[serde(default)]
472    pub start_day: Option<i32>,
473
474    /// Base multiplier
475    #[serde(default)]
476    pub base_multiplier: Option<f64>,
477
478    /// Peak multiplier
479    #[serde(default)]
480    pub peak_multiplier: Option<f64>,
481
482    /// Decay rate for exponential model
483    #[serde(default)]
484    pub decay_rate: Option<f64>,
485
486    /// Sustained high days for crunch model
487    #[serde(default)]
488    pub sustained_high_days: Option<i32>,
489}
490
491impl PeriodEndSchemaConfig {
492    /// Convert to PeriodEndDynamics.
493    pub fn to_dynamics(&self) -> PeriodEndDynamics {
494        let mut dynamics = PeriodEndDynamics::default();
495
496        // Apply model type if specified
497        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                    // Use defaults (already exponential)
504                }
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        // Apply specific overrides
524        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        // Start from inherited config if available
553        if let Some(inherited) = inherit {
554            target.model = inherited.model.clone();
555            target.additional_multiplier = inherited.additional_multiplier;
556        }
557
558        // Apply additional multiplier
559        if let Some(mult) = config.additional_multiplier {
560            target.additional_multiplier = mult;
561        }
562
563        // Update model parameters based on what's provided
564        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        // Within period-end window
620        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        // Outside window
625        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        // At start (day -10), should be near base
639        let at_start = model.get_multiplier(-10);
640        assert!((1.0..1.3).contains(&at_start));
641
642        // At peak (day 0), should be at peak
643        let at_peak = model.get_multiplier(0);
644        assert!((at_peak - 3.5).abs() < 0.01);
645
646        // Midway should be between
647        let mid = model.get_multiplier(-5);
648        assert!(mid > 1.5 && mid < 3.0);
649
650        // Outside window should be 1.0
651        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        // Exact values
668        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        // Interpolated: midpoint between -10 (1.0) and -5 (2.0) should be ~1.5
673        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        // Before window
687        assert!((model.get_multiplier(-15) - 1.0).abs() < 0.01);
688
689        // At start, beginning ramp-up
690        let at_start = model.get_multiplier(-10);
691        assert!((1.0..2.0).contains(&at_start));
692
693        // After ramp-up, in sustained phase
694        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        // Regular month-end (June 30, 2024)
703        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); // Should have some elevation
706
707        // Quarter-end (March 31, 2024)
708        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); // Quarter-end should be higher
711
712        // Year-end (December 31, 2024)
713        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); // Year-end should be highest
716    }
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        // Mid-month should not be in period-end
730        let mid_month = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
731        assert!(!dynamics.is_in_period_end(mid_month));
732
733        // Last day should be in period-end
734        let last_day = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
735        assert!(dynamics.is_in_period_end(last_day));
736
737        // 5 days before should be in period-end
738        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        // Check month-end was customized
770        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        // Check quarter-end inherited and has additional multiplier
778        assert!((dynamics.quarter_end.additional_multiplier - 1.5).abs() < 0.01);
779    }
780}