1use crate::constants::Interval;
13use crate::models::chart::Candle;
14
15pub fn resample(candles: &[Candle], interval: Interval, utc_offset_secs: i64) -> Vec<Candle> {
31 if candles.is_empty() {
32 return vec![];
33 }
34
35 let mut result = Vec::new();
36 let mut group_start = 0;
37 let mut current_bucket = bucket_id(&candles[0], interval, utc_offset_secs);
38
39 for i in 1..candles.len() {
40 let b = bucket_id(&candles[i], interval, utc_offset_secs);
41 if b != current_bucket {
42 result.push(aggregate(&candles[group_start..i]));
43 group_start = i;
44 current_bucket = b;
45 }
46 }
47 result.push(aggregate(&candles[group_start..]));
48 result
49}
50
51pub fn base_to_htf_index(base_candles: &[Candle], htf_candles: &[Candle]) -> Vec<Option<usize>> {
69 let mut result = Vec::with_capacity(base_candles.len());
70 let mut last_completed: Option<usize> = None;
71 let mut htf_idx = 0;
72
73 for base in base_candles {
74 while htf_idx < htf_candles.len() && htf_candles[htf_idx].timestamp <= base.timestamp {
79 last_completed = Some(htf_idx);
80 htf_idx += 1;
81 }
82 result.push(last_completed);
83 }
84 result
85}
86
87fn aggregate(group: &[Candle]) -> Candle {
88 let first = &group[0];
89 let last = &group[group.len() - 1];
90 Candle {
91 timestamp: last.timestamp,
92 open: first.open,
93 high: group
94 .iter()
95 .map(|c| c.high)
96 .fold(f64::NEG_INFINITY, f64::max),
97 low: group.iter().map(|c| c.low).fold(f64::INFINITY, f64::min),
98 close: last.close,
99 volume: group.iter().map(|c| c.volume).sum(),
100 adj_close: last.adj_close,
101 }
102}
103
104fn bucket_id(candle: &Candle, interval: Interval, utc_offset_secs: i64) -> i64 {
105 let ts = candle.timestamp + utc_offset_secs;
109 match interval {
110 Interval::OneDay => ts.div_euclid(86_400),
114 Interval::OneWeek => {
115 let days = ts.div_euclid(86_400);
118 let weekday = (days + 3).rem_euclid(7); days - weekday
120 }
121 Interval::OneMonth => {
122 let (y, m, _) = ymd(ts);
123 y * 100 + m
124 }
125 Interval::ThreeMonths => {
126 let (y, m, _) = ymd(ts);
127 y * 10 + (m - 1) / 3 + 1
128 }
129 _ => ts.div_euclid(interval_seconds(interval)),
130 }
131}
132
133const fn interval_seconds(interval: Interval) -> i64 {
134 match interval {
135 Interval::OneMinute => 60,
136 Interval::FiveMinutes => 300,
137 Interval::FifteenMinutes => 900,
138 Interval::ThirtyMinutes => 1_800,
139 Interval::OneHour => 3_600,
140 Interval::OneDay => 86_400,
141 Interval::OneWeek => 604_800,
142 Interval::OneMonth => 2_592_000,
143 Interval::ThreeMonths => 7_776_000,
144 }
145}
146
147fn ymd(ts: i64) -> (i64, i64, i64) {
152 let days = ts.div_euclid(86_400);
153 let jdn = days + 2_440_588;
155 let a = jdn + 32_044;
156 let b = (4 * a + 3) / 146_097;
157 let c = a - (146_097 * b) / 4;
158 let d = (4 * c + 3) / 1_461;
159 let e = c - (1_461 * d) / 4;
160 let m = (5 * e + 2) / 153;
161 let day = e - (153 * m + 2) / 5 + 1;
162 let month = m + 3 - 12 * (m / 10);
163 let year = 100 * b + d - 4_800 + m / 10;
164 (year, month, day)
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 fn candle(ts: i64, o: f64, h: f64, l: f64, c: f64, v: i64) -> Candle {
172 Candle {
173 timestamp: ts,
174 open: o,
175 high: h,
176 low: l,
177 close: c,
178 volume: v,
179 adj_close: None,
180 }
181 }
182
183 #[test]
184 fn test_resample_empty() {
185 assert!(resample(&[], Interval::OneWeek, 0).is_empty());
186 }
187
188 #[test]
189 fn test_resample_weekly_ohlcv() {
190 let mon = 1_704_672_000_i64;
192 let base: Vec<Candle> = (0..5)
193 .map(|d| {
194 candle(
195 mon + d * 86_400,
196 100.0 + d as f64,
197 110.0 + d as f64,
198 90.0 + d as f64,
199 105.0 + d as f64,
200 1_000 + d * 100,
201 )
202 })
203 .collect();
204
205 let weekly = resample(&base, Interval::OneWeek, 0);
206 assert_eq!(weekly.len(), 1);
207
208 let w = &weekly[0];
209 assert_eq!(w.open, base[0].open);
210 assert_eq!(w.close, base[4].close);
211 assert!((w.high - 114.0).abs() < f64::EPSILON);
212 assert!((w.low - 90.0).abs() < f64::EPSILON);
213 assert_eq!(w.volume, base.iter().map(|c| c.volume).sum::<i64>());
214 assert_eq!(w.timestamp, base[4].timestamp);
215 }
216
217 #[test]
218 fn test_resample_two_weeks() {
219 let mon_wk1 = 1_704_672_000_i64; let mon_wk2 = mon_wk1 + 7 * 86_400; let mut base: Vec<Candle> = (0..5)
222 .map(|d| candle(mon_wk1 + d * 86_400, 100.0, 110.0, 90.0, 105.0, 1_000))
223 .collect();
224 base.extend(
225 (0..5).map(|d| candle(mon_wk2 + d * 86_400, 200.0, 210.0, 190.0, 205.0, 2_000)),
226 );
227
228 let weekly = resample(&base, Interval::OneWeek, 0);
229 assert_eq!(weekly.len(), 2);
230 assert!((weekly[0].open - 100.0).abs() < f64::EPSILON);
231 assert!((weekly[1].open - 200.0).abs() < f64::EPSILON);
232 }
233
234 #[test]
235 fn test_base_to_htf_no_completed_yet() {
236 let mon = 1_704_672_000_i64;
237 let base: Vec<Candle> = (0..5)
238 .map(|d| candle(mon + d * 86_400, 100.0, 110.0, 90.0, 105.0, 1_000))
239 .collect();
240 let htf = resample(&base, Interval::OneWeek, 0);
241 let mapping = base_to_htf_index(&base, &htf);
245 for (i, val) in mapping.iter().enumerate().take(4) {
246 assert_eq!(
247 *val, None,
248 "bar {i} (Mon-Thu) should have no completed HTF bar"
249 );
250 }
251 assert_eq!(
252 mapping[4],
253 Some(0),
254 "bar 4 (Fri) should see its own completed weekly bar"
255 );
256 }
257
258 #[test]
259 fn test_base_to_htf_with_completed() {
260 let mon_wk1 = 1_704_672_000_i64;
261 let mon_wk2 = mon_wk1 + 7 * 86_400;
262 let mut base: Vec<Candle> = (0..5)
263 .map(|d| candle(mon_wk1 + d * 86_400, 100.0, 110.0, 90.0, 105.0, 1_000))
264 .collect();
265 base.extend(
266 (0..5).map(|d| candle(mon_wk2 + d * 86_400, 200.0, 210.0, 190.0, 205.0, 2_000)),
267 );
268
269 let htf = resample(&base, Interval::OneWeek, 0);
270 assert_eq!(htf.len(), 2);
271
272 let mapping = base_to_htf_index(&base, &htf);
273 for (i, val) in mapping.iter().enumerate().take(4) {
275 assert_eq!(
276 *val, None,
277 "bar {i} (Mon-Thu wk1) should have no completed HTF bar"
278 );
279 }
280 assert_eq!(
281 mapping[4],
282 Some(0),
283 "bar 4 (Fri wk1) should see wk1 bar as completed"
284 );
285 for (i, val) in mapping.iter().enumerate().take(9).skip(5) {
287 assert_eq!(
288 *val,
289 Some(0),
290 "bar {i} (Mon-Thu wk2) should see HTF bar 0 as completed"
291 );
292 }
293 assert_eq!(
294 mapping[9],
295 Some(1),
296 "bar 9 (Fri wk2) should see its own completed weekly bar"
297 );
298 }
299
300 #[test]
301 fn test_utc_offset_bucketing() {
302 let sun_22_utc = 1_704_585_600_i64 + 22 * 3600; let fri_utc = 1_704_585_600_i64 + 5 * 86_400; let c1 = candle(sun_22_utc, 100.0, 101.0, 99.0, 100.0, 1_000);
313 let c2 = candle(fri_utc, 105.0, 106.0, 104.0, 105.0, 1_000);
314
315 let utc_result = resample(&[c1.clone(), c2.clone()], Interval::OneWeek, 0);
317 assert_eq!(
318 utc_result.len(),
319 2,
320 "UTC bucketing splits the Sunday bar into the prior week"
321 );
322
323 let jst_result = resample(&[c1, c2], Interval::OneWeek, 28_800);
325 assert_eq!(
326 jst_result.len(),
327 1,
328 "JST bucketing groups Sunday-22h-UTC into Monday JST week"
329 );
330 }
331
332 #[test]
333 fn test_subdaily_utc_offset_bucketing() {
334 let bar_a_utc = 1_704_758_400_i64; let bar_b_utc = bar_a_utc + 3_600; let c_a = candle(bar_a_utc - 3_600, 100.0, 101.0, 99.0, 100.0, 500); let c_b = candle(bar_a_utc, 101.0, 102.0, 100.0, 101.0, 600); let c_c = candle(bar_b_utc, 102.0, 103.0, 101.0, 102.0, 700); let utc_daily = resample(
352 &[c_a.clone(), c_b.clone(), c_c.clone()],
353 Interval::OneDay,
354 0,
355 );
356 assert_eq!(
357 utc_daily.len(),
358 2,
359 "UTC: Jan 8 23h and Jan 9 00h/01h are two calendar days"
360 );
361
362 let jst_daily = resample(&[c_a, c_b, c_c], Interval::OneDay, 32_400);
364 assert_eq!(
365 jst_daily.len(),
366 1,
367 "JST: all three bars fall on the same local calendar day"
368 );
369 }
370
371 #[test]
372 fn test_ymd() {
373 let (y, m, d) = ymd(1_704_672_000);
375 assert_eq!((y, m, d), (2024, 1, 8));
376
377 let (y, m, d) = ymd(1_710_460_800);
379 assert_eq!((y, m, d), (2024, 3, 15));
380 }
381}