use volas_core::{DataFrame, Index, IndexKind, Result, Tz, VolasError};
use crate::agg::AggSpec;
use crate::time_frame::TimeFrame;
pub struct Cumulator {
tf: TimeFrame,
spec: AggSpec,
closed: Option<DataFrame>,
open: Option<DataFrame>,
}
impl Cumulator {
pub fn new(tf: TimeFrame, spec: AggSpec) -> Self {
Cumulator {
tf,
spec,
closed: None,
open: None,
}
}
pub fn append(&mut self, fine: &DataFrame) -> Result<()> {
if fine.height() == 0 {
return Err(VolasError::Value(
"the data frame to be appended is empty".into(),
));
}
let raw = match self.open.take() {
Some(mut open) => {
open.append(fine)?;
open
}
None => fine.clone(),
};
let (ts, tz): (Vec<i64>, _) = match raw.index().kind() {
IndexKind::Datetime(v, tz) => (v.clone(), *tz),
_ => {
return Err(VolasError::Value(
"cumulation target must have a DatetimeIndex".into(),
))
}
};
if ts.contains(&i64::MIN) {
return Err(VolasError::Value(
"cannot cumulate over a DatetimeIndex containing NaT; drop or fill \
the missing index timestamps first"
.into(),
));
}
if ts.windows(2).any(|w| w[1] < w[0]) {
return Err(VolasError::Value(
"cannot cumulate over a non-monotonic DatetimeIndex (bars out of \
order); sort the input by time first"
.into(),
));
}
let runs = group_runs(&ts, self.tf, tz);
let last = runs.len() - 1;
for &(a, b) in &runs[..last] {
let coarse = aggregate_period(&raw.slice(a, b), &self.spec, self.tf)?;
self.closed = Some(match self.closed.take() {
Some(mut closed) => {
closed.append(&coarse)?;
closed
}
None => coarse,
});
}
let (a, b) = runs[last];
self.open = Some(raw.slice(a, b));
Ok(())
}
pub fn frame(&self) -> Result<DataFrame> {
let open_coarse = match &self.open {
Some(open) => Some(aggregate_period(open, &self.spec, self.tf)?),
None => None,
};
match (&self.closed, open_coarse) {
(Some(closed), Some(open)) => {
let mut out = closed.clone();
out.append(&open)?;
Ok(out)
}
(Some(closed), None) => Ok(closed.clone()), (None, Some(open)) => Ok(open),
(None, None) => DataFrame::new(Vec::new(), Vec::new(), None),
}
}
pub fn last(&self) -> Result<Option<DataFrame>> {
match &self.open {
Some(open) => Ok(Some(aggregate_period(open, &self.spec, self.tf)?)),
None => Ok(None),
}
}
pub fn open_clone(&self) -> Option<DataFrame> {
self.open.clone()
}
}
pub fn cumulate(df: &DataFrame, tf: TimeFrame, spec: &AggSpec) -> Result<DataFrame> {
let mut cumulator = Cumulator::new(tf, spec.clone());
cumulator.append(df)?;
cumulator.frame()
}
fn group_runs(ts: &[i64], tf: TimeFrame, tz: Tz) -> Vec<(usize, usize)> {
let mut runs = Vec::new();
let mut start = 0;
let mut key = tf.unify_tz(ts[0], tz);
for (i, &t) in ts.iter().enumerate().skip(1) {
let k = tf.unify_tz(t, tz);
if k != key {
runs.push((start, i));
start = i;
key = k;
}
}
runs.push((start, ts.len()));
runs
}
fn dedup_keep_last(ts: &[i64]) -> Vec<usize> {
let n = ts.len();
(0..n)
.filter(|&i| i + 1 == n || ts[i + 1] != ts[i])
.collect()
}
pub fn aggregate_period(period: &DataFrame, spec: &AggSpec, tf: TimeFrame) -> Result<DataFrame> {
let (ts, tz): (&[i64], _) = match period.index().kind() {
IndexKind::Datetime(v, tz) => (v, *tz),
_ => {
return Err(VolasError::Value(
"cumulation period must have a DatetimeIndex".into(),
))
}
};
let kept = dedup_keep_last(ts);
let start_ns = tf.period_start_ns(ts[kept[0]], tz);
let names = period.names().to_vec();
let mut columns = Vec::with_capacity(names.len());
for (name, col) in names.iter().zip(period.columns()) {
columns.push(spec.agg_for(name).reduce(col, &kept)?);
}
let index = Index::datetime(vec![start_ns], tz).with_name(period.index().name().map(String::from));
DataFrame::new(names, columns, Some(index))
}
#[cfg(test)]
mod tests {
use super::*;
use volas_core::{datetime, Column};
fn frame(
times: &[&str],
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
vol: &[f64],
) -> DataFrame {
let idx: Vec<i64> = times
.iter()
.map(|t| datetime::parse_ns(t).unwrap())
.collect();
DataFrame::new(
vec![
"open".into(),
"high".into(),
"low".into(),
"close".into(),
"volume".into(),
],
vec![
Column::f64(open.to_vec()),
Column::f64(high.to_vec()),
Column::f64(low.to_vec()),
Column::f64(close.to_vec()),
Column::f64(vol.to_vec()),
],
Some(Index::datetime(idx, Tz::Utc)),
)
.unwrap()
}
fn sample() -> DataFrame {
frame(
&[
"2020-01-01 00:00:00",
"2020-01-01 00:01:00",
"2020-01-01 00:02:00",
"2020-01-01 00:03:00",
"2020-01-01 00:04:00",
"2020-01-01 00:05:00",
],
&[10.0, 11.0, 12.0, 13.0, 14.0, 20.0],
&[15.0, 16.0, 17.0, 18.0, 19.0, 25.0],
&[5.0, 6.0, 7.0, 8.0, 9.0, 15.0],
&[11.0, 12.0, 13.0, 14.0, 15.0, 21.0],
&[100.0, 100.0, 100.0, 100.0, 100.0, 50.0],
)
}
#[test]
fn cumulate_5m_aggregates_each_group() {
let df = sample();
let out = cumulate(&df, TimeFrame::Min5, &AggSpec::ohlcv()).unwrap();
assert_eq!(out.height(), 2);
assert_eq!(out.column("open").unwrap().as_f64().unwrap()[0], 10.0);
assert_eq!(out.column("high").unwrap().as_f64().unwrap()[0], 19.0);
assert_eq!(out.column("low").unwrap().as_f64().unwrap()[0], 5.0);
assert_eq!(out.column("close").unwrap().as_f64().unwrap()[0], 15.0);
assert_eq!(out.column("volume").unwrap().as_f64().unwrap()[0], 500.0);
assert_eq!(out.column("open").unwrap().as_f64().unwrap()[1], 20.0);
assert_eq!(out.column("volume").unwrap().as_f64().unwrap()[1], 50.0);
match out.index().kind() {
IndexKind::Datetime(v, _) => {
assert_eq!(v[0], datetime::parse_ns("2020-01-01 00:00:00").unwrap());
assert_eq!(v[1], datetime::parse_ns("2020-01-01 00:05:00").unwrap());
}
_ => panic!("expected DatetimeIndex"), }
}
#[test]
fn cumulate_is_idempotent() {
let df = sample();
let once = cumulate(&df, TimeFrame::Min5, &AggSpec::ohlcv()).unwrap();
let twice = cumulate(&once, TimeFrame::Min5, &AggSpec::ohlcv()).unwrap();
assert!(once.equals(&twice));
}
#[test]
fn incremental_matches_one_shot() {
let df = sample();
let one_shot = cumulate(&df, TimeFrame::Min5, &AggSpec::ohlcv()).unwrap();
let mut cum = Cumulator::new(TimeFrame::Min5, AggSpec::ohlcv());
for i in 0..df.height() {
cum.append(&df.slice(i, i + 1)).unwrap();
}
assert!(cum.frame().unwrap().equals(&one_shot));
}
#[test]
fn cumulator_empty_then_open_only() {
let mut cum = Cumulator::new(TimeFrame::Min5, AggSpec::ohlcv());
assert_eq!(cum.frame().unwrap().height(), 0);
assert!(cum.last().unwrap().is_none());
cum.append(&sample().slice(0, 2)).unwrap();
assert!(cum.last().unwrap().is_some());
assert_eq!(cum.frame().unwrap().height(), 1);
}
#[test]
fn dedup_keeps_last_same_timestamp() {
let df = frame(
&[
"2020-01-01 00:00:00",
"2020-01-01 00:00:00",
"2020-01-01 00:01:00",
],
&[10.0, 99.0, 12.0],
&[15.0, 99.0, 17.0],
&[5.0, 99.0, 7.0],
&[11.0, 99.0, 13.0],
&[100.0, 7.0, 100.0],
);
let out = cumulate(&df, TimeFrame::Min5, &AggSpec::ohlcv()).unwrap();
assert_eq!(out.height(), 1);
assert_eq!(out.column("volume").unwrap().as_f64().unwrap()[0], 107.0);
assert_eq!(out.column("open").unwrap().as_f64().unwrap()[0], 99.0);
}
#[test]
fn empty_append_errors() {
let mut cum = Cumulator::new(TimeFrame::Min5, AggSpec::ohlcv());
let empty = DataFrame::new(
vec!["open".into()],
vec![Column::f64(vec![])],
Some(Index::datetime(vec![], Tz::Utc)),
)
.unwrap();
assert!(cum.append(&empty).is_err());
}
#[test]
fn non_datetime_index_errors() {
let df = DataFrame::new(
vec!["open".into()],
vec![Column::f64(vec![1.0, 2.0])],
None, )
.unwrap();
assert!(cumulate(&df, TimeFrame::Min5, &AggSpec::ohlcv()).is_err());
}
#[test]
fn aggregate_period_rejects_non_datetime_index() {
let df = DataFrame::new(vec!["open".into()], vec![Column::f64(vec![1.0])], None).unwrap();
assert!(aggregate_period(&df, &AggSpec::ohlcv(), TimeFrame::Min5).is_err());
}
#[test]
fn label_is_period_start_not_first_bar() {
let df = frame(
&["2024-01-02 09:07:00", "2024-01-02 09:09:00", "2024-01-02 09:11:00"],
&[1.0, 2.0, 3.0],
&[1.0, 2.0, 3.0],
&[1.0, 2.0, 3.0],
&[1.0, 2.0, 3.0],
&[1.0, 1.0, 1.0],
);
let out = cumulate(&df, TimeFrame::Min5, &AggSpec::ohlcv()).unwrap();
let labels: Vec<i64> = match out.index().kind() {
IndexKind::Datetime(v, _) => v.clone(),
_ => unreachable!(), };
assert_eq!(
labels,
vec![
datetime::parse_ns("2024-01-02 09:05:00").unwrap(),
datetime::parse_ns("2024-01-02 09:10:00").unwrap(),
]
);
}
}