1use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Clone, Debug, Serialize, Deserialize)]
14pub struct Dataset {
15 pub dates: Vec<String>,
16 pub closes: BTreeMap<String, Vec<f64>>,
18 #[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 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 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 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 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 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 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 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 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 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 };
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; let mut ret = drift + momentum + 0.5 * shock;
220 if jump_prob > 0.0 && next() < jump_prob {
223 ret += (next() - 0.5) * 2.0 * jump_size;
225 }
226 price *= (1.0 + ret).max(1e-9); 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 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 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 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 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 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()); assert!(Dataset::from_csv("date,symbol,close\n2025-01-01,AAA,10").is_err()); assert!(
337 Dataset::from_csv("date,symbol,close\n2025-01-01,AAA,oops\n2025-01-02,AAA,11").is_err()
338 ); }
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 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 fn closes_fingerprint(d: &Dataset) -> u64 {
360 let mut h = 0xcbf2_9ce4_8422_2325u64; 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); }
366 }
367 h
368 }
369
370 #[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 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 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 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 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 assert_eq!(
450 d.closes.values().next().unwrap(),
451 m.closes.values().next().unwrap()
452 );
453 }
454}