use crate::model::{Bar, BarQuality};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq)]
pub struct SynchronizedBarPair {
pub timestamp: i64,
pub primary: Bar,
pub benchmark: Bar,
pub benchmark_source_timestamp: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MissingSeriesPolicy {
Skip,
#[default]
ForwardFill,
}
#[derive(Debug, Clone, Default)]
pub struct MultiSeriesSync {
last_benchmark: Option<Bar>,
missing_policy: MissingSeriesPolicy,
}
impl MultiSeriesSync {
pub fn new() -> Self {
Self {
last_benchmark: None,
missing_policy: MissingSeriesPolicy::ForwardFill,
}
}
pub fn with_policy(missing_policy: MissingSeriesPolicy) -> Self {
Self {
last_benchmark: None,
missing_policy,
}
}
pub fn reset(&mut self) {
self.last_benchmark = None;
}
pub fn align_step(
&mut self,
primary: &Bar,
benchmark: Option<&Bar>,
) -> Option<SynchronizedBarPair> {
let bench_bar = match benchmark {
Some(b) => {
self.last_benchmark = Some(b.clone());
b.clone()
}
None if self.missing_policy == MissingSeriesPolicy::ForwardFill => {
self.last_benchmark.clone()?
}
None => return None,
};
let benchmark_source_timestamp = bench_bar.timestamp;
Some(SynchronizedBarPair {
timestamp: primary.timestamp,
primary: primary.clone(),
benchmark: bench_bar,
benchmark_source_timestamp,
})
}
pub fn align_series(
primary_series: &[Bar],
benchmark_series: &[Bar],
) -> Vec<SynchronizedBarPair> {
let mut bench_map: HashMap<i64, Bar> = HashMap::new();
for b in benchmark_series {
bench_map.insert(b.timestamp, b.clone());
}
let mut sync = MultiSeriesSync::new();
let mut result = Vec::with_capacity(primary_series.len());
for p_bar in primary_series {
let b_bar = bench_map.get(&p_bar.timestamp);
if let Some(pair) = sync.align_step(p_bar, b_bar) {
result.push(pair);
}
}
result
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AlignedSeriesBar {
pub bar: Bar,
pub quality: BarQuality,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct AlignedStep {
pub timestamp: i64,
pub series: HashMap<String, AlignedSeriesBar>,
}
#[derive(Debug, Clone)]
pub struct MultiSeriesAligner {
series_ids: Vec<String>,
last_seen: HashMap<String, (Bar, i64)>,
missing_policy: MissingSeriesPolicy,
max_forward_fill_age_seconds: Option<i64>,
}
impl MultiSeriesAligner {
pub fn new(series_ids: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
series_ids: series_ids.into_iter().map(Into::into).collect(),
last_seen: HashMap::new(),
missing_policy: MissingSeriesPolicy::ForwardFill,
max_forward_fill_age_seconds: None,
}
}
pub fn with_policy(mut self, policy: MissingSeriesPolicy) -> Self {
self.missing_policy = policy;
self
}
pub fn with_max_forward_fill_age(mut self, seconds: i64) -> Self {
self.max_forward_fill_age_seconds = Some(seconds);
self
}
pub fn reset(&mut self) {
self.last_seen.clear();
}
pub fn align_step(&mut self, timestamp: i64, incoming: &HashMap<String, Bar>) -> AlignedStep {
let mut series = HashMap::with_capacity(self.series_ids.len());
for id in &self.series_ids {
if let Some(bar) = incoming.get(id) {
self.last_seen.insert(id.clone(), (bar.clone(), timestamp));
series.insert(
id.clone(),
AlignedSeriesBar {
bar: bar.clone(),
quality: BarQuality::observed(),
},
);
continue;
}
if self.missing_policy != MissingSeriesPolicy::ForwardFill {
continue;
}
let Some((last_bar, last_ts)) = self.last_seen.get(id) else {
continue;
};
let age = timestamp - last_ts;
let within_age = self
.max_forward_fill_age_seconds
.map(|max| age <= max)
.unwrap_or(true);
if !within_age {
continue;
}
let mut filled = last_bar.clone();
filled.timestamp = timestamp;
series.insert(
id.clone(),
AlignedSeriesBar {
bar: filled,
quality: BarQuality {
volume_available: false,
is_synthetic: false,
is_forward_filled: true,
has_gap: age > 0,
},
},
);
}
AlignedStep { timestamp, series }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_multi_series_sync_forward_fill() {
let mut sync = MultiSeriesSync::new();
let b1 = Bar::new(1000, 10.0, 11.0, 9.0, 10.5, 500.0);
let p1 = Bar::new(1000, 100.0, 105.0, 95.0, 102.0, 1000.0);
let p2 = Bar::new(2000, 102.0, 106.0, 101.0, 105.0, 1000.0);
let pair1 = sync.align_step(&p1, Some(&b1)).unwrap();
assert_eq!(pair1.benchmark.timestamp, 1000);
let pair2 = sync.align_step(&p2, None).unwrap();
assert_eq!(pair2.benchmark.timestamp, 1000);
assert_eq!(pair2.benchmark_source_timestamp, 1000);
assert_eq!(pair2.benchmark.close, 10.5);
}
#[test]
fn never_substitutes_the_primary_for_a_missing_benchmark() {
let primary = Bar::new(1_000, 100.0, 105.0, 95.0, 102.0, 1_000.0);
assert!(MultiSeriesSync::new().align_step(&primary, None).is_none());
assert!(MultiSeriesSync::with_policy(MissingSeriesPolicy::Skip)
.align_step(&primary, None)
.is_none());
}
#[test]
fn test_multi_series_aligner_aligns_more_than_two_series() {
let mut aligner =
MultiSeriesAligner::new(["a", "b", "c"]).with_policy(MissingSeriesPolicy::ForwardFill);
let mut incoming = HashMap::new();
incoming.insert("a".to_string(), Bar::new(0, 1.0, 2.0, 0.5, 1.5, 10.0));
incoming.insert("b".to_string(), Bar::new(0, 2.0, 3.0, 1.5, 2.5, 10.0));
incoming.insert("c".to_string(), Bar::new(0, 3.0, 4.0, 2.5, 3.5, 10.0));
let step0 = aligner.align_step(0, &incoming);
assert_eq!(step0.series.len(), 3);
for bar in step0.series.values() {
assert_eq!(bar.quality, BarQuality::observed());
}
let mut incoming = HashMap::new();
incoming.insert("a".to_string(), Bar::new(60, 1.1, 2.1, 0.6, 1.6, 10.0));
let step1 = aligner.align_step(60, &incoming);
assert_eq!(step1.series.len(), 3);
assert_eq!(step1.series["a"].quality, BarQuality::observed());
assert!(step1.series["b"].quality.is_forward_filled);
assert!(step1.series["b"].quality.has_gap);
assert_eq!(step1.series["b"].bar.close, 2.5);
assert_eq!(step1.series["b"].bar.timestamp, 60);
}
#[test]
fn test_multi_series_aligner_drops_stale_forward_fills() {
let mut aligner = MultiSeriesAligner::new(["a", "b"])
.with_policy(MissingSeriesPolicy::ForwardFill)
.with_max_forward_fill_age(90);
let mut incoming = HashMap::new();
incoming.insert("a".to_string(), Bar::new(0, 1.0, 2.0, 0.5, 1.5, 10.0));
incoming.insert("b".to_string(), Bar::new(0, 2.0, 3.0, 1.5, 2.5, 10.0));
aligner.align_step(0, &incoming);
let step = aligner.align_step(
200,
&HashMap::from([("a".to_string(), Bar::new(200, 1.2, 2.2, 0.7, 1.7, 10.0))]),
);
assert!(step.series.contains_key("a"));
assert!(
!step.series.contains_key("b"),
"stale forward-fill beyond max age must be omitted, not silently reused"
);
}
#[test]
fn test_multi_series_aligner_skip_policy_never_forward_fills() {
let mut aligner =
MultiSeriesAligner::new(["a", "b"]).with_policy(MissingSeriesPolicy::Skip);
aligner.align_step(
0,
&HashMap::from([
("a".to_string(), Bar::new(0, 1.0, 2.0, 0.5, 1.5, 10.0)),
("b".to_string(), Bar::new(0, 2.0, 3.0, 1.5, 2.5, 10.0)),
]),
);
let step = aligner.align_step(
60,
&HashMap::from([("a".to_string(), Bar::new(60, 1.1, 2.1, 0.6, 1.6, 10.0))]),
);
assert!(step.series.contains_key("a"));
assert!(!step.series.contains_key("b"));
}
}