hyper-ta 0.1.0

Technical analysis indicators — RSI, MACD, Bollinger Bands, multi-timeframe
Documentation
use serde::{Deserialize, Serialize};

use crate::candle::Candle;
#[allow(deprecated)]
use crate::technical_analysis::{calculate_indicators, TechnicalIndicators};

// ---------------------------------------------------------------------------
// #224 — Multi-Timeframe Candle Subscription
// ---------------------------------------------------------------------------

/// Aggregated multi-timeframe data: holds candles and indicators for both
/// the base timeframe and a higher timeframe.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MultiTimeframeData {
    /// Original (lower) timeframe candles.
    pub base_candles: Vec<Candle>,
    /// Aggregated higher-timeframe candles.
    pub htf_candles: Vec<Candle>,
    /// Multiplier used (e.g. 4 means "4x the base interval").
    pub multiplier: u32,
}

/// Aggregate lower-timeframe candles into higher-timeframe candles.
///
/// `multiplier` indicates how many base candles form one HTF candle.
/// For example, with 1H candles and `multiplier = 4`, you get 4H candles.
///
/// Aggregation rules per group:
/// - **open**: first candle's open
/// - **close**: last candle's close
/// - **high**: max high across the group
/// - **low**: min low across the group
/// - **volume**: sum of all volumes
/// - **time**: first candle's time (period start)
///
/// Incomplete trailing groups (fewer than `multiplier` candles) are dropped.
pub fn aggregate_candles(candles: &[Candle], multiplier: u32) -> Vec<Candle> {
    if multiplier == 0 || candles.is_empty() {
        return Vec::new();
    }
    let m = multiplier as usize;
    let full_groups = candles.len() / m;
    let mut result = Vec::with_capacity(full_groups);

    for i in 0..full_groups {
        let group = &candles[i * m..(i + 1) * m];
        let first = &group[0];
        let last = &group[group.len() - 1];

        let high = group
            .iter()
            .map(|c| c.high)
            .fold(f64::NEG_INFINITY, f64::max);
        let low = group.iter().map(|c| c.low).fold(f64::INFINITY, f64::min);
        let volume: f64 = group.iter().map(|c| c.volume).sum();

        result.push(Candle {
            time: first.time,
            open: first.open,
            high,
            low,
            close: last.close,
            volume,
        });
    }

    result
}

/// Compute technical indicators on higher-timeframe candles.
///
/// This is a convenience wrapper: aggregate first, then calculate.
#[allow(deprecated)]
pub fn compute_htf_indicators(base_candles: &[Candle], multiplier: u32) -> TechnicalIndicators {
    let htf = aggregate_candles(base_candles, multiplier);
    if htf.is_empty() {
        return TechnicalIndicators::empty();
    }
    calculate_indicators(&htf)
}

/// Build a full `MultiTimeframeData` bundle from base candles.
pub fn build_multi_timeframe(base_candles: Vec<Candle>, multiplier: u32) -> MultiTimeframeData {
    let htf_candles = aggregate_candles(&base_candles, multiplier);
    MultiTimeframeData {
        base_candles,
        htf_candles,
        multiplier,
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    fn make_candle(time: u64, open: f64, high: f64, low: f64, close: f64, volume: f64) -> Candle {
        Candle {
            time,
            open,
            high,
            low,
            close,
            volume,
        }
    }

    fn sample_1h_candles() -> Vec<Candle> {
        vec![
            make_candle(3600 * 0, 100.0, 105.0, 98.0, 103.0, 1000.0),
            make_candle(3600 * 1, 103.0, 108.0, 101.0, 106.0, 1200.0),
            make_candle(3600 * 2, 106.0, 110.0, 104.0, 109.0, 800.0),
            make_candle(3600 * 3, 109.0, 112.0, 107.0, 111.0, 1500.0),
            make_candle(3600 * 4, 111.0, 115.0, 109.0, 113.0, 900.0),
            make_candle(3600 * 5, 113.0, 116.0, 112.0, 114.0, 1100.0),
            make_candle(3600 * 6, 114.0, 118.0, 113.0, 117.0, 1300.0),
            make_candle(3600 * 7, 117.0, 120.0, 115.0, 119.0, 1000.0),
        ]
    }

    // --- Basic aggregation ---

    #[test]
    fn test_aggregate_4h_from_1h() {
        let candles = sample_1h_candles();
        let agg = aggregate_candles(&candles, 4);

        assert_eq!(agg.len(), 2);

        // First 4H candle: hours 0-3
        assert_eq!(agg[0].time, 0);
        assert_eq!(agg[0].open, 100.0); // first candle's open
        assert_eq!(agg[0].close, 111.0); // last candle's close
        assert_eq!(agg[0].high, 112.0); // max high across group
        assert_eq!(agg[0].low, 98.0); // min low across group
        assert_eq!(agg[0].volume, 4500.0); // sum: 1000+1200+800+1500

        // Second 4H candle: hours 4-7
        assert_eq!(agg[1].time, 3600 * 4);
        assert_eq!(agg[1].open, 111.0);
        assert_eq!(agg[1].close, 119.0);
        assert_eq!(agg[1].high, 120.0);
        assert_eq!(agg[1].low, 109.0);
        assert_eq!(agg[1].volume, 4300.0); // sum: 900+1100+1300+1000
    }

    #[test]
    fn test_aggregate_2h_from_1h() {
        let candles = sample_1h_candles();
        let agg = aggregate_candles(&candles, 2);

        assert_eq!(agg.len(), 4);

        // First 2H candle
        assert_eq!(agg[0].open, 100.0);
        assert_eq!(agg[0].close, 106.0);
        assert_eq!(agg[0].high, 108.0);
        assert_eq!(agg[0].low, 98.0);
        assert_eq!(agg[0].volume, 2200.0);
    }

    #[test]
    fn test_aggregate_drops_incomplete_trailing_group() {
        let candles = &sample_1h_candles()[..7]; // 7 candles, multiplier=4 → 1 full group
        let agg = aggregate_candles(candles, 4);
        assert_eq!(agg.len(), 1);
    }

    #[test]
    fn test_aggregate_multiplier_1_identity() {
        let candles = sample_1h_candles();
        let agg = aggregate_candles(&candles, 1);
        assert_eq!(agg.len(), candles.len());
        for (a, c) in agg.iter().zip(candles.iter()) {
            assert_eq!(a.open, c.open);
            assert_eq!(a.close, c.close);
            assert_eq!(a.high, c.high);
            assert_eq!(a.low, c.low);
            assert_eq!(a.volume, c.volume);
            assert_eq!(a.time, c.time);
        }
    }

    // --- Edge cases ---

    #[test]
    fn test_aggregate_empty_candles() {
        let agg = aggregate_candles(&[], 4);
        assert!(agg.is_empty());
    }

    #[test]
    fn test_aggregate_multiplier_zero() {
        let candles = sample_1h_candles();
        let agg = aggregate_candles(&candles, 0);
        assert!(agg.is_empty());
    }

    #[test]
    fn test_aggregate_multiplier_larger_than_input() {
        let candles = sample_1h_candles(); // 8 candles
        let agg = aggregate_candles(&candles, 10);
        assert!(agg.is_empty());
    }

    #[test]
    fn test_aggregate_single_candle_multiplier_1() {
        let candles = vec![make_candle(100, 50.0, 55.0, 45.0, 52.0, 500.0)];
        let agg = aggregate_candles(&candles, 1);
        assert_eq!(agg.len(), 1);
        assert_eq!(agg[0].open, 50.0);
        assert_eq!(agg[0].close, 52.0);
    }

    #[test]
    fn test_aggregate_exactly_one_group() {
        let candles = &sample_1h_candles()[..4]; // exactly 4 candles, multiplier=4
        let agg = aggregate_candles(candles, 4);
        assert_eq!(agg.len(), 1);
        assert_eq!(agg[0].open, 100.0);
        assert_eq!(agg[0].close, 111.0);
    }

    // --- Volume summation ---

    #[test]
    fn test_aggregate_volume_is_sum() {
        let candles = vec![
            make_candle(0, 100.0, 100.0, 100.0, 100.0, 100.0),
            make_candle(1, 100.0, 100.0, 100.0, 100.0, 200.0),
            make_candle(2, 100.0, 100.0, 100.0, 100.0, 300.0),
        ];
        let agg = aggregate_candles(&candles, 3);
        assert_eq!(agg.len(), 1);
        assert_eq!(agg[0].volume, 600.0);
    }

    // --- High/Low correctness ---

    #[test]
    fn test_aggregate_high_is_max_low_is_min() {
        let candles = vec![
            make_candle(0, 100.0, 200.0, 50.0, 100.0, 100.0),
            make_candle(1, 100.0, 150.0, 80.0, 100.0, 100.0),
            make_candle(2, 100.0, 300.0, 90.0, 100.0, 100.0),
            make_candle(3, 100.0, 180.0, 40.0, 100.0, 100.0),
        ];
        let agg = aggregate_candles(&candles, 4);
        assert_eq!(agg[0].high, 300.0);
        assert_eq!(agg[0].low, 40.0);
    }

    // --- compute_htf_indicators ---

    #[test]
    fn test_compute_htf_indicators_empty() {
        let ind = compute_htf_indicators(&[], 4);
        assert!(ind.sma_20.is_none());
        assert!(ind.rsi_14.is_none());
    }

    #[test]
    fn test_compute_htf_indicators_too_few() {
        // Only 3 candles with multiplier 4 → 0 HTF candles → empty indicators
        let candles = &sample_1h_candles()[..3];
        let ind = compute_htf_indicators(candles, 4);
        assert!(ind.sma_20.is_none());
    }

    // --- build_multi_timeframe ---

    #[test]
    fn test_build_multi_timeframe() {
        let candles = sample_1h_candles();
        let mtf = build_multi_timeframe(candles.clone(), 4);
        assert_eq!(mtf.base_candles.len(), 8);
        assert_eq!(mtf.htf_candles.len(), 2);
        assert_eq!(mtf.multiplier, 4);
    }

    // --- Serialization ---

    #[test]
    fn test_multi_timeframe_data_serialization() {
        let mtf = build_multi_timeframe(sample_1h_candles(), 4);
        let json = serde_json::to_string(&mtf).unwrap();
        let parsed: MultiTimeframeData = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.multiplier, 4);
        assert_eq!(parsed.base_candles.len(), 8);
        assert_eq!(parsed.htf_candles.len(), 2);
    }
}