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                            .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                    // Ramp-up phase
190                    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                    // Sustained high phase
194                    *peak_multiplier
195                } else {
196                    // Gradual decrease (optional wind-down)
197                    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    /// Create a flat multiplier model.
206    pub fn flat(multiplier: f64) -> Self {
207        Self::FlatMultiplier { multiplier }
208    }
209
210    /// Create an exponential acceleration model with typical accounting close parameters.
211    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    /// Create a custom daily profile.
221    pub fn custom_profile(profile: HashMap<i32, f64>) -> Self {
222        Self::DailyProfile {
223            profile,
224            interpolation: InterpolationMethod::Linear,
225        }
226    }
227}
228
229/// Configuration for a specific period end type (month, quarter, year).
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct PeriodEndConfig {
232    /// Whether this period-end effect is enabled
233    #[serde(default = "default_true")]
234    pub enabled: bool,
235    /// The model to use for this period end
236    #[serde(default)]
237    pub model: PeriodEndModel,
238    /// Additional multiplier applied on top of the model
239    #[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    /// Create a disabled config.
263    pub fn disabled() -> Self {
264        Self {
265            enabled: false,
266            model: PeriodEndModel::default(),
267            additional_multiplier: 1.0,
268        }
269    }
270
271    /// Get the multiplier for days to end.
272    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/// Dynamics for all period-end types.
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct PeriodEndDynamics {
283    /// Month-end configuration
284    #[serde(default)]
285    pub month_end: PeriodEndConfig,
286    /// Quarter-end configuration
287    #[serde(default)]
288    pub quarter_end: PeriodEndConfig,
289    /// Year-end configuration
290    #[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    /// Create with specific configurations for each period type.
333    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    /// Create dynamics with all period ends disabled.
346    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    /// Create with flat multipliers (legacy behavior).
355    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    /// Get the multiplier for a specific date.
376    ///
377    /// Determines which period-end type applies and calculates the appropriate multiplier.
378    /// Priority: year-end > quarter-end > month-end
379    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        // Determine which period-end type this is
383        let is_year_end = period_end.month() == 12;
384        let is_quarter_end = matches!(period_end.month(), 3 | 6 | 9 | 12);
385
386        // Use the most specific applicable config
387        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    /// Get the multiplier for a date, automatically determining the period end.
399    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    /// Get the last day of the month for a date.
405    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    /// Check if a date is within a period-end window.
417    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        // Check if within any active window
422        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/// Configuration struct for YAML/JSON deserialization.
434#[derive(Debug, Clone, Serialize, Deserialize, Default)]
435pub struct PeriodEndSchemaConfig {
436    /// Overall model type
437    #[serde(default)]
438    pub model: Option<String>,
439
440    /// Month-end configuration
441    #[serde(default)]
442    pub month_end: Option<PeriodEndModelConfig>,
443
444    /// Quarter-end configuration
445    #[serde(default)]
446    pub quarter_end: Option<PeriodEndModelConfig>,
447
448    /// Year-end configuration
449    #[serde(default)]
450    pub year_end: Option<PeriodEndModelConfig>,
451}
452
453/// Schema config for a period-end model.
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct PeriodEndModelConfig {
456    /// Inherit from another config (e.g., "month_end")
457    #[serde(default)]
458    pub inherit_from: Option<String>,
459
460    /// Additional multiplier on top of inherited or base model
461    #[serde(default)]
462    pub additional_multiplier: Option<f64>,
463
464    /// Days before period end to start acceleration
465    #[serde(default)]
466    pub start_day: Option<i32>,
467
468    /// Base multiplier
469    #[serde(default)]
470    pub base_multiplier: Option<f64>,
471
472    /// Peak multiplier
473    #[serde(default)]
474    pub peak_multiplier: Option<f64>,
475
476    /// Decay rate for exponential model
477    #[serde(default)]
478    pub decay_rate: Option<f64>,
479
480    /// Sustained high days for crunch model
481    #[serde(default)]
482    pub sustained_high_days: Option<i32>,
483}
484
485impl PeriodEndSchemaConfig {
486    /// Convert to PeriodEndDynamics.
487    pub fn to_dynamics(&self) -> PeriodEndDynamics {
488        let mut dynamics = PeriodEndDynamics::default();
489
490        // Apply model type if specified
491        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                    // Use defaults (already exponential)
498                }
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        // Apply specific overrides
518        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        // Start from inherited config if available
547        if let Some(inherited) = inherit {
548            target.model = inherited.model.clone();
549            target.additional_multiplier = inherited.additional_multiplier;
550        }
551
552        // Apply additional multiplier
553        if let Some(mult) = config.additional_multiplier {
554            target.additional_multiplier = mult;
555        }
556
557        // Update model parameters based on what's provided
558        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        // Within period-end window
613        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        // Outside window
618        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        // At start (day -10), should be near base
632        let at_start = model.get_multiplier(-10);
633        assert!((1.0..1.3).contains(&at_start));
634
635        // At peak (day 0), should be at peak
636        let at_peak = model.get_multiplier(0);
637        assert!((at_peak - 3.5).abs() < 0.01);
638
639        // Midway should be between
640        let mid = model.get_multiplier(-5);
641        assert!(mid > 1.5 && mid < 3.0);
642
643        // Outside window should be 1.0
644        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        // Exact values
661        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        // Interpolated: midpoint between -10 (1.0) and -5 (2.0) should be ~1.5
666        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        // Before window
680        assert!((model.get_multiplier(-15) - 1.0).abs() < 0.01);
681
682        // At start, beginning ramp-up
683        let at_start = model.get_multiplier(-10);
684        assert!((1.0..2.0).contains(&at_start));
685
686        // After ramp-up, in sustained phase
687        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        // Regular month-end (June 30, 2024)
696        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); // Should have some elevation
699
700        // Quarter-end (March 31, 2024)
701        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); // Quarter-end should be higher
704
705        // Year-end (December 31, 2024)
706        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); // Year-end should be highest
709    }
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        // Mid-month should not be in period-end
723        let mid_month = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
724        assert!(!dynamics.is_in_period_end(mid_month));
725
726        // Last day should be in period-end
727        let last_day = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
728        assert!(dynamics.is_in_period_end(last_day));
729
730        // 5 days before should be in period-end
731        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        // Check month-end was customized
763        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        // Check quarter-end inherited and has additional multiplier
771        assert!((dynamics.quarter_end.additional_multiplier - 1.5).abs() < 0.01);
772    }
773}