1use std::collections::BTreeSet;
10
11use chrono::{DateTime, Datelike, Utc};
12use rust_decimal::Decimal;
13
14#[path = "composition_hrp.rs"]
15mod composition_hrp;
16
17use crate::MetricsError;
18
19use super::{PortfolioEquityPoint, ReturnLookup, ReturnSeries};
20
21pub(crate) use composition_hrp::compute_hrp_weights;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum RebalanceMode {
26 None,
28 Daily,
30 Weekly,
32 Monthly,
34 Quarterly,
36}
37
38#[derive(Debug, Clone)]
40pub struct RebalanceEvent {
41 pub timestamp: DateTime<Utc>,
42 pub turnover: Decimal,
43 pub weights_before: Vec<Decimal>,
44 pub weights_after: Vec<Decimal>,
45}
46
47#[derive(Debug, Clone)]
49pub struct WeightScheduleEntry {
50 pub date: DateTime<Utc>,
51 pub weights: Vec<Decimal>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum AllocationMethod {
57 Custom,
59 EqualWeight,
61 InverseVol {
63 lookback: usize,
65 },
66 Hrp,
68}
69
70#[derive(Debug, Clone)]
72pub struct ComposeOptions {
73 pub capital: Decimal,
74 pub rebalance: RebalanceMode,
75 pub weight_schedule: Vec<WeightScheduleEntry>,
76 pub allocation: AllocationMethod,
77}
78
79#[derive(Debug, Clone)]
81pub struct MixedCompositionResult {
82 pub equity_curve: Vec<PortfolioEquityPoint>,
83 pub leg_equity_curves: Vec<Vec<PortfolioEquityPoint>>,
84 pub periods_per_year: u32,
85 pub leg_labels: Vec<String>,
86 pub effective_weights: Vec<(String, Decimal)>,
88 pub final_weights: Vec<(String, Decimal)>,
89 pub rebalance_events: Vec<RebalanceEvent>,
90 pub margin_call: bool,
91 pub warnings: Vec<String>,
92}
93
94fn resolve_target_weights(
96 ts: DateTime<Utc>,
97 schedule: &[WeightScheduleEntry],
98 default: &[Decimal],
99) -> Vec<Decimal> {
100 let mut current = default.to_vec();
101 for entry in schedule {
102 if ts >= entry.date {
103 current.clone_from(&entry.weights);
104 }
105 }
106 current
107}
108
109fn forward_fill_returns(
112 ts: DateTime<Utc>,
113 n_legs: usize,
114 leg_lookups: &[ReturnLookup],
115 leg_first_ts: &[Option<DateTime<Utc>>],
116 last_known_return: &mut [Decimal],
117) {
118 for i in 0..n_legs {
119 if let Some(r) = leg_lookups[i].get(&ts) {
120 last_known_return[i] = *r;
121 }
122 if let Some(first) = leg_first_ts[i] {
123 if ts < first {
124 last_known_return[i] = Decimal::ZERO;
125 }
126 }
127 }
128}
129
130#[derive(Default)]
132pub(crate) struct RebalanceState {
133 last_day: Option<u32>,
134 last_week: Option<u32>,
135 last_month: Option<u32>,
136 last_quarter: Option<u32>,
137}
138
139fn period_changed(slot: &mut Option<u32>, current: u32, is_first: bool) -> bool {
142 if *slot == Some(current) {
143 return false;
144 }
145 *slot = Some(current);
146 !is_first
147}
148
149pub(crate) fn should_rebalance(
151 mode: &RebalanceMode,
152 ts: DateTime<Utc>,
153 is_first: bool,
154 state: &mut RebalanceState,
155) -> bool {
156 match mode {
157 RebalanceMode::None => false,
158 RebalanceMode::Daily => period_changed(&mut state.last_day, ts.ordinal(), is_first),
159 RebalanceMode::Weekly => {
160 period_changed(&mut state.last_week, ts.iso_week().week(), is_first)
161 }
162 RebalanceMode::Monthly => period_changed(&mut state.last_month, ts.month(), is_first),
163 RebalanceMode::Quarterly => {
164 period_changed(&mut state.last_quarter, (ts.month() - 1) / 3, is_first)
165 }
166 }
167}
168
169fn execute_rebalance(
172 dollar_alloc: &mut [Decimal],
173 total: Decimal,
174 target_weights: &[Decimal],
175) -> (Decimal, Vec<Decimal>) {
176 let weights_before: Vec<Decimal> = dollar_alloc.iter().map(|d| *d / total).collect();
177 let mut turnover = Decimal::ZERO;
178 for i in 0..dollar_alloc.len() {
179 let old_w = dollar_alloc[i] / total;
180 turnover += (target_weights[i] - old_w).abs();
181 dollar_alloc[i] = target_weights[i] * total;
182 }
183 (turnover, weights_before)
184}
185
186pub(crate) fn compute_inverse_vol_weights(
191 series: &[ReturnSeries],
192 lookback: usize,
193) -> (Vec<Decimal>, Vec<String>) {
194 let mut warnings = Vec::new();
195 let n = series.len();
196 let mut vols: Vec<f64> = Vec::with_capacity(n);
197
198 for s in series {
199 let returns: Vec<f64> = s
200 .points
201 .iter()
202 .rev()
203 .take(lookback)
204 .map(|p| p.value.try_into().unwrap_or(0.0))
205 .collect();
206
207 if returns.len() < lookback {
208 warnings.push(format!(
209 "leg '{}': only {} periods available for {}-period lookback",
210 s.label,
211 returns.len(),
212 lookback
213 ));
214 }
215
216 if returns.len() < 2 {
217 vols.push(0.0);
218 continue;
219 }
220
221 let mean: f64 = returns.iter().sum::<f64>() / returns.len() as f64;
222 let variance: f64 =
223 returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (returns.len() - 1) as f64;
224 vols.push(variance.sqrt());
225 }
226
227 let has_zero = vols.iter().any(|v| *v < 1e-12);
229 if has_zero {
230 warnings.push("zero volatility detected in one or more legs — using equal weights".into());
231 let equal_w = Decimal::ONE / Decimal::from(n as u32);
232 return (vec![equal_w; n], warnings);
233 }
234
235 let inv_vols: Vec<f64> = vols.iter().map(|v| 1.0 / v).collect();
237 let sum_inv: f64 = inv_vols.iter().sum();
238
239 let weights: Vec<Decimal> = inv_vols
240 .iter()
241 .map(|iv| Decimal::try_from(iv / sum_inv).unwrap_or(Decimal::ZERO))
242 .collect();
243
244 (weights, warnings)
245}
246
247enum StepResult {
248 Continue(Decimal),
249 MarginCall,
250}
251
252fn step_one_period(
254 ts: DateTime<Utc>,
255 n_legs: usize,
256 leg_lookups: &[ReturnLookup],
257 leg_first_ts: &[Option<DateTime<Utc>>],
258 last_known_return: &mut [Decimal],
259 dollar_alloc: &mut [Decimal],
260) -> StepResult {
261 forward_fill_returns(ts, n_legs, leg_lookups, leg_first_ts, last_known_return);
262
263 let total_before: Decimal = dollar_alloc.iter().sum();
264 if total_before <= Decimal::ZERO {
265 return StepResult::MarginCall;
266 }
267
268 for i in 0..n_legs {
269 dollar_alloc[i] *= Decimal::ONE + last_known_return[i];
270 }
271
272 let total_after: Decimal = dollar_alloc.iter().sum();
273 if total_after <= Decimal::ZERO {
274 return StepResult::MarginCall;
275 }
276
277 StepResult::Continue(total_after)
278}
279
280fn build_timeline(series: &[ReturnSeries]) -> Vec<DateTime<Utc>> {
282 let mut all_timestamps = BTreeSet::new();
283 for s in series {
284 for p in &s.points {
285 all_timestamps.insert(p.timestamp);
286 }
287 }
288 all_timestamps.into_iter().collect()
289}
290
291fn validate_mixed_inputs(series: &[ReturnSeries], weights: &[Decimal]) -> Result<(), MetricsError> {
293 if series.is_empty() {
294 return Err(MetricsError::InvalidParameter(
295 "at least one leg required".into(),
296 ));
297 }
298 if series.len() != weights.len() {
299 return Err(MetricsError::InvalidParameter(
300 "series and weights must have same length".into(),
301 ));
302 }
303 Ok(())
304}
305
306fn resolve_allocation_weights(
308 series: &[ReturnSeries],
309 weights: &[Decimal],
310 allocation: &AllocationMethod,
311) -> (Vec<Decimal>, Vec<String>) {
312 let n_legs = series.len();
313 let mut warnings = Vec::new();
314 let effective = match allocation {
315 AllocationMethod::Custom => weights.to_vec(),
316 AllocationMethod::EqualWeight => {
317 let w = Decimal::ONE / Decimal::from(n_legs as u32);
318 vec![w; n_legs]
319 }
320 AllocationMethod::InverseVol { lookback } => {
321 let (inv_weights, inv_warnings) = compute_inverse_vol_weights(series, *lookback);
322 warnings.extend(inv_warnings);
323 inv_weights
324 }
325 AllocationMethod::Hrp => {
326 let (hrp_weights, hrp_warnings) = compute_hrp_weights(series);
327 warnings.extend(hrp_warnings);
328 hrp_weights
329 }
330 };
331 (effective, warnings)
332}
333
334fn compute_final_weights(
336 series: &[ReturnSeries],
337 dollar_alloc: &[Decimal],
338) -> Vec<(String, Decimal)> {
339 let total_final: Decimal = dollar_alloc.iter().sum();
340 if total_final > Decimal::ZERO {
341 series
342 .iter()
343 .enumerate()
344 .map(|(i, s)| (s.label.clone(), dollar_alloc[i] / total_final))
345 .collect()
346 } else {
347 series
348 .iter()
349 .map(|s| (s.label.clone(), Decimal::ZERO))
350 .collect()
351 }
352}
353
354pub fn compose_mixed(
362 series: &[ReturnSeries],
363 weights: &[Decimal],
364 options: &ComposeOptions,
365) -> Result<MixedCompositionResult, MetricsError> {
366 validate_mixed_inputs(series, weights)?;
367
368 let n_legs = series.len();
369 let timeline = build_timeline(series);
370
371 if timeline.is_empty() {
372 return Err(MetricsError::InsufficientData {
373 required: 1,
374 actual: 0,
375 });
376 }
377
378 let max_freq = series
379 .iter()
380 .map(|s| s.frequency.periods_per_year())
381 .max()
382 .unwrap_or(365);
383
384 let leg_lookups: Vec<ReturnLookup> = series
385 .iter()
386 .map(|s| s.points.iter().map(|p| (p.timestamp, p.value)).collect())
387 .collect();
388 let leg_first_ts: Vec<Option<DateTime<Utc>>> = series
389 .iter()
390 .map(|s| s.points.first().map(|p| p.timestamp))
391 .collect();
392
393 let (effective_weights, mut warnings) =
394 resolve_allocation_weights(series, weights, &options.allocation);
395
396 let initial_effective_weights: Vec<(String, Decimal)> = series
397 .iter()
398 .enumerate()
399 .map(|(i, s)| (s.label.clone(), effective_weights[i]))
400 .collect();
401
402 let mut last_known_return: Vec<Decimal> = vec![Decimal::ZERO; n_legs];
403 let mut dollar_alloc: Vec<Decimal> = effective_weights
404 .iter()
405 .map(|w| *w * options.capital)
406 .collect();
407
408 let synthetic_t0 = timeline[0] - chrono::Duration::seconds(1);
409 let mut equity_curve = vec![PortfolioEquityPoint {
410 timestamp: synthetic_t0,
411 value: options.capital,
412 }];
413 let mut leg_equity_curves: Vec<Vec<PortfolioEquityPoint>> = (0..n_legs)
414 .map(|i| {
415 vec![PortfolioEquityPoint {
416 timestamp: synthetic_t0,
417 value: dollar_alloc[i],
418 }]
419 })
420 .collect();
421
422 let mut rebalance_events: Vec<RebalanceEvent> = Vec::new();
423 let mut margin_call = false;
424 let mut rebalance_state = RebalanceState::default();
425
426 for (step_idx, &ts) in timeline.iter().enumerate() {
427 let total_after = match step_one_period(
428 ts,
429 n_legs,
430 &leg_lookups,
431 &leg_first_ts,
432 &mut last_known_return,
433 &mut dollar_alloc,
434 ) {
435 StepResult::MarginCall => {
436 margin_call = true;
437 equity_curve.push(PortfolioEquityPoint {
438 timestamp: ts,
439 value: Decimal::ZERO,
440 });
441 break;
442 }
443 StepResult::Continue(total) => total,
444 };
445
446 if should_rebalance(&options.rebalance, ts, step_idx == 0, &mut rebalance_state) {
447 let target = resolve_target_weights(ts, &options.weight_schedule, &effective_weights);
448 let (turnover, weights_before) =
449 execute_rebalance(&mut dollar_alloc, total_after, &target);
450 rebalance_events.push(RebalanceEvent {
451 timestamp: ts,
452 turnover,
453 weights_before,
454 weights_after: target,
455 });
456 }
457
458 equity_curve.push(PortfolioEquityPoint {
459 timestamp: ts,
460 value: total_after,
461 });
462 for i in 0..n_legs {
463 leg_equity_curves[i].push(PortfolioEquityPoint {
464 timestamp: ts,
465 value: dollar_alloc[i],
466 });
467 }
468 }
469
470 let final_weights = compute_final_weights(series, &dollar_alloc);
471 let leg_labels = series.iter().map(|s| s.label.clone()).collect();
472
473 let weight_sum: Decimal = effective_weights.iter().map(|w| w.abs()).sum();
474 if weight_sum > Decimal::ONE {
475 warnings.push(format!(
476 "leverage detected: absolute weight sum is {} (>1.0)",
477 weight_sum
478 ));
479 }
480
481 Ok(MixedCompositionResult {
482 equity_curve,
483 leg_equity_curves,
484 periods_per_year: max_freq,
485 leg_labels,
486 effective_weights: initial_effective_weights,
487 final_weights,
488 rebalance_events,
489 margin_call,
490 warnings,
491 })
492}