fin_primitives/portfolio/
optimization.rs1#[derive(Debug, Clone)]
10pub struct Asset {
11 pub name: String,
13 pub expected_return: f64,
15 pub weight: f64,
17}
18
19#[derive(Debug, Clone)]
21pub enum OptimizationConstraint {
22 SumToOne,
24 LongOnly,
26 MaxWeight(f64),
28 MinWeight(f64),
30 MaxConcentration(f64),
32}
33
34#[derive(Debug, Clone)]
36pub struct EfficientFrontierPoint {
37 pub expected_return: f64,
39 pub volatility: f64,
41 pub sharpe: f64,
43 pub weights: Vec<f64>,
45}
46
47#[derive(Debug, Clone)]
49pub struct OptimizationResult {
50 pub weights: Vec<f64>,
52 pub expected_return: f64,
54 pub volatility: f64,
56 pub sharpe: f64,
58 pub iterations: usize,
60}
61
62pub struct MeanVarianceOptimizer;
64
65impl MeanVarianceOptimizer {
66 pub fn portfolio_return(weights: &[f64], returns: &[f64]) -> f64 {
68 weights.iter().zip(returns.iter()).map(|(w, r)| w * r).sum()
69 }
70
71 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 pub fn portfolio_volatility(weights: &[f64], cov: &[Vec<f64>]) -> f64 {
85 Self::portfolio_variance(weights, cov).max(0.0).sqrt()
86 }
87
88 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 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 let actual_return = Self::portfolio_return(weights, returns);
109 let lambda = 2.0 * (actual_return - target_return);
110
111 for i in 0..n {
113 weights[i] -= lr * (grad_var[i] - lambda * returns[i]);
114 }
115 }
116
117 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 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 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 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 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 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}