360 lines
13 KiB
Rust
360 lines
13 KiB
Rust
//! Tick-level backtest implementation.
|
|
//!
|
|
//! Accepts raw tick arrays (ltp, bid, ask, per-tick buy/sell qty deltas) plus
|
|
//! parallel entry/exit signal arrays, then simulates each trade to
|
|
//! stop-loss / take-profit / max-hold-time exit at full tick resolution.
|
|
//!
|
|
//! This is the right path for intraday options momentum strategies where the
|
|
//! exact fill tick matters. Do not resample to bars before calling this —
|
|
//! bar resampling discards intra-bar path information and makes scalping
|
|
//! strategies unbacktestable.
|
|
|
|
use crate::core::types::{
|
|
BacktestConfig, BacktestMetrics, BacktestResult, ExitReason, Price, TickData, Timestamp, Trade,
|
|
};
|
|
use crate::portfolio::engine::compute_backtest_metrics;
|
|
|
|
/// Configuration specific to tick backtests.
|
|
#[derive(Debug, Clone)]
|
|
pub struct TickBacktestConfig {
|
|
/// Shared execution config (capital, fees, slippage).
|
|
pub base: BacktestConfig,
|
|
/// Stop-loss as percentage of entry price (e.g. 5.0 = 5%).
|
|
pub stop_loss_pct: f64,
|
|
/// Take-profit as percentage of entry price (e.g. 10.0 = 10%).
|
|
pub take_profit_pct: f64,
|
|
/// Maximum hold time in seconds. 0 = no time limit.
|
|
pub max_hold_seconds: u64,
|
|
/// Minimum ticks between entries (cooldown). Prevents overlapping positions.
|
|
pub entry_cooldown_ticks: usize,
|
|
/// Maximum trades to simulate (bounds runtime for large windows).
|
|
pub max_trades: usize,
|
|
}
|
|
|
|
impl Default for TickBacktestConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: BacktestConfig::default(),
|
|
stop_loss_pct: 5.0,
|
|
take_profit_pct: 10.0,
|
|
max_hold_seconds: 1800,
|
|
entry_cooldown_ticks: 10,
|
|
max_trades: 50,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tick-level backtest runner.
|
|
pub struct TickBacktest {
|
|
config: TickBacktestConfig,
|
|
}
|
|
|
|
impl TickBacktest {
|
|
pub fn new(config: TickBacktestConfig) -> Self {
|
|
Self { config }
|
|
}
|
|
|
|
/// Run the tick backtest.
|
|
///
|
|
/// `ticks` — raw tick data (ltp, bid, ask, per-tick qty deltas)
|
|
/// `entries` — parallel bool array: true at ticks where a new long entry is allowed
|
|
/// `exits` — parallel bool array: true at ticks where an open position must close
|
|
/// `symbol` — instrument label used in trade records
|
|
pub fn run(
|
|
&self,
|
|
ticks: &TickData,
|
|
entries: &[bool],
|
|
exits: &[bool],
|
|
symbol: &str,
|
|
) -> BacktestResult {
|
|
let n = ticks.len();
|
|
assert_eq!(n, entries.len(), "ticks and entries must have same length");
|
|
assert_eq!(n, exits.len(), "ticks and exits must have same length");
|
|
|
|
let slippage_frac = self.config.base.slippage; // e.g. 0.0005 = 0.05%
|
|
let fee_frac = self.config.base.fees; // e.g. 0.001 = 0.1%
|
|
let stop_frac = self.config.stop_loss_pct / 100.0;
|
|
let target_frac = self.config.take_profit_pct / 100.0;
|
|
let max_hold_ns: i64 = self.config.max_hold_seconds as i64 * 1_000_000_000;
|
|
|
|
let mut trades: Vec<Trade> = Vec::new();
|
|
let mut trade_id: u64 = 0;
|
|
|
|
// Position state
|
|
let mut in_position = false;
|
|
let mut entry_idx: usize = 0;
|
|
let mut entry_price: Price = 0.0;
|
|
let mut entry_time: Timestamp = 0;
|
|
let mut stop_level: Price = 0.0;
|
|
let mut target_level: Price = 0.0;
|
|
let mut entry_fees: f64 = 0.0;
|
|
let mut cooldown_until: usize = 0;
|
|
|
|
for i in 0..n {
|
|
let ltp = ticks.ltp[i];
|
|
let bid = if ticks.bid[i] > 0.0 { ticks.bid[i] } else { ltp };
|
|
let ask = if ticks.ask[i] > 0.0 { ticks.ask[i] } else { ltp };
|
|
let ts = ticks.timestamps[i];
|
|
|
|
if in_position {
|
|
// Check time exit first (hard deadline)
|
|
let time_exit = max_hold_ns > 0 && (ts - entry_time) >= max_hold_ns;
|
|
|
|
// Check explicit exit signal
|
|
let signal_exit = exits[i];
|
|
|
|
// Check stop and target against ltp (tick-exact, no OHLC lookahead)
|
|
let stop_hit = ltp <= stop_level;
|
|
let target_hit = ltp >= target_level;
|
|
|
|
let (exit_price, reason) = if stop_hit {
|
|
// Fill at stop level (not ltp — avoid worse-than-stop fills)
|
|
let fill = stop_level * (1.0 - slippage_frac);
|
|
(fill, ExitReason::StopLoss)
|
|
} else if target_hit {
|
|
let fill = target_level * (1.0 - slippage_frac);
|
|
(fill, ExitReason::TakeProfit)
|
|
} else if time_exit || signal_exit {
|
|
let fill = bid * (1.0 - slippage_frac);
|
|
let reason = if time_exit { ExitReason::TimeExit } else { ExitReason::Signal };
|
|
(fill, reason)
|
|
} else if i == n - 1 {
|
|
// End of data — force close at bid
|
|
let fill = bid * (1.0 - slippage_frac);
|
|
(fill, ExitReason::EndOfData)
|
|
} else {
|
|
continue;
|
|
};
|
|
|
|
let exit_fees = exit_price * fee_frac;
|
|
let gross_pnl = (exit_price - entry_price) * 1.0; // qty=1; caller scales by lot_size
|
|
let net_pnl = gross_pnl - entry_fees - exit_fees;
|
|
let return_pct = net_pnl / entry_price * 100.0;
|
|
|
|
trades.push(Trade {
|
|
id: trade_id,
|
|
symbol: symbol.to_string(),
|
|
entry_idx,
|
|
exit_idx: i,
|
|
entry_price,
|
|
exit_price,
|
|
size: 1.0,
|
|
direction: crate::core::types::Direction::Long,
|
|
pnl: net_pnl,
|
|
return_pct,
|
|
entry_time,
|
|
exit_time: ts,
|
|
fees: entry_fees + exit_fees,
|
|
exit_reason: reason,
|
|
});
|
|
|
|
trade_id += 1;
|
|
in_position = false;
|
|
cooldown_until = i + self.config.entry_cooldown_ticks;
|
|
|
|
if trades.len() >= self.config.max_trades {
|
|
break;
|
|
}
|
|
} else {
|
|
// Not in position — check for entry
|
|
if i < cooldown_until {
|
|
continue;
|
|
}
|
|
if !entries[i] {
|
|
continue;
|
|
}
|
|
if ask <= 0.0 {
|
|
continue;
|
|
}
|
|
|
|
entry_price = ask * (1.0 + slippage_frac);
|
|
entry_fees = entry_price * fee_frac;
|
|
entry_idx = i;
|
|
entry_time = ts;
|
|
stop_level = entry_price * (1.0 - stop_frac);
|
|
target_level = entry_price * (1.0 + target_frac);
|
|
in_position = true;
|
|
}
|
|
}
|
|
|
|
Self::build_result(trades, self.config.base.initial_capital, symbol)
|
|
}
|
|
|
|
fn build_result(trades: Vec<Trade>, initial_capital: f64, _symbol: &str) -> BacktestResult {
|
|
if trades.is_empty() {
|
|
let metrics = BacktestMetrics {
|
|
start_value: initial_capital,
|
|
end_value: initial_capital,
|
|
..Default::default()
|
|
};
|
|
return BacktestResult::new(metrics, vec![initial_capital], vec![0.0], vec![], vec![]);
|
|
}
|
|
|
|
// Build per-trade equity and return curves (one point per trade close).
|
|
let mut equity = initial_capital;
|
|
let mut equity_curve = vec![initial_capital];
|
|
let mut returns = Vec::with_capacity(trades.len());
|
|
|
|
for t in &trades {
|
|
let prev = *equity_curve.last().unwrap();
|
|
equity += t.pnl;
|
|
equity_curve.push(equity);
|
|
let ret = if prev > 0.0 { (equity - prev) / prev } else { 0.0 };
|
|
returns.push(ret);
|
|
}
|
|
|
|
// Drawdown curve over equity points (percentage, positive = drawdown).
|
|
let mut peak = initial_capital;
|
|
let drawdown_curve: Vec<f64> = equity_curve
|
|
.iter()
|
|
.map(|&e| {
|
|
if e > peak {
|
|
peak = e;
|
|
}
|
|
if peak > 0.0 { (peak - e) / peak * 100.0 } else { 0.0 }
|
|
})
|
|
.collect();
|
|
|
|
let metrics =
|
|
compute_backtest_metrics(&equity_curve, &drawdown_curve, &returns, &trades, initial_capital);
|
|
|
|
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::core::types::BacktestConfig;
|
|
|
|
fn make_ticks(n: usize, base_price: f64, trend: f64) -> TickData {
|
|
let ltp: Vec<f64> = (0..n).map(|i| base_price + i as f64 * trend).collect();
|
|
let bid: Vec<f64> = ltp.iter().map(|p| p - 0.5).collect();
|
|
let ask: Vec<f64> = ltp.iter().map(|p| p + 0.5).collect();
|
|
TickData {
|
|
timestamps: (0..n as i64).map(|i| i * 1_000_000_000).collect(), // 1s apart
|
|
ltp,
|
|
bid,
|
|
ask,
|
|
buy_qty_delta: vec![100.0; n],
|
|
sell_qty_delta: vec![80.0; n],
|
|
oi: vec![0.0; n],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_target_hit() {
|
|
// 100 ticks trending up — entry at tick 0, target should be hit
|
|
let ticks = make_ticks(100, 100.0, 0.5); // price goes 100 → 149.5
|
|
let mut entries = vec![false; 100];
|
|
entries[0] = true;
|
|
let exits = vec![false; 100];
|
|
|
|
let config = TickBacktestConfig {
|
|
base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() },
|
|
stop_loss_pct: 5.0,
|
|
take_profit_pct: 10.0,
|
|
max_hold_seconds: 0, // no time limit
|
|
entry_cooldown_ticks: 5,
|
|
max_trades: 10,
|
|
};
|
|
|
|
let bt = TickBacktest::new(config);
|
|
let result = bt.run(&ticks, &entries, &exits, "TEST");
|
|
|
|
assert_eq!(result.trades.len(), 1);
|
|
assert_eq!(result.trades[0].exit_reason, ExitReason::TakeProfit);
|
|
assert!(result.trades[0].pnl > 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_stop_hit() {
|
|
// 100 ticks trending down — entry at tick 0, stop should be hit
|
|
let ticks = make_ticks(100, 100.0, -0.5); // price goes 100 → 50.5
|
|
let mut entries = vec![false; 100];
|
|
entries[0] = true;
|
|
let exits = vec![false; 100];
|
|
|
|
let config = TickBacktestConfig {
|
|
base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() },
|
|
stop_loss_pct: 5.0,
|
|
take_profit_pct: 20.0,
|
|
max_hold_seconds: 0,
|
|
entry_cooldown_ticks: 5,
|
|
max_trades: 10,
|
|
};
|
|
|
|
let bt = TickBacktest::new(config);
|
|
let result = bt.run(&ticks, &entries, &exits, "TEST");
|
|
|
|
assert_eq!(result.trades.len(), 1);
|
|
assert_eq!(result.trades[0].exit_reason, ExitReason::StopLoss);
|
|
assert!(result.trades[0].pnl < 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_time_exit() {
|
|
// Flat price — neither stop nor target hit, time exit should fire
|
|
let ticks = make_ticks(200, 100.0, 0.0);
|
|
let mut entries = vec![false; 200];
|
|
entries[0] = true;
|
|
let exits = vec![false; 200];
|
|
|
|
let config = TickBacktestConfig {
|
|
base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() },
|
|
stop_loss_pct: 50.0, // very wide, won't hit
|
|
take_profit_pct: 50.0,
|
|
max_hold_seconds: 10, // 10 ticks at 1s each
|
|
entry_cooldown_ticks: 5,
|
|
max_trades: 10,
|
|
};
|
|
|
|
let bt = TickBacktest::new(config);
|
|
let result = bt.run(&ticks, &entries, &exits, "TEST");
|
|
|
|
assert_eq!(result.trades.len(), 1);
|
|
assert_eq!(result.trades[0].exit_reason, ExitReason::TimeExit);
|
|
}
|
|
|
|
#[test]
|
|
fn test_multiple_trades_with_cooldown() {
|
|
let ticks = make_ticks(200, 100.0, 0.2);
|
|
// Entry every 20 ticks
|
|
let entries: Vec<bool> = (0..200).map(|i| i % 20 == 0).collect();
|
|
let exits = vec![false; 200];
|
|
|
|
let config = TickBacktestConfig {
|
|
base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() },
|
|
stop_loss_pct: 5.0,
|
|
take_profit_pct: 10.0,
|
|
max_hold_seconds: 0,
|
|
entry_cooldown_ticks: 5,
|
|
max_trades: 20,
|
|
};
|
|
|
|
let bt = TickBacktest::new(config);
|
|
let result = bt.run(&ticks, &entries, &exits, "TEST");
|
|
|
|
assert!(result.trades.len() > 1);
|
|
assert!(result.metrics.total_trades > 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_ticks_returns_empty_result() {
|
|
let ticks = TickData {
|
|
timestamps: vec![],
|
|
ltp: vec![],
|
|
bid: vec![],
|
|
ask: vec![],
|
|
buy_qty_delta: vec![],
|
|
sell_qty_delta: vec![],
|
|
oi: vec![],
|
|
};
|
|
let config = TickBacktestConfig::default();
|
|
let bt = TickBacktest::new(config);
|
|
let result = bt.run(&ticks, &[], &[], "TEST");
|
|
assert_eq!(result.trades.len(), 0);
|
|
assert_eq!(result.metrics.total_trades, 0);
|
|
}
|
|
}
|