1use crate::types::{CashFlow, CashFlowCategory, CashFlowForecast, DailyForecast};
9use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
10use std::collections::HashMap;
11
12#[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 #[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 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 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 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 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 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 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 pub fn stress_forecast(
163 cash_flows: &[CashFlow],
164 config: &ForecastConfig,
165 stress: &StressScenario,
166 ) -> CashFlowForecast {
167 let stressed_flows: Vec<CashFlow> = cash_flows
169 .iter()
170 .map(|cf| {
171 let mut stressed = cf.clone();
172
173 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 stressed.amount *= factor * stress.inflow_haircut;
183 } else {
184 stressed.amount *= factor * stress.outflow_multiplier;
186 }
187
188 stressed.certainty *= stress.certainty_reduction;
190
191 stressed
192 })
193 .collect();
194
195 Self::forecast(&stressed_flows, config)
196 }
197
198 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 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 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#[derive(Debug, Clone)]
255pub struct ForecastConfig {
256 pub start_date: u64,
258 pub horizon_days: u32,
260 pub opening_balance: f64,
262 pub use_certainty_weighting: bool,
264 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#[derive(Debug, Clone)]
282pub struct StressScenario {
283 pub name: String,
285 pub inflow_haircut: f64,
287 pub outflow_multiplier: f64,
289 pub certainty_reduction: f64,
291 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#[derive(Debug, Clone)]
309pub struct FundingGap {
310 pub start_date: u64,
312 pub end_date: u64,
314 pub max_shortfall: f64,
316 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, amount: 10000.0, 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, amount: -5000.0, 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, amount: -8000.0, 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, 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 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 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, outflow_multiplier: 1.5, 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 assert!(stressed.total_inflows < normal.total_inflows);
477 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, 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 assert_eq!(gaps.len(), 1);
519 assert_eq!(gaps[0].max_shortfall, 15000.0); }
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}