Skip to main content

fin_primitives/portfolio/
factor_model.rs

1//! Factor model for portfolio analysis: OLS regression, Fama-French 3-factor,
2//! APT, information ratio, factor contributions, and systematic/idiosyncratic decomposition.
3
4/// The type of a factor used in multi-factor models.
5#[derive(Debug, Clone, PartialEq)]
6pub enum FactorType {
7    /// Broad equity market return (e.g. MKT-RF).
8    Market,
9    /// Small-minus-big size factor.
10    Size,
11    /// High-minus-low value factor.
12    Value,
13    /// Price momentum factor.
14    Momentum,
15    /// Profitability / quality factor.
16    Quality,
17    /// Low-volatility / defensive factor.
18    LowVolatility,
19    /// User-defined factor with a descriptive label.
20    Custom(String),
21}
22
23/// A named return series representing one systematic risk factor.
24#[derive(Debug, Clone)]
25pub struct Factor {
26    /// Human-readable factor name.
27    pub name: String,
28    /// Time-series of factor excess returns (one per period).
29    pub returns: Vec<f64>,
30    /// Semantic classification of the factor.
31    pub factor_type: FactorType,
32}
33
34/// Estimated sensitivity of an asset to a single factor.
35#[derive(Debug, Clone)]
36pub struct FactorExposure {
37    /// Name of the factor this exposure refers to.
38    pub factor_name: String,
39    /// OLS beta (sensitivity) of the asset to this factor.
40    pub beta: f64,
41    /// t-statistic for the beta estimate.
42    pub t_stat: f64,
43    /// True when |t_stat| > 2 (conventional 5 % significance).
44    pub is_significant: bool,
45}
46
47/// Full output of a factor-model regression.
48#[derive(Debug, Clone)]
49pub struct FactorModelResult {
50    /// Jensen's alpha (intercept) — excess return not explained by factors.
51    pub alpha: f64,
52    /// t-statistic for the alpha estimate.
53    pub alpha_t_stat: f64,
54    /// Per-factor exposure estimates.
55    pub exposures: Vec<FactorExposure>,
56    /// Coefficient of determination (R²) of the OLS fit.
57    pub r_squared: f64,
58    /// OLS residuals (idiosyncratic returns).
59    pub residuals: Vec<f64>,
60    /// Annualised information ratio: alpha / tracking_error * sqrt(252).
61    pub information_ratio: f64,
62    /// Tracking error — standard deviation of residuals.
63    pub tracking_error: f64,
64}
65
66/// Multi-factor risk model supporting OLS regression and auxiliary analytics.
67#[derive(Debug, Clone, Default)]
68pub struct FactorModel;
69
70impl FactorModel {
71    /// Solve an OLS system using Gaussian elimination on the augmented matrix of
72    /// the normal equations (X^T X) β = X^T y.
73    ///
74    /// Returns `(coefficients, r_squared)`.  `x` rows are observations; columns
75    /// are regressors (the intercept column must already be included by the caller).
76    pub fn ols(y: &[f64], x: &[Vec<f64>]) -> (Vec<f64>, f64) {
77        let n = y.len();
78        if n == 0 || x.is_empty() {
79            return (vec![], 0.0);
80        }
81        let k = x[0].len(); // number of regressors (including intercept)
82
83        // Build X^T X (k×k) and X^T y (k×1).
84        let mut xtx = vec![vec![0.0_f64; k]; k];
85        let mut xty = vec![0.0_f64; k];
86        for (i, row) in x.iter().enumerate() {
87            let yi = y[i];
88            for j in 0..k {
89                xty[j] += row[j] * yi;
90                for l in 0..k {
91                    xtx[j][l] += row[j] * row[l];
92                }
93            }
94        }
95
96        // Augment [X^T X | X^T y] and solve via Gaussian elimination with partial pivoting.
97        let mut aug: Vec<Vec<f64>> = (0..k)
98            .map(|r| {
99                let mut row = xtx[r].clone();
100                row.push(xty[r]);
101                row
102            })
103            .collect();
104
105        for col in 0..k {
106            // Partial pivot
107            let mut max_row = col;
108            let mut max_val = aug[col][col].abs();
109            for row in (col + 1)..k {
110                if aug[row][col].abs() > max_val {
111                    max_val = aug[row][col].abs();
112                    max_row = row;
113                }
114            }
115            aug.swap(col, max_row);
116
117            let pivot = aug[col][col];
118            if pivot.abs() < 1e-14 {
119                continue; // singular column — skip
120            }
121            for row in 0..k {
122                if row == col {
123                    continue;
124                }
125                let factor = aug[row][col] / pivot;
126                for c in col..=k {
127                    aug[row][c] -= factor * aug[col][c];
128                }
129            }
130            let d = aug[col][col];
131            for c in col..=k {
132                aug[col][c] /= d;
133            }
134        }
135
136        let coefficients: Vec<f64> = (0..k).map(|r| aug[r][k]).collect();
137
138        // Compute R².
139        let y_mean = y.iter().sum::<f64>() / n as f64;
140        let ss_tot: f64 = y.iter().map(|&yi| (yi - y_mean).powi(2)).sum();
141        let ss_res: f64 = x
142            .iter()
143            .zip(y.iter())
144            .map(|(row, &yi)| {
145                let y_hat: f64 = row.iter().zip(coefficients.iter()).map(|(&xi, &b)| xi * b).sum();
146                (yi - y_hat).powi(2)
147            })
148            .sum();
149        let r_squared = if ss_tot < 1e-14 { 0.0 } else { 1.0 - ss_res / ss_tot };
150
151        (coefficients, r_squared)
152    }
153
154    /// Compute t-statistics for each coefficient: β_i / SE_i where SE is derived from
155    /// the OLS residual variance.
156    pub fn t_statistics(
157        coefficients: &[f64],
158        x: &[Vec<f64>],
159        y: &[f64],
160        betas: &[f64],
161    ) -> Vec<f64> {
162        let n = y.len();
163        let k = coefficients.len();
164        if n <= k {
165            return vec![0.0; k];
166        }
167        // Residual sum of squares.
168        let rss: f64 = x
169            .iter()
170            .zip(y.iter())
171            .map(|(row, &yi)| {
172                let y_hat: f64 = row.iter().zip(betas.iter()).map(|(&xi, &b)| xi * b).sum();
173                (yi - y_hat).powi(2)
174            })
175            .sum();
176        let sigma2 = rss / (n - k) as f64;
177
178        // (X^T X)^{-1} diagonal for SE.
179        // Re-solve X^T X to get the diagonal of its inverse via the same augmented system.
180        let kk = k;
181        let mut xtx = vec![vec![0.0_f64; kk]; kk];
182        for row in x.iter() {
183            for j in 0..kk {
184                for l in 0..kk {
185                    xtx[j][l] += row[j] * row[l];
186                }
187            }
188        }
189        // Invert X^T X using Gauss-Jordan with identity augmentation.
190        let mut aug: Vec<Vec<f64>> = (0..kk)
191            .map(|r| {
192                let mut row = xtx[r].clone();
193                let mut id = vec![0.0_f64; kk];
194                id[r] = 1.0;
195                row.extend(id);
196                row
197            })
198            .collect();
199
200        for col in 0..kk {
201            let mut max_row = col;
202            let mut max_val = aug[col][col].abs();
203            for row in (col + 1)..kk {
204                if aug[row][col].abs() > max_val {
205                    max_val = aug[row][col].abs();
206                    max_row = row;
207                }
208            }
209            aug.swap(col, max_row);
210            let pivot = aug[col][col];
211            if pivot.abs() < 1e-14 {
212                continue;
213            }
214            for row in 0..kk {
215                if row == col {
216                    continue;
217                }
218                let f = aug[row][col] / pivot;
219                for c in 0..(2 * kk) {
220                    aug[row][c] -= f * aug[col][c];
221                }
222            }
223            let d = aug[col][col];
224            for c in 0..(2 * kk) {
225                aug[col][c] /= d;
226            }
227        }
228
229        // Diagonal of inverse is in columns kk..2kk.
230        (0..k)
231            .map(|i| {
232                let var_i = sigma2 * aug[i][kk + i];
233                if var_i <= 0.0 { 0.0 } else { coefficients[i] / var_i.sqrt() }
234            })
235            .collect()
236    }
237
238    /// Fit the factor model: regress `asset_returns` onto the provided `factors`.
239    ///
240    /// The design matrix includes an intercept column (index 0).
241    pub fn fit(&self, asset_returns: &[f64], factors: &[Factor]) -> FactorModelResult {
242        let n = asset_returns.len();
243        if n == 0 || factors.is_empty() {
244            return FactorModelResult {
245                alpha: 0.0,
246                alpha_t_stat: 0.0,
247                exposures: vec![],
248                r_squared: 0.0,
249                residuals: vec![],
250                information_ratio: 0.0,
251                tracking_error: 0.0,
252            };
253        }
254
255        // Build design matrix: [1, f1, f2, …].
256        let x: Vec<Vec<f64>> = (0..n)
257            .map(|i| {
258                let mut row = vec![1.0_f64];
259                for fac in factors.iter() {
260                    row.push(*fac.returns.get(i).unwrap_or(&0.0));
261                }
262                row
263            })
264            .collect();
265
266        let (coeffs, r_squared) = Self::ols(asset_returns, &x);
267        let t_stats = Self::t_statistics(&coeffs, &x, asset_returns, &coeffs);
268
269        let alpha = *coeffs.first().unwrap_or(&0.0);
270        let alpha_t_stat = *t_stats.first().unwrap_or(&0.0);
271
272        let exposures: Vec<FactorExposure> = factors
273            .iter()
274            .enumerate()
275            .map(|(idx, fac)| {
276                let beta = *coeffs.get(idx + 1).unwrap_or(&0.0);
277                let t_stat = *t_stats.get(idx + 1).unwrap_or(&0.0);
278                FactorExposure {
279                    factor_name: fac.name.clone(),
280                    beta,
281                    t_stat,
282                    is_significant: t_stat.abs() > 2.0,
283                }
284            })
285            .collect();
286
287        // Residuals.
288        let residuals: Vec<f64> = x
289            .iter()
290            .zip(asset_returns.iter())
291            .map(|(row, &yi)| {
292                let y_hat: f64 = row.iter().zip(coeffs.iter()).map(|(&xi, &b)| xi * b).sum();
293                yi - y_hat
294            })
295            .collect();
296
297        let tracking_error = Self::std_dev(&residuals);
298        let information_ratio = Self::information_ratio(alpha, &residuals);
299
300        FactorModelResult {
301            alpha,
302            alpha_t_stat,
303            exposures,
304            r_squared,
305            residuals,
306            information_ratio,
307            tracking_error,
308        }
309    }
310
311    /// Fama-French three-factor model: regress excess asset returns onto MKT-RF, SMB, HML.
312    ///
313    /// `rf_rate` is the per-period risk-free rate subtracted from `asset_returns`.
314    pub fn fama_french_3(
315        asset_returns: &[f64],
316        mkt_rf: &[f64],
317        smb: &[f64],
318        hml: &[f64],
319        rf_rate: f64,
320    ) -> FactorModelResult {
321        let n = asset_returns.len();
322        let excess: Vec<f64> = asset_returns.iter().map(|&r| r - rf_rate).collect();
323
324        let mkt_factor = Factor {
325            name: "MKT-RF".to_string(),
326            returns: mkt_rf[..n.min(mkt_rf.len())].to_vec(),
327            factor_type: FactorType::Market,
328        };
329        let smb_factor = Factor {
330            name: "SMB".to_string(),
331            returns: smb[..n.min(smb.len())].to_vec(),
332            factor_type: FactorType::Size,
333        };
334        let hml_factor = Factor {
335            name: "HML".to_string(),
336            returns: hml[..n.min(hml.len())].to_vec(),
337            factor_type: FactorType::Value,
338        };
339
340        let model = FactorModel;
341        model.fit(&excess, &[mkt_factor, smb_factor, hml_factor])
342    }
343
344    /// Annualised information ratio: alpha / tracking_error * sqrt(252).
345    ///
346    /// Returns 0.0 when tracking_error is (near) zero.
347    pub fn information_ratio(alpha: f64, residuals: &[f64]) -> f64 {
348        let te = Self::std_dev(residuals);
349        if te < 1e-14 { 0.0 } else { alpha / te * 252.0_f64.sqrt() }
350    }
351
352    /// Per-period factor contribution: beta_i * factor_return_i for each period.
353    pub fn factor_contribution(
354        exposures: &[FactorExposure],
355        factor_returns: &[f64],
356    ) -> Vec<f64> {
357        exposures
358            .iter()
359            .zip(factor_returns.iter())
360            .map(|(exp, &fr)| exp.beta * fr)
361            .collect()
362    }
363
364    /// Total systematic return for a single period: sum of beta_i * factor_return_i.
365    pub fn systematic_return(
366        exposures: &[FactorExposure],
367        period_factor_returns: &[f64],
368    ) -> f64 {
369        exposures
370            .iter()
371            .zip(period_factor_returns.iter())
372            .map(|(exp, &fr)| exp.beta * fr)
373            .sum()
374    }
375
376    /// Idiosyncratic (alpha) return for a single period.
377    pub fn idiosyncratic_return(asset_return: f64, systematic: f64) -> f64 {
378        asset_return - systematic
379    }
380
381    // -----------------------------------------------------------------------
382    // Internal helpers
383    // -----------------------------------------------------------------------
384
385    fn std_dev(v: &[f64]) -> f64 {
386        let n = v.len();
387        if n < 2 {
388            return 0.0;
389        }
390        let mean = v.iter().sum::<f64>() / n as f64;
391        let var = v.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
392        var.sqrt()
393    }
394}
395
396// ---------------------------------------------------------------------------
397// Arbitrage Pricing Theory model
398// ---------------------------------------------------------------------------
399
400/// Arbitrage Pricing Theory (APT) factor model.
401///
402/// Fits an asset's returns against an arbitrary set of macro factors and
403/// computes the expected risk premium from pre-specified factor risk premia.
404#[derive(Debug, Clone, Default)]
405pub struct AptModel;
406
407impl AptModel {
408    /// Fit the APT model: regress `asset_returns` onto `macro_factors`.
409    ///
410    /// Each element of `macro_factors` is a full time-series for one factor.
411    pub fn fit(asset_returns: &[f64], macro_factors: &[Vec<f64>]) -> FactorModelResult {
412        let factors: Vec<Factor> = macro_factors
413            .iter()
414            .enumerate()
415            .map(|(i, series)| Factor {
416                name: format!("MacroFactor{}", i + 1),
417                returns: series.clone(),
418                factor_type: FactorType::Custom(format!("macro_{}", i + 1)),
419            })
420            .collect();
421        let model = FactorModel;
422        model.fit(asset_returns, &factors)
423    }
424
425    /// Expected risk premium: sum of beta_i * factor_risk_premium_i.
426    pub fn risk_premium(exposures: &[FactorExposure], factor_risk_premia: &[f64]) -> f64 {
427        exposures
428            .iter()
429            .zip(factor_risk_premia.iter())
430            .map(|(exp, &rp)| exp.beta * rp)
431            .sum()
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn test_ols_simple() {
441        // y = 2 + 3x
442        let x: Vec<Vec<f64>> = (0..10).map(|i| vec![1.0, i as f64]).collect();
443        let y: Vec<f64> = (0..10).map(|i| 2.0 + 3.0 * i as f64).collect();
444        let (coeffs, r2) = FactorModel::ols(&y, &x);
445        assert!((coeffs[0] - 2.0).abs() < 1e-8, "intercept");
446        assert!((coeffs[1] - 3.0).abs() < 1e-8, "slope");
447        assert!((r2 - 1.0).abs() < 1e-8, "R2");
448    }
449
450    #[test]
451    fn test_fama_french_3() {
452        let n = 50;
453        let mkt: Vec<f64> = (0..n).map(|i| 0.01 * (i as f64).sin()).collect();
454        let smb: Vec<f64> = (0..n).map(|i| 0.005 * (i as f64).cos()).collect();
455        let hml: Vec<f64> = vec![0.002; n];
456        let asset: Vec<f64> = mkt.iter().zip(smb.iter()).map(|(&m, &s)| m + 0.5 * s + 0.001).collect();
457        let result = FactorModel::fama_french_3(&asset, &mkt, &smb, &hml, 0.0);
458        assert!(result.r_squared >= 0.0 && result.r_squared <= 1.0 + 1e-9);
459        assert_eq!(result.exposures.len(), 3);
460    }
461
462    #[test]
463    fn test_information_ratio() {
464        let residuals = vec![0.01, -0.01, 0.02, -0.02, 0.01];
465        let ir = FactorModel::information_ratio(0.001, &residuals);
466        assert!(ir.is_finite());
467    }
468
469    #[test]
470    fn test_apt_risk_premium() {
471        let exposures = vec![
472            FactorExposure { factor_name: "f1".into(), beta: 1.2, t_stat: 3.0, is_significant: true },
473            FactorExposure { factor_name: "f2".into(), beta: 0.5, t_stat: 1.5, is_significant: false },
474        ];
475        let premia = vec![0.04, 0.02];
476        let rp = AptModel::risk_premium(&exposures, &premia);
477        assert!((rp - (1.2 * 0.04 + 0.5 * 0.02)).abs() < 1e-10);
478    }
479}