1pub const UNIVERSE: &[&str] = &[
8 "SPY", "QQQ", "IWM", "DIA", "MDY", "EFA", "EEM", "TLT", "IEF", "GLD", "SLV", "USO", "UNG",
10 "XLK", "XLF", "XLE", "XLV", "XLI", "XLY", "XLP", "XLU", "XLB", "XLRE", "SMH", "XBI", "KRE",
12 "AAPL", "MSFT", "NVDA", "GOOGL", "META", "AMZN", "ORCL", "CSCO", "INTC", "IBM", "ADBE", "CRM",
14 "QCOM", "TXN", "AMD", "MU", "NFLX", "AVGO", "AMAT", "T", "VZ", "DIS",
15 "JPM", "BAC", "WFC", "C", "GS", "MS", "AXP", "BLK", "SCHW", "USB", "PNC", "AIG", "MET",
17 "JNJ", "PFE", "MRK", "UNH", "ABT", "LLY", "BMY", "AMGN", "GILD", "MDT", "CVS", "TMO",
19 "WMT", "PG", "KO", "PEP", "MCD", "NKE", "SBUX", "HD", "LOW", "TGT", "COST", "CL", "KHC", "F", "GM",
21 "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 "TSLA", "SHOP", "UBER", "PYPL", "XYZ", "ROKU", "ETSY", "AAL", "CCL", "MRNA", "PLTR", "SNAP",
26];
27
28use 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
52pub const HORIZONS: [usize; 4] = [5, 10, 21, 63];
54
55pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Split {
71 Train,
72 Valid,
73 Test,
74}
75
76#[derive(Debug, Clone)]
78pub struct Sample {
79 pub symbol: u16,
80 pub date: NaiveDate,
81 pub split: Split,
82 pub x: Vec<f64>,
83 pub mag_x: Vec<f64>,
85 pub fwd: [f64; 4],
87 pub barrier: f64,
89 pub excess: [f64; 4],
91 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 pub phase: f64,
100 pub energy: f64,
101 pub tier21: u8,
102 pub tier10: u8,
103 pub bars_in_squeeze: usize,
104}
105
106pub 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
124pub 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 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
148pub 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 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
177pub const MAGNITUDE_NAMES: &[&str] = &[
179 "ln_vol_ratio_20_126",
181 "ln_atr_vs_close_vol",
182 "energy",
184 "depth21",
185 "depth10",
186 "tier",
187 "ln_bars_in_squeeze",
188 "fired_x_decay",
189 "width_pct",
190 "abs_blend",
192 "abs_agree",
193];
194
195pub const MAGNITUDE_BASELINE: usize = 2;
197
198pub 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
217pub 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 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
289pub 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#[derive(Debug, Clone, Default, serde::Serialize)]
329pub struct Scorecard {
330 pub calls: usize,
331 pub coverage: f64,
332 pub hit_rate: f64,
333 pub base_rate: f64,
335 pub long_share: f64,
336 pub balanced: f64,
338 pub spearman: f64,
339 pub mean_signed_return: f64,
340}
341
342pub 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
393pub 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
409pub struct Market {
416 pub dates: Vec<NaiveDate>,
417 pub close: Vec<f64>,
418 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 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
463pub 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}