rust_portfolio_opt/
prelude.rs1use std::collections::BTreeMap;
5
6use nalgebra::{DMatrix, DVector};
7
8use crate::{PortfolioError, Result};
9
10#[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 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 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#[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 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
86pub 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
104pub(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
123pub 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
146pub 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
163pub 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
180pub 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() * ¢ered / (rows as f64 - 1.0);
199 Ok(cov)
200}
201
202pub(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
213pub 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
239pub 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}