Skip to main content

portfolio_analyzer/
direction.rs

1//! # Direction: what is the best SD can do at calling *up or down*?
2//!
3//! Chapter 2 of the book found that squeeze and momentum measurements, on
4//! their own, call direction no better than a coin flip once the market's
5//! upward drift is removed.  This module widens the search to every family
6//! of measurement that the finance literature says carries *some* direction
7//! information, and lets a walk-forward test (`sd_direction`) decide which
8//! of them earn their place.
9//!
10//! The families, and the one-line reason each is here:
11//!
12//! | Group | Measurements | Why it might know direction |
13//! | --- | --- | --- |
14//! | `sd` | blend, agreement, fired×decay×blend, tier, energy, stretch, semi | SD's own reading |
15//! | `mom` | 5/21/63/126-bar returns, 12-minus-1-month return | trends persist; very short moves reverse |
16//! | `trend` | distance from 50/200-bar averages, drawdown from 1-year high | trend-following, anchoring |
17//! | `vol` | 20/126 vol ratio, vol level | calm markets drift up; panics overshoot |
18//! | `rel` | 63-bar return minus SPY's | relative strength |
19//! | `mkt` | SPY trend, momentum, vol ratio, 5/21-bar return | the tide lifts (and sinks) most boats |
20//! | `vix` | VIX vs its 1-year average, VIX level, VIX minus realized vol | fear is paid for: high implied vol precedes better returns |
21//! | `season` | November–April flag, month sine/cosine | the "Halloween" seasonal |
22//! | `sdw`, `sdm` | SD's reading on weekly and monthly bars | Extended SD's idea: slower squeezes, slower trends |
23//!
24//! Every value at bar `t` uses only bars `<= t`.
25
26use crate::history::DatedBar;
27use crate::indicators::{rolling_std, sma, OHLCV};
28use crate::sd2::features::FeatureFrame;
29use crate::sd2::primitives::{highest, squash};
30use chrono::{Datelike, NaiveDate};
31
32/// Feature groups, in column order.
33pub const GROUPS: &[(&str, &[&str])] = &[
34    ("sd", &["sd_blend", "sd_agree", "sd_fire_blend", "sd_comp", "sd_energy", "sd_stretch", "sd_semi"]),
35    ("mom", &["r5", "r21", "r63", "r126", "mom12_1"]),
36    ("trend", &["d50", "d200", "dd252"]),
37    ("vol", &["vol_ratio", "vol_level"]),
38    ("rel", &["rel63"]),
39    ("mkt", &["spy_trend200", "spy_tsmom", "spy_volratio", "spy_r5", "spy_r21"]),
40    ("vix", &["vix_rel", "vix_level", "vix_vrp"]),
41    ("season", &["halloween", "month_sin", "month_cos"]),
42    ("sdw", &["w_blend", "w_agree", "w_fire_blend", "w_comp", "w_energy", "w_stretch"]),
43    ("sdm", &["m_blend", "m_agree", "m_fire_blend", "m_comp", "m_energy", "m_stretch"]),
44];
45
46pub fn n_features() -> usize {
47    GROUPS.iter().map(|g| g.1.len()).sum()
48}
49
50/// Column range of a group.
51pub fn group_range(name: &str) -> std::ops::Range<usize> {
52    let mut start = 0;
53    for (g, cols) in GROUPS {
54        if *g == name {
55            return start..start + cols.len();
56        }
57        start += cols.len();
58    }
59    panic!("unknown feature group {name}");
60}
61
62fn log_returns(close: &[f64]) -> Vec<f64> {
63    (0..close.len()).map(|i| if i == 0 { 0.0 } else { (close[i] / close[i - 1]).ln() }).collect()
64}
65
66/// A move of `h` bars in units of its normal size (`σ₁₂₆·√h`), squashed.
67fn norm_ret(close: &[f64], v126: &[f64], i: usize, from: usize, h: usize) -> f64 {
68    if i < from + h || !v126[i].is_finite() || v126[i] <= 0.0 {
69        return f64::NAN;
70    }
71    squash((close[i - from] / close[i - from - h]).ln() / (v126[i] * (h as f64).sqrt()), 2.0)
72}
73
74/// Market weather on each SPY date: SPY's own trend and VIX's fear gauge.
75pub struct MarketWeather {
76    pub dates: Vec<NaiveDate>,
77    pub rows: Vec<[f64; 8]>,
78    pub close: Vec<f64>,
79}
80
81impl MarketWeather {
82    /// `vix` may be empty (then the VIX columns are neutral zeros).
83    pub fn new(spy: &[DatedBar], vix: &[DatedBar]) -> Self {
84        let close: Vec<f64> = spy.iter().map(|b| b.bar.close).collect();
85        let n = close.len();
86        let lr = log_returns(&close);
87        let v20 = rolling_std(&lr, 20);
88        let v126 = rolling_std(&lr, 126);
89        let s200 = sma(&close, 200);
90        let vix_dates: Vec<NaiveDate> = vix.iter().map(|b| b.date).collect();
91        let vix_close: Vec<f64> = vix.iter().map(|b| b.bar.close).collect();
92        let vix_mean = sma(&vix_close, 252);
93        let rows = (0..n)
94            .map(|i| {
95                if i < 252 {
96                    return [f64::NAN; 8];
97                }
98                let j = match vix_dates.binary_search(&spy[i].date) {
99                    Ok(j) => Some(j),
100                    Err(0) => None,
101                    Err(j) => Some(j - 1),
102                };
103                let (vrel, vlvl, vrp) = match j {
104                    Some(j) if j >= 252 && vix_close[j] > 0.0 => {
105                        let v = vix_close[j];
106                        (
107                            squash((v / vix_mean[j]).ln(), 0.5),
108                            squash((v / 20.0).ln(), 0.5),
109                            squash((v / 100.0 / (v20[i] * 252f64.sqrt())).ln(), 0.5),
110                        )
111                    }
112                    _ => (0.0, 0.0, 0.0),
113                };
114                [
115                    squash((close[i] / s200[i]).ln() / (v126[i] * 200f64.sqrt() * 0.5), 1.0),
116                    squash((close[i] / close[i - 126]).ln() / (v126[i] * 126f64.sqrt()), 1.0),
117                    squash(v20[i] / v126[i] - 1.0, 0.5),
118                    norm_ret(&close, &v126, i, 0, 5),
119                    norm_ret(&close, &v126, i, 0, 21),
120                    vrel,
121                    vlvl,
122                    vrp,
123                ]
124            })
125            .collect();
126        Self { dates: spy.iter().map(|b| b.date).collect(), rows, close }
127    }
128
129    /// Latest row at or before `d`.
130    pub fn at(&self, d: NaiveDate) -> Option<(usize, [f64; 8])> {
131        let i = match self.dates.binary_search(&d) {
132            Ok(i) => i,
133            Err(0) => return None,
134            Err(i) => i - 1,
135        };
136        Some((i, self.rows[i]))
137    }
138}
139
140/// SD's reading on slower bars (weekly or monthly), mapped back to days.
141///
142/// Day `i` sees only *completed* slow bars: the last week (or month) that
143/// ended before the one day `i` belongs to.  Unusable values become 0
144/// (neutral), so a young listing is not dropped, merely uninformed.
145pub fn slow_sd(bars: &[OHLCV], dates: &[NaiveDate], monthly: bool) -> Vec<[f64; 6]> {
146    let key = |d: &NaiveDate| if monthly { d.year() * 12 + d.month() as i32 } else { d.iso_week().year() * 53 + d.iso_week().week() as i32 };
147    let mut agg: Vec<OHLCV> = Vec::new();
148    let mut period_of_day = Vec::with_capacity(bars.len());
149    let mut last_key = None;
150    for (b, d) in bars.iter().zip(dates) {
151        let k = key(d);
152        if last_key != Some(k) {
153            agg.push(b.clone());
154            last_key = Some(k);
155        } else {
156            let a = agg.last_mut().unwrap();
157            a.high = a.high.max(b.high);
158            a.low = a.low.min(b.low);
159            a.close = b.close;
160            a.volume += b.volume;
161        }
162        period_of_day.push(agg.len() - 1);
163    }
164    let f = FeatureFrame::compute(&agg, crate::sd2::features::FeatureConfig::default());
165    let clean = |v: f64| if v.is_finite() { v } else { 0.0 };
166    period_of_day
167        .into_iter()
168        .map(|p| {
169            if p == 0 || p - 1 < 30 {
170                return [0.0; 6];
171            }
172            let j = p - 1;
173            let alive = if f.fire_alive(j) { 1.0 } else { 0.0 };
174            [
175                clean(f.blend[j]),
176                clean(f.agree[j]),
177                clean(alive * f.blend[j] * f.decay[j]),
178                f.comp[j] as f64 / 3.0,
179                clean(squash(f.energy[j], 8.0)),
180                clean(f.stretch[j]),
181            ]
182        })
183        .collect()
184}
185
186/// Every direction feature for every bar of one symbol (`NaN` = unusable).
187pub fn direction_frame(bars: &[OHLCV], dates: &[NaiveDate], f: &FeatureFrame, mkt: &MarketWeather) -> Vec<Vec<f64>> {
188    let close: Vec<f64> = bars.iter().map(|b| b.close).collect();
189    let n = close.len();
190    let lr = log_returns(&close);
191    let v20 = rolling_std(&lr, 20);
192    let v126 = rolling_std(&lr, 126);
193    let s50 = sma(&close, 50);
194    let s200 = sma(&close, 200);
195    let hi252 = highest(&close, 252);
196    let weekly = slow_sd(bars, dates, false);
197    let monthly = slow_sd(bars, dates, true);
198    (0..n)
199        .map(|i| {
200            if i < 260 || !v126[i].is_finite() || v126[i] <= 0.0 {
201                return vec![f64::NAN; n_features()];
202            }
203            let alive = if f.fire_alive(i) { 1.0 } else { 0.0 };
204            let mut r = vec![
205                f.blend[i],
206                f.agree[i],
207                alive * f.blend[i] * f.decay[i],
208                f.comp[i] as f64 / 3.0,
209                squash(f.energy[i], 8.0),
210                f.stretch[i],
211                f.semi[i],
212            ];
213            r.extend([
214                norm_ret(&close, &v126, i, 0, 5),
215                norm_ret(&close, &v126, i, 0, 21),
216                norm_ret(&close, &v126, i, 0, 63),
217                norm_ret(&close, &v126, i, 0, 126),
218                norm_ret(&close, &v126, i, 21, 231),
219            ]);
220            r.extend([
221                squash((close[i] / s50[i]).ln() / (v126[i] * 25f64.sqrt()), 2.0),
222                squash((close[i] / s200[i]).ln() / (v126[i] * 100f64.sqrt()), 2.0),
223                squash((close[i] / hi252[i]).ln() / (v126[i] * 252f64.sqrt()), 1.0),
224            ]);
225            r.extend([squash((v20[i] / v126[i]).ln(), 0.5), squash((v126[i] * 252f64.sqrt() / 0.25).ln(), 1.0)]);
226            let (mi, m) = match mkt.at(dates[i]) {
227                Some(x) => x,
228                None => return vec![f64::NAN; n_features()],
229            };
230            let spy63 = if mi >= 63 { (mkt.close[mi] / mkt.close[mi - 63]).ln() } else { f64::NAN };
231            r.push(squash(((close[i] / close[i - 63]).ln() - spy63) / (v126[i] * 63f64.sqrt()), 2.0));
232            r.extend(&m[0..5]);
233            r.extend(&m[5..8]);
234            let month = dates[i].month() as f64;
235            let angle = std::f64::consts::TAU * (month - 0.5) / 12.0;
236            r.extend([if (5..=10).contains(&dates[i].month()) { 0.0 } else { 1.0 }, angle.sin(), angle.cos()]);
237            r.extend(weekly[i]);
238            r.extend(monthly[i]);
239            r
240        })
241        .collect()
242}
243
244/// A small gradient-boosted tree ensemble for "will it close higher?".
245///
246/// The linear model can only say "more of X, more up".  Direction might
247/// instead live in *regimes*: "falling market **and** fear spiking **and**
248/// this stock far below its average".  Shallow trees find such corners.
249/// Kept tiny (depth 3, coarse 32-bin thresholds, big leaves) because
250/// direction data is mostly noise and big trees would memorise it.
251#[derive(Debug, Clone)]
252pub struct Gbm {
253    pub base: f64,
254    pub edges: Vec<Vec<f64>>,
255    pub trees: Vec<Vec<GbmNode>>,
256}
257
258#[derive(Debug, Clone)]
259pub enum GbmNode {
260    /// Rows with `bin(feature) <= bin` go to `left`, others to `right`.
261    Split { feature: usize, bin: u8, left: usize, right: usize },
262    Leaf(f64),
263}
264
265pub struct GbmParams {
266    pub rounds: usize,
267    pub learning_rate: f64,
268    pub depth: usize,
269    /// Smallest leaf, as a share of training rows.
270    pub min_leaf: f64,
271}
272
273impl Default for GbmParams {
274    fn default() -> Self {
275        Self { rounds: 150, learning_rate: 0.05, depth: 3, min_leaf: 0.01 }
276    }
277}
278
279const BINS: usize = 32;
280
281fn bin_of(edges: &[f64], v: f64) -> u8 {
282    edges.partition_point(|&e| e < v) as u8
283}
284
285impl Gbm {
286    pub fn fit(x: &[Vec<f64>], y: &[f64], p: &GbmParams) -> Self {
287        let n = x.len();
288        let d = x[0].len();
289        // Quantile thresholds from a strided sample.
290        let step = (n / 20_000).max(1);
291        let edges: Vec<Vec<f64>> = (0..d)
292            .map(|j| {
293                let mut v: Vec<f64> = x.iter().step_by(step).map(|r| r[j]).collect();
294                v.sort_by(|a, b| a.partial_cmp(b).unwrap());
295                let mut e: Vec<f64> = (1..BINS).map(|k| v[k * (v.len() - 1) / BINS]).collect();
296                e.dedup();
297                e
298            })
299            .collect();
300        let binned: Vec<Vec<u8>> = x.iter().map(|r| r.iter().enumerate().map(|(j, &v)| bin_of(&edges[j], v)).collect()).collect();
301        let mean = (y.iter().sum::<f64>() / n as f64).clamp(1e-6, 1.0 - 1e-6);
302        let base = (mean / (1.0 - mean)).ln();
303        let mut f = vec![base; n];
304        let min_leaf = ((n as f64 * p.min_leaf) as usize).max(50);
305        let mut trees = Vec::with_capacity(p.rounds);
306        for _ in 0..p.rounds {
307            let prob: Vec<f64> = f.iter().map(|&v| 1.0 / (1.0 + (-v).exp())).collect();
308            let g: Vec<f64> = (0..n).map(|i| y[i] - prob[i]).collect();
309            let h: Vec<f64> = prob.iter().map(|&q| (q * (1.0 - q)).max(1e-6)).collect();
310            let mut nodes = Vec::new();
311            let idx: Vec<u32> = (0..n as u32).collect();
312            grow(&binned, &g, &h, idx, 0, p, min_leaf, d, &mut nodes);
313            for i in 0..n {
314                f[i] += leaf_value(&nodes, &binned[i]);
315            }
316            trees.push(nodes);
317        }
318        Self { base, edges, trees }
319    }
320
321    pub fn predict(&self, r: &[f64]) -> f64 {
322        let b: Vec<u8> = r.iter().enumerate().map(|(j, &v)| bin_of(&self.edges[j], v)).collect();
323        let z = self.base + self.trees.iter().map(|t| leaf_value(t, &b)).sum::<f64>();
324        1.0 / (1.0 + (-z).exp())
325    }
326}
327
328fn leaf_value(nodes: &[GbmNode], b: &[u8]) -> f64 {
329    let mut k = 0;
330    loop {
331        match nodes[k] {
332            GbmNode::Leaf(v) => return v,
333            GbmNode::Split { feature, bin, left, right } => k = if b[feature] <= bin { left } else { right },
334        }
335    }
336}
337
338#[allow(clippy::too_many_arguments)]
339fn grow(
340    x: &[Vec<u8>],
341    g: &[f64],
342    h: &[f64],
343    idx: Vec<u32>,
344    depth: usize,
345    p: &GbmParams,
346    min_leaf: usize,
347    d: usize,
348    nodes: &mut Vec<GbmNode>,
349) -> usize {
350    const L2: f64 = 1.0;
351    let me = nodes.len();
352    let (gs, hs) = idx.iter().fold((0.0, 0.0), |(a, b), &i| (a + g[i as usize], b + h[i as usize]));
353    nodes.push(GbmNode::Leaf(p.learning_rate * gs / (hs + L2)));
354    if depth >= p.depth || idx.len() < 2 * min_leaf {
355        return me;
356    }
357    let parent = gs * gs / (hs + L2);
358    let mut best: Option<(f64, usize, u8)> = None;
359    for j in 0..d {
360        let mut hist = [(0.0f64, 0.0f64, 0usize); BINS];
361        for &i in &idx {
362            let e = &mut hist[x[i as usize][j] as usize];
363            e.0 += g[i as usize];
364            e.1 += h[i as usize];
365            e.2 += 1;
366        }
367        let (mut gl, mut hl, mut nl) = (0.0, 0.0, 0usize);
368        for (b, e) in hist.iter().enumerate().take(BINS - 1) {
369            gl += e.0;
370            hl += e.1;
371            nl += e.2;
372            let nr = idx.len() - nl;
373            if nl < min_leaf || nr < min_leaf {
374                continue;
375            }
376            let (gr, hr) = (gs - gl, hs - hl);
377            let gain = gl * gl / (hl + L2) + gr * gr / (hr + L2) - parent;
378            if best.is_none_or(|(bg, _, _)| gain > bg) {
379                best = Some((gain, j, b as u8));
380            }
381        }
382    }
383    let Some((gain, feature, bin)) = best else { return me };
384    if gain <= 0.0 {
385        return me;
386    }
387    let (li, ri): (Vec<u32>, Vec<u32>) = idx.into_iter().partition(|&i| x[i as usize][feature] <= bin);
388    let left = grow(x, g, h, li, depth + 1, p, min_leaf, d, nodes);
389    let right = grow(x, g, h, ri, depth + 1, p, min_leaf, d, nodes);
390    nodes[me] = GbmNode::Split { feature, bin, left, right };
391    me
392}