use std::sync::Arc;
use polars::{
datatypes::{TimeUnit, TimeZone},
prelude::{DataType, Expr, LazyFrame, NULL, Schema, SortMultipleOptions, col, lit, when},
};
use crate::{
data::domain::{SessionWindow, WindowKind},
error::{ChapatyError, ChapatyResult, DataError},
transport::schema::CanonicalCol,
};
pub mod event;
pub mod ohlcv;
pub mod trades;
pub trait WithBatchIndicators: Sized {
type BatchIndicator: Clone;
#[must_use]
fn with_indicator(self, kind: Self::BatchIndicator) -> Self;
#[must_use]
fn with_indicators(self, kinds: &[Self::BatchIndicator]) -> Self {
kinds
.iter()
.fold(self, |acc, kind| acc.with_indicator(kind.clone()))
}
}
pub(crate) trait BatchCompute {
fn pre_compute(&self, lf: LazyFrame) -> ChapatyResult<LazyFrame>;
fn output_schema(&self) -> Arc<Schema>;
}
trait IndicatorExprExt {
fn vwap_with_volume(self, volume: Expr) -> Expr;
fn agg_vwap_with_volume(self, volume: Expr) -> Expr;
fn session_date(self, session: SessionWindow) -> Expr;
fn ts_as_microseconds(self) -> Expr;
}
impl IndicatorExprExt for Expr {
fn vwap_with_volume(self, volume: Expr) -> Expr {
let (price_x_volume, vol) = prepare_vwap_components(self, volume);
let cumulative_v = vol.cum_sum(false);
when(cumulative_v.clone().gt(lit(0.0)))
.then(price_x_volume.cum_sum(false) / cumulative_v)
.otherwise(lit(NULL))
}
fn agg_vwap_with_volume(self, volume: Expr) -> Expr {
let (price_x_volume, vol) = prepare_vwap_components(self, volume);
let sum_price_x_vol = price_x_volume.sum();
let sum_vol = vol.sum();
when(sum_vol.clone().gt(lit(0.0)))
.then(sum_price_x_vol / sum_vol)
.otherwise(lit(NULL))
}
fn session_date(self, session: SessionWindow) -> Expr {
let local_ts = self.dt().convert_time_zone(session.pl_time_zone());
let local_time = local_ts.clone().dt().time();
let local_date = local_ts.dt().date();
let start_time = lit(session.start_nanos_since_midnight());
let end_time = lit(session.end_nanos_since_midnight());
match session.window_kind() {
WindowKind::Intraday => when(
local_time
.clone()
.gt_eq(start_time)
.and(local_time.lt(end_time)),
)
.then(local_date)
.otherwise(lit(NULL)),
WindowKind::Overnight => when(local_time.clone().gt_eq(start_time))
.then(local_date.clone())
.when(local_time.lt(end_time))
.then(local_date - lit(chrono::Duration::days(1)))
.otherwise(lit(NULL)),
}
}
fn ts_as_microseconds(self) -> Expr {
let ts = self.cast(DataType::Int64);
when(ts.clone().lt(lit(100_000_000_000_i64))) .then(ts.clone() * lit(1_000_000))
.when(ts.clone().lt(lit(100_000_000_000_000_i64))) .then(ts.clone() * lit(1_000))
.when(ts.clone().gt(lit(100_000_000_000_000_000_i64))) .then(ts.clone() / lit(1000)) .otherwise(ts) .cast(DataType::Datetime(
TimeUnit::Microseconds,
Some(TimeZone::UTC),
))
}
}
fn prepare_vwap_components(price: Expr, volume: Expr) -> (Expr, Expr) {
let has_volume = volume.clone().gt(lit(0.0));
let price_x_volume = when(has_volume.clone())
.then(price * volume.clone())
.otherwise(lit(0.0));
let valid_vol = when(has_volume).then(volume).otherwise(lit(0.0));
(price_x_volume, valid_vol)
}
trait LazyFrameIndicatorExt {
fn into_price_timeseries(self, value: Expr) -> Self;
}
impl LazyFrameIndicatorExt for LazyFrame {
fn into_price_timeseries(self, value: Expr) -> Self {
self.sort(
[CanonicalCol::PointInTime],
SortMultipleOptions::default().with_maintain_order(false),
)
.select([
col(CanonicalCol::PointInTime),
value.alias(CanonicalCol::Price),
])
.filter(col(CanonicalCol::Price).is_not_null())
}
}
#[expect(
clippy::needless_pass_by_value,
reason = "error-mapping helper consumes the owned error to convert it into a ChapatyError"
)]
fn convert_err(e: polars::error::PolarsError) -> ChapatyError {
ChapatyError::Data(DataError::DataFrame(format!(
"Error while building batch indicator: {e}"
)))
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
clippy::expect_used,
reason = "tests assert against known-valid fixtures; unwrap and expect surface failures as panics that fail the test"
)]
use chrono::{TimeZone, Utc};
use polars::prelude::{IntoLazy, NamedFrom, Series};
use super::*;
use crate::transport::schema::CanonicalCol;
fn utc_micros(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> i64 {
Utc.with_ymd_and_hms(y, mo, d, h, mi, 0)
.unwrap()
.timestamp_micros()
}
fn session_dates(window: SessionWindow, ts_utc: &[i64]) -> Vec<Option<String>> {
let series = Series::new(CanonicalCol::PointInTime.as_str().into(), ts_utc.to_vec())
.cast(&DataType::Datetime(
TimeUnit::Microseconds,
Some(polars::prelude::TimeZone::UTC),
))
.expect("cast to datetime");
let df = series
.into_frame()
.lazy()
.select([col(CanonicalCol::PointInTime)
.session_date(window)
.alias("session_date")])
.collect()
.expect("collect");
let as_str = df
.column("session_date")
.unwrap()
.cast(&DataType::String)
.unwrap();
let series = as_str.as_materialized_series();
let ca = series.str().unwrap();
(0..ca.len())
.map(|i| ca.get(i).map(str::to_string))
.collect()
}
fn date(value: &str) -> String {
value.to_string()
}
#[test]
fn session_date_intraday_us_core_session() {
let window = SessionWindow::us_core_session();
let timestamps = [
utc_micros(2026, 6, 13, 13, 29), utc_micros(2026, 6, 13, 13, 30), utc_micros(2026, 6, 13, 16, 0), utc_micros(2026, 6, 13, 19, 59), utc_micros(2026, 6, 13, 20, 0), utc_micros(2026, 6, 13, 22, 0), ];
assert_eq!(
session_dates(window, ×tamps),
vec![
None,
Some(date("2026-06-13")),
Some(date("2026-06-13")),
Some(date("2026-06-13")),
None,
None,
]
);
}
#[test]
fn session_date_overnight_pulls_morning_leg_to_previous_day() {
let window = SessionWindow::us_extended_overnight();
let timestamps = [
utc_micros(2026, 6, 13, 21, 59), utc_micros(2026, 6, 13, 22, 0), utc_micros(2026, 6, 14, 3, 0), utc_micros(2026, 6, 14, 6, 0), utc_micros(2026, 6, 14, 13, 29), utc_micros(2026, 6, 14, 13, 30), utc_micros(2026, 6, 14, 16, 0), ];
assert_eq!(
session_dates(window, ×tamps),
vec![
None,
Some(date("2026-06-13")),
Some(date("2026-06-13")),
Some(date("2026-06-13")),
Some(date("2026-06-13")),
None,
None,
]
);
}
}