rustkernel_treasury/
collateral.rs

1//! Collateral optimization kernel.
2//!
3//! This module provides collateral optimization for treasury:
4//! - Optimal allocation of collateral to requirements
5//! - Cheapest-to-deliver optimization
6//! - Eligibility and haircut handling
7
8use crate::types::{
9    AssetType, CollateralAllocation, CollateralAsset, CollateralOptimizationResult,
10    CollateralRequirement,
11};
12use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
13use std::collections::HashMap;
14
15// ============================================================================
16// Collateral Optimization Kernel
17// ============================================================================
18
19/// Collateral optimization kernel.
20///
21/// Optimizes allocation of collateral assets to requirements.
22#[derive(Debug, Clone)]
23pub struct CollateralOptimization {
24    metadata: KernelMetadata,
25}
26
27impl Default for CollateralOptimization {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl CollateralOptimization {
34    /// Create a new collateral optimization kernel.
35    #[must_use]
36    pub fn new() -> Self {
37        Self {
38            metadata: KernelMetadata::batch("treasury/collateral-opt", Domain::TreasuryManagement)
39                .with_description("Collateral allocation optimization")
40                .with_throughput(5_000)
41                .with_latency_us(1000.0),
42        }
43    }
44
45    /// Optimize collateral allocation.
46    pub fn optimize(
47        assets: &[CollateralAsset],
48        requirements: &[CollateralRequirement],
49        config: &CollateralConfig,
50    ) -> CollateralOptimizationResult {
51        // Sort requirements by priority (highest first)
52        let mut sorted_reqs: Vec<_> = requirements.iter().collect();
53        sorted_reqs.sort_by_key(|r| std::cmp::Reverse(r.priority));
54
55        // Track available collateral
56        let mut available: HashMap<String, f64> = assets
57            .iter()
58            .filter(|a| !a.is_pledged || config.allow_rehypothecation)
59            .map(|a| (a.id.clone(), a.eligible_value))
60            .collect();
61
62        let mut allocations = Vec::new();
63        let mut total_allocated = 0.0;
64        let mut shortfall = 0.0;
65
66        for req in sorted_reqs {
67            let mut remaining = req.required_amount;
68
69            // Find eligible assets for this requirement
70            let mut eligible_assets: Vec<_> = assets
71                .iter()
72                .filter(|a| {
73                    req.eligible_types.contains(&a.asset_type)
74                        && a.currency == req.currency
75                        && available.get(&a.id).copied().unwrap_or(0.0) > 0.0
76                })
77                .collect();
78
79            // Sort by optimization strategy
80            match config.strategy {
81                OptimizationStrategy::CheapestToDeliver => {
82                    // Prefer assets with lower quality (highest haircut)
83                    eligible_assets.sort_by(|a, b| {
84                        b.haircut
85                            .partial_cmp(&a.haircut)
86                            .unwrap_or(std::cmp::Ordering::Equal)
87                    });
88                }
89                OptimizationStrategy::HighestQuality => {
90                    // Prefer assets with highest quality (lowest haircut)
91                    eligible_assets.sort_by(|a, b| {
92                        a.haircut
93                            .partial_cmp(&b.haircut)
94                            .unwrap_or(std::cmp::Ordering::Equal)
95                    });
96                }
97                OptimizationStrategy::LargestFirst => {
98                    // Prefer largest eligible values
99                    eligible_assets.sort_by(|a, b| {
100                        b.eligible_value
101                            .partial_cmp(&a.eligible_value)
102                            .unwrap_or(std::cmp::Ordering::Equal)
103                    });
104                }
105            }
106
107            // Allocate collateral
108            for asset in eligible_assets {
109                if remaining <= 0.0 {
110                    break;
111                }
112
113                let available_amount = available.get(&asset.id).copied().unwrap_or(0.0);
114                if available_amount <= 0.0 {
115                    continue;
116                }
117
118                let allocate_value = available_amount.min(remaining);
119                let allocate_quantity =
120                    allocate_value / (1.0 - asset.haircut) / (asset.market_value / asset.quantity);
121
122                allocations.push(CollateralAllocation {
123                    asset_id: asset.id.clone(),
124                    counterparty_id: req.counterparty_id.clone(),
125                    quantity: allocate_quantity,
126                    value: allocate_value,
127                });
128
129                *available.get_mut(&asset.id).unwrap() -= allocate_value;
130                remaining -= allocate_value;
131                total_allocated += allocate_value;
132            }
133
134            if remaining > 0.0 {
135                shortfall += remaining;
136            }
137        }
138
139        // Calculate excess
140        let total_required: f64 = requirements.iter().map(|r| r.required_amount).sum();
141        let excess = if total_allocated > total_required {
142            total_allocated - total_required
143        } else {
144            0.0
145        };
146
147        // Calculate optimization score
148        let score = Self::calculate_score(&allocations, assets, requirements, config);
149
150        CollateralOptimizationResult {
151            allocations,
152            total_allocated,
153            excess,
154            shortfall,
155            score,
156        }
157    }
158
159    /// Calculate optimization score.
160    fn calculate_score(
161        allocations: &[CollateralAllocation],
162        assets: &[CollateralAsset],
163        requirements: &[CollateralRequirement],
164        config: &CollateralConfig,
165    ) -> f64 {
166        if requirements.is_empty() {
167            return 1.0;
168        }
169
170        let total_required: f64 = requirements.iter().map(|r| r.required_amount).sum();
171        if total_required == 0.0 {
172            return 1.0;
173        }
174
175        // Base score: coverage ratio
176        let total_allocated: f64 = allocations.iter().map(|a| a.value).sum();
177        let coverage = (total_allocated / total_required).min(1.0);
178
179        // Quality factor: prefer using lower-quality assets if CTD strategy
180        let quality_factor = if config.strategy == OptimizationStrategy::CheapestToDeliver {
181            let avg_haircut: f64 = allocations
182                .iter()
183                .filter_map(|alloc| {
184                    assets
185                        .iter()
186                        .find(|a| a.id == alloc.asset_id)
187                        .map(|a| a.haircut)
188                })
189                .sum::<f64>()
190                / allocations.len().max(1) as f64;
191            avg_haircut // Higher haircut = better for CTD
192        } else {
193            1.0 - allocations
194                .iter()
195                .filter_map(|alloc| {
196                    assets
197                        .iter()
198                        .find(|a| a.id == alloc.asset_id)
199                        .map(|a| a.haircut)
200                })
201                .sum::<f64>()
202                / allocations.len().max(1) as f64
203        };
204
205        // Concentration factor: penalize over-concentration
206        let mut by_counterparty: HashMap<String, f64> = HashMap::new();
207        for alloc in allocations {
208            *by_counterparty
209                .entry(alloc.counterparty_id.clone())
210                .or_default() += alloc.value;
211        }
212        let concentration = if by_counterparty.len() > 1 {
213            1.0 - Self::herfindahl_index(&by_counterparty.values().copied().collect::<Vec<_>>())
214        } else {
215            0.5
216        };
217
218        // Weighted score
219        coverage * 0.6 + quality_factor * 0.25 + concentration * 0.15
220    }
221
222    /// Calculate Herfindahl index (concentration measure).
223    fn herfindahl_index(values: &[f64]) -> f64 {
224        let total: f64 = values.iter().sum();
225        if total == 0.0 {
226            return 0.0;
227        }
228        values.iter().map(|v| (v / total).powi(2)).sum()
229    }
230
231    /// Calculate total eligible collateral by asset type.
232    pub fn eligible_by_type(assets: &[CollateralAsset]) -> HashMap<AssetType, f64> {
233        let mut by_type: HashMap<AssetType, f64> = HashMap::new();
234        for asset in assets {
235            if !asset.is_pledged {
236                *by_type.entry(asset.asset_type).or_default() += asset.eligible_value;
237            }
238        }
239        by_type
240    }
241
242    /// Calculate utilization metrics.
243    pub fn utilization_metrics(
244        assets: &[CollateralAsset],
245        allocations: &[CollateralAllocation],
246    ) -> UtilizationMetrics {
247        let total_available: f64 = assets.iter().map(|a| a.eligible_value).sum();
248        let total_allocated: f64 = allocations.iter().map(|a| a.value).sum();
249
250        let pledged_count = assets.iter().filter(|a| a.is_pledged).count();
251        let pledged_value: f64 = assets
252            .iter()
253            .filter(|a| a.is_pledged)
254            .map(|a| a.market_value)
255            .sum();
256
257        UtilizationMetrics {
258            total_available,
259            total_allocated,
260            utilization_rate: if total_available > 0.0 {
261                total_allocated / total_available
262            } else {
263                0.0
264            },
265            pledged_count: pledged_count as u64,
266            pledged_value,
267            free_collateral: total_available - total_allocated,
268        }
269    }
270}
271
272impl GpuKernel for CollateralOptimization {
273    fn metadata(&self) -> &KernelMetadata {
274        &self.metadata
275    }
276}
277
278/// Collateral optimization configuration.
279#[derive(Debug, Clone)]
280pub struct CollateralConfig {
281    /// Optimization strategy.
282    pub strategy: OptimizationStrategy,
283    /// Allow rehypothecation of pledged assets.
284    pub allow_rehypothecation: bool,
285    /// Minimum allocation amount.
286    pub min_allocation: f64,
287    /// Maximum concentration per counterparty (0-1).
288    pub max_concentration: f64,
289}
290
291impl Default for CollateralConfig {
292    fn default() -> Self {
293        Self {
294            strategy: OptimizationStrategy::CheapestToDeliver,
295            allow_rehypothecation: false,
296            min_allocation: 0.0,
297            max_concentration: 1.0,
298        }
299    }
300}
301
302/// Optimization strategy.
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum OptimizationStrategy {
305    /// Use cheapest (highest haircut) assets first.
306    CheapestToDeliver,
307    /// Use highest quality (lowest haircut) assets first.
308    HighestQuality,
309    /// Use largest assets first.
310    LargestFirst,
311}
312
313/// Collateral utilization metrics.
314#[derive(Debug, Clone)]
315pub struct UtilizationMetrics {
316    /// Total available eligible collateral.
317    pub total_available: f64,
318    /// Total allocated collateral.
319    pub total_allocated: f64,
320    /// Utilization rate (0-1).
321    pub utilization_rate: f64,
322    /// Number of pledged assets.
323    pub pledged_count: u64,
324    /// Total pledged value.
325    pub pledged_value: f64,
326    /// Free collateral.
327    pub free_collateral: f64,
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    fn create_test_assets() -> Vec<CollateralAsset> {
335        vec![
336            CollateralAsset {
337                id: "CASH_001".to_string(),
338                asset_type: AssetType::Cash,
339                quantity: 1_000_000.0,
340                market_value: 1_000_000.0,
341                haircut: 0.0,
342                eligible_value: 1_000_000.0,
343                currency: "USD".to_string(),
344                is_pledged: false,
345                pledged_to: None,
346            },
347            CollateralAsset {
348                id: "GOV_001".to_string(),
349                asset_type: AssetType::GovBond,
350                quantity: 100.0,
351                market_value: 500_000.0,
352                haircut: 0.02,
353                eligible_value: 490_000.0,
354                currency: "USD".to_string(),
355                is_pledged: false,
356                pledged_to: None,
357            },
358            CollateralAsset {
359                id: "CORP_001".to_string(),
360                asset_type: AssetType::CorpBond,
361                quantity: 200.0,
362                market_value: 400_000.0,
363                haircut: 0.10,
364                eligible_value: 360_000.0,
365                currency: "USD".to_string(),
366                is_pledged: false,
367                pledged_to: None,
368            },
369        ]
370    }
371
372    fn create_test_requirements() -> Vec<CollateralRequirement> {
373        vec![
374            CollateralRequirement {
375                counterparty_id: "CP_A".to_string(),
376                required_amount: 300_000.0,
377                currency: "USD".to_string(),
378                eligible_types: vec![AssetType::Cash, AssetType::GovBond],
379                priority: 1,
380            },
381            CollateralRequirement {
382                counterparty_id: "CP_B".to_string(),
383                required_amount: 200_000.0,
384                currency: "USD".to_string(),
385                eligible_types: vec![AssetType::Cash, AssetType::GovBond, AssetType::CorpBond],
386                priority: 2,
387            },
388        ]
389    }
390
391    #[test]
392    fn test_collateral_metadata() {
393        let kernel = CollateralOptimization::new();
394        assert_eq!(kernel.metadata().id, "treasury/collateral-opt");
395        assert_eq!(kernel.metadata().domain, Domain::TreasuryManagement);
396    }
397
398    #[test]
399    fn test_basic_optimization() {
400        let assets = create_test_assets();
401        let requirements = create_test_requirements();
402        let config = CollateralConfig::default();
403
404        let result = CollateralOptimization::optimize(&assets, &requirements, &config);
405
406        assert!(result.shortfall < 0.01);
407        assert!(result.total_allocated >= 500_000.0);
408        assert!(!result.allocations.is_empty());
409    }
410
411    #[test]
412    fn test_cheapest_to_deliver() {
413        let assets = create_test_assets();
414        let requirements = vec![CollateralRequirement {
415            counterparty_id: "CP_A".to_string(),
416            required_amount: 300_000.0,
417            currency: "USD".to_string(),
418            eligible_types: vec![AssetType::Cash, AssetType::GovBond, AssetType::CorpBond],
419            priority: 1,
420        }];
421
422        let config = CollateralConfig {
423            strategy: OptimizationStrategy::CheapestToDeliver,
424            ..Default::default()
425        };
426
427        let result = CollateralOptimization::optimize(&assets, &requirements, &config);
428
429        // Should prefer corporate bonds (highest haircut) first
430        let first_alloc = &result.allocations[0];
431        assert_eq!(first_alloc.asset_id, "CORP_001");
432    }
433
434    #[test]
435    fn test_highest_quality() {
436        let assets = create_test_assets();
437        let requirements = vec![CollateralRequirement {
438            counterparty_id: "CP_A".to_string(),
439            required_amount: 300_000.0,
440            currency: "USD".to_string(),
441            eligible_types: vec![AssetType::Cash, AssetType::GovBond, AssetType::CorpBond],
442            priority: 1,
443        }];
444
445        let config = CollateralConfig {
446            strategy: OptimizationStrategy::HighestQuality,
447            ..Default::default()
448        };
449
450        let result = CollateralOptimization::optimize(&assets, &requirements, &config);
451
452        // Should prefer cash (lowest haircut) first
453        let first_alloc = &result.allocations[0];
454        assert_eq!(first_alloc.asset_id, "CASH_001");
455    }
456
457    #[test]
458    fn test_shortfall() {
459        let assets = create_test_assets();
460        let requirements = vec![CollateralRequirement {
461            counterparty_id: "CP_A".to_string(),
462            required_amount: 5_000_000.0, // More than available
463            currency: "USD".to_string(),
464            eligible_types: vec![AssetType::Cash, AssetType::GovBond, AssetType::CorpBond],
465            priority: 1,
466        }];
467
468        let config = CollateralConfig::default();
469        let result = CollateralOptimization::optimize(&assets, &requirements, &config);
470
471        assert!(result.shortfall > 0.0);
472    }
473
474    #[test]
475    fn test_currency_filtering() {
476        let assets = create_test_assets();
477        let requirements = vec![CollateralRequirement {
478            counterparty_id: "CP_A".to_string(),
479            required_amount: 300_000.0,
480            currency: "EUR".to_string(), // Different currency
481            eligible_types: vec![AssetType::Cash, AssetType::GovBond],
482            priority: 1,
483        }];
484
485        let config = CollateralConfig::default();
486        let result = CollateralOptimization::optimize(&assets, &requirements, &config);
487
488        // No assets match EUR, so shortfall should equal requirement
489        assert!((result.shortfall - 300_000.0).abs() < 0.01);
490    }
491
492    #[test]
493    fn test_pledged_assets() {
494        let mut assets = create_test_assets();
495        assets[0].is_pledged = true; // Cash is pledged
496
497        let requirements = vec![CollateralRequirement {
498            counterparty_id: "CP_A".to_string(),
499            required_amount: 1_500_000.0,
500            currency: "USD".to_string(),
501            eligible_types: vec![AssetType::Cash, AssetType::GovBond, AssetType::CorpBond],
502            priority: 1,
503        }];
504
505        let config = CollateralConfig {
506            allow_rehypothecation: false,
507            ..Default::default()
508        };
509
510        let result = CollateralOptimization::optimize(&assets, &requirements, &config);
511
512        // Should not use pledged cash
513        assert!(!result.allocations.iter().any(|a| a.asset_id == "CASH_001"));
514    }
515
516    #[test]
517    fn test_eligible_by_type() {
518        let assets = create_test_assets();
519        let by_type = CollateralOptimization::eligible_by_type(&assets);
520
521        assert_eq!(by_type.get(&AssetType::Cash), Some(&1_000_000.0));
522        assert_eq!(by_type.get(&AssetType::GovBond), Some(&490_000.0));
523        assert_eq!(by_type.get(&AssetType::CorpBond), Some(&360_000.0));
524    }
525
526    #[test]
527    fn test_utilization_metrics() {
528        let assets = create_test_assets();
529        let allocations = vec![CollateralAllocation {
530            asset_id: "CASH_001".to_string(),
531            counterparty_id: "CP_A".to_string(),
532            quantity: 500_000.0,
533            value: 500_000.0,
534        }];
535
536        let metrics = CollateralOptimization::utilization_metrics(&assets, &allocations);
537
538        assert_eq!(metrics.total_available, 1_850_000.0);
539        assert_eq!(metrics.total_allocated, 500_000.0);
540        assert!((metrics.utilization_rate - 500_000.0 / 1_850_000.0).abs() < 0.001);
541    }
542
543    #[test]
544    fn test_priority_ordering() {
545        let assets = vec![CollateralAsset {
546            id: "CASH_001".to_string(),
547            asset_type: AssetType::Cash,
548            quantity: 500_000.0,
549            market_value: 500_000.0,
550            haircut: 0.0,
551            eligible_value: 500_000.0,
552            currency: "USD".to_string(),
553            is_pledged: false,
554            pledged_to: None,
555        }];
556
557        let requirements = vec![
558            CollateralRequirement {
559                counterparty_id: "LOW".to_string(),
560                required_amount: 300_000.0,
561                currency: "USD".to_string(),
562                eligible_types: vec![AssetType::Cash],
563                priority: 1, // Lower priority
564            },
565            CollateralRequirement {
566                counterparty_id: "HIGH".to_string(),
567                required_amount: 300_000.0,
568                currency: "USD".to_string(),
569                eligible_types: vec![AssetType::Cash],
570                priority: 10, // Higher priority
571            },
572        ];
573
574        let config = CollateralConfig::default();
575        let result = CollateralOptimization::optimize(&assets, &requirements, &config);
576
577        // Higher priority should be fully allocated
578        let high_alloc: f64 = result
579            .allocations
580            .iter()
581            .filter(|a| a.counterparty_id == "HIGH")
582            .map(|a| a.value)
583            .sum();
584        assert!((high_alloc - 300_000.0).abs() < 0.01);
585    }
586}