1use crate::model::{Bar, BarQuality};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, PartialEq)]
6pub struct SynchronizedBarPair {
7 pub timestamp: i64,
8 pub primary: Bar,
9 pub benchmark: Bar,
10 pub benchmark_source_timestamp: i64,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum MissingSeriesPolicy {
16 Skip,
17 #[default]
18 ForwardFill,
19}
20
21#[derive(Debug, Clone, Default)]
23pub struct MultiSeriesSync {
24 last_benchmark: Option<Bar>,
25 missing_policy: MissingSeriesPolicy,
26}
27
28impl MultiSeriesSync {
29 pub fn new() -> Self {
30 Self {
31 last_benchmark: None,
32 missing_policy: MissingSeriesPolicy::ForwardFill,
33 }
34 }
35
36 pub fn with_policy(missing_policy: MissingSeriesPolicy) -> Self {
37 Self {
38 last_benchmark: None,
39 missing_policy,
40 }
41 }
42
43 pub fn reset(&mut self) {
44 self.last_benchmark = None;
45 }
46
47 pub fn align_step(
50 &mut self,
51 primary: &Bar,
52 benchmark: Option<&Bar>,
53 ) -> Option<SynchronizedBarPair> {
54 let bench_bar = match benchmark {
55 Some(b) => {
56 self.last_benchmark = Some(b.clone());
57 b.clone()
58 }
59 None if self.missing_policy == MissingSeriesPolicy::ForwardFill => {
60 self.last_benchmark.clone()?
61 }
62 None => return None,
63 };
64
65 let benchmark_source_timestamp = bench_bar.timestamp;
66
67 Some(SynchronizedBarPair {
68 timestamp: primary.timestamp,
69 primary: primary.clone(),
70 benchmark: bench_bar,
71 benchmark_source_timestamp,
72 })
73 }
74
75 pub fn align_series(
77 primary_series: &[Bar],
78 benchmark_series: &[Bar],
79 ) -> Vec<SynchronizedBarPair> {
80 let mut bench_map: HashMap<i64, Bar> = HashMap::new();
81 for b in benchmark_series {
82 bench_map.insert(b.timestamp, b.clone());
83 }
84
85 let mut sync = MultiSeriesSync::new();
86 let mut result = Vec::with_capacity(primary_series.len());
87
88 for p_bar in primary_series {
89 let b_bar = bench_map.get(&p_bar.timestamp);
90 if let Some(pair) = sync.align_step(p_bar, b_bar) {
91 result.push(pair);
92 }
93 }
94
95 result
96 }
97}
98
99#[derive(Debug, Clone, PartialEq)]
102pub struct AlignedSeriesBar {
103 pub bar: Bar,
104 pub quality: BarQuality,
105}
106
107#[derive(Debug, Clone, PartialEq, Default)]
109pub struct AlignedStep {
110 pub timestamp: i64,
111 pub series: HashMap<String, AlignedSeriesBar>,
116}
117
118#[derive(Debug, Clone)]
123pub struct MultiSeriesAligner {
124 series_ids: Vec<String>,
125 last_seen: HashMap<String, (Bar, i64)>,
126 missing_policy: MissingSeriesPolicy,
127 max_forward_fill_age_seconds: Option<i64>,
128}
129
130impl MultiSeriesAligner {
131 pub fn new(series_ids: impl IntoIterator<Item = impl Into<String>>) -> Self {
132 Self {
133 series_ids: series_ids.into_iter().map(Into::into).collect(),
134 last_seen: HashMap::new(),
135 missing_policy: MissingSeriesPolicy::ForwardFill,
136 max_forward_fill_age_seconds: None,
137 }
138 }
139
140 pub fn with_policy(mut self, policy: MissingSeriesPolicy) -> Self {
141 self.missing_policy = policy;
142 self
143 }
144
145 pub fn with_max_forward_fill_age(mut self, seconds: i64) -> Self {
149 self.max_forward_fill_age_seconds = Some(seconds);
150 self
151 }
152
153 pub fn reset(&mut self) {
154 self.last_seen.clear();
155 }
156
157 pub fn align_step(&mut self, timestamp: i64, incoming: &HashMap<String, Bar>) -> AlignedStep {
162 let mut series = HashMap::with_capacity(self.series_ids.len());
163
164 for id in &self.series_ids {
165 if let Some(bar) = incoming.get(id) {
166 self.last_seen.insert(id.clone(), (bar.clone(), timestamp));
167 series.insert(
168 id.clone(),
169 AlignedSeriesBar {
170 bar: bar.clone(),
171 quality: BarQuality::observed(),
172 },
173 );
174 continue;
175 }
176
177 if self.missing_policy != MissingSeriesPolicy::ForwardFill {
178 continue;
179 }
180
181 let Some((last_bar, last_ts)) = self.last_seen.get(id) else {
182 continue;
183 };
184 let age = timestamp - last_ts;
185 let within_age = self
186 .max_forward_fill_age_seconds
187 .map(|max| age <= max)
188 .unwrap_or(true);
189 if !within_age {
190 continue;
191 }
192
193 let mut filled = last_bar.clone();
194 filled.timestamp = timestamp;
195 series.insert(
196 id.clone(),
197 AlignedSeriesBar {
198 bar: filled,
199 quality: BarQuality {
200 volume_available: false,
201 is_synthetic: false,
202 is_forward_filled: true,
203 has_gap: age > 0,
204 },
205 },
206 );
207 }
208
209 AlignedStep { timestamp, series }
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn test_multi_series_sync_forward_fill() {
219 let mut sync = MultiSeriesSync::new();
220 let b1 = Bar::new(1000, 10.0, 11.0, 9.0, 10.5, 500.0);
221 let p1 = Bar::new(1000, 100.0, 105.0, 95.0, 102.0, 1000.0);
222 let p2 = Bar::new(2000, 102.0, 106.0, 101.0, 105.0, 1000.0);
223
224 let pair1 = sync.align_step(&p1, Some(&b1)).unwrap();
225 assert_eq!(pair1.benchmark.timestamp, 1000);
226
227 let pair2 = sync.align_step(&p2, None).unwrap();
229 assert_eq!(pair2.benchmark.timestamp, 1000);
230 assert_eq!(pair2.benchmark_source_timestamp, 1000);
231 assert_eq!(pair2.benchmark.close, 10.5);
232 }
233
234 #[test]
235 fn never_substitutes_the_primary_for_a_missing_benchmark() {
236 let primary = Bar::new(1_000, 100.0, 105.0, 95.0, 102.0, 1_000.0);
237 assert!(MultiSeriesSync::new().align_step(&primary, None).is_none());
238 assert!(MultiSeriesSync::with_policy(MissingSeriesPolicy::Skip)
239 .align_step(&primary, None)
240 .is_none());
241 }
242
243 #[test]
244 fn test_multi_series_aligner_aligns_more_than_two_series() {
245 let mut aligner =
246 MultiSeriesAligner::new(["a", "b", "c"]).with_policy(MissingSeriesPolicy::ForwardFill);
247
248 let mut incoming = HashMap::new();
249 incoming.insert("a".to_string(), Bar::new(0, 1.0, 2.0, 0.5, 1.5, 10.0));
250 incoming.insert("b".to_string(), Bar::new(0, 2.0, 3.0, 1.5, 2.5, 10.0));
251 incoming.insert("c".to_string(), Bar::new(0, 3.0, 4.0, 2.5, 3.5, 10.0));
252 let step0 = aligner.align_step(0, &incoming);
253 assert_eq!(step0.series.len(), 3);
254 for bar in step0.series.values() {
255 assert_eq!(bar.quality, BarQuality::observed());
256 }
257
258 let mut incoming = HashMap::new();
260 incoming.insert("a".to_string(), Bar::new(60, 1.1, 2.1, 0.6, 1.6, 10.0));
261 let step1 = aligner.align_step(60, &incoming);
262 assert_eq!(step1.series.len(), 3);
263 assert_eq!(step1.series["a"].quality, BarQuality::observed());
264 assert!(step1.series["b"].quality.is_forward_filled);
265 assert!(step1.series["b"].quality.has_gap);
266 assert_eq!(step1.series["b"].bar.close, 2.5);
267 assert_eq!(step1.series["b"].bar.timestamp, 60);
268 }
269
270 #[test]
271 fn test_multi_series_aligner_drops_stale_forward_fills() {
272 let mut aligner = MultiSeriesAligner::new(["a", "b"])
273 .with_policy(MissingSeriesPolicy::ForwardFill)
274 .with_max_forward_fill_age(90);
275
276 let mut incoming = HashMap::new();
277 incoming.insert("a".to_string(), Bar::new(0, 1.0, 2.0, 0.5, 1.5, 10.0));
278 incoming.insert("b".to_string(), Bar::new(0, 2.0, 3.0, 1.5, 2.5, 10.0));
279 aligner.align_step(0, &incoming);
280
281 let step = aligner.align_step(
283 200,
284 &HashMap::from([("a".to_string(), Bar::new(200, 1.2, 2.2, 0.7, 1.7, 10.0))]),
285 );
286 assert!(step.series.contains_key("a"));
287 assert!(
288 !step.series.contains_key("b"),
289 "stale forward-fill beyond max age must be omitted, not silently reused"
290 );
291 }
292
293 #[test]
294 fn test_multi_series_aligner_skip_policy_never_forward_fills() {
295 let mut aligner =
296 MultiSeriesAligner::new(["a", "b"]).with_policy(MissingSeriesPolicy::Skip);
297 aligner.align_step(
298 0,
299 &HashMap::from([
300 ("a".to_string(), Bar::new(0, 1.0, 2.0, 0.5, 1.5, 10.0)),
301 ("b".to_string(), Bar::new(0, 2.0, 3.0, 1.5, 2.5, 10.0)),
302 ]),
303 );
304
305 let step = aligner.align_step(
306 60,
307 &HashMap::from([("a".to_string(), Bar::new(60, 1.1, 2.1, 0.6, 1.6, 10.0))]),
308 );
309 assert!(step.series.contains_key("a"));
310 assert!(!step.series.contains_key("b"));
311 }
312}