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 provider_id: None,
102 }
103}
104
105fn bucket_id(candle: &Candle, interval: Interval, utc_offset_secs: i64) -> i64 {
106 let ts = candle.timestamp + utc_offset_secs;
110 match interval {
111 Interval::OneDay => ts.div_euclid(86_400),
115 Interval::OneWeek => {
116 let days = ts.div_euclid(86_400);
119 let weekday = (days + 3).rem_euclid(7); days - weekday
121 }
122 Interval::OneMonth => {
123 let (y, m, _) = ymd(ts);
124 y * 100 + m
125 }
126 Interval::ThreeMonths => {
127 let (y, m, _) = ymd(ts);
128 y * 10 + (m - 1) / 3 + 1
129 }
130 _ => ts.div_euclid(interval_seconds(interval)),
131 }
132}
133
134const fn interval_seconds(interval: Interval) -> i64 {
135 match interval {
136 Interval::OneMinute => 60,
137 Interval::FiveMinutes => 300,
138 Interval::FifteenMinutes => 900,
139 Interval::ThirtyMinutes => 1_800,
140 Interval::OneHour => 3_600,
141 Interval::OneDay => 86_400,
142 Interval::OneWeek => 604_800,
143 Interval::OneMonth => 2_592_000,
144 Interval::ThreeMonths => 7_776_000,
145 }
146}
147
148fn ymd(ts: i64) -> (i64, i64, i64) {
153 let days = ts.div_euclid(86_400);
154 let jdn = days + 2_440_588;
156 let a = jdn + 32_044;
157 let b = (4 * a + 3) / 146_097;
158 let c = a - (146_097 * b) / 4;
159 let d = (4 * c + 3) / 1_461;
160 let e = c - (1_461 * d) / 4;
161 let m = (5 * e + 2) / 153;
162 let day = e - (153 * m + 2) / 5 + 1;
163 let month = m + 3 - 12 * (m / 10);
164 let year = 100 * b + d - 4_800 + m / 10;
165 (year, month, day)
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 fn candle(ts: i64, o: f64, h: f64, l: f64, c: f64, v: i64) -> Candle {
173 Candle {
174 timestamp: ts,
175 open: o,
176 high: h,
177 low: l,
178 close: c,
179 volume: v,
180 adj_close: None,
181 provider_id: None,
182 }
183 }
184
185 #[test]
186 fn test_resample_empty() {
187 assert!(resample(&[], Interval::OneWeek, 0).is_empty());
188 }
189
190 #[test]
191 fn test_resample_weekly_ohlcv() {
192 let mon = 1_704_672_000_i64;
194 let base: Vec<Candle> = (0..5)
195 .map(|d| {
196 candle(
197 mon + d * 86_400,
198 100.0 + d as f64,
199 110.0 + d as f64,
200 90.0 + d as f64,
201 105.0 + d as f64,
202 1_000 + d * 100,
203 )
204 })
205 .collect();
206
207 let weekly = resample(&base, Interval::OneWeek, 0);
208 assert_eq!(weekly.len(), 1);
209
210 let w = &weekly[0];
211 assert_eq!(w.open, base[0].open);
212 assert_eq!(w.close, base[4].close);
213 assert!((w.high - 114.0).abs() < f64::EPSILON);
214 assert!((w.low - 90.0).abs() < f64::EPSILON);
215 assert_eq!(w.volume, base.iter().map(|c| c.volume).sum::<i64>());
216 assert_eq!(w.timestamp, base[4].timestamp);
217 }
218
219 #[test]
220 fn test_resample_two_weeks() {
221 let mon_wk1 = 1_704_672_000_i64; let mon_wk2 = mon_wk1 + 7 * 86_400; let mut base: Vec<Candle> = (0..5)
224 .map(|d| candle(mon_wk1 + d * 86_400, 100.0, 110.0, 90.0, 105.0, 1_000))
225 .collect();
226 base.extend(
227 (0..5).map(|d| candle(mon_wk2 + d * 86_400, 200.0, 210.0, 190.0, 205.0, 2_000)),
228 );
229
230 let weekly = resample(&base, Interval::OneWeek, 0);
231 assert_eq!(weekly.len(), 2);
232 assert!((weekly[0].open - 100.0).abs() < f64::EPSILON);
233 assert!((weekly[1].open - 200.0).abs() < f64::EPSILON);
234 }
235
236 #[test]
237 fn test_base_to_htf_no_completed_yet() {
238 let mon = 1_704_672_000_i64;
239 let base: Vec<Candle> = (0..5)
240 .map(|d| candle(mon + d * 86_400, 100.0, 110.0, 90.0, 105.0, 1_000))
241 .collect();
242 let htf = resample(&base, Interval::OneWeek, 0);
243 let mapping = base_to_htf_index(&base, &htf);
247 for (i, val) in mapping.iter().enumerate().take(4) {
248 assert_eq!(
249 *val, None,
250 "bar {i} (Mon-Thu) should have no completed HTF bar"
251 );
252 }
253 assert_eq!(
254 mapping[4],
255 Some(0),
256 "bar 4 (Fri) should see its own completed weekly bar"
257 );
258 }
259
260 #[test]
261 fn test_base_to_htf_with_completed() {
262 let mon_wk1 = 1_704_672_000_i64;
263 let mon_wk2 = mon_wk1 + 7 * 86_400;
264 let mut base: Vec<Candle> = (0..5)
265 .map(|d| candle(mon_wk1 + d * 86_400, 100.0, 110.0, 90.0, 105.0, 1_000))
266 .collect();
267 base.extend(
268 (0..5).map(|d| candle(mon_wk2 + d * 86_400, 200.0, 210.0, 190.0, 205.0, 2_000)),
269 );
270
271 let htf = resample(&base, Interval::OneWeek, 0);
272 assert_eq!(htf.len(), 2);
273
274 let mapping = base_to_htf_index(&base, &htf);
275 for (i, val) in mapping.iter().enumerate().take(4) {
277 assert_eq!(
278 *val, None,
279 "bar {i} (Mon-Thu wk1) should have no completed HTF bar"
280 );
281 }
282 assert_eq!(
283 mapping[4],
284 Some(0),
285 "bar 4 (Fri wk1) should see wk1 bar as completed"
286 );
287 for (i, val) in mapping.iter().enumerate().take(9).skip(5) {
289 assert_eq!(
290 *val,
291 Some(0),
292 "bar {i} (Mon-Thu wk2) should see HTF bar 0 as completed"
293 );
294 }
295 assert_eq!(
296 mapping[9],
297 Some(1),
298 "bar 9 (Fri wk2) should see its own completed weekly bar"
299 );
300 }
301
302 #[test]
303 fn test_utc_offset_bucketing() {
304 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);
315 let c2 = candle(fri_utc, 105.0, 106.0, 104.0, 105.0, 1_000);
316
317 let utc_result = resample(&[c1.clone(), c2.clone()], Interval::OneWeek, 0);
319 assert_eq!(
320 utc_result.len(),
321 2,
322 "UTC bucketing splits the Sunday bar into the prior week"
323 );
324
325 let jst_result = resample(&[c1, c2], Interval::OneWeek, 28_800);
327 assert_eq!(
328 jst_result.len(),
329 1,
330 "JST bucketing groups Sunday-22h-UTC into Monday JST week"
331 );
332 }
333
334 #[test]
335 fn test_subdaily_utc_offset_bucketing() {
336 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(
354 &[c_a.clone(), c_b.clone(), c_c.clone()],
355 Interval::OneDay,
356 0,
357 );
358 assert_eq!(
359 utc_daily.len(),
360 2,
361 "UTC: Jan 8 23h and Jan 9 00h/01h are two calendar days"
362 );
363
364 let jst_daily = resample(&[c_a, c_b, c_c], Interval::OneDay, 32_400);
366 assert_eq!(
367 jst_daily.len(),
368 1,
369 "JST: all three bars fall on the same local calendar day"
370 );
371 }
372
373 #[test]
374 fn test_ymd() {
375 let (y, m, d) = ymd(1_704_672_000);
377 assert_eq!((y, m, d), (2024, 1, 8));
378
379 let (y, m, d) = ymd(1_710_460_800);
381 assert_eq!((y, m, d), (2024, 3, 15));
382 }
383}