finance_query/backtesting/resample.rs
1//! Higher-timeframe candle resampling.
2//!
3//! Aggregates base-timeframe candles into a higher-timeframe (HTF) series
4//! using standard OHLCV rules:
5//! - Open = first constituent bar's open
6//! - High = max of constituent highs
7//! - Low = min of constituent lows
8//! - Close = last constituent bar's close
9//! - Volume = sum of constituent volumes
10//! - Timestamp = last constituent bar's timestamp (marks bar completion)
11
12use crate::constants::Interval;
13use crate::models::chart::Candle;
14
15/// Resample `candles` from their base timeframe to `interval`.
16///
17/// `utc_offset_secs` shifts each candle's timestamp into the exchange's local
18/// time before computing calendar bucket boundaries (weekly Monday start,
19/// month boundary, etc.). Pass `0` for UTC-aligned bucketing (default for US
20/// markets). Use [`Region::utc_offset_secs`] to obtain the correct value for
21/// non-US exchanges.
22///
23/// # Notes
24///
25/// - Calendar-aligned intervals (`OneWeek`, `OneMonth`, `ThreeMonths`) respect
26/// `utc_offset_secs`. Weekly bars start on the local Monday.
27/// - Sub-daily intervals use fixed-second buckets relative to local midnight.
28///
29/// [`Region::utc_offset_secs`]: crate::constants::Region::utc_offset_secs
30pub 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
51/// Map each base-timeframe index to the most recently *completed* HTF bar index.
52///
53/// A "completed" HTF bar is one whose timestamp (the last constituent bar's
54/// timestamp) is less than or equal to the current base bar's timestamp.
55/// Using `<=` rather than `<` ensures that on the final bar of an HTF period
56/// (e.g. a Friday close for a weekly bar), the engine can immediately see the
57/// now-finalized HTF candle. Using `<` would introduce an artificial one-bar
58/// delay: on Friday, `htf.timestamp == base.timestamp`, so `<` fails and the
59/// engine falls back to the prior week's data even though the weekly bar is
60/// already complete.
61///
62/// `htf_candles` must have been produced by [`resample`] with the same
63/// `utc_offset_secs` used for the base series so that bucket boundaries are
64/// consistent.
65///
66/// Returns `None` for bars where no HTF bar has completed yet (e.g. during
67/// the first HTF period).
68pub 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 // Advance past any HTF bars whose period has fully closed by this bar.
75 // `<=` includes the bar where htf.timestamp == base.timestamp, i.e. the
76 // last constituent bar of the HTF period — that bar IS completed at this
77 // point, so it should be visible.
78 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 // Shift the raw UTC timestamp into the exchange's local time before computing
107 // calendar boundaries. For sub-daily intervals this aligns session buckets;
108 // for weekly/monthly it ensures Monday/month-start is local, not UTC.
109 let ts = candle.timestamp + utc_offset_secs;
110 match interval {
111 // Use Euclidean division so that negative timestamps (pre-1970 data)
112 // are bucketed correctly. Truncation-toward-zero would map e.g.
113 // Dec 31 1969 (-1 s) and Jan 1 1970 (0 s) to the same bucket 0.
114 Interval::OneDay => ts.div_euclid(86_400),
115 Interval::OneWeek => {
116 // Days-since-epoch (Euclidean) of the local Monday that starts this ISO week.
117 // Unix epoch (1970-01-01) was a Thursday; adding 3 shifts so Mon = 0.
118 let days = ts.div_euclid(86_400);
119 let weekday = (days + 3).rem_euclid(7); // 0 = Mon … 6 = Sun
120 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.duration_secs()),
131 }
132}
133
134/// Gregorian calendar date from a Unix timestamp (seconds since epoch, UTC).
135///
136/// Uses the proleptic Gregorian calendar via Julian Day Number conversion.
137/// Does not account for leap seconds.
138fn ymd(ts: i64) -> (i64, i64, i64) {
139 let days = ts.div_euclid(86_400);
140 // Julian Day Number: Unix epoch (1970-01-01) = JDN 2_440_588
141 let jdn = days + 2_440_588;
142 let a = jdn + 32_044;
143 let b = (4 * a + 3) / 146_097;
144 let c = a - (146_097 * b) / 4;
145 let d = (4 * c + 3) / 1_461;
146 let e = c - (1_461 * d) / 4;
147 let m = (5 * e + 2) / 153;
148 let day = e - (153 * m + 2) / 5 + 1;
149 let month = m + 3 - 12 * (m / 10);
150 let year = 100 * b + d - 4_800 + m / 10;
151 (year, month, day)
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 fn candle(ts: i64, o: f64, h: f64, l: f64, c: f64, v: i64) -> Candle {
159 Candle {
160 timestamp: ts,
161 open: o,
162 high: h,
163 low: l,
164 close: c,
165 volume: v,
166 adj_close: None,
167 provider_id: None,
168 }
169 }
170
171 #[test]
172 fn test_resample_empty() {
173 assert!(resample(&[], Interval::OneWeek, 0).is_empty());
174 }
175
176 #[test]
177 fn test_resample_weekly_ohlcv() {
178 // 2024-01-08 (Mon) = 1_704_672_000
179 let mon = 1_704_672_000_i64;
180 let base: Vec<Candle> = (0..5)
181 .map(|d| {
182 candle(
183 mon + d * 86_400,
184 100.0 + d as f64,
185 110.0 + d as f64,
186 90.0 + d as f64,
187 105.0 + d as f64,
188 1_000 + d * 100,
189 )
190 })
191 .collect();
192
193 let weekly = resample(&base, Interval::OneWeek, 0);
194 assert_eq!(weekly.len(), 1);
195
196 let w = &weekly[0];
197 assert_eq!(w.open, base[0].open);
198 assert_eq!(w.close, base[4].close);
199 assert!((w.high - 114.0).abs() < f64::EPSILON);
200 assert!((w.low - 90.0).abs() < f64::EPSILON);
201 assert_eq!(w.volume, base.iter().map(|c| c.volume).sum::<i64>());
202 assert_eq!(w.timestamp, base[4].timestamp);
203 }
204
205 #[test]
206 fn test_resample_two_weeks() {
207 let mon_wk1 = 1_704_672_000_i64; // 2024-01-08
208 let mon_wk2 = mon_wk1 + 7 * 86_400; // 2024-01-15
209 let mut base: Vec<Candle> = (0..5)
210 .map(|d| candle(mon_wk1 + d * 86_400, 100.0, 110.0, 90.0, 105.0, 1_000))
211 .collect();
212 base.extend(
213 (0..5).map(|d| candle(mon_wk2 + d * 86_400, 200.0, 210.0, 190.0, 205.0, 2_000)),
214 );
215
216 let weekly = resample(&base, Interval::OneWeek, 0);
217 assert_eq!(weekly.len(), 2);
218 assert!((weekly[0].open - 100.0).abs() < f64::EPSILON);
219 assert!((weekly[1].open - 200.0).abs() < f64::EPSILON);
220 }
221
222 #[test]
223 fn test_base_to_htf_no_completed_yet() {
224 let mon = 1_704_672_000_i64;
225 let base: Vec<Candle> = (0..5)
226 .map(|d| candle(mon + d * 86_400, 100.0, 110.0, 90.0, 105.0, 1_000))
227 .collect();
228 let htf = resample(&base, Interval::OneWeek, 0);
229 // htf[0].timestamp = Friday's timestamp.
230 // Mon–Thu: htf[0].timestamp > their timestamps → None.
231 // Fri: htf[0].timestamp == Friday.timestamp, so <= passes → Some(0).
232 let mapping = base_to_htf_index(&base, &htf);
233 for (i, val) in mapping.iter().enumerate().take(4) {
234 assert_eq!(
235 *val, None,
236 "bar {i} (Mon-Thu) should have no completed HTF bar"
237 );
238 }
239 assert_eq!(
240 mapping[4],
241 Some(0),
242 "bar 4 (Fri) should see its own completed weekly bar"
243 );
244 }
245
246 #[test]
247 fn test_base_to_htf_with_completed() {
248 let mon_wk1 = 1_704_672_000_i64;
249 let mon_wk2 = mon_wk1 + 7 * 86_400;
250 let mut base: Vec<Candle> = (0..5)
251 .map(|d| candle(mon_wk1 + d * 86_400, 100.0, 110.0, 90.0, 105.0, 1_000))
252 .collect();
253 base.extend(
254 (0..5).map(|d| candle(mon_wk2 + d * 86_400, 200.0, 210.0, 190.0, 205.0, 2_000)),
255 );
256
257 let htf = resample(&base, Interval::OneWeek, 0);
258 assert_eq!(htf.len(), 2);
259
260 let mapping = base_to_htf_index(&base, &htf);
261 // Week 1: Mon–Thu have no completed HTF bar; Fri sees its own completed weekly bar.
262 for (i, val) in mapping.iter().enumerate().take(4) {
263 assert_eq!(
264 *val, None,
265 "bar {i} (Mon-Thu wk1) should have no completed HTF bar"
266 );
267 }
268 assert_eq!(
269 mapping[4],
270 Some(0),
271 "bar 4 (Fri wk1) should see wk1 bar as completed"
272 );
273 // Week 2: Mon–Thu see wk1 as the last completed bar; Fri sees its own wk2 bar completed.
274 for (i, val) in mapping.iter().enumerate().take(9).skip(5) {
275 assert_eq!(
276 *val,
277 Some(0),
278 "bar {i} (Mon-Thu wk2) should see HTF bar 0 as completed"
279 );
280 }
281 assert_eq!(
282 mapping[9],
283 Some(1),
284 "bar 9 (Fri wk2) should see its own completed weekly bar"
285 );
286 }
287
288 #[test]
289 fn test_utc_offset_bucketing() {
290 // UTC midnight on 2024-01-08 (Mon) = 1_704_672_000.
291 // For a UTC+8 exchange (e.g. Tokyo/HK), that UTC midnight IS already
292 // Monday 08:00 local time — still Monday, so offset makes no difference here.
293 //
294 // The key case: a bar whose UTC timestamp is Sunday 22:00 (= Monday 06:00 JST).
295 // With offset=0 it falls in Sunday's bucket → prior week.
296 // With offset=+28800 (+8 h) it becomes Monday → current week.
297 let sun_22_utc = 1_704_585_600_i64 + 22 * 3600; // Sun 2024-01-07 22:00 UTC
298 let fri_utc = 1_704_585_600_i64 + 5 * 86_400; // Fri 2024-01-12 00:00 UTC (same "week" in JST)
299
300 let c1 = candle(sun_22_utc, 100.0, 101.0, 99.0, 100.0, 1_000);
301 let c2 = candle(fri_utc, 105.0, 106.0, 104.0, 105.0, 1_000);
302
303 // Without offset: sun_22_utc is in the Sunday/prior week bucket → two separate weeks.
304 let utc_result = resample(&[c1.clone(), c2.clone()], Interval::OneWeek, 0);
305 assert_eq!(
306 utc_result.len(),
307 2,
308 "UTC bucketing splits the Sunday bar into the prior week"
309 );
310
311 // With UTC+8: sun_22_utc + 28800 = Monday 06:00 JST → same week as Friday.
312 let jst_result = resample(&[c1, c2], Interval::OneWeek, 28_800);
313 assert_eq!(
314 jst_result.len(),
315 1,
316 "JST bucketing groups Sunday-22h-UTC into Monday JST week"
317 );
318 }
319
320 #[test]
321 fn test_subdaily_utc_offset_bucketing() {
322 // Verify that utc_offset_secs aligns intraday session boundaries.
323 // Scenario: an exchange opens at 09:00 JST (= 00:00 UTC).
324 // Two 1-hour bars bracketing local midnight:
325 // bar_a: 2024-01-08 23:00 UTC = 2024-01-09 08:00 JST → still Monday JST
326 // bar_b: 2024-01-09 00:00 UTC = 2024-01-09 09:00 JST → Monday JST session open
327 // With UTC bucketing (offset=0) and OneDay, bar_a falls on 2024-01-08 and
328 // bar_b falls on 2024-01-09 → two separate daily buckets.
329 // With JST offset (+32400 = +9 h), bar_a + 32400 = 2024-01-09 08:00 JST and
330 // bar_b + 32400 = 2024-01-09 09:00 JST → both on the same local date → one bucket.
331 let bar_a_utc = 1_704_758_400_i64; // 2024-01-09 00:00 UTC — Mon midnight UTC
332 let bar_b_utc = bar_a_utc + 3_600; // 2024-01-09 01:00 UTC
333
334 let c_a = candle(bar_a_utc - 3_600, 100.0, 101.0, 99.0, 100.0, 500); // 2024-01-08 23:00 UTC
335 let c_b = candle(bar_a_utc, 101.0, 102.0, 100.0, 101.0, 600); // 2024-01-09 00:00 UTC
336 let c_c = candle(bar_b_utc, 102.0, 103.0, 101.0, 102.0, 700); // 2024-01-09 01:00 UTC
337
338 // UTC bucketing: c_a is on Jan 8, c_b and c_c are on Jan 9 → 2 daily buckets.
339 let utc_daily = resample(
340 &[c_a.clone(), c_b.clone(), c_c.clone()],
341 Interval::OneDay,
342 0,
343 );
344 assert_eq!(
345 utc_daily.len(),
346 2,
347 "UTC: Jan 8 23h and Jan 9 00h/01h are two calendar days"
348 );
349
350 // JST bucketing (+9h): c_a (23:00 UTC) + 9h = 08:00 JST Jan 9 → same day as c_b/c_c.
351 let jst_daily = resample(&[c_a, c_b, c_c], Interval::OneDay, 32_400);
352 assert_eq!(
353 jst_daily.len(),
354 1,
355 "JST: all three bars fall on the same local calendar day"
356 );
357 }
358
359 #[test]
360 fn test_ymd() {
361 // 2024-01-08 = 1_704_672_000 (confirmed via date math)
362 let (y, m, d) = ymd(1_704_672_000);
363 assert_eq!((y, m, d), (2024, 1, 8));
364
365 // 2024-03-15
366 let (y, m, d) = ymd(1_710_460_800);
367 assert_eq!((y, m, d), (2024, 3, 15));
368 }
369}