Skip to main content

fin_primitives/factor/
mod.rs

1//! Fama-French style multi-factor regression and portfolio factor exposure.
2//!
3//! ## Overview
4//!
5//! This module provides:
6//! - [`Factor`]: a named return series (e.g. market, value, momentum).
7//! - [`FactorModel`]: OLS regression of asset returns on a set of factors.
8//! - [`FactorExposure`]: betas, alpha, R², and residual variance for one asset.
9//! - [`VarianceDecomposition`]: breaks total variance into systematic and idiosyncratic parts.
10//! - [`FactorPortfolio`]: aggregates per-asset exposures weighted by portfolio weights.
11
12use crate::portfolio::CovarianceMatrix;
13
14// ─── Factor ───────────────────────────────────────────────────────────────────
15
16/// A named return series representing one risk factor (e.g. market, value, momentum).
17#[derive(Debug, Clone)]
18pub struct Factor {
19    /// Human-readable name (e.g. `"MKT"`, `"SMB"`, `"HML"`).
20    pub name: String,
21    /// Time-series of factor returns aligned with the asset return series.
22    pub returns: Vec<f64>,
23}
24
25impl Factor {
26    /// Creates a new [`Factor`] with the given name and return series.
27    pub fn new(name: impl Into<String>, returns: Vec<f64>) -> Self {
28        Self { name: name.into(), returns }
29    }
30}
31
32// ─── FactorExposure ───────────────────────────────────────────────────────────
33
34/// Regression output for one asset against a set of factors.
35#[derive(Debug, Clone)]
36pub struct FactorExposure {
37    /// Asset identifier.
38    pub asset: String,
39    /// Factor loadings (betas), one per factor in the same order as `factors` passed to `fit`.
40    pub betas: Vec<f64>,
41    /// Intercept term (alpha): excess return unexplained by factors.
42    pub alpha: f64,
43    /// Coefficient of determination R² ∈ [0, 1].
44    pub r_squared: f64,
45    /// Variance of the OLS residuals (idiosyncratic variance).
46    pub residual_variance: f64,
47}
48
49// ─── VarianceDecomposition ────────────────────────────────────────────────────
50
51/// Decomposes an asset's total variance into systematic and idiosyncratic parts.
52#[derive(Debug, Clone)]
53pub struct VarianceDecomposition {
54    /// Variance explained by the factor model: `β' Σ β`.
55    pub systematic_variance: f64,
56    /// Residual variance not explained by factors.
57    pub idiosyncratic_variance: f64,
58    /// Per-factor contributions including cross terms with all subsequent factors.
59    ///
60    /// `factor_contributions[i] = β_i² σ_i² + 2 Σ_{j>i} β_i β_j cov(i,j)`
61    pub factor_contributions: Vec<(String, f64)>,
62}
63
64// ─── FactorModel ──────────────────────────────────────────────────────────────
65
66/// Fama-French style multi-factor OLS regression engine.
67///
68/// ## Algorithm
69///
70/// Given `T` observations and `K` factors, form the `T × (K+1)` design matrix
71/// `X` (first column all-ones for the intercept) and solve the normal equations:
72///
73/// ```text
74/// β̂ = (X'X)⁻¹ X'y
75/// ```
76///
77/// Matrix inversion is done analytically for 2×2 and 3×3 systems and via
78/// Gaussian elimination with partial pivoting for larger systems.
79pub struct FactorModel;
80
81impl FactorModel {
82    /// Fit a factor model to `asset_returns` using the provided `factors`.
83    ///
84    /// # Panics
85    ///
86    /// Does not panic; returns a zero-exposure result if the system is singular or
87    /// if the return series have mismatched lengths.
88    pub fn fit(asset: &str, asset_returns: &[f64], factors: &[Factor]) -> FactorExposure {
89        let t = asset_returns.len();
90        let k = factors.len();
91
92        // Validate all factor series are the same length.
93        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; // intercept + K factors
104
105        // Build X (T × cols) — column 0 is all ones (intercept).
106        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        // Compute X'X (cols × cols).
115        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        // Compute X'y (cols × 1).
127        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        // Invert X'X and solve for β̂ = (X'X)⁻¹ X'y.
137        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        // Compute fitted values and residuals.
154        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    /// Decompose total variance into systematic and idiosyncratic components.
188    ///
189    /// `factor_contributions[i] = β_i² σ_i² + 2 Σ_{j>i} β_i β_j cov(i,j)`
190    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        // Compute β' Σ β via explicit double sum.
199        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        // Per-factor contributions (diagonal + cross terms with j > i).
208        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            // Diagonal term: β_i² * σ_i²
212            let mut contrib = exposure.betas[i] * exposure.betas[i] * factor_cov.get(ci, ci);
213            // Cross terms with j > i: 2 * β_i * β_j * cov(i,j)
214            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// ─── FactorPortfolio ──────────────────────────────────────────────────────────
230
231/// Portfolio-level factor exposure: weighted sum of individual asset exposures.
232#[derive(Debug, Clone)]
233pub struct FactorPortfolio {
234    /// Asset identifiers matching the order of `exposures`.
235    pub exposures: Vec<(String, f64)>, // (asset, weight)
236}
237
238impl FactorPortfolio {
239    /// Creates a new [`FactorPortfolio`] from a list of `(asset, weight)` pairs.
240    pub fn new(exposures: Vec<(String, f64)>) -> Self {
241        Self { exposures }
242    }
243
244    /// Aggregates per-asset [`FactorExposure`]s into a portfolio-level exposure.
245    ///
246    /// The result's `betas` are the weighted sum of individual asset betas.
247    /// The result's `alpha` is the weighted sum of individual asset alphas.
248    /// `r_squared` and `residual_variance` are weight-averaged.
249    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; // variance scales by w²
271                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
286// ─── Matrix utilities ─────────────────────────────────────────────────────────
287
288/// Solves `A x = b` where `A` is a square matrix using analytic inversion for
289/// 1×1, 2×2, 3×3 and Gaussian elimination with partial pivoting for larger systems.
290fn 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
349/// Gaussian elimination with partial pivoting. Returns `None` if singular.
350fn 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        // Find pivot.
362        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// ─── tests ────────────────────────────────────────────────────────────────────
388
389#[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    // ── OLS regression correctness ──────────────────────────────────────────
398
399    #[test]
400    fn single_factor_known_output() {
401        // y = 0.5 * x + 0.01 (alpha=0.01, beta=0.5)
402        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        // y = 0.02 + 0.7*x1 + 0.3*x2
415        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        // y = 0.001 + 1.1*f1 + 0.5*f2 - 0.2*f3
432        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        // Noisy data — R² must still be in [0, 1].
455        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        // Pure factor return (no alpha)
464        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    // ── VarianceDecomposition ────────────────────────────────────────────────
502
503    #[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        // Build diagonal cov matrix: var(MKT)
510        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        // systematic + idiosyncratic ≈ total sample variance of y
518        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        // Sum of factor_contributions should equal systematic_variance
538        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    // ── FactorPortfolio ──────────────────────────────────────────────────────
556
557    #[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        // beta0: 0.6*1.0 + 0.4*0.5 = 0.8
576        assert!((agg.betas[0] - 0.8).abs() < 1e-9, "b0={}", agg.betas[0]);
577        // beta1: 0.6*0.5 + 0.4*1.5 = 0.9
578        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        // alpha: 0.5*0.01 + 0.5*0.03 = 0.02
588        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        // Only A contributes
603        assert!((agg.betas[0] - 0.6).abs() < 1e-9);
604    }
605
606    #[test]
607    fn four_factor_gaussian_elimination() {
608        // y = 0.005 + 0.8*f1 + 0.4*f2 + 0.2*f3 + 0.1*f4
609        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 as f64 * -0.005).collect();
614        let y: Vec<f64> = (0..n)
615            .map(|i| 0.005 + 0.8 * f1[i] + 0.4 * f2[i] + 0.2 * f3[i] + 0.1 * f4[i])
616            .collect();
617        let factors = vec![
618            make_factor("F1", f1),
619            make_factor("F2", f2),
620            make_factor("F3", f3),
621            make_factor("F4", f4),
622        ];
623        let exp = FactorModel::fit("X", &y, &factors);
624        assert!((exp.alpha - 0.005).abs() < 1e-6, "alpha={}", exp.alpha);
625        assert!((exp.betas[0] - 0.8).abs() < 1e-5);
626        assert!((exp.betas[1] - 0.4).abs() < 1e-5);
627        assert!((exp.betas[2] - 0.2).abs() < 1e-5);
628        assert!((exp.betas[3] - 0.1).abs() < 1e-5);
629        assert!((exp.r_squared - 1.0).abs() < 1e-5);
630    }
631
632    #[test]
633    fn beta_count_matches_factor_count() {
634        let y: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 5.0];
635        let factors = vec![
636            make_factor("F1", vec![0.1, 0.2, 0.3, 0.4, 0.5]),
637            make_factor("F2", vec![0.5, 0.4, 0.3, 0.2, 0.1]),
638        ];
639        let exp = FactorModel::fit("X", &y, &factors);
640        assert_eq!(exp.betas.len(), 2);
641    }
642
643    #[test]
644    fn no_factors_alpha_is_mean() {
645        // With no factors, alpha should be the mean of y
646        let y: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 5.0];
647        let exp = FactorModel::fit("X", &y, &[]);
648        let mean = y.iter().sum::<f64>() / y.len() as f64;
649        assert!((exp.alpha - mean).abs() < 1e-9, "alpha={}", exp.alpha);
650    }
651
652    #[test]
653    fn systematic_variance_non_negative() {
654        let xs: Vec<f64> = (0..20).map(|i| i as f64 * 0.01 - 0.1).collect();
655        let ys: Vec<f64> = xs.iter().map(|&x| 0.5 * x).collect();
656        let factor_names = vec!["MKT".to_string()];
657        let exp = FactorModel::fit("A", &ys, &[make_factor("MKT", xs)]);
658        let mut cov = CovarianceMatrix::new(factor_names.clone());
659        cov.set(0, 0, 0.05);
660        let decomp = FactorModel::decompose(&exp, &cov, &factor_names);
661        assert!(decomp.systematic_variance >= 0.0);
662    }
663
664    #[test]
665    fn portfolio_r_squared_weighted_average() {
666        let exp_a = FactorExposure { asset: "A".to_string(), betas: vec![1.0], alpha: 0.0, r_squared: 0.8, residual_variance: 0.0 };
667        let exp_b = FactorExposure { asset: "B".to_string(), betas: vec![1.0], alpha: 0.0, r_squared: 0.6, residual_variance: 0.0 };
668        // Equal weights → R² should be (0.8+0.6)/2 / 1.0 = 0.7
669        let portfolio = FactorPortfolio::new(vec![("A".to_string(), 0.5), ("B".to_string(), 0.5)]);
670        let agg = portfolio.aggregate(&[exp_a, exp_b]).expect("aggregate");
671        assert!((agg.r_squared - 0.7).abs() < 1e-9, "r2={}", agg.r_squared);
672    }
673
674    #[test]
675    fn constant_factor_singular_handled() {
676        // Constant factor creates a collinear matrix with intercept → singular.
677        let y: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 5.0];
678        let f: Vec<f64> = vec![1.0, 1.0, 1.0, 1.0, 1.0]; // constant = collinear with intercept
679        let exp = FactorModel::fit("X", &y, &[make_factor("CONST", f)]);
680        // Should return zero-exposure rather than panic
681        assert_eq!(exp.betas.len(), 1);
682    }
683
684    #[test]
685    fn negative_betas_supported() {
686        // y = -0.5 * x
687        let xs: Vec<f64> = (0..20).map(|i| i as f64 * 0.01).collect();
688        let ys: Vec<f64> = xs.iter().map(|&x| -0.5 * x).collect();
689        let exp = FactorModel::fit("A", &ys, &[make_factor("F", xs)]);
690        assert!((exp.betas[0] - (-0.5)).abs() < 1e-9, "beta={}", exp.betas[0]);
691    }
692
693    #[test]
694    fn factor_name_preserved_in_exposure() {
695        let xs: Vec<f64> = (0..10).map(|i| i as f64).collect();
696        let ys: Vec<f64> = xs.iter().map(|&x| x).collect();
697        let exp = FactorModel::fit("MY_ASSET", &ys, &[make_factor("MY_FACTOR", xs)]);
698        assert_eq!(exp.asset, "MY_ASSET");
699    }
700}