Skip to main content

quant_metrics/
composition_mixed.rs

1//! Mixed-frequency portfolio composition with rebalancing.
2//!
3//! Extends the basic equal-frequency `compose()` with support for:
4//! - Multi-frequency legs (forward-fill alignment)
5//! - Periodic rebalancing (daily, weekly, monthly, quarterly)
6//! - Dynamic allocation methods (equal-weight, inverse-vol, HRP)
7//! - Time-varying weight schedules
8
9use 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/// Rebalancing mode for portfolio composition.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum RebalanceMode {
26    /// Buy-and-hold: weights drift with performance.
27    None,
28    /// Reset weights to target every period.
29    Daily,
30    /// Reset weights to target at each week boundary (Monday).
31    Weekly,
32    /// Reset weights to target at each month boundary.
33    Monthly,
34    /// Reset weights to target at each quarter boundary.
35    Quarterly,
36}
37
38/// A rebalance event recording when and how much turnover occurred.
39#[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/// An entry in a time-varying weight schedule.
48#[derive(Debug, Clone)]
49pub struct WeightScheduleEntry {
50    pub date: DateTime<Utc>,
51    pub weights: Vec<Decimal>,
52}
53
54/// Allocation method for portfolio composition.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum AllocationMethod {
57    /// Use weights as provided (user-specified).
58    Custom,
59    /// Equal weight: 1/N for each leg.
60    EqualWeight,
61    /// Inverse volatility: allocate inversely proportional to trailing vol.
62    InverseVol {
63        /// Lookback window in periods for volatility calculation.
64        lookback: usize,
65    },
66    /// Hierarchical Risk Parity: correlation-aware diversification weights.
67    Hrp,
68}
69
70/// Options for mixed-frequency portfolio composition.
71#[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/// Result of mixed-frequency composition with rebalancing.
80#[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    /// Initial weights as computed by the allocation method (before any drift).
87    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
94/// Resolve current target weights from a schedule, falling back to defaults.
95fn 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
109/// Forward-fill returns: use actual return if available, else carry last known.
110/// Before a leg's first observation, return is zero (don't fabricate data).
111fn 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/// Mutable state for rebalance period tracking.
131#[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
139/// Returns true (and updates `slot`) when `current` differs from the stored value.
140/// Suppresses the first observation so the initial period is never a rebalance.
141fn 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
149/// Check whether a rebalance should fire at this timestamp.
150pub(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
169/// Execute a rebalance: reset dollar allocations to target weights.
170/// Returns turnover (sum of absolute weight changes).
171fn 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
186/// Compute inverse-volatility weights from return series.
187///
188/// Returns `(weights, warnings)`. If a leg has zero vol or insufficient data,
189/// warnings are emitted and fallback behavior is applied.
190pub(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    // Check for zero-vol legs
228    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    // Inverse vol: w_i = (1/σ_i) / Σ(1/σ_j)
236    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
252/// Process one timeline period: forward-fill, apply returns, check margin call.
253fn 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
280/// Build the master timeline from all legs' timestamps.
281fn 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
291/// Validate inputs for `compose_mixed` and return early on bad data.
292fn 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
306/// Resolve allocation-method weights and collect any warnings.
307fn 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
334/// Compute final effective weights from dollar allocations.
335fn 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
354/// Compose multiple return series of potentially different frequencies.
355///
356/// Lower-frequency legs are forward-filled: the last known return value is
357/// carried forward until the next observation. This avoids the zero-fill bias
358/// that understates volatility and overstates Sharpe.
359///
360/// `series` and `weights` must have the same length.
361pub 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}