quantforge 0.1.0

Deterministic CLI-first market data ingestion and backtesting framework in Rust.
Documentation
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::{collections::HashSet, fmt, str::FromStr};
use thiserror::Error;

pub type TimestampMs = i64;

#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Symbol(String);

impl Symbol {
    pub fn new(s: impl Into<String>) -> Result<Self, ModelError> {
        let s = s.into().trim().to_string();
        if s.is_empty() {
            return Err(ModelError::InvalidSymbol("empty".to_string()));
        }
        Ok(Self(s.to_ascii_uppercase()))
    }

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

impl fmt::Display for Symbol {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl FromStr for Symbol {
    type Err = ModelError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Self::new(s)
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ExchangeId {
    BinanceSpot,
}

impl ExchangeId {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::BinanceSpot => "binance_spot",
        }
    }
}

impl fmt::Display for ExchangeId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Interval {
    M1,
    M5,
    H1,
    D1,
}

impl Interval {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::M1 => "1m",
            Self::M5 => "5m",
            Self::H1 => "1h",
            Self::D1 => "1d",
        }
    }

    pub fn step_ms(self) -> i64 {
        match self {
            Self::M1 => 60_000,
            Self::M5 => 300_000,
            Self::H1 => 3_600_000,
            Self::D1 => 86_400_000,
        }
    }
}

impl fmt::Display for Interval {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl FromStr for Interval {
    type Err = ModelError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.trim() {
            "1m" => Ok(Self::M1),
            "5m" => Ok(Self::M5),
            "1h" => Ok(Self::H1),
            "1d" => Ok(Self::D1),
            other => Err(ModelError::InvalidInterval(other.to_string())),
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MarketId {
    pub exchange: ExchangeId,
    pub symbol: Symbol,
    pub interval: Interval,
}

impl MarketId {
    pub fn new(exchange: ExchangeId, symbol: Symbol, interval: Interval) -> Self {
        Self {
            exchange,
            symbol,
            interval,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Candle {
    pub open_time_ms: TimestampMs,
    pub close_time_ms: TimestampMs,
    pub open: Decimal,
    pub high: Decimal,
    pub low: Decimal,
    pub close: Decimal,
    pub volume: Decimal,
    pub trades: Option<u64>,
}

#[derive(Error, Debug)]
pub enum ModelError {
    #[error("invalid symbol: {0}")]
    InvalidSymbol(String),

    #[error("invalid interval: {0}")]
    InvalidInterval(String),

    #[error("invalid rfc3339 timestamp: {0}")]
    InvalidTimestamp(String),

    #[error("time parse error: {0}")]
    TimeParse(#[from] time::error::Parse),
}

pub fn parse_rfc3339_to_ms(input: &str) -> Result<TimestampMs, ModelError> {
    let input = input.trim();
    if input.is_empty() {
        return Err(ModelError::InvalidTimestamp("empty".to_string()));
    }

    let dt = time::OffsetDateTime::parse(input, &time::format_description::well_known::Rfc3339)?;
    Ok(dt.unix_timestamp() * 1000 + i64::from(dt.millisecond()))
}

pub fn ms_to_rfc3339(ms: TimestampMs) -> String {
    let seconds = ms.div_euclid(1000);
    let millis = ms.rem_euclid(1000) as u16;

    let dt = match time::OffsetDateTime::from_unix_timestamp(seconds) {
        Ok(dt) => match dt.replace_millisecond(millis) {
            Ok(adjusted) => adjusted,
            Err(_) => time::OffsetDateTime::UNIX_EPOCH,
        },
        Err(_) => time::OffsetDateTime::UNIX_EPOCH,
    };

    match dt.format(&time::format_description::well_known::Rfc3339) {
        Ok(s) => s,
        Err(_) => "1970-01-01T00:00:00Z".to_string(),
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ValidationIssue {
    OutOfOrder {
        prev_open_time_ms: TimestampMs,
        open_time_ms: TimestampMs,
    },
    DuplicateOpenTime {
        open_time_ms: TimestampMs,
    },
    Gap {
        expected_open_time_ms: TimestampMs,
        open_time_ms: TimestampMs,
    },
    OhlcInvalid {
        open_time_ms: TimestampMs,
        reason: String,
    },
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ValidationReport {
    pub market: MarketId,
    pub candle_count: usize,
    pub issues: Vec<ValidationIssue>,
}

impl ValidationReport {
    pub fn is_ok(&self) -> bool {
        self.issues.is_empty()
    }
}

pub fn validate_candles(market: &MarketId, candles: &[Candle]) -> ValidationReport {
    let mut issues = Vec::new();
    let mut seen = HashSet::<TimestampMs>::new();
    let step = market.interval.step_ms();
    let mut prev_open: Option<TimestampMs> = None;

    for candle in candles {
        if !seen.insert(candle.open_time_ms) {
            issues.push(ValidationIssue::DuplicateOpenTime {
                open_time_ms: candle.open_time_ms,
            });
        }

        if let Some(prev) = prev_open {
            if candle.open_time_ms <= prev {
                issues.push(ValidationIssue::OutOfOrder {
                    prev_open_time_ms: prev,
                    open_time_ms: candle.open_time_ms,
                });
            } else {
                let expected = prev + step;
                if candle.open_time_ms != expected {
                    issues.push(ValidationIssue::Gap {
                        expected_open_time_ms: expected,
                        open_time_ms: candle.open_time_ms,
                    });
                }
            }
        }

        if candle.low > candle.high {
            issues.push(ValidationIssue::OhlcInvalid {
                open_time_ms: candle.open_time_ms,
                reason: "low > high".to_string(),
            });
        }
        if candle.open < candle.low || candle.open > candle.high {
            issues.push(ValidationIssue::OhlcInvalid {
                open_time_ms: candle.open_time_ms,
                reason: "open not within [low, high]".to_string(),
            });
        }
        if candle.close < candle.low || candle.close > candle.high {
            issues.push(ValidationIssue::OhlcInvalid {
                open_time_ms: candle.open_time_ms,
                reason: "close not within [low, high]".to_string(),
            });
        }
        if candle.close_time_ms < candle.open_time_ms {
            issues.push(ValidationIssue::OhlcInvalid {
                open_time_ms: candle.open_time_ms,
                reason: "close_time_ms < open_time_ms".to_string(),
            });
        }

        prev_open = Some(candle.open_time_ms);
    }

    ValidationReport {
        market: market.clone(),
        candle_count: candles.len(),
        issues,
    }
}

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

    #[test]
    fn interval_parse_and_step() {
        assert_eq!(Interval::from_str("1m").expect("interval"), Interval::M1);
        assert_eq!(Interval::M5.step_ms(), 300_000);
    }

    #[test]
    fn parse_timestamp() {
        let ms = parse_rfc3339_to_ms("2024-01-01T00:00:00Z").expect("timestamp");
        assert_eq!(ms, 1_704_067_200_000);
    }

    #[test]
    fn detect_gap_and_duplicate() {
        let market = MarketId::new(
            ExchangeId::BinanceSpot,
            Symbol::new("BTCUSDT").expect("symbol"),
            Interval::M1,
        );
        let c0 = Candle {
            open_time_ms: 0,
            close_time_ms: 59_999,
            open: Decimal::from(1),
            high: Decimal::from(2),
            low: Decimal::from(1),
            close: Decimal::from(2),
            volume: Decimal::from(10),
            trades: Some(1),
        };
        let c1 = Candle {
            open_time_ms: 120_000,
            ..c0.clone()
        };
        let c2 = Candle {
            open_time_ms: 120_000,
            ..c0.clone()
        };

        let report = validate_candles(&market, &[c0, c1, c2]);
        assert!(!report.is_ok());
        assert!(
            report
                .issues
                .iter()
                .any(|issue| matches!(issue, ValidationIssue::Gap { .. }))
        );
        assert!(
            report
                .issues
                .iter()
                .any(|issue| matches!(issue, ValidationIssue::DuplicateOpenTime { .. }))
        );
    }
}