Skip to main content

fin_primitives/portfolio/
optimization.rs

1//! Mean-variance portfolio optimization.
2//!
3//! Implements gradient-based mean-variance optimization supporting:
4//! - Maximum Sharpe ratio via gradient ascent
5//! - Minimum variance subject to a target return
6//! - Efficient frontier generation by sweeping target returns
7
8/// A single investable asset with expected return and current weight.
9#[derive(Debug, Clone)]
10pub struct Asset {
11    /// Asset name or ticker.
12    pub name: String,
13    /// Annualized expected return (e.g. 0.08 = 8%).
14    pub expected_return: f64,
15    /// Current portfolio weight (0.0 – 1.0).
16    pub weight: f64,
17}
18
19/// Portfolio optimization constraint.
20#[derive(Debug, Clone)]
21pub enum OptimizationConstraint {
22    /// Weights must sum to one.
23    SumToOne,
24    /// All weights must be non-negative (long-only).
25    LongOnly,
26    /// No single weight may exceed this value.
27    MaxWeight(f64),
28    /// Every weight must be at least this value.
29    MinWeight(f64),
30    /// Maximum concentration: single-asset weight ceiling.
31    MaxConcentration(f64),
32}
33
34/// A single point on the efficient frontier.
35#[derive(Debug, Clone)]
36pub struct EfficientFrontierPoint {
37    /// Expected portfolio return.
38    pub expected_return: f64,
39    /// Portfolio volatility (annualized standard deviation).
40    pub volatility: f64,
41    /// Sharpe ratio at this point.
42    pub sharpe: f64,
43    /// Asset weights at this frontier point.
44    pub weights: Vec<f64>,
45}
46
47/// Result of a portfolio optimization run.
48#[derive(Debug, Clone)]
49pub struct OptimizationResult {
50    /// Optimal asset weights.
51    pub weights: Vec<f64>,
52    /// Expected portfolio return.
53    pub expected_return: f64,
54    /// Portfolio volatility.
55    pub volatility: f64,
56    /// Sharpe ratio of the optimized portfolio.
57    pub sharpe: f64,
58    /// Number of gradient iterations taken.
59    pub iterations: usize,
60}
61
62/// Mean-variance portfolio optimizer using gradient descent.
63pub struct MeanVarianceOptimizer;
64
65impl MeanVarianceOptimizer {
66    /// Compute expected portfolio return: dot product of weights and returns.
67    pub fn portfolio_return(weights: &[f64], returns: &[f64]) -> f64 {
68        weights.iter().zip(returns.iter()).map(|(w, r)| w * r).sum()
69    }
70
71    /// Compute portfolio variance: w^T Σ w.
72    pub fn portfolio_variance(weights: &[f64], cov: &[Vec<f64>]) -> f64 {
73        let n = weights.len();
74        let mut var = 0.0_f64;
75        for i in 0..n {
76            for j in 0..n {
77                var += weights[i] * weights[j] * cov[i][j];
78            }
79        }
80        var
81    }
82
83    /// Compute portfolio volatility: sqrt(w^T Σ w).
84    pub fn portfolio_volatility(weights: &[f64], cov: &[Vec<f64>]) -> f64 {
85        Self::portfolio_variance(weights, cov).max(0.0).sqrt()
86    }
87
88    /// Perform one gradient step minimizing variance subject to a target return.
89    ///
90    /// Uses the gradient of `Var(w) - lambda * (Return(w) - target)` and projects
91    /// onto the unit simplex afterwards.
92    pub fn gradient_step(
93        weights: &mut Vec<f64>,
94        returns: &[f64],
95        cov: &[Vec<f64>],
96        target_return: f64,
97        lr: f64,
98    ) {
99        let n = weights.len();
100        // gradient of variance w.r.t. weights: 2 * Σ w
101        let mut grad_var = vec![0.0_f64; n];
102        for i in 0..n {
103            for j in 0..n {
104                grad_var[i] += 2.0 * cov[i][j] * weights[j];
105            }
106        }
107        // Lagrange multiplier for return constraint
108        let actual_return = Self::portfolio_return(weights, returns);
109        let lambda = 2.0 * (actual_return - target_return);
110
111        // Combined gradient: variance gradient - lambda * return gradient
112        for i in 0..n {
113            weights[i] -= lr * (grad_var[i] - lambda * returns[i]);
114        }
115    }
116
117    /// Apply constraints to a weight vector (projection onto constraint set).
118    pub fn apply_constraints(weights: &mut Vec<f64>, constraints: &[OptimizationConstraint]) {
119        let n = weights.len();
120        for c in constraints {
121            match c {
122                OptimizationConstraint::LongOnly => {
123                    for w in weights.iter_mut() {
124                        if *w < 0.0 {
125                            *w = 0.0;
126                        }
127                    }
128                }
129                OptimizationConstraint::MaxWeight(max) => {
130                    for w in weights.iter_mut() {
131                        if *w > *max {
132                            *w = *max;
133                        }
134                    }
135                }
136                OptimizationConstraint::MinWeight(min) => {
137                    for w in weights.iter_mut() {
138                        if *w < *min {
139                            *w = *min;
140                        }
141                    }
142                }
143                OptimizationConstraint::MaxConcentration(max_conc) => {
144                    for w in weights.iter_mut() {
145                        if *w > *max_conc {
146                            *w = *max_conc;
147                        }
148                    }
149                }
150                OptimizationConstraint::SumToOne => {
151                    let sum: f64 = weights.iter().sum();
152                    if sum.abs() > 1e-12 {
153                        for w in weights.iter_mut() {
154                            *w /= sum;
155                        }
156                    } else {
157                        // Fallback: equal weights
158                        let eq = 1.0 / n as f64;
159                        for w in weights.iter_mut() {
160                            *w = eq;
161                        }
162                    }
163                }
164            }
165        }
166    }
167
168    /// Maximize Sharpe ratio via gradient ascent.
169    ///
170    /// Iterates gradient ascent on the Sharpe ratio objective:
171    /// `(Return(w) - rf) / Volatility(w)`.
172    pub fn maximize_sharpe(
173        returns: &[f64],
174        cov: &[Vec<f64>],
175        rf_rate: f64,
176        constraints: &[OptimizationConstraint],
177    ) -> OptimizationResult {
178        let n = returns.len();
179        if n == 0 {
180            return OptimizationResult {
181                weights: vec![],
182                expected_return: 0.0,
183                volatility: 0.0,
184                sharpe: 0.0,
185                iterations: 0,
186            };
187        }
188
189        let mut weights = vec![1.0 / n as f64; n];
190        Self::apply_constraints(&mut weights, constraints);
191
192        let max_iter = 1000_usize;
193        let lr = 0.01_f64;
194        let tol = 1e-8_f64;
195        let mut prev_sharpe = f64::NEG_INFINITY;
196
197        let mut iters = 0_usize;
198        for iter in 0..max_iter {
199            iters = iter + 1;
200            let ret = Self::portfolio_return(&weights, returns);
201            let vol = Self::portfolio_volatility(&weights, cov);
202
203            if vol < 1e-12 {
204                break;
205            }
206
207            let excess = ret - rf_rate;
208            let sharpe = excess / vol;
209
210            // Gradient of Sharpe w.r.t. weights
211            // d(Sharpe)/dw_i = (r_i * vol - excess * dVol/dw_i) / vol^2
212            // dVol/dw_i = (Σ w)_i / vol  = sum_j(cov[i][j]*w[j]) / vol
213            let mut grad = vec![0.0_f64; n];
214            for i in 0..n {
215                let dcov_i: f64 = (0..n).map(|j| cov[i][j] * weights[j]).sum();
216                let dvol_i = dcov_i / vol;
217                grad[i] = (returns[i] * vol - excess * dvol_i) / (vol * vol);
218            }
219
220            for i in 0..n {
221                weights[i] += lr * grad[i];
222            }
223
224            Self::apply_constraints(&mut weights, constraints);
225
226            if (sharpe - prev_sharpe).abs() < tol {
227                break;
228            }
229            prev_sharpe = sharpe;
230        }
231
232        let ret = Self::portfolio_return(&weights, returns);
233        let vol = Self::portfolio_volatility(&weights, cov);
234        let sharpe = if vol > 1e-12 { (ret - rf_rate) / vol } else { 0.0 };
235
236        OptimizationResult {
237            weights,
238            expected_return: ret,
239            volatility: vol,
240            sharpe,
241            iterations: iters,
242        }
243    }
244
245    /// Minimize portfolio variance subject to a target return.
246    pub fn minimize_variance(
247        returns: &[f64],
248        cov: &[Vec<f64>],
249        target_return: f64,
250        constraints: &[OptimizationConstraint],
251    ) -> OptimizationResult {
252        let n = returns.len();
253        if n == 0 {
254            return OptimizationResult {
255                weights: vec![],
256                expected_return: 0.0,
257                volatility: 0.0,
258                sharpe: 0.0,
259                iterations: 0,
260            };
261        }
262
263        let mut weights = vec![1.0 / n as f64; n];
264        Self::apply_constraints(&mut weights, constraints);
265
266        let max_iter = 2000_usize;
267        let lr = 0.005_f64;
268        let tol = 1e-10_f64;
269        let mut prev_var = f64::MAX;
270
271        let mut iters = 0_usize;
272        for iter in 0..max_iter {
273            iters = iter + 1;
274            Self::gradient_step(&mut weights, returns, cov, target_return, lr);
275            Self::apply_constraints(&mut weights, constraints);
276
277            let var = Self::portfolio_variance(&weights, cov);
278            if (var - prev_var).abs() < tol {
279                break;
280            }
281            prev_var = var;
282        }
283
284        let ret = Self::portfolio_return(&weights, returns);
285        let vol = Self::portfolio_volatility(&weights, cov);
286        let sharpe = if vol > 1e-12 { ret / vol } else { 0.0 };
287
288        OptimizationResult {
289            weights,
290            expected_return: ret,
291            volatility: vol,
292            sharpe,
293            iterations: iters,
294        }
295    }
296
297    /// Generate the efficient frontier by sweeping target returns.
298    ///
299    /// Produces `n_points` `EfficientFrontierPoint` values spanning the range
300    /// from the minimum expected return to the maximum expected return.
301    pub fn efficient_frontier(
302        returns: &[f64],
303        cov: &[Vec<f64>],
304        n_points: usize,
305        rf_rate: f64,
306    ) -> Vec<EfficientFrontierPoint> {
307        if returns.is_empty() || n_points == 0 {
308            return vec![];
309        }
310
311        let min_ret = returns.iter().cloned().fold(f64::MAX, f64::min);
312        let max_ret = returns.iter().cloned().fold(f64::MIN, f64::max);
313
314        if (max_ret - min_ret).abs() < 1e-12 {
315            return vec![];
316        }
317
318        let constraints = &[OptimizationConstraint::SumToOne, OptimizationConstraint::LongOnly];
319        let mut frontier = Vec::with_capacity(n_points);
320
321        for k in 0..n_points {
322            let t = k as f64 / (n_points - 1).max(1) as f64;
323            let target = min_ret + t * (max_ret - min_ret);
324            let result = Self::minimize_variance(returns, cov, target, constraints);
325            let sharpe = if result.volatility > 1e-12 {
326                (result.expected_return - rf_rate) / result.volatility
327            } else {
328                0.0
329            };
330            frontier.push(EfficientFrontierPoint {
331                expected_return: result.expected_return,
332                volatility: result.volatility,
333                sharpe,
334                weights: result.weights,
335            });
336        }
337
338        frontier
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    fn simple_cov() -> Vec<Vec<f64>> {
347        vec![
348            vec![0.04, 0.006],
349            vec![0.006, 0.09],
350        ]
351    }
352
353    #[test]
354    fn portfolio_return_correct() {
355        let weights = vec![0.6, 0.4];
356        let returns = vec![0.10, 0.08];
357        let r = MeanVarianceOptimizer::portfolio_return(&weights, &returns);
358        assert!((r - 0.092).abs() < 1e-9);
359    }
360
361    #[test]
362    fn portfolio_variance_correct() {
363        let weights = vec![1.0, 0.0];
364        let cov = simple_cov();
365        let v = MeanVarianceOptimizer::portfolio_variance(&weights, &cov);
366        assert!((v - 0.04).abs() < 1e-9);
367    }
368
369    #[test]
370    fn apply_sum_to_one() {
371        let mut w = vec![2.0, 3.0];
372        MeanVarianceOptimizer::apply_constraints(&mut w, &[OptimizationConstraint::SumToOne]);
373        assert!((w[0] - 0.4).abs() < 1e-9);
374        assert!((w[1] - 0.6).abs() < 1e-9);
375    }
376
377    #[test]
378    fn maximize_sharpe_runs() {
379        let returns = vec![0.10, 0.08, 0.12];
380        let cov = vec![
381            vec![0.04, 0.006, 0.002],
382            vec![0.006, 0.09, 0.003],
383            vec![0.002, 0.003, 0.16],
384        ];
385        let constraints = [OptimizationConstraint::SumToOne, OptimizationConstraint::LongOnly];
386        let result = MeanVarianceOptimizer::maximize_sharpe(&returns, &cov, 0.02, &constraints);
387        let sum: f64 = result.weights.iter().sum();
388        assert!((sum - 1.0).abs() < 1e-6);
389        assert!(result.weights.iter().all(|&w| w >= -1e-9));
390    }
391
392    #[test]
393    fn efficient_frontier_has_correct_length() {
394        let returns = vec![0.05, 0.10];
395        let cov = simple_cov();
396        let frontier = MeanVarianceOptimizer::efficient_frontier(&returns, &cov, 5, 0.02);
397        assert_eq!(frontier.len(), 5);
398    }
399}