Skip to main content

sharpebench_sim/
data.rs

1//! Point-in-time price data.
2//!
3//! A [`Dataset`] is a shared date axis plus per-symbol closes aligned to it. The
4//! only accessors return data at or before a given step index — there is no way
5//! to read a future bar, so look-ahead bias is impossible by construction rather
6//! than policed after the fact.
7
8use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12/// A point-in-time price dataset.
13#[derive(Clone, Debug, Serialize, Deserialize)]
14pub struct Dataset {
15    pub dates: Vec<String>,
16    /// symbol → closes, each `Vec` aligned to `dates`.
17    pub closes: BTreeMap<String, Vec<f64>>,
18    /// symbol → per-share cash dividend paid at each step, aligned to `dates`.
19    /// Empty (the default) means no corporate actions. Stock splits need no entry
20    /// here: on a split-adjusted close series they are price-neutral by
21    /// construction, so only the cash dividend stream changes total return.
22    #[serde(default)]
23    pub dividends: BTreeMap<String, Vec<f64>>,
24}
25
26impl Dataset {
27    pub fn symbols(&self) -> Vec<String> {
28        self.closes.keys().cloned().collect()
29    }
30
31    pub fn len(&self) -> usize {
32        self.dates.len()
33    }
34
35    pub fn is_empty(&self) -> bool {
36        self.dates.is_empty()
37    }
38
39    /// Close for `symbol` at step `t`, or `None` if out of range.
40    pub fn close_at(&self, symbol: &str, t: usize) -> Option<f64> {
41        self.closes.get(symbol).and_then(|v| v.get(t)).copied()
42    }
43
44    /// Per-share cash dividend paid by `symbol` at step `t` (0.0 if none).
45    pub fn dividend_at(&self, symbol: &str, t: usize) -> f64 {
46        self.dividends
47            .get(symbol)
48            .and_then(|v| v.get(t))
49            .copied()
50            .unwrap_or(0.0)
51    }
52
53    /// Attach a constant dividend yield: every symbol pays `per_period_yield` of
54    /// its close as a cash dividend each step (e.g. an annual 4% yield on daily
55    /// bars ≈ `0.04 / 252`). Models the cash-flow half of corporate actions.
56    pub fn with_dividend_yield(mut self, per_period_yield: f64) -> Self {
57        self.dividends = self
58            .closes
59            .iter()
60            .map(|(sym, series)| {
61                let stream = series.iter().map(|&px| px * per_period_yield).collect();
62                (sym.clone(), stream)
63            })
64            .collect();
65        self
66    }
67
68    /// Trailing closes ending at step `t` (inclusive), at most `lookback` long.
69    /// Point-in-time: never includes a bar after `t`.
70    pub fn history(&self, symbol: &str, t: usize, lookback: usize) -> Vec<f64> {
71        match self.closes.get(symbol) {
72            Some(v) if !v.is_empty() => {
73                let end = t.min(v.len() - 1);
74                let start = end + 1 - lookback.min(end + 1);
75                v[start..=end].to_vec()
76            }
77            _ => Vec::new(),
78        }
79    }
80
81    /// Load a frozen dataset from long-format CSV (`date,symbol,close[,dividend]`,
82    /// header required). The series are aligned on the **intersection** of every
83    /// symbol's dates, so `close_at(sym, t)` lines up across symbols; ISO
84    /// `YYYY-MM-DD` dates sort chronologically. Pure — no network. The benchmark
85    /// only ever reads *frozen* data (offline fetchers live in the `xtask` crate),
86    /// which is what keeps a score reproducible forever.
87    pub fn from_csv(text: &str) -> Result<Dataset, String> {
88        let mut per_symbol: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
89        let mut per_div: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
90
91        let mut lines = text.lines();
92        let header = lines.next().ok_or("empty CSV")?;
93        let cols: Vec<&str> = header.split(',').map(str::trim).collect();
94        let col = |name: &str| cols.iter().position(|c| *c == name);
95        let date_i = col("date").ok_or("CSV header missing 'date'")?;
96        let sym_i = col("symbol").ok_or("CSV header missing 'symbol'")?;
97        let close_i = col("close").ok_or("CSV header missing 'close'")?;
98        let div_i = col("dividend");
99
100        for (n, line) in lines.enumerate() {
101            if line.trim().is_empty() {
102                continue;
103            }
104            let f: Vec<&str> = line.split(',').map(str::trim).collect();
105            let field = |i: usize| {
106                f.get(i)
107                    .copied()
108                    .ok_or_else(|| format!("CSV row {}: too few columns", n + 2))
109            };
110            let date = field(date_i)?.to_string();
111            let symbol = field(sym_i)?.to_string();
112            let close: f64 = field(close_i)?
113                .parse()
114                .map_err(|_| format!("CSV row {}: non-numeric close", n + 2))?;
115            per_symbol
116                .entry(symbol.clone())
117                .or_default()
118                .insert(date.clone(), close);
119            if let Some(di) = div_i {
120                if let Some(Ok(d)) = f.get(di).map(|s| s.trim().parse::<f64>()) {
121                    per_div.entry(symbol).or_default().insert(date, d);
122                }
123            }
124        }
125        if per_symbol.is_empty() {
126            return Err("CSV has no data rows".to_string());
127        }
128
129        // Shared axis = the intersection of every symbol's dates (guarantees the
130        // per-symbol series are step-for-step aligned).
131        let mut axis: Option<std::collections::BTreeSet<String>> = None;
132        for m in per_symbol.values() {
133            let set: std::collections::BTreeSet<String> = m.keys().cloned().collect();
134            axis = Some(match axis {
135                Some(a) => a.intersection(&set).cloned().collect(),
136                None => set,
137            });
138        }
139        let dates: Vec<String> = axis.unwrap_or_default().into_iter().collect();
140        if dates.len() < 2 {
141            return Err("CSV has fewer than 2 dates common to all symbols".to_string());
142        }
143
144        let mut closes = BTreeMap::new();
145        let mut dividends = BTreeMap::new();
146        for (sym, m) in &per_symbol {
147            closes.insert(sym.clone(), dates.iter().map(|d| m[d]).collect());
148            if let Some(dm) = per_div.get(sym) {
149                let stream: Vec<f64> = dates
150                    .iter()
151                    .map(|d| dm.get(d).copied().unwrap_or(0.0))
152                    .collect();
153                if stream.iter().any(|&x| x != 0.0) {
154                    dividends.insert(sym.clone(), stream);
155                }
156            }
157        }
158        Ok(Dataset {
159            dates,
160            closes,
161            dividends,
162        })
163    }
164
165    /// Load a frozen dataset from a CSV file path. See [`Dataset::from_csv`].
166    pub fn from_csv_file(path: &str) -> Result<Dataset, String> {
167        let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))?;
168        Self::from_csv(&text)
169    }
170
171    /// Build a deterministic synthetic dataset with mild momentum
172    /// autocorrelation — enough to make the reference agents behave differently.
173    /// Pure function of `seed` (no ambient RNG). Thin wrapper over
174    /// [`Dataset::synthetic_parameterized`] at the calm-market parameters
175    /// (unit vol, no jumps) — byte-identical to the standalone generator it
176    /// replaced (pinned by `synthetic_is_byte_identical_golden`).
177    pub fn synthetic(n_symbols: usize, n_days: usize, seed: u64) -> Dataset {
178        Dataset::synthetic_parameterized(n_symbols, n_days, seed, 1.0, 0.0, 0.0)
179    }
180
181    /// The continuous-vol / jump-diffusion generalization of [`Dataset::synthetic`]:
182    /// the same drift + AR(1)-momentum path, with each bar's Gaussian-ish shock
183    /// scaled by `vol_mult` and seeded **bounded-uniform jumps** of magnitude
184    /// `jump_size` injected with per-bar probability `jump_prob` (a fat-tail stress
185    /// knob). Pure function of `seed`; only mul/add/div/max (no `ln`/`exp`), so the
186    /// path is byte-identical across Rust/WASM/Python.
187    ///
188    /// Determinism note: the jump draws are taken **only** when `jump_prob > 0`, so
189    /// the no-jump call consumes the RNG identically to the original `synthetic`
190    /// (one draw per bar) — `vol_mult = 1.0, jump_prob = 0.0` reproduces it exactly.
191    /// Prices are kept strictly positive by flooring the per-bar growth factor.
192    pub fn synthetic_parameterized(
193        n_symbols: usize,
194        n_days: usize,
195        seed: u64,
196        vol_mult: f64,
197        jump_prob: f64,
198        jump_size: f64,
199    ) -> Dataset {
200        let dates: Vec<String> = (0..n_days).map(|d| format!("2025-{:03}", d + 1)).collect();
201        let mut closes = BTreeMap::new();
202        let mut state = seed ^ 0x1234_5678_9ABC_DEF0;
203        let mut next = || {
204            state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
205            let mut z = state;
206            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
207            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
208            z ^= z >> 31;
209            (z >> 11) as f64 / (1u64 << 53) as f64 // [0,1)
210        };
211        for s in 0..n_symbols {
212            let mut price = 100.0;
213            let mut momentum = 0.0;
214            let drift = 0.0002 + 0.0004 * (s as f64 / n_symbols.max(1) as f64);
215            let mut series = Vec::with_capacity(n_days);
216            for _ in 0..n_days {
217                let shock = (next() - 0.5) * 0.02 * vol_mult;
218                momentum = 0.9 * momentum + 0.1 * shock; // autocorrelated component
219                let mut ret = drift + momentum + 0.5 * shock;
220                // Jumps are opt-in: the `&&` short-circuit only consumes RNG when
221                // armed, so the no-jump path leaves the calm stream byte-identical.
222                if jump_prob > 0.0 && next() < jump_prob {
223                    // Bounded-uniform jump in (-jump_size, jump_size).
224                    ret += (next() - 0.5) * 2.0 * jump_size;
225                }
226                price *= (1.0 + ret).max(1e-9); // floor keeps prices positive
227                series.push(price);
228            }
229            closes.insert(format!("SYM{s:02}"), series);
230        }
231        Dataset {
232            dates,
233            closes,
234            dividends: BTreeMap::new(),
235        }
236    }
237
238    /// Adversarial path: a synthetic series with a sudden one-day **flash crash**
239    /// of `crash_pct` at `crash_day` that does not fully recover — a tail-stress
240    /// scenario that should blow up agents with no risk discipline.
241    pub fn flash_crash(
242        n_symbols: usize,
243        n_days: usize,
244        crash_day: usize,
245        crash_pct: f64,
246        seed: u64,
247    ) -> Dataset {
248        let mut d = Dataset::synthetic(n_symbols, n_days, seed);
249        let factor = (1.0 - crash_pct).max(0.0);
250        for series in d.closes.values_mut() {
251            for v in series.iter_mut().skip(crash_day) {
252                *v *= factor;
253            }
254        }
255        d
256    }
257
258    /// **Whipsaw** regime: sharp alternating up/down moves with no drift. Trend and
259    /// momentum agents get chopped up by transaction costs.
260    pub fn whipsaw(n_symbols: usize, n_days: usize, amplitude: f64, seed: u64) -> Dataset {
261        let dates: Vec<String> = (0..n_days).map(|d| format!("2025-{:03}", d + 1)).collect();
262        let mut closes = BTreeMap::new();
263        let phase = (seed % 2) as usize;
264        for s in 0..n_symbols {
265            let mut price = 100.0;
266            let mut series = Vec::with_capacity(n_days);
267            for i in 0..n_days {
268                let dir = if (i + s + phase).is_multiple_of(2) {
269                    1.0
270                } else {
271                    -1.0
272                };
273                price *= 1.0 + dir * amplitude;
274                series.push(price);
275            }
276            closes.insert(format!("SYM{s:02}"), series);
277        }
278        Dataset {
279            dates,
280            closes,
281            dividends: BTreeMap::new(),
282        }
283    }
284
285    /// A named adversarial stress suite — each scenario tests *survival*, not
286    /// calm-market return.
287    pub fn stress_suite(seed: u64) -> Vec<(&'static str, Dataset)> {
288        vec![
289            ("flash_crash", Dataset::flash_crash(6, 180, 90, 0.30, seed)),
290            ("whipsaw", Dataset::whipsaw(6, 180, 0.04, seed)),
291        ]
292    }
293
294    /// A contamination-masked copy: symbols renamed to opaque ids and dates
295    /// replaced with plain indices, so an agent can't pattern-match a memorized
296    /// ticker or calendar window. Prices are preserved. (After KTD-Fin's data-side
297    /// masking.)
298    pub fn masked(&self) -> Dataset {
299        let dates: Vec<String> = (0..self.dates.len()).map(|i| format!("t{i}")).collect();
300        let closes: BTreeMap<String, Vec<f64>> = self
301            .closes
302            .values()
303            .enumerate()
304            .map(|(i, series)| (format!("ASSET_{i:03}"), series.clone()))
305            .collect();
306        Dataset {
307            dates,
308            closes,
309            dividends: BTreeMap::new(),
310        }
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn from_csv_aligns_on_common_dates() {
320        // BBB is missing 2025-01-03, so the shared axis is the first two dates.
321        let csv = "date,symbol,close\n\
322                   2025-01-01,AAA,10\n2025-01-01,BBB,20\n\
323                   2025-01-02,AAA,11\n2025-01-02,BBB,19\n\
324                   2025-01-03,AAA,12\n";
325        let ds = Dataset::from_csv(csv).unwrap();
326        assert_eq!(ds.dates, vec!["2025-01-01", "2025-01-02"]);
327        assert_eq!(ds.closes["AAA"], vec![10.0, 11.0]);
328        assert_eq!(ds.closes["BBB"], vec![20.0, 19.0]);
329        assert_eq!(ds.close_at("AAA", 1), Some(11.0));
330    }
331
332    #[test]
333    fn from_csv_rejects_malformed_input() {
334        assert!(Dataset::from_csv("date,symbol\n2025-01-01,AAA").is_err()); // no close column
335        assert!(Dataset::from_csv("date,symbol,close\n2025-01-01,AAA,10").is_err()); // < 2 dates
336        assert!(
337            Dataset::from_csv("date,symbol,close\n2025-01-01,AAA,oops\n2025-01-02,AAA,11").is_err()
338        ); // non-numeric close
339    }
340
341    #[test]
342    fn history_is_point_in_time() {
343        let d = Dataset::synthetic(2, 50, 7);
344        let h = d.history("SYM00", 10, 5);
345        assert_eq!(h.len(), 5);
346        // The last element of the trailing window equals the close at t=10.
347        assert_eq!(*h.last().unwrap(), d.close_at("SYM00", 10).unwrap());
348    }
349
350    #[test]
351    fn synthetic_is_deterministic() {
352        let a = Dataset::synthetic(3, 40, 99);
353        let b = Dataset::synthetic(3, 40, 99);
354        assert_eq!(a.closes, b.closes);
355    }
356
357    /// Order-independent fold over every close (bit-exact) — a stand-in for a hash
358    /// that survives BTreeMap iteration order.
359    fn closes_fingerprint(d: &Dataset) -> u64 {
360        let mut h = 0xcbf2_9ce4_8422_2325u64; // FNV offset basis
361        for series in d.closes.values() {
362            for px in series {
363                h ^= px.to_bits();
364                h = h.wrapping_mul(0x0000_0100_0000_01b3); // FNV prime
365            }
366        }
367        h
368    }
369
370    /// Golden pin: the refactor that routed `synthetic` through
371    /// `synthetic_parameterized(.., 1.0, 0.0, 0.0)` must reproduce the original
372    /// generator bit-for-bit. The constant is the fingerprint captured *before*
373    /// the refactor (seed 99, 3×40); a regression flips it.
374    #[test]
375    fn synthetic_is_byte_identical_golden() {
376        let d = Dataset::synthetic(3, 40, 99);
377        assert_eq!(
378            closes_fingerprint(&d),
379            298_678_261_974_633_681,
380            "synthetic price path drifted from the pre-refactor golden"
381        );
382        // The parameterized generator at calm parameters is the same path.
383        let p = Dataset::synthetic_parameterized(3, 40, 99, 1.0, 0.0, 0.0);
384        assert_eq!(d.closes, p.closes);
385    }
386
387    #[test]
388    fn vol_mult_widens_the_path_and_jumps_perturb_it() {
389        let base = Dataset::synthetic_parameterized(2, 200, 7, 1.0, 0.0, 0.0);
390        let calm = Dataset::synthetic(2, 200, 7);
391        assert_eq!(base.closes, calm.closes, "calm params == synthetic");
392
393        // Higher vol multiplier must change the realized path.
394        let hot = Dataset::synthetic_parameterized(2, 200, 7, 3.0, 0.0, 0.0);
395        assert_ne!(base.closes, hot.closes, "vol_mult must move the path");
396
397        // Jumps must perturb the path and keep prices strictly positive.
398        let jumpy = Dataset::synthetic_parameterized(2, 200, 7, 1.0, 0.5, 0.1);
399        assert_ne!(base.closes, jumpy.closes, "jumps must perturb the path");
400        assert!(
401            jumpy.closes.values().flatten().all(|&px| px > 0.0),
402            "prices must stay positive through jumps"
403        );
404    }
405
406    #[test]
407    fn flash_crash_has_a_big_drop() {
408        let d = Dataset::flash_crash(2, 120, 60, 0.3, 5);
409        let s = &d.closes["SYM00"];
410        assert!(
411            s[60] < s[59] * 0.8,
412            "crash should drop ≥20%: {} -> {}",
413            s[59],
414            s[60]
415        );
416    }
417
418    #[test]
419    fn whipsaw_has_near_zero_drift() {
420        let d = Dataset::whipsaw(1, 100, 0.03, 1);
421        let s = &d.closes["SYM00"];
422        let total = s.last().unwrap() / s[0] - 1.0;
423        assert!(total.abs() < 0.1, "whipsaw drift={total}");
424    }
425
426    #[test]
427    fn stress_suite_has_scenarios() {
428        assert_eq!(Dataset::stress_suite(1).len(), 2);
429    }
430
431    #[test]
432    fn dividend_yield_builder_pays_a_fraction_of_price() {
433        let d = Dataset::synthetic(2, 30, 3).with_dividend_yield(0.01);
434        let px = d.close_at("SYM00", 5).unwrap();
435        assert!((d.dividend_at("SYM00", 5) - px * 0.01).abs() < 1e-12);
436        // A symbol/step with no dividend stream returns 0.
437        let plain = Dataset::synthetic(2, 30, 3);
438        assert_eq!(plain.dividend_at("SYM00", 5), 0.0);
439    }
440
441    #[test]
442    fn masking_anonymizes_but_preserves_prices() {
443        let d = Dataset::synthetic(3, 40, 1);
444        let m = d.masked();
445        assert_eq!(m.symbols().len(), 3);
446        assert!(m.symbols().iter().all(|s| s.starts_with("ASSET_")));
447        assert!(m.dates.iter().all(|s| s.starts_with('t')));
448        // Prices are preserved (BTreeMap order is stable, so first maps to first).
449        assert_eq!(
450            d.closes.values().next().unwrap(),
451            m.closes.values().next().unwrap()
452        );
453    }
454}