Skip to main content

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    }
102}
103
104fn bucket_id(candle: &Candle, interval: Interval, utc_offset_secs: i64) -> i64 {
105    // Shift the raw UTC timestamp into the exchange's local time before computing
106    // calendar boundaries. For sub-daily intervals this aligns session buckets;
107    // for weekly/monthly it ensures Monday/month-start is local, not UTC.
108    let ts = candle.timestamp + utc_offset_secs;
109    match interval {
110        // Use Euclidean division so that negative timestamps (pre-1970 data)
111        // are bucketed correctly. Truncation-toward-zero would map e.g.
112        // Dec 31 1969 (-1 s) and Jan 1 1970 (0 s) to the same bucket 0.
113        Interval::OneDay => ts.div_euclid(86_400),
114        Interval::OneWeek => {
115            // Days-since-epoch (Euclidean) of the local Monday that starts this ISO week.
116            // Unix epoch (1970-01-01) was a Thursday; adding 3 shifts so Mon = 0.
117            let days = ts.div_euclid(86_400);
118            let weekday = (days + 3).rem_euclid(7); // 0 = Mon … 6 = Sun
119            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
147/// Gregorian calendar date from a Unix timestamp (seconds since epoch, UTC).
148///
149/// Uses the proleptic Gregorian calendar via Julian Day Number conversion.
150/// Does not account for leap seconds.
151fn ymd(ts: i64) -> (i64, i64, i64) {
152    let days = ts.div_euclid(86_400);
153    // Julian Day Number: Unix epoch (1970-01-01) = JDN 2_440_588
154    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        // 2024-01-08 (Mon) = 1_704_672_000
191        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; // 2024-01-08
220        let mon_wk2 = mon_wk1 + 7 * 86_400; // 2024-01-15
221        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        // htf[0].timestamp = Friday's timestamp.
242        // Mon–Thu: htf[0].timestamp > their timestamps → None.
243        // Fri: htf[0].timestamp == Friday.timestamp, so <= passes → Some(0).
244        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        // Week 1: Mon–Thu have no completed HTF bar; Fri sees its own completed weekly bar.
274        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        // Week 2: Mon–Thu see wk1 as the last completed bar; Fri sees its own wk2 bar completed.
286        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        // UTC midnight on 2024-01-08 (Mon) = 1_704_672_000.
303        // For a UTC+8 exchange (e.g. Tokyo/HK), that UTC midnight IS already
304        // Monday 08:00 local time — still Monday, so offset makes no difference here.
305        //
306        // The key case: a bar whose UTC timestamp is Sunday 22:00 (= Monday 06:00 JST).
307        // With offset=0  it falls in Sunday's bucket  → prior week.
308        // With offset=+28800 (+8 h) it becomes Monday → current week.
309        let sun_22_utc = 1_704_585_600_i64 + 22 * 3600; // Sun 2024-01-07 22:00 UTC
310        let fri_utc = 1_704_585_600_i64 + 5 * 86_400; // Fri 2024-01-12 00:00 UTC (same "week" in JST)
311
312        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        // Without offset: sun_22_utc is in the Sunday/prior week bucket → two separate weeks.
316        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        // With UTC+8: sun_22_utc + 28800 = Monday 06:00 JST → same week as Friday.
324        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        // Verify that utc_offset_secs aligns intraday session boundaries.
335        // Scenario: an exchange opens at 09:00 JST (= 00:00 UTC).
336        // Two 1-hour bars bracketing local midnight:
337        //   bar_a: 2024-01-08 23:00 UTC = 2024-01-09 08:00 JST  → still Monday JST
338        //   bar_b: 2024-01-09 00:00 UTC = 2024-01-09 09:00 JST  → Monday JST session open
339        // With UTC bucketing (offset=0) and OneDay, bar_a falls on 2024-01-08 and
340        // bar_b falls on 2024-01-09 → two separate daily buckets.
341        // With JST offset (+32400 = +9 h), bar_a + 32400 = 2024-01-09 08:00 JST and
342        // bar_b + 32400 = 2024-01-09 09:00 JST → both on the same local date → one bucket.
343        let bar_a_utc = 1_704_758_400_i64; // 2024-01-09 00:00 UTC — Mon midnight UTC
344        let bar_b_utc = bar_a_utc + 3_600; // 2024-01-09 01:00 UTC
345
346        let c_a = candle(bar_a_utc - 3_600, 100.0, 101.0, 99.0, 100.0, 500); // 2024-01-08 23:00 UTC
347        let c_b = candle(bar_a_utc, 101.0, 102.0, 100.0, 101.0, 600); // 2024-01-09 00:00 UTC
348        let c_c = candle(bar_b_utc, 102.0, 103.0, 101.0, 102.0, 700); // 2024-01-09 01:00 UTC
349
350        // UTC bucketing: c_a is on Jan 8, c_b and c_c are on Jan 9 → 2 daily buckets.
351        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        // JST bucketing (+9h): c_a (23:00 UTC) + 9h = 08:00 JST Jan 9 → same day as c_b/c_c.
363        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        // 2024-01-08 = 1_704_672_000 (confirmed via date math)
374        let (y, m, d) = ymd(1_704_672_000);
375        assert_eq!((y, m, d), (2024, 1, 8));
376
377        // 2024-03-15
378        let (y, m, d) = ymd(1_710_460_800);
379        assert_eq!((y, m, d), (2024, 3, 15));
380    }
381}