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        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_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
148/// Gregorian calendar date from a Unix timestamp (seconds since epoch, UTC).
149///
150/// Uses the proleptic Gregorian calendar via Julian Day Number conversion.
151/// Does not account for leap seconds.
152fn ymd(ts: i64) -> (i64, i64, i64) {
153    let days = ts.div_euclid(86_400);
154    // Julian Day Number: Unix epoch (1970-01-01) = JDN 2_440_588
155    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        // 2024-01-08 (Mon) = 1_704_672_000
193        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; // 2024-01-08
222        let mon_wk2 = mon_wk1 + 7 * 86_400; // 2024-01-15
223        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        // htf[0].timestamp = Friday's timestamp.
244        // Mon–Thu: htf[0].timestamp > their timestamps → None.
245        // Fri: htf[0].timestamp == Friday.timestamp, so <= passes → Some(0).
246        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        // Week 1: Mon–Thu have no completed HTF bar; Fri sees its own completed weekly bar.
276        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        // Week 2: Mon–Thu see wk1 as the last completed bar; Fri sees its own wk2 bar completed.
288        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        // UTC midnight on 2024-01-08 (Mon) = 1_704_672_000.
305        // For a UTC+8 exchange (e.g. Tokyo/HK), that UTC midnight IS already
306        // Monday 08:00 local time — still Monday, so offset makes no difference here.
307        //
308        // The key case: a bar whose UTC timestamp is Sunday 22:00 (= Monday 06:00 JST).
309        // With offset=0  it falls in Sunday's bucket  → prior week.
310        // With offset=+28800 (+8 h) it becomes Monday → current week.
311        let sun_22_utc = 1_704_585_600_i64 + 22 * 3600; // Sun 2024-01-07 22:00 UTC
312        let fri_utc = 1_704_585_600_i64 + 5 * 86_400; // Fri 2024-01-12 00:00 UTC (same "week" in JST)
313
314        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        // Without offset: sun_22_utc is in the Sunday/prior week bucket → two separate weeks.
318        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        // With UTC+8: sun_22_utc + 28800 = Monday 06:00 JST → same week as Friday.
326        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        // Verify that utc_offset_secs aligns intraday session boundaries.
337        // Scenario: an exchange opens at 09:00 JST (= 00:00 UTC).
338        // Two 1-hour bars bracketing local midnight:
339        //   bar_a: 2024-01-08 23:00 UTC = 2024-01-09 08:00 JST  → still Monday JST
340        //   bar_b: 2024-01-09 00:00 UTC = 2024-01-09 09:00 JST  → Monday JST session open
341        // With UTC bucketing (offset=0) and OneDay, bar_a falls on 2024-01-08 and
342        // bar_b falls on 2024-01-09 → two separate daily buckets.
343        // With JST offset (+32400 = +9 h), bar_a + 32400 = 2024-01-09 08:00 JST and
344        // bar_b + 32400 = 2024-01-09 09:00 JST → both on the same local date → one bucket.
345        let bar_a_utc = 1_704_758_400_i64; // 2024-01-09 00:00 UTC — Mon midnight UTC
346        let bar_b_utc = bar_a_utc + 3_600; // 2024-01-09 01:00 UTC
347
348        let c_a = candle(bar_a_utc - 3_600, 100.0, 101.0, 99.0, 100.0, 500); // 2024-01-08 23:00 UTC
349        let c_b = candle(bar_a_utc, 101.0, 102.0, 100.0, 101.0, 600); // 2024-01-09 00:00 UTC
350        let c_c = candle(bar_b_utc, 102.0, 103.0, 101.0, 102.0, 700); // 2024-01-09 01:00 UTC
351
352        // UTC bucketing: c_a is on Jan 8, c_b and c_c are on Jan 9 → 2 daily buckets.
353        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        // JST bucketing (+9h): c_a (23:00 UTC) + 9h = 08:00 JST Jan 9 → same day as c_b/c_c.
365        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        // 2024-01-08 = 1_704_672_000 (confirmed via date math)
376        let (y, m, d) = ymd(1_704_672_000);
377        assert_eq!((y, m, d), (2024, 1, 8));
378
379        // 2024-03-15
380        let (y, m, d) = ymd(1_710_460_800);
381        assert_eq!((y, m, d), (2024, 3, 15));
382    }
383}