Skip to main content

hyper_ta/
multi_timeframe.rs

1use serde::{Deserialize, Serialize};
2
3use crate::candle::Candle;
4#[allow(deprecated)]
5use crate::technical_analysis::{calculate_indicators, TechnicalIndicators};
6
7// ---------------------------------------------------------------------------
8// #224 — Multi-Timeframe Candle Subscription
9// ---------------------------------------------------------------------------
10
11/// Aggregated multi-timeframe data: holds candles and indicators for both
12/// the base timeframe and a higher timeframe.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct MultiTimeframeData {
16    /// Original (lower) timeframe candles.
17    pub base_candles: Vec<Candle>,
18    /// Aggregated higher-timeframe candles.
19    pub htf_candles: Vec<Candle>,
20    /// Multiplier used (e.g. 4 means "4x the base interval").
21    pub multiplier: u32,
22}
23
24/// Aggregate lower-timeframe candles into higher-timeframe candles.
25///
26/// `multiplier` indicates how many base candles form one HTF candle.
27/// For example, with 1H candles and `multiplier = 4`, you get 4H candles.
28///
29/// Aggregation rules per group:
30/// - **open**: first candle's open
31/// - **close**: last candle's close
32/// - **high**: max high across the group
33/// - **low**: min low across the group
34/// - **volume**: sum of all volumes
35/// - **time**: first candle's time (period start)
36///
37/// Incomplete trailing groups (fewer than `multiplier` candles) are dropped.
38pub fn aggregate_candles(candles: &[Candle], multiplier: u32) -> Vec<Candle> {
39    if multiplier == 0 || candles.is_empty() {
40        return Vec::new();
41    }
42    let m = multiplier as usize;
43    let full_groups = candles.len() / m;
44    let mut result = Vec::with_capacity(full_groups);
45
46    for i in 0..full_groups {
47        let group = &candles[i * m..(i + 1) * m];
48        let first = &group[0];
49        let last = &group[group.len() - 1];
50
51        let high = group
52            .iter()
53            .map(|c| c.high)
54            .fold(f64::NEG_INFINITY, f64::max);
55        let low = group.iter().map(|c| c.low).fold(f64::INFINITY, f64::min);
56        let volume: f64 = group.iter().map(|c| c.volume).sum();
57
58        result.push(Candle {
59            time: first.time,
60            open: first.open,
61            high,
62            low,
63            close: last.close,
64            volume,
65        });
66    }
67
68    result
69}
70
71/// Compute technical indicators on higher-timeframe candles.
72///
73/// This is a convenience wrapper: aggregate first, then calculate.
74#[allow(deprecated)]
75pub fn compute_htf_indicators(base_candles: &[Candle], multiplier: u32) -> TechnicalIndicators {
76    let htf = aggregate_candles(base_candles, multiplier);
77    if htf.is_empty() {
78        return TechnicalIndicators::empty();
79    }
80    calculate_indicators(&htf)
81}
82
83/// Build a full `MultiTimeframeData` bundle from base candles.
84pub fn build_multi_timeframe(base_candles: Vec<Candle>, multiplier: u32) -> MultiTimeframeData {
85    let htf_candles = aggregate_candles(&base_candles, multiplier);
86    MultiTimeframeData {
87        base_candles,
88        htf_candles,
89        multiplier,
90    }
91}
92
93// ---------------------------------------------------------------------------
94// Tests
95// ---------------------------------------------------------------------------
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    fn make_candle(time: u64, open: f64, high: f64, low: f64, close: f64, volume: f64) -> Candle {
102        Candle {
103            time,
104            open,
105            high,
106            low,
107            close,
108            volume,
109        }
110    }
111
112    fn sample_1h_candles() -> Vec<Candle> {
113        vec![
114            make_candle(3600 * 0, 100.0, 105.0, 98.0, 103.0, 1000.0),
115            make_candle(3600 * 1, 103.0, 108.0, 101.0, 106.0, 1200.0),
116            make_candle(3600 * 2, 106.0, 110.0, 104.0, 109.0, 800.0),
117            make_candle(3600 * 3, 109.0, 112.0, 107.0, 111.0, 1500.0),
118            make_candle(3600 * 4, 111.0, 115.0, 109.0, 113.0, 900.0),
119            make_candle(3600 * 5, 113.0, 116.0, 112.0, 114.0, 1100.0),
120            make_candle(3600 * 6, 114.0, 118.0, 113.0, 117.0, 1300.0),
121            make_candle(3600 * 7, 117.0, 120.0, 115.0, 119.0, 1000.0),
122        ]
123    }
124
125    // --- Basic aggregation ---
126
127    #[test]
128    fn test_aggregate_4h_from_1h() {
129        let candles = sample_1h_candles();
130        let agg = aggregate_candles(&candles, 4);
131
132        assert_eq!(agg.len(), 2);
133
134        // First 4H candle: hours 0-3
135        assert_eq!(agg[0].time, 0);
136        assert_eq!(agg[0].open, 100.0); // first candle's open
137        assert_eq!(agg[0].close, 111.0); // last candle's close
138        assert_eq!(agg[0].high, 112.0); // max high across group
139        assert_eq!(agg[0].low, 98.0); // min low across group
140        assert_eq!(agg[0].volume, 4500.0); // sum: 1000+1200+800+1500
141
142        // Second 4H candle: hours 4-7
143        assert_eq!(agg[1].time, 3600 * 4);
144        assert_eq!(agg[1].open, 111.0);
145        assert_eq!(agg[1].close, 119.0);
146        assert_eq!(agg[1].high, 120.0);
147        assert_eq!(agg[1].low, 109.0);
148        assert_eq!(agg[1].volume, 4300.0); // sum: 900+1100+1300+1000
149    }
150
151    #[test]
152    fn test_aggregate_2h_from_1h() {
153        let candles = sample_1h_candles();
154        let agg = aggregate_candles(&candles, 2);
155
156        assert_eq!(agg.len(), 4);
157
158        // First 2H candle
159        assert_eq!(agg[0].open, 100.0);
160        assert_eq!(agg[0].close, 106.0);
161        assert_eq!(agg[0].high, 108.0);
162        assert_eq!(agg[0].low, 98.0);
163        assert_eq!(agg[0].volume, 2200.0);
164    }
165
166    #[test]
167    fn test_aggregate_drops_incomplete_trailing_group() {
168        let candles = &sample_1h_candles()[..7]; // 7 candles, multiplier=4 → 1 full group
169        let agg = aggregate_candles(candles, 4);
170        assert_eq!(agg.len(), 1);
171    }
172
173    #[test]
174    fn test_aggregate_multiplier_1_identity() {
175        let candles = sample_1h_candles();
176        let agg = aggregate_candles(&candles, 1);
177        assert_eq!(agg.len(), candles.len());
178        for (a, c) in agg.iter().zip(candles.iter()) {
179            assert_eq!(a.open, c.open);
180            assert_eq!(a.close, c.close);
181            assert_eq!(a.high, c.high);
182            assert_eq!(a.low, c.low);
183            assert_eq!(a.volume, c.volume);
184            assert_eq!(a.time, c.time);
185        }
186    }
187
188    // --- Edge cases ---
189
190    #[test]
191    fn test_aggregate_empty_candles() {
192        let agg = aggregate_candles(&[], 4);
193        assert!(agg.is_empty());
194    }
195
196    #[test]
197    fn test_aggregate_multiplier_zero() {
198        let candles = sample_1h_candles();
199        let agg = aggregate_candles(&candles, 0);
200        assert!(agg.is_empty());
201    }
202
203    #[test]
204    fn test_aggregate_multiplier_larger_than_input() {
205        let candles = sample_1h_candles(); // 8 candles
206        let agg = aggregate_candles(&candles, 10);
207        assert!(agg.is_empty());
208    }
209
210    #[test]
211    fn test_aggregate_single_candle_multiplier_1() {
212        let candles = vec![make_candle(100, 50.0, 55.0, 45.0, 52.0, 500.0)];
213        let agg = aggregate_candles(&candles, 1);
214        assert_eq!(agg.len(), 1);
215        assert_eq!(agg[0].open, 50.0);
216        assert_eq!(agg[0].close, 52.0);
217    }
218
219    #[test]
220    fn test_aggregate_exactly_one_group() {
221        let candles = &sample_1h_candles()[..4]; // exactly 4 candles, multiplier=4
222        let agg = aggregate_candles(candles, 4);
223        assert_eq!(agg.len(), 1);
224        assert_eq!(agg[0].open, 100.0);
225        assert_eq!(agg[0].close, 111.0);
226    }
227
228    // --- Volume summation ---
229
230    #[test]
231    fn test_aggregate_volume_is_sum() {
232        let candles = vec![
233            make_candle(0, 100.0, 100.0, 100.0, 100.0, 100.0),
234            make_candle(1, 100.0, 100.0, 100.0, 100.0, 200.0),
235            make_candle(2, 100.0, 100.0, 100.0, 100.0, 300.0),
236        ];
237        let agg = aggregate_candles(&candles, 3);
238        assert_eq!(agg.len(), 1);
239        assert_eq!(agg[0].volume, 600.0);
240    }
241
242    // --- High/Low correctness ---
243
244    #[test]
245    fn test_aggregate_high_is_max_low_is_min() {
246        let candles = vec![
247            make_candle(0, 100.0, 200.0, 50.0, 100.0, 100.0),
248            make_candle(1, 100.0, 150.0, 80.0, 100.0, 100.0),
249            make_candle(2, 100.0, 300.0, 90.0, 100.0, 100.0),
250            make_candle(3, 100.0, 180.0, 40.0, 100.0, 100.0),
251        ];
252        let agg = aggregate_candles(&candles, 4);
253        assert_eq!(agg[0].high, 300.0);
254        assert_eq!(agg[0].low, 40.0);
255    }
256
257    // --- compute_htf_indicators ---
258
259    #[test]
260    fn test_compute_htf_indicators_empty() {
261        let ind = compute_htf_indicators(&[], 4);
262        assert!(ind.sma_20.is_none());
263        assert!(ind.rsi_14.is_none());
264    }
265
266    #[test]
267    fn test_compute_htf_indicators_too_few() {
268        // Only 3 candles with multiplier 4 → 0 HTF candles → empty indicators
269        let candles = &sample_1h_candles()[..3];
270        let ind = compute_htf_indicators(candles, 4);
271        assert!(ind.sma_20.is_none());
272    }
273
274    // --- build_multi_timeframe ---
275
276    #[test]
277    fn test_build_multi_timeframe() {
278        let candles = sample_1h_candles();
279        let mtf = build_multi_timeframe(candles.clone(), 4);
280        assert_eq!(mtf.base_candles.len(), 8);
281        assert_eq!(mtf.htf_candles.len(), 2);
282        assert_eq!(mtf.multiplier, 4);
283    }
284
285    // --- Serialization ---
286
287    #[test]
288    fn test_multi_timeframe_data_serialization() {
289        let mtf = build_multi_timeframe(sample_1h_candles(), 4);
290        let json = serde_json::to_string(&mtf).unwrap();
291        let parsed: MultiTimeframeData = serde_json::from_str(&json).unwrap();
292        assert_eq!(parsed.multiplier, 4);
293        assert_eq!(parsed.base_candles.len(), 8);
294        assert_eq!(parsed.htf_candles.len(), 2);
295    }
296}