rustkernel_treasury/
cashflow.rs

1//! Cash flow forecasting kernel.
2//!
3//! This module provides cash flow forecasting for treasury:
4//! - Multi-horizon cash flow projections
5//! - Certainty-weighted aggregation
6//! - Min/max balance tracking
7
8use crate::types::{CashFlow, CashFlowCategory, CashFlowForecast, DailyForecast};
9use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
10use std::collections::HashMap;
11
12// ============================================================================
13// Cash Flow Forecasting Kernel
14// ============================================================================
15
16/// Cash flow forecasting kernel.
17///
18/// Projects cash flows across multiple time horizons with certainty weighting.
19#[derive(Debug, Clone)]
20pub struct CashFlowForecasting {
21    metadata: KernelMetadata,
22}
23
24impl Default for CashFlowForecasting {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl CashFlowForecasting {
31    /// Create a new cash flow forecasting kernel.
32    #[must_use]
33    pub fn new() -> Self {
34        Self {
35            metadata: KernelMetadata::batch(
36                "treasury/cashflow-forecast",
37                Domain::TreasuryManagement,
38            )
39            .with_description("Multi-horizon cash flow forecasting")
40            .with_throughput(10_000)
41            .with_latency_us(500.0),
42        }
43    }
44
45    /// Generate cash flow forecast.
46    pub fn forecast(cash_flows: &[CashFlow], config: &ForecastConfig) -> CashFlowForecast {
47        let start_date = config.start_date;
48        let end_date = start_date + (config.horizon_days as u64 * 86400);
49
50        // Group cash flows by date
51        let mut by_date: HashMap<u64, Vec<&CashFlow>> = HashMap::new();
52        for cf in cash_flows {
53            if cf.date >= start_date && cf.date < end_date {
54                // Normalize to day boundary
55                let day = (cf.date - start_date) / 86400;
56                let day_start = start_date + day * 86400;
57                by_date.entry(day_start).or_default().push(cf);
58            }
59        }
60
61        // Generate daily forecasts
62        let mut daily_forecasts = Vec::with_capacity(config.horizon_days as usize);
63        let mut cumulative_balance = config.opening_balance;
64        let mut min_balance = cumulative_balance;
65        let mut max_balance = cumulative_balance;
66        let mut total_inflows = 0.0;
67        let mut total_outflows = 0.0;
68
69        for day in 0..config.horizon_days {
70            let day_date = start_date + (day as u64 * 86400);
71            let day_flows = by_date.get(&day_date);
72
73            let (inflows, outflows, uncertainty) = if let Some(flows) = day_flows {
74                Self::aggregate_flows(flows, config)
75            } else {
76                (0.0, 0.0, 0.0)
77            };
78
79            let net = inflows - outflows;
80            cumulative_balance += net;
81
82            min_balance = min_balance.min(cumulative_balance);
83            max_balance = max_balance.max(cumulative_balance);
84            total_inflows += inflows;
85            total_outflows += outflows;
86
87            daily_forecasts.push(DailyForecast {
88                date: day_date,
89                inflows,
90                outflows,
91                net,
92                cumulative_balance,
93                uncertainty,
94            });
95        }
96
97        CashFlowForecast {
98            horizon_days: config.horizon_days,
99            daily_forecasts,
100            total_inflows,
101            total_outflows,
102            net_position: cumulative_balance,
103            min_balance,
104            max_balance,
105        }
106    }
107
108    /// Aggregate flows for a single day with certainty weighting.
109    fn aggregate_flows(flows: &[&CashFlow], config: &ForecastConfig) -> (f64, f64, f64) {
110        let mut inflows = 0.0;
111        let mut outflows = 0.0;
112        let mut total_certainty = 0.0;
113        let mut count = 0;
114
115        for flow in flows {
116            let weighted_amount = if config.use_certainty_weighting {
117                flow.amount * flow.certainty
118            } else {
119                flow.amount
120            };
121
122            if weighted_amount > 0.0 {
123                inflows += weighted_amount;
124            } else {
125                outflows += weighted_amount.abs();
126            }
127
128            total_certainty += flow.certainty;
129            count += 1;
130        }
131
132        let avg_uncertainty = if count > 0 {
133            1.0 - (total_certainty / count as f64)
134        } else {
135            0.0
136        };
137
138        (inflows, outflows, avg_uncertainty)
139    }
140
141    /// Forecast by category.
142    pub fn forecast_by_category(
143        cash_flows: &[CashFlow],
144        config: &ForecastConfig,
145    ) -> HashMap<CashFlowCategory, CashFlowForecast> {
146        let mut by_category: HashMap<CashFlowCategory, Vec<CashFlow>> = HashMap::new();
147
148        for cf in cash_flows {
149            by_category.entry(cf.category).or_default().push(cf.clone());
150        }
151
152        by_category
153            .into_iter()
154            .map(|(category, flows)| {
155                let forecast = Self::forecast(&flows, config);
156                (category, forecast)
157            })
158            .collect()
159    }
160
161    /// Calculate stress scenario forecast.
162    pub fn stress_forecast(
163        cash_flows: &[CashFlow],
164        config: &ForecastConfig,
165        stress: &StressScenario,
166    ) -> CashFlowForecast {
167        // Apply stress factors to cash flows
168        let stressed_flows: Vec<CashFlow> = cash_flows
169            .iter()
170            .map(|cf| {
171                let mut stressed = cf.clone();
172
173                // Apply category-specific stress
174                let factor = stress
175                    .category_factors
176                    .get(&cf.category)
177                    .copied()
178                    .unwrap_or(1.0);
179
180                if cf.amount > 0.0 {
181                    // Reduce inflows
182                    stressed.amount *= factor * stress.inflow_haircut;
183                } else {
184                    // Increase outflows
185                    stressed.amount *= factor * stress.outflow_multiplier;
186                }
187
188                // Reduce certainty under stress
189                stressed.certainty *= stress.certainty_reduction;
190
191                stressed
192            })
193            .collect();
194
195        Self::forecast(&stressed_flows, config)
196    }
197
198    /// Identify funding gaps.
199    pub fn identify_gaps(
200        forecast: &CashFlowForecast,
201        min_balance_threshold: f64,
202    ) -> Vec<FundingGap> {
203        let mut gaps = Vec::new();
204        let mut in_gap = false;
205        let mut gap_start = 0u64;
206        let mut gap_max_shortfall = 0.0;
207
208        for daily in &forecast.daily_forecasts {
209            if daily.cumulative_balance < min_balance_threshold {
210                let shortfall = min_balance_threshold - daily.cumulative_balance;
211
212                if !in_gap {
213                    in_gap = true;
214                    gap_start = daily.date;
215                    gap_max_shortfall = shortfall;
216                } else {
217                    gap_max_shortfall = gap_max_shortfall.max(shortfall);
218                }
219            } else if in_gap {
220                // Gap ended
221                gaps.push(FundingGap {
222                    start_date: gap_start,
223                    end_date: daily.date,
224                    max_shortfall: gap_max_shortfall,
225                    duration_days: ((daily.date - gap_start) / 86400) as u32,
226                });
227                in_gap = false;
228            }
229        }
230
231        // Handle gap extending to end of horizon
232        if in_gap {
233            if let Some(last) = forecast.daily_forecasts.last() {
234                gaps.push(FundingGap {
235                    start_date: gap_start,
236                    end_date: last.date + 86400,
237                    max_shortfall: gap_max_shortfall,
238                    duration_days: ((last.date + 86400 - gap_start) / 86400) as u32,
239                });
240            }
241        }
242
243        gaps
244    }
245}
246
247impl GpuKernel for CashFlowForecasting {
248    fn metadata(&self) -> &KernelMetadata {
249        &self.metadata
250    }
251}
252
253/// Forecast configuration.
254#[derive(Debug, Clone)]
255pub struct ForecastConfig {
256    /// Start date (Unix timestamp).
257    pub start_date: u64,
258    /// Forecast horizon in days.
259    pub horizon_days: u32,
260    /// Opening balance.
261    pub opening_balance: f64,
262    /// Use certainty weighting.
263    pub use_certainty_weighting: bool,
264    /// Base currency.
265    pub base_currency: String,
266}
267
268impl Default for ForecastConfig {
269    fn default() -> Self {
270        Self {
271            start_date: 0,
272            horizon_days: 30,
273            opening_balance: 0.0,
274            use_certainty_weighting: true,
275            base_currency: "USD".to_string(),
276        }
277    }
278}
279
280/// Stress scenario for forecasting.
281#[derive(Debug, Clone)]
282pub struct StressScenario {
283    /// Name of the scenario.
284    pub name: String,
285    /// Haircut on inflows (e.g., 0.8 = 20% reduction).
286    pub inflow_haircut: f64,
287    /// Multiplier on outflows (e.g., 1.2 = 20% increase).
288    pub outflow_multiplier: f64,
289    /// Reduction in certainty (e.g., 0.5 = halve certainty).
290    pub certainty_reduction: f64,
291    /// Category-specific stress factors.
292    pub category_factors: HashMap<CashFlowCategory, f64>,
293}
294
295impl Default for StressScenario {
296    fn default() -> Self {
297        Self {
298            name: "Base Stress".to_string(),
299            inflow_haircut: 0.8,
300            outflow_multiplier: 1.2,
301            certainty_reduction: 0.8,
302            category_factors: HashMap::new(),
303        }
304    }
305}
306
307/// Funding gap identified in forecast.
308#[derive(Debug, Clone)]
309pub struct FundingGap {
310    /// Start date of gap.
311    pub start_date: u64,
312    /// End date of gap.
313    pub end_date: u64,
314    /// Maximum shortfall during gap.
315    pub max_shortfall: f64,
316    /// Duration in days.
317    pub duration_days: u32,
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    fn create_test_flows() -> Vec<CashFlow> {
325        vec![
326            CashFlow {
327                id: 1,
328                date: 86400,     // Day 1
329                amount: 10000.0, // Inflow
330                currency: "USD".to_string(),
331                category: CashFlowCategory::Operating,
332                certainty: 1.0,
333                description: "Sales".to_string(),
334                attributes: HashMap::new(),
335            },
336            CashFlow {
337                id: 2,
338                date: 86400,     // Day 1
339                amount: -5000.0, // Outflow
340                currency: "USD".to_string(),
341                category: CashFlowCategory::Operating,
342                certainty: 1.0,
343                description: "Expenses".to_string(),
344                attributes: HashMap::new(),
345            },
346            CashFlow {
347                id: 3,
348                date: 172800,    // Day 2
349                amount: -8000.0, // Outflow
350                currency: "USD".to_string(),
351                category: CashFlowCategory::DebtService,
352                certainty: 0.9,
353                description: "Loan payment".to_string(),
354                attributes: HashMap::new(),
355            },
356        ]
357    }
358
359    #[test]
360    fn test_cashflow_metadata() {
361        let kernel = CashFlowForecasting::new();
362        assert_eq!(kernel.metadata().id, "treasury/cashflow-forecast");
363        assert_eq!(kernel.metadata().domain, Domain::TreasuryManagement);
364    }
365
366    #[test]
367    fn test_basic_forecast() {
368        let flows = create_test_flows();
369        let config = ForecastConfig {
370            start_date: 0,
371            horizon_days: 5,
372            opening_balance: 10000.0,
373            use_certainty_weighting: false,
374            ..Default::default()
375        };
376
377        let forecast = CashFlowForecasting::forecast(&flows, &config);
378
379        assert_eq!(forecast.horizon_days, 5);
380        assert_eq!(forecast.daily_forecasts.len(), 5);
381        assert_eq!(forecast.total_inflows, 10000.0);
382        assert_eq!(forecast.total_outflows, 13000.0);
383    }
384
385    #[test]
386    fn test_certainty_weighting() {
387        let flows = vec![CashFlow {
388            id: 1,
389            date: 86400,
390            amount: 10000.0,
391            currency: "USD".to_string(),
392            category: CashFlowCategory::Operating,
393            certainty: 0.5, // 50% certainty
394            description: "Expected payment".to_string(),
395            attributes: HashMap::new(),
396        }];
397
398        let config = ForecastConfig {
399            start_date: 0,
400            horizon_days: 3,
401            opening_balance: 0.0,
402            use_certainty_weighting: true,
403            ..Default::default()
404        };
405
406        let forecast = CashFlowForecasting::forecast(&flows, &config);
407
408        // Should be 5000 due to 50% certainty weighting
409        assert!((forecast.total_inflows - 5000.0).abs() < 0.01);
410    }
411
412    #[test]
413    fn test_min_max_balance() {
414        let flows = create_test_flows();
415        let config = ForecastConfig {
416            start_date: 0,
417            horizon_days: 5,
418            opening_balance: 5000.0,
419            use_certainty_weighting: false,
420            ..Default::default()
421        };
422
423        let forecast = CashFlowForecasting::forecast(&flows, &config);
424
425        // Day 0: 5000
426        // Day 1: 5000 + 10000 - 5000 = 10000 (max)
427        // Day 2: 10000 - 8000 = 2000 (min)
428        assert_eq!(forecast.min_balance, 2000.0);
429        assert_eq!(forecast.max_balance, 10000.0);
430    }
431
432    #[test]
433    fn test_forecast_by_category() {
434        let flows = create_test_flows();
435        let config = ForecastConfig {
436            start_date: 0,
437            horizon_days: 5,
438            opening_balance: 0.0,
439            use_certainty_weighting: false,
440            ..Default::default()
441        };
442
443        let by_cat = CashFlowForecasting::forecast_by_category(&flows, &config);
444
445        assert!(by_cat.contains_key(&CashFlowCategory::Operating));
446        assert!(by_cat.contains_key(&CashFlowCategory::DebtService));
447
448        let operating = by_cat.get(&CashFlowCategory::Operating).unwrap();
449        assert_eq!(operating.total_inflows, 10000.0);
450        assert_eq!(operating.total_outflows, 5000.0);
451    }
452
453    #[test]
454    fn test_stress_forecast() {
455        let flows = create_test_flows();
456        let config = ForecastConfig {
457            start_date: 0,
458            horizon_days: 5,
459            opening_balance: 10000.0,
460            use_certainty_weighting: false,
461            ..Default::default()
462        };
463
464        let stress = StressScenario {
465            name: "Severe".to_string(),
466            inflow_haircut: 0.5,     // 50% reduction
467            outflow_multiplier: 1.5, // 50% increase
468            certainty_reduction: 0.5,
469            category_factors: HashMap::new(),
470        };
471
472        let normal = CashFlowForecasting::forecast(&flows, &config);
473        let stressed = CashFlowForecasting::stress_forecast(&flows, &config, &stress);
474
475        // Stressed inflows should be lower
476        assert!(stressed.total_inflows < normal.total_inflows);
477        // Stressed outflows should be higher
478        assert!(stressed.total_outflows > normal.total_outflows);
479    }
480
481    #[test]
482    fn test_identify_gaps() {
483        let flows = vec![
484            CashFlow {
485                id: 1,
486                date: 86400,
487                amount: -20000.0,
488                currency: "USD".to_string(),
489                category: CashFlowCategory::Operating,
490                certainty: 1.0,
491                description: "Large payment".to_string(),
492                attributes: HashMap::new(),
493            },
494            CashFlow {
495                id: 2,
496                date: 259200, // Day 3
497                amount: 25000.0,
498                currency: "USD".to_string(),
499                category: CashFlowCategory::Operating,
500                certainty: 1.0,
501                description: "Funding received".to_string(),
502                attributes: HashMap::new(),
503            },
504        ];
505
506        let config = ForecastConfig {
507            start_date: 0,
508            horizon_days: 5,
509            opening_balance: 10000.0,
510            use_certainty_weighting: false,
511            ..Default::default()
512        };
513
514        let forecast = CashFlowForecasting::forecast(&flows, &config);
515        let gaps = CashFlowForecasting::identify_gaps(&forecast, 5000.0);
516
517        // Should have a gap from day 1-3
518        assert_eq!(gaps.len(), 1);
519        assert_eq!(gaps[0].max_shortfall, 15000.0); // 5000 - (-10000) = 15000 shortfall
520    }
521
522    #[test]
523    fn test_empty_flows() {
524        let flows: Vec<CashFlow> = vec![];
525        let config = ForecastConfig {
526            start_date: 0,
527            horizon_days: 5,
528            opening_balance: 10000.0,
529            use_certainty_weighting: false,
530            ..Default::default()
531        };
532
533        let forecast = CashFlowForecasting::forecast(&flows, &config);
534
535        assert_eq!(forecast.total_inflows, 0.0);
536        assert_eq!(forecast.total_outflows, 0.0);
537        assert_eq!(forecast.net_position, 10000.0);
538    }
539}