Skip to main content

portfolio_analyzer/sd2/
model.rs

1//! # Model: how much is each measurement worth?
2//!
3//! Classic SD hard-coded its opinions: Group A `+` is worth 1.0, `(+)` is
4//! worth 0.75, every `*` adds 0.05.  Those numbers were sensible guesses, but
5//! nobody had checked them against what prices actually did next.
6//!
7//! SD v2 keeps the *structure* (the features in `features.rs` are all squeeze
8//! and momentum ideas from the paper) but lets history set the *weights*.
9//! Two tiny, transparent models do this:
10//!
11//! * [`Logistic`]: "given these measurements, what is the chance the next
12//!   `h` bars close higher?"  Its output is the SD number, in `[0, 1]`.
13//! * [`Ridge`]: "how big will the move be, in units of normal daily noise?"
14//!   This is the *expected movement* that SD's conviction is scaled by.
15//!
16//! Both are linear, so every weight can be printed, read, argued with and
17//! copied into ThinkScript.  Both use an L2 ("ridge") penalty, which pulls
18//! weights toward zero unless the data insists, the cheapest defence there
19//! is against fitting noise.
20
21use serde::{Deserialize, Serialize};
22
23/// Solve `A x = b` for a small symmetric positive-definite `A` (Cholesky).
24fn solve_spd(a: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
25    let k = b.len();
26    let mut l = vec![vec![0.0; k]; k];
27    for i in 0..k {
28        for j in 0..=i {
29            let s: f64 = (0..j).map(|p| l[i][p] * l[j][p]).sum();
30            if i == j {
31                l[i][j] = (a[i][i] - s).max(1e-12).sqrt();
32            } else {
33                l[i][j] = (a[i][j] - s) / l[j][j];
34            }
35        }
36    }
37    let mut y = vec![0.0; k];
38    for i in 0..k {
39        y[i] = (b[i] - (0..i).map(|p| l[i][p] * y[p]).sum::<f64>()) / l[i][i];
40    }
41    let mut x = vec![0.0; k];
42    for i in (0..k).rev() {
43        x[i] = (y[i] - (i + 1..k).map(|p| l[p][i] * x[p]).sum::<f64>()) / l[i][i];
44    }
45    x
46}
47
48/// Per-column mean and standard deviation, so weights are comparable.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct Scaler {
51    pub mean: Vec<f64>,
52    pub std: Vec<f64>,
53}
54
55impl Scaler {
56    pub fn fit(x: &[Vec<f64>]) -> Self {
57        let k = x[0].len();
58        let n = x.len() as f64;
59        let mean: Vec<f64> = (0..k).map(|j| x.iter().map(|r| r[j]).sum::<f64>() / n).collect();
60        let std = (0..k)
61            .map(|j| {
62                let v = x.iter().map(|r| (r[j] - mean[j]).powi(2)).sum::<f64>() / n;
63                v.sqrt().max(1e-9)
64            })
65            .collect();
66        Self { mean, std }
67    }
68    pub fn apply(&self, r: &[f64]) -> Vec<f64> {
69        r.iter().enumerate().map(|(j, v)| (v - self.mean[j]) / self.std[j]).collect()
70    }
71}
72
73/// Logistic regression `p = σ(b₀ + w·z)` on standardized inputs `z`.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Logistic {
76    pub scaler: Scaler,
77    pub intercept: f64,
78    pub weights: Vec<f64>,
79}
80
81pub fn sigmoid(x: f64) -> f64 {
82    1.0 / (1.0 + (-x).exp())
83}
84
85impl Logistic {
86    /// Fit by Newton's method (IRLS) with ridge penalty `lambda` per row.
87    /// Samples can be weighted (e.g. to stop one symbol dominating).
88    pub fn fit(x: &[Vec<f64>], y: &[f64], w: &[f64], lambda: f64) -> Self {
89        let scaler = Scaler::fit(x);
90        let z: Vec<Vec<f64>> = x.iter().map(|r| scaler.apply(r)).collect();
91        let k = z[0].len() + 1;
92        let total_w: f64 = w.iter().sum();
93        let mut beta = vec![0.0; k];
94        for _ in 0..25 {
95            let mut h = vec![vec![0.0; k]; k];
96            let mut g = vec![0.0; k];
97            for ((row, &yi), &wi) in z.iter().zip(y).zip(w) {
98                let eta = beta[0] + row.iter().zip(&beta[1..]).map(|(a, b)| a * b).sum::<f64>();
99                let p = sigmoid(eta);
100                let s = wi * p * (1.0 - p);
101                let r = wi * (yi - p);
102                let f = |j: usize| if j == 0 { 1.0 } else { row[j - 1] };
103                for a in 0..k {
104                    g[a] += r * f(a);
105                    for b in 0..=a {
106                        h[a][b] += s * f(a) * f(b);
107                    }
108                }
109            }
110            for a in 0..k {
111                for b in 0..a {
112                    h[b][a] = h[a][b];
113                }
114                if a > 0 {
115                    h[a][a] += lambda * total_w;
116                    g[a] -= lambda * total_w * beta[a];
117                }
118            }
119            let step = solve_spd(&h, &g);
120            let size: f64 = step.iter().map(|s| s * s).sum::<f64>().sqrt();
121            for (b, s) in beta.iter_mut().zip(&step) {
122                *b += s;
123            }
124            if size < 1e-8 {
125                break;
126            }
127        }
128        Self { scaler, intercept: beta[0], weights: beta[1..].to_vec() }
129    }
130
131    pub fn logit(&self, r: &[f64]) -> f64 {
132        let z = self.scaler.apply(r);
133        self.intercept + z.iter().zip(&self.weights).map(|(a, b)| a * b).sum::<f64>()
134    }
135
136    pub fn predict(&self, r: &[f64]) -> f64 {
137        sigmoid(self.logit(r))
138    }
139
140    /// The part of the logit that comes from the features, not the base rate.
141    /// Positive means "the measurements lean up".
142    pub fn lean(&self, r: &[f64]) -> f64 {
143        self.logit(r) - self.intercept
144    }
145}
146
147/// Ridge regression `y = b₀ + w·z`.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct Ridge {
150    pub scaler: Scaler,
151    pub intercept: f64,
152    pub weights: Vec<f64>,
153}
154
155impl Ridge {
156    pub fn fit(x: &[Vec<f64>], y: &[f64], lambda: f64) -> Self {
157        let scaler = Scaler::fit(x);
158        let k = x[0].len() + 1;
159        let n = x.len() as f64;
160        let mut a = vec![vec![0.0; k]; k];
161        let mut b = vec![0.0; k];
162        for (r, &yi) in x.iter().zip(y) {
163            let z = scaler.apply(r);
164            let f = |j: usize| if j == 0 { 1.0 } else { z[j - 1] };
165            for i in 0..k {
166                b[i] += f(i) * yi;
167                for j in 0..=i {
168                    a[i][j] += f(i) * f(j);
169                }
170            }
171        }
172        for i in 0..k {
173            for j in 0..i {
174                a[j][i] = a[i][j];
175            }
176            if i > 0 {
177                a[i][i] += lambda * n;
178            }
179        }
180        let beta = solve_spd(&a, &b);
181        Self { scaler, intercept: beta[0], weights: beta[1..].to_vec() }
182    }
183
184    pub fn predict(&self, r: &[f64]) -> f64 {
185        let z = self.scaler.apply(r);
186        self.intercept + z.iter().zip(&self.weights).map(|(a, b)| a * b).sum::<f64>()
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn logistic_recovers_a_clear_signal() {
196        let x: Vec<Vec<f64>> = (0..2000).map(|i| vec![((i * 37) % 200) as f64 / 100.0 - 1.0]).collect();
197        let y: Vec<f64> = x.iter().map(|r| if r[0] > 0.0 { 1.0 } else { 0.0 }).collect();
198        let m = Logistic::fit(&x, &y, &vec![1.0; 2000], 1e-4);
199        assert!(m.predict(&[0.8]) > 0.9 && m.predict(&[-0.8]) < 0.1);
200    }
201
202    #[test]
203    fn ridge_recovers_a_line() {
204        let x: Vec<Vec<f64>> = (0..100).map(|i| vec![i as f64]).collect();
205        let y: Vec<f64> = x.iter().map(|r| 3.0 * r[0] + 2.0).collect();
206        let m = Ridge::fit(&x, &y, 0.0);
207        assert!((m.predict(&[10.0]) - 32.0).abs() < 1e-6);
208    }
209}