Skip to main content

portfolio_analyzer/
research.rs

1//! Research universe and shared helpers for the backtesters.
2
3/// A deliberately mixed research universe: index and sector ETFs, mega caps,
4/// cyclicals, defensives, laggards and a few high-volatility names.  It is
5/// *not* the investable portfolio; it exists so the indicator is judged on
6/// many kinds of price behaviour, including stocks that went nowhere.
7pub const UNIVERSE: &[&str] = &[
8    // Broad market, size, style, rates, commodities
9    "SPY", "QQQ", "IWM", "DIA", "MDY", "EFA", "EEM", "TLT", "IEF", "GLD", "SLV", "USO", "UNG",
10    // Sector ETFs
11    "XLK", "XLF", "XLE", "XLV", "XLI", "XLY", "XLP", "XLU", "XLB", "XLRE", "SMH", "XBI", "KRE",
12    // Technology / communication
13    "AAPL", "MSFT", "NVDA", "GOOGL", "META", "AMZN", "ORCL", "CSCO", "INTC", "IBM", "ADBE", "CRM",
14    "QCOM", "TXN", "AMD", "MU", "NFLX", "AVGO", "AMAT", "T", "VZ", "DIS",
15    // Financials
16    "JPM", "BAC", "WFC", "C", "GS", "MS", "AXP", "BLK", "SCHW", "USB", "PNC", "AIG", "MET",
17    // Health care
18    "JNJ", "PFE", "MRK", "UNH", "ABT", "LLY", "BMY", "AMGN", "GILD", "MDT", "CVS", "TMO",
19    // Consumer
20    "WMT", "PG", "KO", "PEP", "MCD", "NKE", "SBUX", "HD", "LOW", "TGT", "COST", "CL", "KHC", "F", "GM",
21    // Industrials / materials / energy / utilities / REIT
22    "CAT", "DE", "BA", "GE", "HON", "MMM", "UPS", "FDX", "LMT", "RTX", "UNP", "XOM", "CVX", "COP",
23    "SLB", "OXY", "DOW", "NEM", "FCX", "NEE", "DUK", "SO", "AMT", "SPG", "O",
24    // Higher-volatility / mid caps
25    "TSLA", "SHOP", "UBER", "PYPL", "XYZ", "ROKU", "ETSY", "AAL", "CCL", "MRNA", "PLTR", "SNAP",
26];
27
28// ════════════════════════════════════════════════════════════════════════════
29// The steady backtester
30// ════════════════════════════════════════════════════════════════════════════
31//
32// "Steady" means three things here:
33//
34// 1. **Deterministic.**  Every random draw comes from a seeded generator, so
35//    the same command prints the same numbers tomorrow.
36// 2. **Honest about time.**  Models learn only from bars before
37//    `TRAIN_END`, are tuned on bars before `VALID_END`, and are graded on
38//    the bars after.  A gap of `h` bars is dropped at each boundary so that a
39//    label never peeks across it.
40// 3. **Shaken, not stirred.**  Gaussian noise is added to the *history the
41//    indicator sees*, while outcomes are always measured on the *real*
42//    future.  If SD only works on the exact historical prints, the noise
43//    will expose it.
44
45use crate::history::{self, DatedBar};
46use crate::indicators::OHLCV;
47use crate::sd2::classic::classic_series;
48use crate::sd2::features::{FeatureConfig, FeatureFrame};
49use chrono::NaiveDate;
50use std::path::Path;
51
52/// Forecast horizons in trading days: one week, two weeks, one month.
53pub const HORIZONS: [usize; 4] = [5, 10, 21, 63];
54
55/// Triple-barrier settings: which of ±`BARRIER_ATR`·ATR is touched first
56/// within `BARRIER_BARS` bars.  This is the cleanest form of the question
57/// "which way does the squeeze break?".
58pub const BARRIER_ATR: f64 = 1.5;
59pub const BARRIER_BARS: usize = 21;
60
61pub fn train_end() -> NaiveDate {
62    NaiveDate::from_ymd_opt(2014, 1, 1).unwrap()
63}
64pub fn valid_end() -> NaiveDate {
65    NaiveDate::from_ymd_opt(2019, 1, 1).unwrap()
66}
67
68/// Which slice of history a sample belongs to.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Split {
71    Train,
72    Valid,
73    Test,
74}
75
76/// One (symbol, bar) observation.
77#[derive(Debug, Clone)]
78pub struct Sample {
79    pub symbol: u16,
80    pub date: NaiveDate,
81    pub split: Split,
82    pub x: Vec<f64>,
83    /// Direction-free inputs for the movement model.
84    pub mag_x: Vec<f64>,
85    /// Real forward log return for each of [`HORIZONS`]; NaN if unavailable.
86    pub fwd: [f64; 4],
87    /// +1 if the upper barrier was touched first, -1 lower, 0 neither / both.
88    pub barrier: f64,
89    /// Forward log return of the symbol minus SPY's, per horizon.
90    pub excess: [f64; 4],
91    /// Daily volatility at the cutoff, so moves can be expressed in σ units.
92    pub vol: f64,
93    pub close: f64,
94    pub classic_score: f64,
95    pub alive: bool,
96    pub comp: u8,
97    pub fired_start: bool,
98    /// τ/θ: how far through its expected life the current fire is (NaN if not fired).
99    pub phase: f64,
100    pub energy: f64,
101    pub tier21: u8,
102    pub tier10: u8,
103    pub bars_in_squeeze: usize,
104}
105
106/// A loaded symbol: name plus dated bars.
107pub struct Series {
108    pub symbol: String,
109    pub bars: Vec<DatedBar>,
110}
111
112pub fn load_universe(dir: &Path) -> Vec<Series> {
113    let mut out = Vec::new();
114    for s in UNIVERSE {
115        if let Ok(bars) = history::load(&dir.join(format!("{s}.csv"))) {
116            if bars.len() > 600 {
117                out.push(Series { symbol: s.to_string(), bars });
118            }
119        }
120    }
121    out
122}
123
124/// Small, fast, seedable PRNG (SplitMix64) with Gaussian draws.
125pub struct Rng(u64);
126impl Rng {
127    pub fn new(seed: u64) -> Self {
128        Self(seed ^ 0x9E37_79B9_7F4A_7C15)
129    }
130    pub fn next_u64(&mut self) -> u64 {
131        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
132        let mut z = self.0;
133        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
134        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
135        z ^ (z >> 31)
136    }
137    pub fn uniform(&mut self) -> f64 {
138        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
139    }
140    /// Standard normal via Box–Muller.
141    pub fn gauss(&mut self) -> f64 {
142        let u1 = self.uniform().max(1e-300);
143        let u2 = self.uniform();
144        (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
145    }
146}
147
148/// Add Gaussian *measurement* noise to a price history.
149///
150/// Each bar is multiplied by `exp(ε)`, `ε ~ N(0, (level · σ)²)`, where `σ` is
151/// that symbol's typical daily log-return volatility.  `level = 0.5` means
152/// "every print is wrong by about half a normal day's move", which is a lot
153/// more than any real data error.  On top of that, high and low each get a
154/// smaller independent wobble so the bar's *shape* is perturbed too (that is
155/// what ATR and the Keltner channels feel).
156pub fn add_noise(bars: &[OHLCV], level: f64, rng: &mut Rng) -> Vec<OHLCV> {
157    if level <= 0.0 {
158        return bars.to_vec();
159    }
160    let mut rets: Vec<f64> = bars.windows(2).map(|w| (w[1].close / w[0].close).ln().abs()).collect();
161    rets.sort_by(|a, b| a.partial_cmp(b).unwrap());
162    // Median absolute return × 1.4826 is a robust σ that ignores crash days.
163    let sigma = rets.get(rets.len() / 2).copied().unwrap_or(0.01) * 1.4826;
164    bars.iter()
165        .map(|b| {
166            let common = (level * sigma * rng.gauss()).exp();
167            let wob = |rng: &mut Rng| (0.5 * level * sigma * rng.gauss()).abs();
168            let open = b.open * common;
169            let close = b.close * common;
170            let high = (b.high * common * (1.0 + wob(rng))).max(open).max(close);
171            let low = (b.low * common * (1.0 - wob(rng))).min(open).min(close);
172            OHLCV { open, high, low, close, volume: b.volume }
173        })
174        .collect()
175}
176
177/// Names of the expected-movement inputs, in [`magnitude_inputs`] order.
178pub const MAGNITUDE_NAMES: &[&str] = &[
179    // Baseline: volatility mean reversion alone.
180    "ln_vol_ratio_20_126",
181    "ln_atr_vs_close_vol",
182    // Squeeze structure.
183    "energy",
184    "depth21",
185    "depth10",
186    "tier",
187    "ln_bars_in_squeeze",
188    "fired_x_decay",
189    "width_pct",
190    // Momentum strength (unsigned).
191    "abs_blend",
192    "abs_agree",
193];
194
195/// How many leading [`MAGNITUDE_NAMES`] form the volatility-only baseline.
196pub const MAGNITUDE_BASELINE: usize = 2;
197
198/// Direction-free inputs for the expected-movement model.
199pub fn magnitude_inputs(f: &FeatureFrame, i: usize) -> Vec<f64> {
200    use crate::sd2::primitives::squash;
201    let fired = if f.clock.is_fired[i] { 1.0 } else { 0.0 };
202    vec![
203        (f.vol20[i] / f.vol126[i]).ln(),
204        ((f.atr[i] / f.close[i]) / f.vol20[i].max(1e-6)).ln(),
205        squash(f.energy[i], 8.0),
206        (2.0 - f.q21[i]).clamp(0.0, 2.0),
207        (2.0 - f.q10[i]).clamp(0.0, 2.0),
208        f.comp[i] as f64,
209        (1.0 + f.clock.bars_since_start[i] as f64).ln(),
210        fired * f.decay[i],
211        f.width_pct[i],
212        f.blend[i].abs(),
213        f.agree[i].abs(),
214    ]
215}
216
217/// Build samples for one symbol.  `signal_bars` is what the indicator sees
218/// (maybe noisy); `truth` is the real history used for outcomes.
219pub fn build_samples(
220    symbol: u16,
221    truth: &[DatedBar],
222    signal_bars: &[OHLCV],
223    cfg: FeatureConfig,
224    market: &Market,
225    out: &mut Vec<Sample>,
226) {
227    let f = FeatureFrame::compute(signal_bars, cfg);
228    let classic = classic_series(signal_bars);
229    let n = truth.len();
230    let max_h = *HORIZONS.iter().max().unwrap();
231    for i in FeatureFrame::WARMUP..n {
232        if !f.row_is_valid(i) || !f.vol20[i].is_finite() || f.vol20[i] <= 0.0 {
233            continue;
234        }
235        let mut x = f.row(i);
236        x.extend(market.context(truth[i].date));
237        if x.iter().any(|v| !v.is_finite()) {
238            continue;
239        }
240        let mag_x = magnitude_inputs(&f, i);
241        if mag_x.iter().any(|v| !v.is_finite()) {
242            continue;
243        }
244        let date = truth[i].date;
245        let mut fwd = [f64::NAN; 4];
246        let mut excess = [f64::NAN; 4];
247        for (k, h) in HORIZONS.iter().enumerate() {
248            if i + h < n {
249                fwd[k] = (truth[i + h].bar.close / truth[i].bar.close).ln();
250                if let (Some(a), Some(b)) = (market.close_on(date), market.close_on(truth[i + h].date)) {
251                    excess[k] = fwd[k] - (b / a).ln();
252                }
253            }
254        }
255        let barrier = barrier_label(truth, i, f.atr[i]);
256        // Purge: drop samples whose longest label would cross a split edge.
257        let end_date = truth.get(i + max_h).map(|b| b.date);
258        let split = if date < train_end() {
259            if end_date.is_none_or(|d| d >= train_end()) { continue } else { Split::Train }
260        } else if date < valid_end() {
261            if end_date.is_none_or(|d| d >= valid_end()) && i + max_h < n { continue } else { Split::Valid }
262        } else {
263            Split::Test
264        };
265        out.push(Sample {
266            symbol,
267            date,
268            split,
269            x,
270            mag_x,
271            fwd,
272            barrier,
273            excess,
274            vol: f.vol20[i],
275            close: truth[i].bar.close,
276            classic_score: classic.score[i],
277            alive: f.fire_alive(i),
278            comp: f.comp[i],
279            fired_start: f.clock.fired_start[i],
280            phase: if f.clock.is_fired[i] { f.clock.bars_since_fired[i] as f64 / f.clock.theta[i].max(1.0) } else { f64::NAN },
281            energy: f.energy[i],
282            tier21: crate::sd2::features::tier(f.q21[i]),
283            tier10: crate::sd2::features::tier(f.q10[i]),
284            bars_in_squeeze: f.clock.bars_since_start[i],
285        });
286    }
287}
288
289/// Spearman rank correlation.
290pub fn spearman(a: &[f64], b: &[f64]) -> f64 {
291    fn ranks(v: &[f64]) -> Vec<f64> {
292        let mut idx: Vec<usize> = (0..v.len()).collect();
293        idx.sort_by(|&i, &j| v[i].partial_cmp(&v[j]).unwrap_or(std::cmp::Ordering::Equal));
294        let mut r = vec![0.0; v.len()];
295        let mut k = 0;
296        while k < idx.len() {
297            let mut e = k;
298            while e + 1 < idx.len() && v[idx[e + 1]] == v[idx[k]] {
299                e += 1;
300            }
301            let avg = (k + e) as f64 / 2.0;
302            for &i in &idx[k..=e] {
303                r[i] = avg;
304            }
305            k = e + 1;
306        }
307        r
308    }
309    pearson(&ranks(a), &ranks(b))
310}
311
312pub fn pearson(a: &[f64], b: &[f64]) -> f64 {
313    let n = a.len() as f64;
314    if n < 3.0 {
315        return f64::NAN;
316    }
317    let (ma, mb) = (a.iter().sum::<f64>() / n, b.iter().sum::<f64>() / n);
318    let (mut sab, mut saa, mut sbb) = (0.0, 0.0, 0.0);
319    for (x, y) in a.iter().zip(b) {
320        sab += (x - ma) * (y - mb);
321        saa += (x - ma).powi(2);
322        sbb += (y - mb).powi(2);
323    }
324    sab / (saa * sbb).sqrt()
325}
326
327/// Directional scorecard for a set of (signed call strength, realized return).
328#[derive(Debug, Clone, Default, serde::Serialize)]
329pub struct Scorecard {
330    pub calls: usize,
331    pub coverage: f64,
332    pub hit_rate: f64,
333    /// Hit rate of "always call the majority direction" on the same rows.
334    pub base_rate: f64,
335    pub long_share: f64,
336    /// Average of the up-call hit rate and the down-call hit rate.
337    pub balanced: f64,
338    pub spearman: f64,
339    pub mean_signed_return: f64,
340}
341
342/// Grade calls.  `strength[i] > 0` is an up call, `< 0` a down call, `0` no
343/// call.  Only rows with `|strength| >= threshold` are graded.
344pub fn scorecard(strength: &[f64], ret: &[f64], threshold: f64) -> Scorecard {
345    let total = strength.len();
346    let (mut calls, mut hits, mut ups, mut up_hits, mut downs, mut down_hits, mut real_up) =
347        (0, 0, 0, 0, 0, 0, 0);
348    let mut signed = 0.0;
349    let (mut s_used, mut r_used) = (Vec::new(), Vec::new());
350    for (&s, &r) in strength.iter().zip(ret) {
351        if !r.is_finite() || s.abs() < threshold || s == 0.0 || r == 0.0 {
352            continue;
353        }
354        calls += 1;
355        s_used.push(s);
356        r_used.push(r);
357        if r > 0.0 {
358            real_up += 1;
359        }
360        let hit = (s > 0.0) == (r > 0.0);
361        if hit {
362            hits += 1;
363        }
364        signed += s.signum() * r;
365        if s > 0.0 {
366            ups += 1;
367            up_hits += hit as usize;
368        } else {
369            downs += 1;
370            down_hits += hit as usize;
371        }
372    }
373    let frac = |a: usize, b: usize| if b == 0 { f64::NAN } else { a as f64 / b as f64 };
374    let up_rate = frac(up_hits, ups);
375    let down_rate = frac(down_hits, downs);
376    Scorecard {
377        calls,
378        coverage: frac(calls, total),
379        hit_rate: frac(hits, calls),
380        base_rate: frac(real_up.max(calls - real_up), calls),
381        long_share: frac(ups, calls),
382        balanced: match (up_rate.is_nan(), down_rate.is_nan()) {
383            (false, false) => (up_rate + down_rate) / 2.0,
384            (false, true) => up_rate,
385            (true, false) => down_rate,
386            _ => f64::NAN,
387        },
388        spearman: spearman(&s_used, &r_used),
389        mean_signed_return: if calls > 0 { signed / calls as f64 } else { f64::NAN },
390    }
391}
392
393/// First-touch label.  Uses the *real* future highs and lows.
394pub fn barrier_label(truth: &[DatedBar], i: usize, atr: f64) -> f64 {
395    let c = truth[i].bar.close;
396    let (up, dn) = (c + BARRIER_ATR * atr, c - BARRIER_ATR * atr);
397    for b in truth.iter().skip(i + 1).take(BARRIER_BARS) {
398        let (hit_up, hit_dn) = (b.bar.high >= up, b.bar.low <= dn);
399        match (hit_up, hit_dn) {
400            (true, false) => return 1.0,
401            (false, true) => return -1.0,
402            (true, true) => return 0.0,
403            _ => {}
404        }
405    }
406    0.0
407}
408
409/// Market context from SPY, joined by date.
410///
411/// A single stock's squeeze does not fire in a vacuum: in a calm, rising
412/// market, breakouts tend to resolve upward; in a falling, volatile one the
413/// downside breaks are fast and large.  These three numbers let the model
414/// know which weather it is in.
415pub struct Market {
416    pub dates: Vec<NaiveDate>,
417    pub close: Vec<f64>,
418    /// [trend vs 200-day average in σ, 126-day time-series momentum, vol ratio 20/126]
419    pub ctx: Vec<[f64; 3]>,
420}
421
422pub const MARKET_FEATURES: &[&str] = &["mkt_trend200", "mkt_tsmom", "mkt_volratio"];
423
424impl Market {
425    pub fn new(spy: &[DatedBar]) -> Self {
426        use crate::sd2::primitives::{rolling_std, sma, squash};
427        let close: Vec<f64> = spy.iter().map(|b| b.bar.close).collect();
428        let n = close.len();
429        let lr: Vec<f64> = (0..n).map(|i| if i == 0 { 0.0 } else { (close[i] / close[i - 1]).ln() }).collect();
430        let v20 = rolling_std(&lr, 20);
431        let v126 = rolling_std(&lr, 126);
432        let s200 = sma(&close, 200);
433        let ctx = (0..n)
434            .map(|i| {
435                if i < 200 {
436                    return [f64::NAN; 3];
437                }
438                [
439                    squash((close[i] / s200[i]).ln() / (v126[i] * 200f64.sqrt() * 0.5), 1.0),
440                    squash((close[i] / close[i - 126]).ln() / (v126[i] * 126f64.sqrt()), 1.0),
441                    squash(v20[i] / v126[i] - 1.0, 0.5),
442                ]
443            })
444            .collect();
445        Self { dates: spy.iter().map(|b| b.date).collect(), close, ctx }
446    }
447    fn idx(&self, d: NaiveDate) -> Option<usize> {
448        self.dates.binary_search(&d).ok()
449    }
450    pub fn close_on(&self, d: NaiveDate) -> Option<f64> {
451        self.idx(d).map(|i| self.close[i])
452    }
453    pub fn context(&self, d: NaiveDate) -> [f64; 3] {
454        // Use the latest SPY bar at or before `d`.
455        match self.dates.binary_search(&d) {
456            Ok(i) => self.ctx[i],
457            Err(0) => [f64::NAN; 3],
458            Err(i) => self.ctx[i - 1],
459        }
460    }
461}
462
463/// Area under the ROC curve: the chance a random "up" row scores higher
464/// than a random "down" row.  0.5 = no skill, regardless of base rate.
465pub fn auc(score: &[f64], label: &[bool]) -> f64 {
466    let mut idx: Vec<usize> = (0..score.len()).collect();
467    idx.sort_by(|&a, &b| score[a].partial_cmp(&score[b]).unwrap_or(std::cmp::Ordering::Equal));
468    let (mut rank_sum, mut pos) = (0.0, 0.0);
469    for (r, &i) in idx.iter().enumerate() {
470        if label[i] {
471            rank_sum += (r + 1) as f64;
472            pos += 1.0;
473        }
474    }
475    let neg = score.len() as f64 - pos;
476    if pos == 0.0 || neg == 0.0 {
477        return f64::NAN;
478    }
479    (rank_sum - pos * (pos + 1.0) / 2.0) / (pos * neg)
480}