hyper-agent-core 0.1.0

Core domain logic for hyper-agent: pipeline, executor, signals, positions
Documentation
use hyper_ta::Candle;
use std::collections::VecDeque;

const DEFAULT_CAPACITY: usize = 200;

pub struct CandleBuffer {
    candles: VecDeque<Candle>,
    capacity: usize,
    symbol: String,
}

impl CandleBuffer {
    pub fn new(symbol: String) -> Self {
        Self::with_capacity(symbol, DEFAULT_CAPACITY)
    }

    pub fn with_capacity(symbol: String, capacity: usize) -> Self {
        Self {
            candles: VecDeque::with_capacity(capacity),
            capacity,
            symbol,
        }
    }

    pub fn symbol(&self) -> &str {
        &self.symbol
    }

    pub fn len(&self) -> usize {
        self.candles.len()
    }

    pub fn is_empty(&self) -> bool {
        self.candles.is_empty()
    }

    /// Push a new candle. If at capacity, the oldest candle is evicted.
    pub fn push(&mut self, candle: Candle) {
        if self.candles.len() >= self.capacity {
            self.candles.pop_front();
        }
        self.candles.push_back(candle);
    }

    /// Get the most recent candle.
    pub fn last(&self) -> Option<&Candle> {
        self.candles.back()
    }

    /// Return all candles as a slice (for indicator calculation).
    pub fn as_slice(&self) -> Vec<Candle> {
        self.candles.iter().cloned().collect()
    }

    /// Backfill candles from REST API response.
    /// Merges by timestamp — existing candles are not duplicated.
    /// Candles are inserted in time order, evicting oldest if over capacity.
    pub fn backfill(&mut self, candles: Vec<Candle>) {
        // Collect existing timestamps for dedup
        let existing_times: std::collections::HashSet<u64> =
            self.candles.iter().map(|c| c.time).collect();

        let mut new_candles: Vec<Candle> = candles
            .into_iter()
            .filter(|c| !existing_times.contains(&c.time))
            .collect();

        // Sort by time ascending
        new_candles.sort_by_key(|c| c.time);

        // Merge: rebuild the buffer with all candles sorted
        let mut all: Vec<Candle> = self.candles.drain(..).collect();
        all.extend(new_candles);
        all.sort_by_key(|c| c.time);

        // Keep only the most recent `capacity` candles
        if all.len() > self.capacity {
            all = all.split_off(all.len() - self.capacity);
        }

        self.candles = all.into_iter().collect();
    }
}

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

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

    #[test]
    fn push_and_len() {
        let mut buf = CandleBuffer::new("BTC-PERP".into());
        assert!(buf.is_empty());
        buf.push(make_candle(1000, 50000.0));
        assert_eq!(buf.len(), 1);
        assert_eq!(buf.symbol(), "BTC-PERP");
    }

    #[test]
    fn capacity_eviction() {
        let mut buf = CandleBuffer::with_capacity("ETH".into(), 3);
        buf.push(make_candle(1, 100.0));
        buf.push(make_candle(2, 200.0));
        buf.push(make_candle(3, 300.0));
        buf.push(make_candle(4, 400.0));
        assert_eq!(buf.len(), 3);
        assert_eq!(buf.as_slice()[0].time, 2); // oldest evicted
        assert_eq!(buf.last().unwrap().time, 4);
    }

    #[test]
    fn backfill_dedup() {
        let mut buf = CandleBuffer::with_capacity("BTC".into(), 5);
        buf.push(make_candle(3, 300.0));
        buf.push(make_candle(5, 500.0));

        // Backfill with some overlapping timestamps
        buf.backfill(vec![
            make_candle(1, 100.0),
            make_candle(3, 300.0), // duplicate
            make_candle(4, 400.0),
        ]);

        assert_eq!(buf.len(), 4); // 1, 3, 4, 5
        let times: Vec<u64> = buf.as_slice().iter().map(|c| c.time).collect();
        assert_eq!(times, vec![1, 3, 4, 5]);
    }

    #[test]
    fn backfill_respects_capacity() {
        let mut buf = CandleBuffer::with_capacity("BTC".into(), 3);
        buf.backfill(vec![
            make_candle(1, 100.0),
            make_candle(2, 200.0),
            make_candle(3, 300.0),
            make_candle(4, 400.0),
            make_candle(5, 500.0),
        ]);
        assert_eq!(buf.len(), 3);
        assert_eq!(buf.as_slice()[0].time, 3); // keeps most recent 3
    }

    #[test]
    fn last_returns_most_recent() {
        let mut buf = CandleBuffer::new("SOL".into());
        assert!(buf.last().is_none());
        buf.push(make_candle(1, 10.0));
        buf.push(make_candle(2, 20.0));
        assert_eq!(buf.last().unwrap().close, 20.0);
    }
}