quantforge 0.1.0

Deterministic CLI-first market data ingestion and backtesting framework in Rust.
Documentation
use rust_decimal::Decimal;
use thiserror::Error;
use tracing::info;

use crate::{
    model::{Candle, MarketId, TimestampMs},
    sdk::{Strategy, StrategyContext, StrategyError, TargetPosition},
};

#[derive(Error, Debug)]
pub enum BacktestError {
    #[error("no candles provided")]
    NoCandles,

    #[error("strategy error: {0}")]
    Strategy(#[from] StrategyError),
}

#[derive(Clone, Debug)]
pub struct BacktestConfig {
    pub initial_cash: Decimal,
    pub fee_bps: Decimal,
    pub close_out_at_end: bool,
}

impl Default for BacktestConfig {
    fn default() -> Self {
        Self {
            initial_cash: Decimal::from(10_000),
            fee_bps: Decimal::from(10),
            close_out_at_end: true,
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct Trade {
    pub entry_time_ms: TimestampMs,
    pub entry_price: Decimal,
    pub exit_time_ms: TimestampMs,
    pub exit_price: Decimal,
    pub qty: Decimal,
    pub pnl: Decimal,
}

#[derive(Clone, Debug, PartialEq)]
pub struct BacktestResult {
    pub initial_cash: Decimal,
    pub final_equity: Decimal,
    pub total_return_pct: Decimal,
    pub trade_count: usize,
    pub max_drawdown_pct: Decimal,
    pub trades: Vec<Trade>,
}

#[derive(Clone, Debug)]
pub struct BacktestEngine {
    cfg: BacktestConfig,
}

impl BacktestEngine {
    pub fn new(cfg: BacktestConfig) -> Self {
        Self { cfg }
    }

    pub fn run(
        &self,
        market: &MarketId,
        candles: &[Candle],
        strategy: &mut dyn Strategy,
    ) -> Result<BacktestResult, BacktestError> {
        if candles.is_empty() {
            return Err(BacktestError::NoCandles);
        }

        let fee_rate = self.cfg.fee_bps / Decimal::from(10_000);
        let mut cash = self.cfg.initial_cash;
        let mut qty = Decimal::ZERO;
        let mut open_trade: Option<OpenTrade> = None;
        let mut trades = Vec::new();
        let mut pending_target: Option<TargetPosition> = None;
        let mut peak_equity = Decimal::ZERO;
        let mut max_drawdown = Decimal::ZERO;

        let mut ctx = EngineContext {
            market: market.clone(),
            now_ms: candles[0].open_time_ms,
            cash,
            position_qty: qty,
            desired_next: None,
        };

        strategy.on_start(&mut ctx)?;

        for (index, candle) in candles.iter().enumerate() {
            ctx.now_ms = candle.open_time_ms;

            if index > 0 {
                if let Some(target) = pending_target.take() {
                    execute_target(
                        target,
                        candle.open,
                        candle.open_time_ms,
                        fee_rate,
                        &mut cash,
                        &mut qty,
                        &mut open_trade,
                        &mut trades,
                    );
                }
            }

            ctx.cash = cash;
            ctx.position_qty = qty;
            ctx.desired_next = None;

            let equity = cash + qty * candle.close;
            if equity > peak_equity {
                peak_equity = equity;
            }
            if peak_equity > Decimal::ZERO {
                let drawdown = (peak_equity - equity) / peak_equity;
                if drawdown > max_drawdown {
                    max_drawdown = drawdown;
                }
            }

            strategy.on_bar(&mut ctx, candle)?;
            pending_target = ctx.desired_next;
        }

        if self.cfg.close_out_at_end && qty > Decimal::ZERO {
            let last = candles.last().expect("non-empty checked above");
            execute_target(
                TargetPosition::Flat,
                last.close,
                last.close_time_ms,
                fee_rate,
                &mut cash,
                &mut qty,
                &mut open_trade,
                &mut trades,
            );
        }

        ctx.cash = cash;
        ctx.position_qty = qty;
        strategy.on_finish(&mut ctx)?;

        let last_close = candles.last().map(|c| c.close).unwrap_or(Decimal::ZERO);
        let final_equity = cash + qty * last_close;
        let total_return_pct = if self.cfg.initial_cash == Decimal::ZERO {
            Decimal::ZERO
        } else {
            (final_equity - self.cfg.initial_cash) / self.cfg.initial_cash * Decimal::from(100)
        };

        info!(
            strategy = strategy.name(),
            final_equity = %final_equity,
            total_return_pct = %total_return_pct,
            trades = trades.len(),
            "backtest completed"
        );

        Ok(BacktestResult {
            initial_cash: self.cfg.initial_cash,
            final_equity,
            total_return_pct,
            trade_count: trades.len(),
            max_drawdown_pct: max_drawdown * Decimal::from(100),
            trades,
        })
    }
}

#[derive(Debug)]
struct OpenTrade {
    entry_time_ms: TimestampMs,
    entry_price: Decimal,
    qty: Decimal,
    cash_before: Decimal,
}

#[allow(clippy::too_many_arguments)]
fn execute_target(
    target: TargetPosition,
    price: Decimal,
    timestamp_ms: TimestampMs,
    fee_rate: Decimal,
    cash: &mut Decimal,
    qty: &mut Decimal,
    open_trade: &mut Option<OpenTrade>,
    trades: &mut Vec<Trade>,
) {
    match target {
        TargetPosition::Flat => {
            if *qty <= Decimal::ZERO {
                return;
            }

            let notional = *qty * price;
            let fee = notional * fee_rate;
            let cash_after = *cash + notional - fee;

            if let Some(open_trade) = open_trade.take() {
                trades.push(Trade {
                    entry_time_ms: open_trade.entry_time_ms,
                    entry_price: open_trade.entry_price,
                    exit_time_ms: timestamp_ms,
                    exit_price: price,
                    qty: open_trade.qty,
                    pnl: cash_after - open_trade.cash_before,
                });
            }

            *cash = cash_after;
            *qty = Decimal::ZERO;
        }
        TargetPosition::LongAllIn => {
            if *qty > Decimal::ZERO || *cash <= Decimal::ZERO {
                return;
            }

            let denominator = price * (Decimal::ONE + fee_rate);
            if denominator <= Decimal::ZERO {
                return;
            }

            let cash_before = *cash;
            let buy_qty = *cash / denominator;
            let notional = buy_qty * price;
            let fee = notional * fee_rate;

            *cash = *cash - notional - fee;
            *qty = buy_qty;
            *open_trade = Some(OpenTrade {
                entry_time_ms: timestamp_ms,
                entry_price: price,
                qty: buy_qty,
                cash_before,
            });
        }
    }
}

#[derive(Debug)]
struct EngineContext {
    market: MarketId,
    now_ms: TimestampMs,
    cash: Decimal,
    position_qty: Decimal,
    desired_next: Option<TargetPosition>,
}

impl StrategyContext for EngineContext {
    fn market(&self) -> &MarketId {
        &self.market
    }

    fn now_ms(&self) -> TimestampMs {
        self.now_ms
    }

    fn cash(&self) -> Decimal {
        self.cash
    }

    fn position_qty(&self) -> Decimal {
        self.position_qty
    }

    fn set_target_position(&mut self, target: TargetPosition) {
        self.desired_next = Some(target);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        model::{ExchangeId, Interval, Symbol},
        sdk::strategies::SmaCrossStrategy,
    };
    use std::str::FromStr;

    fn candle(open_time_ms: i64, open: &str, close: &str) -> Candle {
        let open = Decimal::from_str(open).expect("decimal");
        let close = Decimal::from_str(close).expect("decimal");
        let high = open.max(close) + Decimal::ONE;
        let low = open.min(close) - Decimal::ONE;

        Candle {
            open_time_ms,
            close_time_ms: open_time_ms + 59_999,
            open,
            high,
            low,
            close,
            volume: Decimal::from(100),
            trades: Some(1),
        }
    }

    #[test]
    fn backtest_is_deterministic() {
        let market = MarketId::new(
            ExchangeId::BinanceSpot,
            Symbol::new("BTCUSDT").expect("symbol"),
            Interval::M1,
        );
        let candles = vec![
            candle(0, "100", "100"),
            candle(60_000, "100", "101"),
            candle(120_000, "101", "102"),
            candle(180_000, "102", "105"),
            candle(240_000, "105", "103"),
            candle(300_000, "103", "99"),
            candle(360_000, "99", "98"),
            candle(420_000, "98", "101"),
        ];

        let mut strategy = SmaCrossStrategy::new(2, 3).expect("strategy");
        let engine = BacktestEngine::new(BacktestConfig {
            initial_cash: Decimal::from(10_000),
            fee_bps: Decimal::from(10),
            close_out_at_end: true,
        });

        let result_a = engine
            .run(&market, &candles, &mut strategy)
            .expect("backtest");
        let mut strategy_b = SmaCrossStrategy::new(2, 3).expect("strategy");
        let result_b = engine
            .run(&market, &candles, &mut strategy_b)
            .expect("backtest");

        assert_eq!(result_a, result_b);
    }
}