use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Dataset {
pub dates: Vec<String>,
pub closes: BTreeMap<String, Vec<f64>>,
#[serde(default)]
pub dividends: BTreeMap<String, Vec<f64>>,
}
impl Dataset {
pub fn symbols(&self) -> Vec<String> {
self.closes.keys().cloned().collect()
}
pub fn len(&self) -> usize {
self.dates.len()
}
pub fn is_empty(&self) -> bool {
self.dates.is_empty()
}
pub fn close_at(&self, symbol: &str, t: usize) -> Option<f64> {
self.closes.get(symbol).and_then(|v| v.get(t)).copied()
}
pub fn dividend_at(&self, symbol: &str, t: usize) -> f64 {
self.dividends
.get(symbol)
.and_then(|v| v.get(t))
.copied()
.unwrap_or(0.0)
}
pub fn with_dividend_yield(mut self, per_period_yield: f64) -> Self {
self.dividends = self
.closes
.iter()
.map(|(sym, series)| {
let stream = series.iter().map(|&px| px * per_period_yield).collect();
(sym.clone(), stream)
})
.collect();
self
}
pub fn history(&self, symbol: &str, t: usize, lookback: usize) -> Vec<f64> {
match self.closes.get(symbol) {
Some(v) if !v.is_empty() => {
let end = t.min(v.len() - 1);
let start = end + 1 - lookback.min(end + 1);
v[start..=end].to_vec()
}
_ => Vec::new(),
}
}
pub fn from_csv(text: &str) -> Result<Dataset, String> {
let mut per_symbol: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
let mut per_div: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
let mut lines = text.lines();
let header = lines.next().ok_or("empty CSV")?;
let cols: Vec<&str> = header.split(',').map(str::trim).collect();
let col = |name: &str| cols.iter().position(|c| *c == name);
let date_i = col("date").ok_or("CSV header missing 'date'")?;
let sym_i = col("symbol").ok_or("CSV header missing 'symbol'")?;
let close_i = col("close").ok_or("CSV header missing 'close'")?;
let div_i = col("dividend");
for (n, line) in lines.enumerate() {
if line.trim().is_empty() {
continue;
}
let f: Vec<&str> = line.split(',').map(str::trim).collect();
let field = |i: usize| {
f.get(i)
.copied()
.ok_or_else(|| format!("CSV row {}: too few columns", n + 2))
};
let date = field(date_i)?.to_string();
let symbol = field(sym_i)?.to_string();
let close: f64 = field(close_i)?
.parse()
.map_err(|_| format!("CSV row {}: non-numeric close", n + 2))?;
per_symbol
.entry(symbol.clone())
.or_default()
.insert(date.clone(), close);
if let Some(di) = div_i {
if let Some(Ok(d)) = f.get(di).map(|s| s.trim().parse::<f64>()) {
per_div.entry(symbol).or_default().insert(date, d);
}
}
}
if per_symbol.is_empty() {
return Err("CSV has no data rows".to_string());
}
let mut axis: Option<std::collections::BTreeSet<String>> = None;
for m in per_symbol.values() {
let set: std::collections::BTreeSet<String> = m.keys().cloned().collect();
axis = Some(match axis {
Some(a) => a.intersection(&set).cloned().collect(),
None => set,
});
}
let dates: Vec<String> = axis.unwrap_or_default().into_iter().collect();
if dates.len() < 2 {
return Err("CSV has fewer than 2 dates common to all symbols".to_string());
}
let mut closes = BTreeMap::new();
let mut dividends = BTreeMap::new();
for (sym, m) in &per_symbol {
closes.insert(sym.clone(), dates.iter().map(|d| m[d]).collect());
if let Some(dm) = per_div.get(sym) {
let stream: Vec<f64> = dates
.iter()
.map(|d| dm.get(d).copied().unwrap_or(0.0))
.collect();
if stream.iter().any(|&x| x != 0.0) {
dividends.insert(sym.clone(), stream);
}
}
}
Ok(Dataset {
dates,
closes,
dividends,
})
}
pub fn from_csv_file(path: &str) -> Result<Dataset, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))?;
Self::from_csv(&text)
}
pub fn synthetic(n_symbols: usize, n_days: usize, seed: u64) -> Dataset {
let dates: Vec<String> = (0..n_days).map(|d| format!("2025-{:03}", d + 1)).collect();
let mut closes = BTreeMap::new();
let mut state = seed ^ 0x1234_5678_9ABC_DEF0;
let mut next = || {
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
(z >> 11) as f64 / (1u64 << 53) as f64 };
for s in 0..n_symbols {
let mut price = 100.0;
let mut momentum = 0.0;
let drift = 0.0002 + 0.0004 * (s as f64 / n_symbols.max(1) as f64);
let mut series = Vec::with_capacity(n_days);
for _ in 0..n_days {
let shock = (next() - 0.5) * 0.02;
momentum = 0.9 * momentum + 0.1 * shock; let ret = drift + momentum + 0.5 * shock;
price *= 1.0 + ret;
series.push(price);
}
closes.insert(format!("SYM{s:02}"), series);
}
Dataset {
dates,
closes,
dividends: BTreeMap::new(),
}
}
pub fn flash_crash(
n_symbols: usize,
n_days: usize,
crash_day: usize,
crash_pct: f64,
seed: u64,
) -> Dataset {
let mut d = Dataset::synthetic(n_symbols, n_days, seed);
let factor = (1.0 - crash_pct).max(0.0);
for series in d.closes.values_mut() {
for v in series.iter_mut().skip(crash_day) {
*v *= factor;
}
}
d
}
pub fn whipsaw(n_symbols: usize, n_days: usize, amplitude: f64, seed: u64) -> Dataset {
let dates: Vec<String> = (0..n_days).map(|d| format!("2025-{:03}", d + 1)).collect();
let mut closes = BTreeMap::new();
let phase = (seed % 2) as usize;
for s in 0..n_symbols {
let mut price = 100.0;
let mut series = Vec::with_capacity(n_days);
for i in 0..n_days {
let dir = if (i + s + phase).is_multiple_of(2) {
1.0
} else {
-1.0
};
price *= 1.0 + dir * amplitude;
series.push(price);
}
closes.insert(format!("SYM{s:02}"), series);
}
Dataset {
dates,
closes,
dividends: BTreeMap::new(),
}
}
pub fn stress_suite(seed: u64) -> Vec<(&'static str, Dataset)> {
vec![
("flash_crash", Dataset::flash_crash(6, 180, 90, 0.30, seed)),
("whipsaw", Dataset::whipsaw(6, 180, 0.04, seed)),
]
}
pub fn masked(&self) -> Dataset {
let dates: Vec<String> = (0..self.dates.len()).map(|i| format!("t{i}")).collect();
let closes: BTreeMap<String, Vec<f64>> = self
.closes
.values()
.enumerate()
.map(|(i, series)| (format!("ASSET_{i:03}"), series.clone()))
.collect();
Dataset {
dates,
closes,
dividends: BTreeMap::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_csv_aligns_on_common_dates() {
let csv = "date,symbol,close\n\
2025-01-01,AAA,10\n2025-01-01,BBB,20\n\
2025-01-02,AAA,11\n2025-01-02,BBB,19\n\
2025-01-03,AAA,12\n";
let ds = Dataset::from_csv(csv).unwrap();
assert_eq!(ds.dates, vec!["2025-01-01", "2025-01-02"]);
assert_eq!(ds.closes["AAA"], vec![10.0, 11.0]);
assert_eq!(ds.closes["BBB"], vec![20.0, 19.0]);
assert_eq!(ds.close_at("AAA", 1), Some(11.0));
}
#[test]
fn from_csv_rejects_malformed_input() {
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!(
Dataset::from_csv("date,symbol,close\n2025-01-01,AAA,oops\n2025-01-02,AAA,11").is_err()
); }
#[test]
fn history_is_point_in_time() {
let d = Dataset::synthetic(2, 50, 7);
let h = d.history("SYM00", 10, 5);
assert_eq!(h.len(), 5);
assert_eq!(*h.last().unwrap(), d.close_at("SYM00", 10).unwrap());
}
#[test]
fn synthetic_is_deterministic() {
let a = Dataset::synthetic(3, 40, 99);
let b = Dataset::synthetic(3, 40, 99);
assert_eq!(a.closes, b.closes);
}
#[test]
fn flash_crash_has_a_big_drop() {
let d = Dataset::flash_crash(2, 120, 60, 0.3, 5);
let s = &d.closes["SYM00"];
assert!(
s[60] < s[59] * 0.8,
"crash should drop ≥20%: {} -> {}",
s[59],
s[60]
);
}
#[test]
fn whipsaw_has_near_zero_drift() {
let d = Dataset::whipsaw(1, 100, 0.03, 1);
let s = &d.closes["SYM00"];
let total = s.last().unwrap() / s[0] - 1.0;
assert!(total.abs() < 0.1, "whipsaw drift={total}");
}
#[test]
fn stress_suite_has_scenarios() {
assert_eq!(Dataset::stress_suite(1).len(), 2);
}
#[test]
fn dividend_yield_builder_pays_a_fraction_of_price() {
let d = Dataset::synthetic(2, 30, 3).with_dividend_yield(0.01);
let px = d.close_at("SYM00", 5).unwrap();
assert!((d.dividend_at("SYM00", 5) - px * 0.01).abs() < 1e-12);
let plain = Dataset::synthetic(2, 30, 3);
assert_eq!(plain.dividend_at("SYM00", 5), 0.0);
}
#[test]
fn masking_anonymizes_but_preserves_prices() {
let d = Dataset::synthetic(3, 40, 1);
let m = d.masked();
assert_eq!(m.symbols().len(), 3);
assert!(m.symbols().iter().all(|s| s.starts_with("ASSET_")));
assert!(m.dates.iter().all(|s| s.starts_with('t')));
assert_eq!(
d.closes.values().next().unwrap(),
m.closes.values().next().unwrap()
);
}
}