Skip to main content

rust_portfolio_opt/
prelude.rs

1//! Internal helpers shared across modules. Re-exported from the crate root
2//! for downstream convenience.
3
4use std::collections::BTreeMap;
5
6use nalgebra::{DMatrix, DVector};
7
8use crate::{PortfolioError, Result};
9
10/// A length-N vector of floats paired with N ticker labels. Mirrors a
11/// pandas `Series` indexed by ticker name. Returned by every estimator
12/// that has a `_labeled` companion (e.g.
13/// [`crate::expected_returns::mean_historical_return_labeled`]) so callers
14/// can keep ticker order alongside the numerical values.
15#[derive(Debug, Clone)]
16pub struct LabeledVector {
17    pub values: DVector<f64>,
18    pub tickers: Vec<String>,
19}
20
21impl LabeledVector {
22    pub fn new(values: DVector<f64>, tickers: Vec<String>) -> Result<Self> {
23        if values.len() != tickers.len() {
24            return Err(PortfolioError::DimensionMismatch(format!(
25                "LabeledVector: values len {} ≠ tickers len {}",
26                values.len(),
27                tickers.len()
28            )));
29        }
30        Ok(Self { values, tickers })
31    }
32
33    /// Look up a single value by ticker name.
34    pub fn get(&self, ticker: &str) -> Option<f64> {
35        self.tickers
36            .iter()
37            .position(|t| t == ticker)
38            .map(|i| self.values[i])
39    }
40
41    /// Convert to a `BTreeMap<String, f64>` ordered alphabetically by
42    /// ticker, matching PyPortfolioOpt's `OrderedDict` style output.
43    pub fn to_map(&self) -> BTreeMap<String, f64> {
44        self.tickers
45            .iter()
46            .zip(self.values.iter())
47            .map(|(t, v)| (t.clone(), *v))
48            .collect()
49    }
50}
51
52/// A square N×N matrix paired with N ticker labels (used for both row
53/// and column indexing — the same labelling pandas applies to a
54/// covariance/correlation `DataFrame`).
55#[derive(Debug, Clone)]
56pub struct LabeledMatrix {
57    pub values: DMatrix<f64>,
58    pub tickers: Vec<String>,
59}
60
61impl LabeledMatrix {
62    pub fn new(values: DMatrix<f64>, tickers: Vec<String>) -> Result<Self> {
63        let (r, c) = values.shape();
64        if r != c {
65            return Err(PortfolioError::DimensionMismatch(format!(
66                "LabeledMatrix: expected square matrix, got {r}x{c}"
67            )));
68        }
69        if r != tickers.len() {
70            return Err(PortfolioError::DimensionMismatch(format!(
71                "LabeledMatrix: matrix is {r}x{r} but tickers len is {}",
72                tickers.len()
73            )));
74        }
75        Ok(Self { values, tickers })
76    }
77
78    /// Look up `[row_ticker, col_ticker]`.
79    pub fn get(&self, row_ticker: &str, col_ticker: &str) -> Option<f64> {
80        let r = self.tickers.iter().position(|t| t == row_ticker)?;
81        let c = self.tickers.iter().position(|t| t == col_ticker)?;
82        Some(self.values[(r, c)])
83    }
84}
85
86/// Convert a `(values, tickers)` pair into a ticker-keyed `BTreeMap`,
87/// matching PyPortfolioOpt's habit of returning weights as an
88/// `OrderedDict[str, float]`. Errors if the lengths disagree.
89pub fn to_weight_map(values: &DVector<f64>, tickers: &[String]) -> Result<BTreeMap<String, f64>> {
90    if values.len() != tickers.len() {
91        return Err(PortfolioError::DimensionMismatch(format!(
92            "to_weight_map: values len {} ≠ tickers len {}",
93            values.len(),
94            tickers.len()
95        )));
96    }
97    Ok(tickers
98        .iter()
99        .zip(values.iter())
100        .map(|(t, v)| (t.clone(), *v))
101        .collect())
102}
103
104/// Validate that two ticker label vectors agree in length and order.
105pub(crate) fn assert_tickers_match(a: &[String], b: &[String], label: &str) -> Result<()> {
106    if a.len() != b.len() {
107        return Err(PortfolioError::DimensionMismatch(format!(
108            "{label}: ticker counts disagree ({} vs {})",
109            a.len(),
110            b.len()
111        )));
112    }
113    for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() {
114        if x != y {
115            return Err(PortfolioError::InvalidArgument(format!(
116                "{label}: ticker mismatch at position {i}: '{x}' vs '{y}'"
117            )));
118        }
119    }
120    Ok(())
121}
122
123/// Compute simple period-over-period returns from a `T x N` price matrix.
124///
125/// Rows are time, columns are assets. The result has shape `(T-1) x N`.
126/// Any non-finite entry (NaN or +/- inf) is preserved — callers handle
127/// missing data upstream as PyPortfolioOpt does.
128pub fn returns_from_prices(prices: &DMatrix<f64>) -> Result<DMatrix<f64>> {
129    let (rows, cols) = prices.shape();
130    if rows < 2 {
131        return Err(PortfolioError::InvalidArgument(
132            "need at least two rows of prices to compute returns".into(),
133        ));
134    }
135    let mut out = DMatrix::<f64>::zeros(rows - 1, cols);
136    for j in 0..cols {
137        for i in 1..rows {
138            let prev = prices[(i - 1, j)];
139            let curr = prices[(i, j)];
140            out[(i - 1, j)] = curr / prev - 1.0;
141        }
142    }
143    Ok(out)
144}
145
146/// Log returns: `ln(p_t / p_{t-1})`.
147pub fn log_returns_from_prices(prices: &DMatrix<f64>) -> Result<DMatrix<f64>> {
148    let (rows, cols) = prices.shape();
149    if rows < 2 {
150        return Err(PortfolioError::InvalidArgument(
151            "need at least two rows of prices to compute log returns".into(),
152        ));
153    }
154    let mut out = DMatrix::<f64>::zeros(rows - 1, cols);
155    for j in 0..cols {
156        for i in 1..rows {
157            out[(i - 1, j)] = (prices[(i, j)] / prices[(i - 1, j)]).ln();
158        }
159    }
160    Ok(out)
161}
162
163/// Column-wise mean of an `T x N` matrix → length-N vector.
164pub fn column_means(m: &DMatrix<f64>) -> DVector<f64> {
165    let (rows, cols) = m.shape();
166    let mut out = DVector::<f64>::zeros(cols);
167    if rows == 0 {
168        return out;
169    }
170    for j in 0..cols {
171        let mut acc = 0.0;
172        for i in 0..rows {
173            acc += m[(i, j)];
174        }
175        out[j] = acc / rows as f64;
176    }
177    out
178}
179
180/// Sample covariance with Bessel's correction (divisor `T-1`).
181///
182/// Operates on a `T x N` returns matrix and returns an `N x N` covariance.
183pub fn sample_covariance(returns: &DMatrix<f64>) -> Result<DMatrix<f64>> {
184    let (rows, cols) = returns.shape();
185    if rows < 2 {
186        return Err(PortfolioError::InvalidArgument(
187            "need at least two observations for sample covariance".into(),
188        ));
189    }
190    let means = column_means(returns);
191    let mut centered = returns.clone();
192    for j in 0..cols {
193        let mu = means[j];
194        for i in 0..rows {
195            centered[(i, j)] -= mu;
196        }
197    }
198    let cov = centered.transpose() * &centered / (rows as f64 - 1.0);
199    Ok(cov)
200}
201
202/// Validate that a square covariance matrix has the expected dimension.
203pub(crate) fn assert_square(m: &DMatrix<f64>, label: &str) -> Result<usize> {
204    let (r, c) = m.shape();
205    if r != c {
206        return Err(PortfolioError::DimensionMismatch(format!(
207            "{label}: expected square matrix, got {r}x{c}"
208        )));
209    }
210    Ok(r)
211}
212
213/// Round entries of `weights` whose absolute value is below `cutoff` to
214/// zero, renormalise the remainder so they sum to the original total,
215/// and optionally round to `rounding` decimal places. Mirrors
216/// PyPortfolioOpt's `base_optimizer.BaseOptimizer.clean_weights`.
217pub fn clean_weights(weights: &DVector<f64>, cutoff: f64, rounding: Option<u32>) -> DVector<f64> {
218    let mut cleaned = weights.clone();
219    for v in cleaned.iter_mut() {
220        if v.abs() < cutoff {
221            *v = 0.0;
222        }
223    }
224    let total: f64 = cleaned.iter().sum();
225    if total.abs() > 1e-12 {
226        for v in cleaned.iter_mut() {
227            *v /= total;
228        }
229    }
230    if let Some(places) = rounding {
231        let factor = 10f64.powi(places as i32);
232        for v in cleaned.iter_mut() {
233            *v = (*v * factor).round() / factor;
234        }
235    }
236    cleaned
237}
238
239/// Symmetrise a matrix in-place (`(A + A^T) / 2`). Useful after numerical
240/// operations that produce minute asymmetry.
241pub fn symmetrise(m: &mut DMatrix<f64>) {
242    let n = m.nrows();
243    debug_assert_eq!(m.nrows(), m.ncols());
244    for i in 0..n {
245        for j in (i + 1)..n {
246            let avg = 0.5 * (m[(i, j)] + m[(j, i)]);
247            m[(i, j)] = avg;
248            m[(j, i)] = avg;
249        }
250    }
251}