Skip to main content

finance_portfolio/
lib.rs

1#[derive(Clone, Debug, Eq, PartialEq)]
2pub struct Example {
3    pub stuff: String,
4}
5
6impl Example {
7    pub fn new(value: String) -> Self {
8        Example { stuff: value }
9    }
10}
11
12pub type Matrix = Vec<Vec<f64>>;
13pub type RiskContributionParts = (Vec<f64>, Vec<f64>, Vec<f64>);
14
15fn validate_matrix(matrix: &Matrix) -> Result<(usize, usize), String> {
16    if matrix.is_empty() {
17        return Err("matrix must not be empty".to_string());
18    }
19    let cols = matrix[0].len();
20    if cols == 0 {
21        return Err("matrix rows must not be empty".to_string());
22    }
23    if matrix.iter().any(|row| row.len() != cols) {
24        return Err("matrix rows must have equal length".to_string());
25    }
26    Ok((matrix.len(), cols))
27}
28
29fn validate_square(matrix: &Matrix) -> Result<usize, String> {
30    let (rows, cols) = validate_matrix(matrix)?;
31    if rows != cols {
32        return Err("matrix must be square".to_string());
33    }
34    Ok(rows)
35}
36
37fn normalize(mut weights: Vec<f64>, gross: bool) -> Result<Vec<f64>, String> {
38    let denominator = if gross {
39        weights.iter().map(|value| value.abs()).sum::<f64>()
40    } else {
41        weights.iter().sum::<f64>()
42    };
43    if denominator == 0.0 {
44        return Err("weights cannot be normalized when the denominator is zero".to_string());
45    }
46    for weight in &mut weights {
47        *weight /= denominator;
48    }
49    Ok(weights)
50}
51
52pub fn equal_weights(count: usize) -> Result<Vec<f64>, String> {
53    if count == 0 {
54        return Err("assets must not be empty".to_string());
55    }
56    Ok(vec![1.0 / count as f64; count])
57}
58
59pub fn rank_weights(values: Vec<f64>, ascending: bool) -> Result<Vec<f64>, String> {
60    if values.is_empty() {
61        return Err("signals must not be empty".to_string());
62    }
63    let mut ranked: Vec<(usize, f64)> = values.into_iter().enumerate().collect();
64    ranked.sort_by(|left, right| {
65        let ordering = left
66            .1
67            .partial_cmp(&right.1)
68            .unwrap_or(std::cmp::Ordering::Equal);
69        if ascending {
70            ordering
71        } else {
72            ordering.reverse()
73        }
74    });
75    let mut raw = vec![0.0; ranked.len()];
76    for (rank, (idx, _value)) in ranked.into_iter().enumerate() {
77        raw[idx] = rank as f64 + 1.0;
78    }
79    normalize(raw, false)
80}
81
82pub fn signal_proportional_weights(values: Vec<f64>) -> Result<Vec<f64>, String> {
83    if values.is_empty() {
84        return Err("signals must not be empty".to_string());
85    }
86    normalize(values, true)
87}
88
89pub fn target_volatility_weights(volatility: Vec<f64>) -> Result<Vec<f64>, String> {
90    if volatility.is_empty() {
91        return Err("volatility must not be empty".to_string());
92    }
93    if volatility.iter().any(|value| *value <= 0.0) {
94        return Err("all volatility values must be positive".to_string());
95    }
96    normalize(
97        volatility.into_iter().map(|value| 1.0 / value).collect(),
98        false,
99    )
100}
101
102fn mat_vec(matrix: &Matrix, vector: &[f64]) -> Vec<f64> {
103    matrix
104        .iter()
105        .map(|row| {
106            row.iter()
107                .zip(vector)
108                .map(|(left, right)| left * right)
109                .sum()
110        })
111        .collect()
112}
113
114fn dot(left: &[f64], right: &[f64]) -> f64 {
115    left.iter().zip(right).map(|(l, r)| l * r).sum()
116}
117
118fn invert_matrix(matrix: &Matrix) -> Option<Matrix> {
119    let n = matrix.len();
120    let mut augmented = vec![vec![0.0; n * 2]; n];
121    for row in 0..n {
122        for col in 0..n {
123            augmented[row][col] = matrix[row][col];
124        }
125        augmented[row][n + row] = 1.0;
126    }
127    for col in 0..n {
128        let mut pivot = col;
129        for row in (col + 1)..n {
130            if augmented[row][col].abs() > augmented[pivot][col].abs() {
131                pivot = row;
132            }
133        }
134        if augmented[pivot][col].abs() < 1e-14 {
135            return None;
136        }
137        augmented.swap(col, pivot);
138        let divisor = augmented[col][col];
139        for value in &mut augmented[col] {
140            *value /= divisor;
141        }
142        for row in 0..n {
143            if row == col {
144                continue;
145            }
146            let factor = augmented[row][col];
147            let pivot_row = augmented[col].clone();
148            for (target, source) in augmented[row].iter_mut().zip(pivot_row) {
149                *target -= factor * source;
150            }
151        }
152    }
153    Some(augmented.into_iter().map(|row| row[n..].to_vec()).collect())
154}
155
156fn invert_with_ridge(covariance: &Matrix) -> Result<Matrix, String> {
157    if let Some(inverse) = invert_matrix(covariance) {
158        return Ok(inverse);
159    }
160    let mut regularized = covariance.clone();
161    for (idx, row) in regularized.iter_mut().enumerate() {
162        row[idx] += 1e-12;
163    }
164    invert_matrix(&regularized).ok_or_else(|| "covariance matrix is singular".to_string())
165}
166
167pub fn minimum_variance_weights(covariance: Matrix) -> Result<Vec<f64>, String> {
168    let size = validate_square(&covariance)?;
169    let inverse = invert_with_ridge(&covariance)?;
170    let ones = vec![1.0; size];
171    let inv_ones = mat_vec(&inverse, &ones);
172    normalize(inv_ones, false)
173}
174
175pub fn mean_variance_weights(
176    expected_returns: Vec<f64>,
177    covariance: Matrix,
178    risk_aversion: f64,
179) -> Result<Vec<f64>, String> {
180    let size = validate_square(&covariance)?;
181    if expected_returns.len() != size {
182        return Err("expected_returns length must match covariance dimensions".to_string());
183    }
184    let inverse = invert_with_ridge(&covariance)?;
185    let scale = risk_aversion.max(1e-12);
186    let raw: Vec<f64> = mat_vec(&inverse, &expected_returns)
187        .into_iter()
188        .map(|value| value / scale)
189        .collect();
190    if raw.iter().sum::<f64>() == 0.0 {
191        return equal_weights(size);
192    }
193    normalize(raw, false)
194}
195
196pub fn risk_parity_weights(
197    covariance: Matrix,
198    max_iter: usize,
199    tolerance: f64,
200) -> Result<Vec<f64>, String> {
201    let size = validate_square(&covariance)?;
202    let mut weights: Vec<f64> = (0..size)
203        .map(|idx| 1.0 / covariance[idx][idx].max(1e-18).sqrt())
204        .collect();
205    weights = normalize(weights, false)?;
206    let target = 1.0 / size as f64;
207    for _ in 0..max_iter {
208        let cov_weights = mat_vec(&covariance, &weights);
209        let variance = dot(&weights, &cov_weights);
210        let vol = variance.max(0.0).sqrt();
211        if vol == 0.0 {
212            break;
213        }
214        let mut max_error: f64 = 0.0;
215        for idx in 0..size {
216            let pct = weights[idx] * cov_weights[idx] / variance;
217            max_error = max_error.max((pct - target).abs());
218            if pct > 0.0 {
219                weights[idx] *= (target / pct).sqrt();
220            }
221            weights[idx] = weights[idx].max(1e-12);
222        }
223        weights = normalize(weights, false)?;
224        if max_error < tolerance {
225            break;
226        }
227    }
228    Ok(weights)
229}
230
231pub fn hierarchical_risk_parity_weights(covariance: Matrix) -> Result<Vec<f64>, String> {
232    let size = validate_square(&covariance)?;
233    let raw: Vec<f64> = (0..size)
234        .map(|idx| 1.0 / covariance[idx][idx].max(1e-18))
235        .collect();
236    normalize(raw, false)
237}
238
239pub fn portfolio_variance(weights: Vec<f64>, covariance: Matrix) -> Result<f64, String> {
240    let size = validate_square(&covariance)?;
241    if weights.len() != size {
242        return Err("covariance dimensions must match weights".to_string());
243    }
244    Ok(dot(&weights, &mat_vec(&covariance, &weights)))
245}
246
247pub fn portfolio_volatility(weights: Vec<f64>, covariance: Matrix) -> Result<f64, String> {
248    Ok(portfolio_variance(weights, covariance)?.max(0.0).sqrt())
249}
250
251pub fn risk_contribution(
252    weights: Vec<f64>,
253    covariance: Matrix,
254) -> Result<RiskContributionParts, String> {
255    let size = validate_square(&covariance)?;
256    if weights.len() != size {
257        return Err("covariance dimensions must match weights".to_string());
258    }
259    let cov_weights = mat_vec(&covariance, &weights);
260    let vol = dot(&weights, &cov_weights).max(0.0).sqrt();
261    let marginal: Vec<f64> = if vol == 0.0 {
262        vec![0.0; size]
263    } else {
264        cov_weights.into_iter().map(|value| value / vol).collect()
265    };
266    let component: Vec<f64> = weights
267        .iter()
268        .zip(&marginal)
269        .map(|(weight, marg)| weight * marg)
270        .collect();
271    let percentage: Vec<f64> = if vol == 0.0 {
272        vec![0.0; size]
273    } else {
274        component.iter().map(|value| value / vol).collect()
275    };
276    Ok((marginal, component, percentage))
277}
278
279pub fn tracking_error(active_weights: Vec<f64>, covariance: Matrix) -> Result<f64, String> {
280    portfolio_volatility(active_weights, covariance)
281}
282
283pub fn active_share(active_weights: Vec<f64>) -> f64 {
284    0.5 * active_weights.iter().map(|value| value.abs()).sum::<f64>()
285}
286
287pub fn brinson_attribution(
288    portfolio_weights: Vec<f64>,
289    benchmark_weights: Vec<f64>,
290    portfolio_returns: Vec<f64>,
291    benchmark_returns: Vec<f64>,
292) -> Result<(f64, f64, f64, f64), String> {
293    let size = portfolio_weights.len();
294    if benchmark_weights.len() != size
295        || portfolio_returns.len() != size
296        || benchmark_returns.len() != size
297    {
298        return Err("all input vectors must have the same length".to_string());
299    }
300    let benchmark_total = benchmark_weights
301        .iter()
302        .zip(&benchmark_returns)
303        .map(|(weight, ret)| weight * ret)
304        .sum::<f64>();
305    let mut allocation = 0.0;
306    let mut selection = 0.0;
307    let mut interaction = 0.0;
308    for idx in 0..size {
309        allocation += (portfolio_weights[idx] - benchmark_weights[idx])
310            * (benchmark_returns[idx] - benchmark_total);
311        selection += benchmark_weights[idx] * (portfolio_returns[idx] - benchmark_returns[idx]);
312        interaction += (portfolio_weights[idx] - benchmark_weights[idx])
313            * (portfolio_returns[idx] - benchmark_returns[idx]);
314    }
315    Ok((
316        allocation,
317        selection,
318        interaction,
319        allocation + selection + interaction,
320    ))
321}
322
323pub fn factor_return_decomposition(
324    exposures: Vec<f64>,
325    factor_returns: Vec<f64>,
326) -> Result<(Vec<f64>, f64), String> {
327    if exposures.len() != factor_returns.len() {
328        return Err("exposures and factor_returns must have the same length".to_string());
329    }
330    let components: Vec<f64> = exposures
331        .iter()
332        .zip(&factor_returns)
333        .map(|(exposure, ret)| exposure * ret)
334        .collect();
335    let total = components.iter().sum();
336    Ok((components, total))
337}
338
339/**********************************/
340#[cfg(test)]
341mod example_tests {
342    use super::*;
343
344    #[test]
345    fn test_new() {
346        let e = Example::new(String::from("test"));
347        assert_eq!(e.stuff, String::from("test"));
348    }
349
350    #[test]
351    fn test_clone_and_eq() {
352        let e = Example::new(String::from("test"));
353        assert_eq!(e, e.clone());
354    }
355
356    #[test]
357    fn test_debug() {
358        let e = Example::new(String::from("test"));
359        assert_eq!(format!("{e:?}"), "Example { stuff: \"test\" }");
360    }
361}