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.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}