1use crate::portfolio::CovarianceMatrix;
13
14#[derive(Debug, Clone)]
18pub struct Factor {
19 pub name: String,
21 pub returns: Vec<f64>,
23}
24
25impl Factor {
26 pub fn new(name: impl Into<String>, returns: Vec<f64>) -> Self {
28 Self { name: name.into(), returns }
29 }
30}
31
32#[derive(Debug, Clone)]
36pub struct FactorExposure {
37 pub asset: String,
39 pub betas: Vec<f64>,
41 pub alpha: f64,
43 pub r_squared: f64,
45 pub residual_variance: f64,
47}
48
49#[derive(Debug, Clone)]
53pub struct VarianceDecomposition {
54 pub systematic_variance: f64,
56 pub idiosyncratic_variance: f64,
58 pub factor_contributions: Vec<(String, f64)>,
62}
63
64pub struct FactorModel;
80
81impl FactorModel {
82 pub fn fit(asset: &str, asset_returns: &[f64], factors: &[Factor]) -> FactorExposure {
89 let t = asset_returns.len();
90 let k = factors.len();
91
92 if t == 0 || factors.iter().any(|f| f.returns.len() != t) {
94 return FactorExposure {
95 asset: asset.to_string(),
96 betas: vec![0.0; k],
97 alpha: 0.0,
98 r_squared: 0.0,
99 residual_variance: 0.0,
100 };
101 }
102
103 let cols = k + 1; let mut x = vec![vec![0.0f64; cols]; t];
107 for row in 0..t {
108 x[row][0] = 1.0;
109 for (j, fac) in factors.iter().enumerate() {
110 x[row][j + 1] = fac.returns[row];
111 }
112 }
113
114 let mut xtx = vec![vec![0.0f64; cols]; cols];
116 for i in 0..cols {
117 for j in 0..cols {
118 let mut s = 0.0;
119 for row in 0..t {
120 s += x[row][i] * x[row][j];
121 }
122 xtx[i][j] = s;
123 }
124 }
125
126 let mut xty = vec![0.0f64; cols];
128 for i in 0..cols {
129 let mut s = 0.0;
130 for row in 0..t {
131 s += x[row][i] * asset_returns[row];
132 }
133 xty[i] = s;
134 }
135
136 let coeffs = match invert_and_solve(&xtx, &xty) {
138 Some(c) => c,
139 None => {
140 return FactorExposure {
141 asset: asset.to_string(),
142 betas: vec![0.0; k],
143 alpha: 0.0,
144 r_squared: 0.0,
145 residual_variance: 0.0,
146 };
147 }
148 };
149
150 let alpha = coeffs[0];
151 let betas: Vec<f64> = coeffs[1..].to_vec();
152
153 let mut ss_res = 0.0;
155 let mut ss_tot = 0.0;
156 let y_mean = asset_returns.iter().sum::<f64>() / t as f64;
157
158 for row in 0..t {
159 let fitted = alpha + betas.iter().enumerate().map(|(j, &b)| b * factors[j].returns[row]).sum::<f64>();
160 let residual = asset_returns[row] - fitted;
161 ss_res += residual * residual;
162 let dev = asset_returns[row] - y_mean;
163 ss_tot += dev * dev;
164 }
165
166 let r_squared = if ss_tot < 1e-15 {
167 0.0
168 } else {
169 (1.0 - ss_res / ss_tot).clamp(0.0, 1.0)
170 };
171
172 let residual_variance = if t > cols {
173 ss_res / (t - cols) as f64
174 } else {
175 0.0
176 };
177
178 FactorExposure {
179 asset: asset.to_string(),
180 betas,
181 alpha,
182 r_squared,
183 residual_variance,
184 }
185 }
186
187 pub fn decompose(
191 exposure: &FactorExposure,
192 factor_cov: &CovarianceMatrix,
193 factor_names: &[String],
194 ) -> VarianceDecomposition {
195 let k = exposure.betas.len();
196 let mut systematic_variance = 0.0;
197
198 for i in 0..k {
200 for j in 0..k {
201 let ci = factor_cov.symbols.iter().position(|s| s == &factor_names[i]).unwrap_or(i);
202 let cj = factor_cov.symbols.iter().position(|s| s == &factor_names[j]).unwrap_or(j);
203 systematic_variance += exposure.betas[i] * exposure.betas[j] * factor_cov.get(ci, cj);
204 }
205 }
206
207 let mut factor_contributions = Vec::with_capacity(k);
209 for i in 0..k {
210 let ci = factor_cov.symbols.iter().position(|s| s == &factor_names[i]).unwrap_or(i);
211 let mut contrib = exposure.betas[i] * exposure.betas[i] * factor_cov.get(ci, ci);
213 for j in (i + 1)..k {
215 let cj = factor_cov.symbols.iter().position(|s| s == &factor_names[j]).unwrap_or(j);
216 contrib += 2.0 * exposure.betas[i] * exposure.betas[j] * factor_cov.get(ci, cj);
217 }
218 factor_contributions.push((factor_names[i].clone(), contrib));
219 }
220
221 VarianceDecomposition {
222 systematic_variance,
223 idiosyncratic_variance: exposure.residual_variance,
224 factor_contributions,
225 }
226 }
227}
228
229#[derive(Debug, Clone)]
233pub struct FactorPortfolio {
234 pub exposures: Vec<(String, f64)>, }
237
238impl FactorPortfolio {
239 pub fn new(exposures: Vec<(String, f64)>) -> Self {
241 Self { exposures }
242 }
243
244 pub fn aggregate(&self, asset_exposures: &[FactorExposure]) -> Option<FactorExposure> {
250 if self.exposures.is_empty() || asset_exposures.is_empty() {
251 return None;
252 }
253 let k = asset_exposures[0].betas.len();
254 let mut portfolio_betas = vec![0.0f64; k];
255 let mut portfolio_alpha = 0.0f64;
256 let mut portfolio_r2 = 0.0f64;
257 let mut portfolio_resid_var = 0.0f64;
258 let mut total_weight = 0.0f64;
259
260 for (asset, weight) in &self.exposures {
261 let w = *weight;
262 if let Some(exp) = asset_exposures.iter().find(|e| &e.asset == asset) {
263 for (i, &b) in exp.betas.iter().enumerate() {
264 if i < k {
265 portfolio_betas[i] += w * b;
266 }
267 }
268 portfolio_alpha += w * exp.alpha;
269 portfolio_r2 += w * exp.r_squared;
270 portfolio_resid_var += w * w * exp.residual_variance; total_weight += w;
272 }
273 }
274
275 let label = "Portfolio".to_string();
276 Some(FactorExposure {
277 asset: label,
278 betas: portfolio_betas,
279 alpha: if total_weight.abs() > 1e-12 { portfolio_alpha } else { 0.0 },
280 r_squared: if total_weight.abs() > 1e-12 { portfolio_r2 / total_weight } else { 0.0 },
281 residual_variance: portfolio_resid_var,
282 })
283 }
284}
285
286fn invert_and_solve(a: &[Vec<f64>], b: &[f64]) -> Option<Vec<f64>> {
291 let n = a.len();
292 match n {
293 0 => Some(vec![]),
294 1 => {
295 let det = a[0][0];
296 if det.abs() < 1e-15 {
297 return None;
298 }
299 Some(vec![b[0] / det])
300 }
301 2 => {
302 let det = a[0][0] * a[1][1] - a[0][1] * a[1][0];
303 if det.abs() < 1e-15 {
304 return None;
305 }
306 let inv = [[a[1][1] / det, -a[0][1] / det], [-a[1][0] / det, a[0][0] / det]];
307 Some(vec![
308 inv[0][0] * b[0] + inv[0][1] * b[1],
309 inv[1][0] * b[0] + inv[1][1] * b[1],
310 ])
311 }
312 3 => {
313 let (a00, a01, a02) = (a[0][0], a[0][1], a[0][2]);
314 let (a10, a11, a12) = (a[1][0], a[1][1], a[1][2]);
315 let (a20, a21, a22) = (a[2][0], a[2][1], a[2][2]);
316 let det = a00 * (a11 * a22 - a12 * a21)
317 - a01 * (a10 * a22 - a12 * a20)
318 + a02 * (a10 * a21 - a11 * a20);
319 if det.abs() < 1e-15 {
320 return None;
321 }
322 let inv = [
323 [
324 (a11 * a22 - a12 * a21) / det,
325 (a02 * a21 - a01 * a22) / det,
326 (a01 * a12 - a02 * a11) / det,
327 ],
328 [
329 (a12 * a20 - a10 * a22) / det,
330 (a00 * a22 - a02 * a20) / det,
331 (a02 * a10 - a00 * a12) / det,
332 ],
333 [
334 (a10 * a21 - a11 * a20) / det,
335 (a01 * a20 - a00 * a21) / det,
336 (a00 * a11 - a01 * a10) / det,
337 ],
338 ];
339 Some(vec![
340 inv[0][0] * b[0] + inv[0][1] * b[1] + inv[0][2] * b[2],
341 inv[1][0] * b[0] + inv[1][1] * b[1] + inv[1][2] * b[2],
342 inv[2][0] * b[0] + inv[2][1] * b[1] + inv[2][2] * b[2],
343 ])
344 }
345 _ => gaussian_solve(a, b),
346 }
347}
348
349fn gaussian_solve(a: &[Vec<f64>], b: &[f64]) -> Option<Vec<f64>> {
351 let n = a.len();
352 let mut mat: Vec<Vec<f64>> = (0..n)
353 .map(|i| {
354 let mut row = a[i].clone();
355 row.push(b[i]);
356 row
357 })
358 .collect();
359
360 for col in 0..n {
361 let pivot_row = (col..n)
363 .max_by(|&r1, &r2| mat[r1][col].abs().partial_cmp(&mat[r2][col].abs()).unwrap_or(std::cmp::Ordering::Equal))?;
364 mat.swap(col, pivot_row);
365
366 let pivot = mat[col][col];
367 if pivot.abs() < 1e-15 {
368 return None;
369 }
370 for j in col..=n {
371 mat[col][j] /= pivot;
372 }
373 for row in 0..n {
374 if row != col {
375 let factor = mat[row][col];
376 for j in col..=n {
377 let v = mat[col][j];
378 mat[row][j] -= factor * v;
379 }
380 }
381 }
382 }
383
384 Some((0..n).map(|i| mat[i][n]).collect())
385}
386
387#[cfg(test)]
390mod tests {
391 use super::*;
392
393 fn make_factor(name: &str, returns: Vec<f64>) -> Factor {
394 Factor::new(name, returns)
395 }
396
397 #[test]
400 fn single_factor_known_output() {
401 let xs: Vec<f64> = (0..20).map(|i| i as f64 * 0.01).collect();
403 let ys: Vec<f64> = xs.iter().map(|&x| 0.01 + 0.5 * x).collect();
404 let factor = make_factor("MKT", xs);
405 let exp = FactorModel::fit("ASSET", &ys, &[factor]);
406 assert!((exp.alpha - 0.01).abs() < 1e-9, "alpha={}", exp.alpha);
407 assert_eq!(exp.betas.len(), 1);
408 assert!((exp.betas[0] - 0.5).abs() < 1e-9, "beta={}", exp.betas[0]);
409 assert!((exp.r_squared - 1.0).abs() < 1e-9, "r2={}", exp.r_squared);
410 }
411
412 #[test]
413 fn two_factor_known_output() {
414 let n = 30;
416 let x1: Vec<f64> = (0..n).map(|i| i as f64 * 0.01).collect();
417 let x2: Vec<f64> = (0..n).map(|i| (i as f64 * 0.02).sin()).collect();
418 let y: Vec<f64> = (0..n)
419 .map(|i| 0.02 + 0.7 * x1[i] + 0.3 * x2[i])
420 .collect();
421 let factors = vec![make_factor("MKT", x1), make_factor("HML", x2)];
422 let exp = FactorModel::fit("ASSET", &y, &factors);
423 assert!((exp.alpha - 0.02).abs() < 1e-7, "alpha={}", exp.alpha);
424 assert!((exp.betas[0] - 0.7).abs() < 1e-7, "beta0={}", exp.betas[0]);
425 assert!((exp.betas[1] - 0.3).abs() < 1e-7, "beta1={}", exp.betas[1]);
426 assert!((exp.r_squared - 1.0).abs() < 1e-7);
427 }
428
429 #[test]
430 fn three_factor_known_output() {
431 let n = 50;
433 let f1: Vec<f64> = (0..n).map(|i| (i as f64) * 0.005).collect();
434 let f2: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).cos()).collect();
435 let f3: Vec<f64> = (0..n).map(|i| (i as f64 * 0.2).sin()).collect();
436 let y: Vec<f64> = (0..n)
437 .map(|i| 0.001 + 1.1 * f1[i] + 0.5 * f2[i] - 0.2 * f3[i])
438 .collect();
439 let factors = vec![
440 make_factor("MKT", f1),
441 make_factor("SMB", f2),
442 make_factor("HML", f3),
443 ];
444 let exp = FactorModel::fit("ASSET", &y, &factors);
445 assert!((exp.alpha - 0.001).abs() < 1e-6, "alpha={}", exp.alpha);
446 assert!((exp.betas[0] - 1.1).abs() < 1e-6);
447 assert!((exp.betas[1] - 0.5).abs() < 1e-6);
448 assert!((exp.betas[2] - (-0.2)).abs() < 1e-6);
449 assert!((exp.r_squared - 1.0).abs() < 1e-6);
450 }
451
452 #[test]
453 fn r_squared_in_zero_one() {
454 let y: Vec<f64> = vec![0.1, -0.2, 0.15, 0.05, -0.1, 0.3, -0.05, 0.2, 0.0, -0.15];
456 let f: Vec<f64> = vec![0.2, -0.1, 0.05, 0.15, -0.2, 0.1, 0.0, 0.25, -0.05, 0.1];
457 let exp = FactorModel::fit("X", &y, &[make_factor("F", f)]);
458 assert!((0.0..=1.0).contains(&exp.r_squared), "r2={}", exp.r_squared);
459 }
460
461 #[test]
462 fn alpha_zero_when_no_intercept_needed() {
463 let xs: Vec<f64> = (0..20).map(|i| i as f64 * 0.01 - 0.1).collect();
465 let ys: Vec<f64> = xs.iter().map(|&x| 2.0 * x).collect();
466 let exp = FactorModel::fit("A", &ys, &[make_factor("F", xs)]);
467 assert!(exp.alpha.abs() < 1e-8, "alpha={}", exp.alpha);
468 assert!((exp.betas[0] - 2.0).abs() < 1e-8);
469 }
470
471 #[test]
472 fn empty_returns_gives_zero_exposure() {
473 let exp = FactorModel::fit("A", &[], &[make_factor("F", vec![])]);
474 assert_eq!(exp.betas.len(), 1);
475 assert_eq!(exp.betas[0], 0.0);
476 assert_eq!(exp.alpha, 0.0);
477 }
478
479 #[test]
480 fn mismatched_length_gives_zero_exposure() {
481 let exp = FactorModel::fit("A", &[1.0, 2.0], &[make_factor("F", vec![1.0])]);
482 assert_eq!(exp.betas[0], 0.0);
483 }
484
485 #[test]
486 fn residual_variance_non_negative() {
487 let y: Vec<f64> = vec![0.1, -0.2, 0.15, 0.05, -0.1, 0.3, -0.05, 0.2, 0.0, -0.15];
488 let f: Vec<f64> = vec![0.2, -0.1, 0.05, 0.15, -0.2, 0.1, 0.0, 0.25, -0.05, 0.1];
489 let exp = FactorModel::fit("X", &y, &[make_factor("F", f)]);
490 assert!(exp.residual_variance >= 0.0);
491 }
492
493 #[test]
494 fn perfect_fit_zero_residual_variance() {
495 let xs: Vec<f64> = (0..20).map(|i| i as f64 * 0.01).collect();
496 let ys: Vec<f64> = xs.iter().map(|&x| 0.5 * x).collect();
497 let exp = FactorModel::fit("X", &ys, &[make_factor("F", xs)]);
498 assert!(exp.residual_variance < 1e-12, "resid_var={}", exp.residual_variance);
499 }
500
501 #[test]
504 fn decompose_sums_to_total() {
505 let xs: Vec<f64> = (0..30).map(|i| i as f64 * 0.01 - 0.15).collect();
506 let ys: Vec<f64> = xs.iter().map(|&x| 0.5 * x + 0.001).collect();
507 let factor_names = vec!["MKT".to_string()];
508 let exp = FactorModel::fit("A", &ys, &[make_factor("MKT", xs.clone())]);
509 let var_mkt = xs.iter().map(|&v| v * v).sum::<f64>() / xs.len() as f64
511 - (xs.iter().sum::<f64>() / xs.len() as f64).powi(2);
512 let mut cov = CovarianceMatrix::new(factor_names.clone());
513 cov.set(0, 0, var_mkt);
514 let decomp = FactorModel::decompose(&exp, &cov, &factor_names);
515 assert!(decomp.systematic_variance >= 0.0);
516 assert!(decomp.idiosyncratic_variance >= 0.0);
517 let total = decomp.systematic_variance + decomp.idiosyncratic_variance;
519 assert!(total >= 0.0, "total={total}");
520 }
521
522 #[test]
523 fn decompose_two_factors_contributions_match_systematic() {
524 let n = 40;
525 let f1: Vec<f64> = (0..n).map(|i| (i as f64) * 0.01).collect();
526 let f2: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).sin()).collect();
527 let y: Vec<f64> = (0..n).map(|i| 0.5 * f1[i] + 0.3 * f2[i]).collect();
528 let factor_names = vec!["F1".to_string(), "F2".to_string()];
529 let factors = vec![make_factor("F1", f1.clone()), make_factor("F2", f2.clone())];
530 let exp = FactorModel::fit("B", &y, &factors);
531 let mut cov = CovarianceMatrix::new(factor_names.clone());
532 let var_f1 = f1.iter().map(|&v| v * v).sum::<f64>() / n as f64;
533 let var_f2 = f2.iter().map(|&v| v * v).sum::<f64>() / n as f64;
534 cov.set(0, 0, var_f1);
535 cov.set(1, 1, var_f2);
536 let decomp = FactorModel::decompose(&exp, &cov, &factor_names);
537 let sum_contrib: f64 = decomp.factor_contributions.iter().map(|(_, v)| v).sum();
539 assert!((sum_contrib - decomp.systematic_variance).abs() < 1e-10,
540 "sum_contrib={sum_contrib}, sys_var={}", decomp.systematic_variance);
541 }
542
543 #[test]
544 fn decompose_single_factor_contribution_label() {
545 let xs: Vec<f64> = (0..20).map(|i| i as f64 * 0.01).collect();
546 let ys: Vec<f64> = xs.iter().map(|&x| 0.5 * x).collect();
547 let factor_names = vec!["MKT".to_string()];
548 let mut cov = CovarianceMatrix::new(factor_names.clone());
549 cov.set(0, 0, 0.01);
550 let exp = FactorModel::fit("A", &ys, &[make_factor("MKT", xs)]);
551 let decomp = FactorModel::decompose(&exp, &cov, &factor_names);
552 assert_eq!(decomp.factor_contributions[0].0, "MKT");
553 }
554
555 #[test]
558 fn portfolio_weighted_betas() {
559 let exp_a = FactorExposure {
560 asset: "A".to_string(),
561 betas: vec![1.0, 0.5],
562 alpha: 0.01,
563 r_squared: 0.9,
564 residual_variance: 0.001,
565 };
566 let exp_b = FactorExposure {
567 asset: "B".to_string(),
568 betas: vec![0.5, 1.5],
569 alpha: 0.02,
570 r_squared: 0.8,
571 residual_variance: 0.002,
572 };
573 let portfolio = FactorPortfolio::new(vec![("A".to_string(), 0.6), ("B".to_string(), 0.4)]);
574 let agg = portfolio.aggregate(&[exp_a, exp_b]).expect("aggregate");
575 assert!((agg.betas[0] - 0.8).abs() < 1e-9, "b0={}", agg.betas[0]);
577 assert!((agg.betas[1] - 0.9).abs() < 1e-9, "b1={}", agg.betas[1]);
579 }
580
581 #[test]
582 fn portfolio_weighted_alpha() {
583 let exp_a = FactorExposure { asset: "A".to_string(), betas: vec![1.0], alpha: 0.01, r_squared: 0.9, residual_variance: 0.001 };
584 let exp_b = FactorExposure { asset: "B".to_string(), betas: vec![0.5], alpha: 0.03, r_squared: 0.8, residual_variance: 0.002 };
585 let portfolio = FactorPortfolio::new(vec![("A".to_string(), 0.5), ("B".to_string(), 0.5)]);
586 let agg = portfolio.aggregate(&[exp_a, exp_b]).expect("aggregate");
587 assert!((agg.alpha - 0.02).abs() < 1e-9, "alpha={}", agg.alpha);
589 }
590
591 #[test]
592 fn portfolio_empty_returns_none() {
593 let portfolio = FactorPortfolio::new(vec![]);
594 assert!(portfolio.aggregate(&[]).is_none());
595 }
596
597 #[test]
598 fn portfolio_missing_asset_ignored() {
599 let exp_a = FactorExposure { asset: "A".to_string(), betas: vec![1.0], alpha: 0.01, r_squared: 0.9, residual_variance: 0.0 };
600 let portfolio = FactorPortfolio::new(vec![("A".to_string(), 0.6), ("Z".to_string(), 0.4)]);
601 let agg = portfolio.aggregate(&[exp_a]).expect("aggregate");
602 assert!((agg.betas[0] - 0.6).abs() < 1e-9);
604 }
605
606 #[test]
607 fn four_factor_gaussian_elimination() {
608 let n = 60;
610 let f1: Vec<f64> = (0..n).map(|i| i as f64 * 0.01).collect();
611 let f2: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).cos()).collect();
612 let f3: Vec<f64> = (0..n).map(|i| (i as f64 * 0.05).sin()).collect();
613 let f4: Vec<f64> = (0..n).map(|i| (i * i) as f64 * -0.0002).collect();
616 let y: Vec<f64> = (0..n)
617 .map(|i| 0.005 + 0.8 * f1[i] + 0.4 * f2[i] + 0.2 * f3[i] + 0.1 * f4[i])
618 .collect();
619 let factors = vec![
620 make_factor("F1", f1),
621 make_factor("F2", f2),
622 make_factor("F3", f3),
623 make_factor("F4", f4),
624 ];
625 let exp = FactorModel::fit("X", &y, &factors);
626 assert!((exp.alpha - 0.005).abs() < 1e-6, "alpha={}", exp.alpha);
627 assert!((exp.betas[0] - 0.8).abs() < 1e-5);
628 assert!((exp.betas[1] - 0.4).abs() < 1e-5);
629 assert!((exp.betas[2] - 0.2).abs() < 1e-5);
630 assert!((exp.betas[3] - 0.1).abs() < 1e-5);
631 assert!((exp.r_squared - 1.0).abs() < 1e-5);
632 }
633
634 #[test]
635 fn beta_count_matches_factor_count() {
636 let y: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 5.0];
637 let factors = vec![
638 make_factor("F1", vec![0.1, 0.2, 0.3, 0.4, 0.5]),
639 make_factor("F2", vec![0.5, 0.4, 0.3, 0.2, 0.1]),
640 ];
641 let exp = FactorModel::fit("X", &y, &factors);
642 assert_eq!(exp.betas.len(), 2);
643 }
644
645 #[test]
646 fn no_factors_alpha_is_mean() {
647 let y: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 5.0];
649 let exp = FactorModel::fit("X", &y, &[]);
650 let mean = y.iter().sum::<f64>() / y.len() as f64;
651 assert!((exp.alpha - mean).abs() < 1e-9, "alpha={}", exp.alpha);
652 }
653
654 #[test]
655 fn systematic_variance_non_negative() {
656 let xs: Vec<f64> = (0..20).map(|i| i as f64 * 0.01 - 0.1).collect();
657 let ys: Vec<f64> = xs.iter().map(|&x| 0.5 * x).collect();
658 let factor_names = vec!["MKT".to_string()];
659 let exp = FactorModel::fit("A", &ys, &[make_factor("MKT", xs)]);
660 let mut cov = CovarianceMatrix::new(factor_names.clone());
661 cov.set(0, 0, 0.05);
662 let decomp = FactorModel::decompose(&exp, &cov, &factor_names);
663 assert!(decomp.systematic_variance >= 0.0);
664 }
665
666 #[test]
667 fn portfolio_r_squared_weighted_average() {
668 let exp_a = FactorExposure { asset: "A".to_string(), betas: vec![1.0], alpha: 0.0, r_squared: 0.8, residual_variance: 0.0 };
669 let exp_b = FactorExposure { asset: "B".to_string(), betas: vec![1.0], alpha: 0.0, r_squared: 0.6, residual_variance: 0.0 };
670 let portfolio = FactorPortfolio::new(vec![("A".to_string(), 0.5), ("B".to_string(), 0.5)]);
672 let agg = portfolio.aggregate(&[exp_a, exp_b]).expect("aggregate");
673 assert!((agg.r_squared - 0.7).abs() < 1e-9, "r2={}", agg.r_squared);
674 }
675
676 #[test]
677 fn constant_factor_singular_handled() {
678 let y: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 5.0];
680 let f: Vec<f64> = vec![1.0, 1.0, 1.0, 1.0, 1.0]; let exp = FactorModel::fit("X", &y, &[make_factor("CONST", f)]);
682 assert_eq!(exp.betas.len(), 1);
684 }
685
686 #[test]
687 fn negative_betas_supported() {
688 let xs: Vec<f64> = (0..20).map(|i| i as f64 * 0.01).collect();
690 let ys: Vec<f64> = xs.iter().map(|&x| -0.5 * x).collect();
691 let exp = FactorModel::fit("A", &ys, &[make_factor("F", xs)]);
692 assert!((exp.betas[0] - (-0.5)).abs() < 1e-9, "beta={}", exp.betas[0]);
693 }
694
695 #[test]
696 fn factor_name_preserved_in_exposure() {
697 let xs: Vec<f64> = (0..10).map(|i| i as f64).collect();
698 let ys: Vec<f64> = xs.iter().map(|&x| x).collect();
699 let exp = FactorModel::fit("MY_ASSET", &ys, &[make_factor("MY_FACTOR", xs)]);
700 assert_eq!(exp.asset, "MY_ASSET");
701 }
702}