use crate::config::BacktestConfig;
use crate::mae_mfe::calculate_mae_mfe_at_exit;
use crate::portfolio::PortfolioState;
use crate::position::{Position, PositionSnapshot};
use crate::stops::{detect_stops, detect_stops_finlab, detect_touched_exit};
use crate::tracker::{WideBacktestResult, NoopIndexTracker, IndexTracker, TradeTracker};
use crate::weights::{normalize_weights_finlab, IntoWeights};
use crate::{is_valid_price, FLOAT_EPSILON};
struct OhlcPrices<'a> {
open: &'a [Vec<f64>],
high: &'a [Vec<f64>],
low: &'a [Vec<f64>],
}
fn simulate_backtest<T: TradeTracker<Key = usize, Date = usize>>(
close_prices: &[Vec<f64>],
trade_prices: &[Vec<f64>],
weights: &[Vec<f64>],
rebalance_indices: &[usize],
config: &BacktestConfig,
tracker: &mut T,
ohlc: Option<OhlcPrices>,
) -> Vec<f64> {
if config.finlab_mode {
simulate_backtest_finlab(close_prices, trade_prices, weights, rebalance_indices, config, tracker, ohlc)
} else {
simulate_backtest_standard(close_prices, trade_prices, weights, rebalance_indices, config, tracker)
}
}
fn simulate_backtest_finlab<T: TradeTracker<Key = usize, Date = usize>>(
close_prices: &[Vec<f64>],
trade_prices: &[Vec<f64>],
weights: &[Vec<f64>],
rebalance_indices: &[usize],
config: &BacktestConfig,
tracker: &mut T,
ohlc: Option<OhlcPrices>,
) -> Vec<f64> {
if close_prices.is_empty() {
return vec![];
}
let n_times = close_prices.len();
let n_assets = close_prices[0].len();
let mut portfolio = PortfolioState::new();
let mut weight_idx = 0;
let mut prev_prices = close_prices[0].clone();
let mut creturn = Vec::with_capacity(n_times);
let mut stopped_stocks: Vec<bool> = vec![false; n_assets];
let mut pending_weights: Option<Vec<f64>> = None;
let mut pending_signal_index: Option<usize> = None;
let mut pending_stop_exits: Vec<usize> = Vec::new();
let mut active_weights: Vec<f64> = vec![0.0; n_assets];
for t in 0..n_times {
if t > 0 {
portfolio.update_max_prices(&close_prices[t]);
if config.touched_exit {
if let Some(ref ohlc_data) = ohlc {
let touched_exits = detect_touched_exit(
&portfolio.positions,
&ohlc_data.open[t],
&ohlc_data.high[t],
&ohlc_data.low[t],
&close_prices[t],
&prev_prices,
config,
);
for touched in &touched_exits {
if let Some(pos) = portfolio.positions.remove(&touched.stock_id) {
let exit_value = pos.last_market_value * touched.exit_ratio;
let sell_value = exit_value - exit_value.abs() * (config.fee_ratio + config.tax_ratio);
portfolio.cash += sell_value;
if config.stop_trading_next_period && touched.stock_id < stopped_stocks.len() {
stopped_stocks[touched.stock_id] = true;
}
if touched.stock_id < active_weights.len() {
active_weights[touched.stock_id] = 0.0;
}
let exit_price = trade_prices[t].get(touched.stock_id).copied().unwrap_or(1.0);
tracker.close_trade(&touched.stock_id, t, Some(t), exit_price, 1.0, config.fee_ratio, config.tax_ratio);
}
}
}
}
portfolio.update_previous_prices(&close_prices[t]);
let mut today_stops = if config.touched_exit {
Vec::new()
} else {
detect_stops_finlab(&portfolio.positions, &close_prices[t], config)
};
if !pending_stop_exits.is_empty() {
let exits_to_process: Vec<usize> = pending_stop_exits
.iter()
.filter(|&&stock_id| {
if let Some(ref weights) = pending_weights {
let has_nonzero_weight = stock_id < weights.len() && weights[stock_id].abs() > FLOAT_EPSILON;
config.stop_trading_next_period || !has_nonzero_weight
} else {
true
}
})
.copied()
.collect();
for stock_id in exits_to_process {
if let Some(pos) = portfolio.positions.remove(&stock_id) {
let market_value = pos.last_market_value;
let sell_value = market_value - market_value.abs() * (config.fee_ratio + config.tax_ratio);
portfolio.cash += sell_value;
if config.stop_trading_next_period && stock_id < stopped_stocks.len() {
stopped_stocks[stock_id] = true;
}
if stock_id < active_weights.len() {
active_weights[stock_id] = 0.0;
}
let exit_price = trade_prices[t].get(stock_id).copied().unwrap_or(1.0);
tracker.close_trade(&stock_id, t, None, exit_price, 1.0, config.fee_ratio, config.tax_ratio);
today_stops.retain(|&x| x != stock_id);
}
}
pending_stop_exits.clear();
}
pending_stop_exits.extend(today_stops);
if let Some(mut target_weights) = pending_weights.take() {
let signal_index = pending_signal_index.take().unwrap_or(t - 1);
if config.stop_trading_next_period {
let original_sum: f64 = target_weights.iter().map(|w| w.abs()).sum();
for (i, stopped) in stopped_stocks.iter().enumerate() {
if *stopped && i < target_weights.len() {
target_weights[i] = 0.0;
}
}
let remaining_sum: f64 = target_weights.iter().map(|w| w.abs()).sum();
if remaining_sum > 0.0 && remaining_sum < original_sum {
let scale_factor = original_sum / remaining_sum;
for w in target_weights.iter_mut() {
*w *= scale_factor;
}
}
}
for stock_id in portfolio.positions.keys().copied().collect::<Vec<_>>() {
let exit_price = trade_prices[t].get(stock_id).copied().unwrap_or(1.0);
tracker.close_trade(&stock_id, t, Some(signal_index), exit_price, 1.0, config.fee_ratio, config.tax_ratio);
}
execute_finlab_rebalance(&mut portfolio, &target_weights, &close_prices[t], config);
active_weights = target_weights.clone();
for (stock_id, &target_weight) in target_weights.iter().enumerate() {
if target_weight != 0.0 && portfolio.positions.contains_key(&stock_id) {
let entry_price = trade_prices[t].get(stock_id).copied().unwrap_or(1.0);
tracker.open_trade(stock_id, t, signal_index, entry_price, target_weight, 1.0);
}
}
stopped_stocks = vec![false; n_assets];
}
update_entry_prices_after_nan(&mut portfolio, &close_prices[t], &prev_prices);
}
if rebalance_indices.contains(&t) && weight_idx < weights.len() {
let target_weights = normalize_weights_finlab(&weights[weight_idx], &stopped_stocks, config.position_limit);
pending_weights = Some(target_weights);
pending_signal_index = Some(t);
weight_idx += 1;
}
creturn.push(portfolio.balance_finlab(&close_prices[t]));
prev_prices = close_prices[t].clone();
}
if let Some(weights) = pending_weights {
let signal_index = pending_signal_index.unwrap_or(n_times.saturating_sub(1));
for (stock_id, &weight) in weights.iter().enumerate() {
if weight > FLOAT_EPSILON && !portfolio.positions.contains_key(&stock_id) {
tracker.add_pending_entry(stock_id, signal_index, weight);
}
}
}
creturn
}
fn simulate_backtest_standard<T: TradeTracker<Key = usize, Date = usize>>(
close_prices: &[Vec<f64>],
trade_prices: &[Vec<f64>],
weights: &[Vec<f64>],
rebalance_indices: &[usize],
config: &BacktestConfig,
tracker: &mut T,
) -> Vec<f64> {
if close_prices.is_empty() {
return vec![];
}
let n_times = close_prices.len();
let n_assets = close_prices[0].len();
let mut portfolio = PortfolioState::new();
let mut weight_idx = 0;
let mut prev_prices = close_prices[0].clone();
let mut creturn = Vec::with_capacity(n_times);
let mut stopped_stocks: Vec<bool> = vec![false; n_assets];
let mut pending_weights: Option<Vec<f64>> = None;
let mut pending_signal_index: Option<usize> = None;
let mut pending_stop_exits: Vec<usize> = Vec::new();
for t in 0..n_times {
if t > 0 {
if let Some(target_weights) = pending_weights.take() {
let signal_index = pending_signal_index.take().unwrap_or(t - 1);
for (&stock_id, _) in portfolio.positions.iter() {
if stock_id < target_weights.len() && target_weights[stock_id] == 0.0 {
let exit_price = trade_prices[t].get(stock_id).copied().unwrap_or(1.0);
tracker.close_trade(&stock_id, t, Some(signal_index), exit_price, 1.0, config.fee_ratio, config.tax_ratio);
}
}
execute_t1_rebalance(&mut portfolio, &target_weights, &prev_prices, &close_prices[t], config);
for (stock_id, &target_weight) in target_weights.iter().enumerate() {
if target_weight != 0.0 && portfolio.positions.contains_key(&stock_id) && !tracker.has_open_trade(&stock_id) {
let entry_price = trade_prices[t].get(stock_id).copied().unwrap_or(1.0);
tracker.open_trade(stock_id, t, signal_index, entry_price, target_weight, 1.0);
}
}
stopped_stocks = vec![false; n_assets];
} else {
update_position_values(&mut portfolio, &close_prices[t], &prev_prices);
}
if !pending_stop_exits.is_empty() {
for &stock_id in &pending_stop_exits {
if let Some(pos) = portfolio.positions.remove(&stock_id) {
let sell_value = pos.value * (1.0 - config.fee_ratio - config.tax_ratio);
portfolio.cash += sell_value;
if config.stop_trading_next_period && stock_id < stopped_stocks.len() {
stopped_stocks[stock_id] = true;
}
let exit_price = trade_prices[t].get(stock_id).copied().unwrap_or(1.0);
tracker.close_trade(&stock_id, t, None, exit_price, 1.0, config.fee_ratio, config.tax_ratio);
}
}
pending_stop_exits.clear();
}
let new_stops = detect_stops(&portfolio.positions, &close_prices[t], config);
pending_stop_exits.extend(new_stops);
}
if rebalance_indices.contains(&t) && weight_idx < weights.len() {
let target_weights = normalize_weights_finlab(&weights[weight_idx], &stopped_stocks, config.position_limit);
pending_weights = Some(target_weights);
pending_signal_index = Some(t);
weight_idx += 1;
}
creturn.push(portfolio.balance());
prev_prices = close_prices[t].clone();
}
if let Some(weights) = pending_weights {
let signal_index = pending_signal_index.unwrap_or(n_times.saturating_sub(1));
for (stock_id, &weight) in weights.iter().enumerate() {
if weight > FLOAT_EPSILON && !portfolio.positions.contains_key(&stock_id) {
tracker.add_pending_entry(stock_id, signal_index, weight);
}
}
}
creturn
}
pub fn run_backtest<S: IntoWeights>(
prices: &[Vec<f64>],
signals: &[S],
rebalance_indices: &[usize],
config: &BacktestConfig,
) -> Vec<f64> {
let weights: Vec<Vec<f64>> = signals
.iter()
.map(|s| s.into_weights(&[], config.position_limit))
.collect();
let mut tracker = NoopIndexTracker::new();
simulate_backtest(
prices,
prices,
&weights,
rebalance_indices,
config,
&mut tracker,
None, )
}
fn update_position_values(
portfolio: &mut PortfolioState,
current_prices: &[f64],
prev_prices: &[f64],
) {
for (&stock_id, pos) in portfolio.positions.iter_mut() {
if stock_id >= current_prices.len() || stock_id >= prev_prices.len() {
continue;
}
let prev_price = prev_prices[stock_id];
let curr_price = current_prices[stock_id];
if prev_price > 0.0 && curr_price > 0.0 {
let return_pct = (curr_price - prev_price) / prev_price;
pos.value *= 1.0 + return_pct;
if curr_price > pos.max_price {
pos.max_price = curr_price;
}
}
}
}
fn update_entry_prices_after_nan(
portfolio: &mut PortfolioState,
current_prices: &[f64],
_prev_prices: &[f64],
) {
for (&stock_id, pos) in portfolio.positions.iter_mut() {
if stock_id >= current_prices.len() {
continue;
}
let curr_price = current_prices[stock_id];
let curr_is_valid = is_valid_price(curr_price);
if !curr_is_valid {
continue;
}
if pos.entry_price <= 0.0 {
pos.entry_price = curr_price;
pos.stop_entry_price = curr_price;
pos.max_price = curr_price;
continue;
}
}
}
fn execute_finlab_rebalance(
portfolio: &mut PortfolioState,
target_weights: &[f64],
prices: &[f64],
config: &BacktestConfig,
) {
for (stock_id, pos) in portfolio.positions.iter_mut() {
if *stock_id < prices.len() {
let close_price = prices[*stock_id];
pos.value = pos.last_market_value;
pos.entry_price = close_price;
}
}
let balance = portfolio.total_cost_basis();
let total_target_weight: f64 = target_weights.iter().map(|w| w.abs()).sum();
if total_target_weight == 0.0 || balance <= 0.0 {
let all_positions: Vec<usize> = portfolio.positions.keys().copied().collect();
for stock_id in all_positions {
if let Some(pos) = portfolio.positions.remove(&stock_id) {
let sell_value = pos.value - pos.value.abs() * (config.fee_ratio + config.tax_ratio);
portfolio.cash += sell_value;
}
}
return;
}
let ratio = balance / total_target_weight.max(1.0);
let old_snapshots: std::collections::HashMap<usize, PositionSnapshot> = portfolio
.positions
.iter()
.map(|(&k, v)| (k, PositionSnapshot::from(v)))
.collect();
portfolio.positions.clear();
let mut cash = portfolio.cash;
for (stock_id, &target_weight) in target_weights.iter().enumerate() {
if stock_id >= prices.len() {
continue;
}
let price = prices[stock_id];
let price_valid = is_valid_price(price);
let target_value = target_weight * ratio;
let snapshot = old_snapshots.get(&stock_id);
let current_value = snapshot.map(|s| s.cost_basis).unwrap_or(0.0);
if !price_valid {
if target_weight.abs() < FLOAT_EPSILON {
if let Some(snap) = snapshot {
if snap.market_value.abs() > FLOAT_EPSILON {
let sell_fee = snap.market_value.abs() * (config.fee_ratio + config.tax_ratio);
cash += snap.market_value - sell_fee;
}
}
continue;
}
if target_value.abs() > FLOAT_EPSILON {
let amount = target_value - current_value;
let is_buy = amount > 0.0;
let is_entry = (target_value >= 0.0 && amount > 0.0) || (target_value <= 0.0 && amount < 0.0);
let cost = if is_entry {
amount.abs() * config.fee_ratio
} else {
amount.abs() * (config.fee_ratio + config.tax_ratio)
};
let new_value = if is_buy {
cash -= amount;
current_value + amount - cost
} else {
let sell_amount = amount.abs();
cash += sell_amount - cost;
current_value - sell_amount
};
portfolio.positions.insert(
stock_id,
Position {
value: new_value,
entry_price: 0.0, stop_entry_price: 0.0,
max_price: 0.0,
last_market_value: new_value, cr: 1.0,
maxcr: 1.0,
previous_price: 0.0,
},
);
}
continue;
}
let amount = target_value - current_value;
if target_value.abs() < FLOAT_EPSILON {
if current_value.abs() > FLOAT_EPSILON {
let sell_fee = current_value.abs() * (config.fee_ratio + config.tax_ratio);
cash += current_value - sell_fee;
}
continue;
}
let is_buy = amount > 0.0;
let is_entry = (target_value >= 0.0 && amount > 0.0) || (target_value <= 0.0 && amount < 0.0);
let cost = if is_entry {
amount.abs() * config.fee_ratio
} else {
amount.abs() * (config.fee_ratio + config.tax_ratio)
};
let new_position_value;
if is_buy {
cash -= amount;
new_position_value = current_value + amount - cost;
} else {
let sell_amount = amount.abs();
cash += sell_amount - cost;
new_position_value = current_value - sell_amount;
}
if new_position_value.abs() > FLOAT_EPSILON {
let old_value = snapshot.map(|s| s.cost_basis).unwrap_or(0.0);
let is_continuing = old_value.abs() > FLOAT_EPSILON && old_value * target_weight > 0.0;
let (stop_entry, max_price_val, cr_val, maxcr_val, prev_price) =
if config.retain_cost_when_rebalance && is_continuing {
let snap = snapshot.unwrap(); (snap.stop_entry_price, snap.max_price, snap.cr, snap.maxcr, snap.previous_price)
} else {
(price, price, 1.0, 1.0, price)
};
portfolio.positions.insert(
stock_id,
Position {
value: new_position_value,
entry_price: price,
stop_entry_price: stop_entry,
max_price: max_price_val,
last_market_value: new_position_value, cr: cr_val,
maxcr: maxcr_val,
previous_price: prev_price,
},
);
}
}
for (&stock_id, snapshot) in old_snapshots.iter() {
if stock_id >= target_weights.len() && snapshot.cost_basis.abs() > FLOAT_EPSILON {
let sell_fee = snapshot.cost_basis.abs() * (config.fee_ratio + config.tax_ratio);
cash += snapshot.cost_basis - sell_fee;
}
}
portfolio.cash = cash;
}
fn execute_t1_rebalance(
portfolio: &mut PortfolioState,
target_weights: &[f64],
prev_prices: &[f64],
current_prices: &[f64],
config: &BacktestConfig,
) {
update_position_values(portfolio, current_prices, prev_prices);
rebalance_to_target_weights(portfolio, target_weights, current_prices, config);
}
fn rebalance_to_target_weights(
portfolio: &mut PortfolioState,
target_weights: &[f64],
prices: &[f64],
config: &BacktestConfig,
) {
if !config.retain_cost_when_rebalance {
for (stock_id, pos) in portfolio.positions.iter_mut() {
if *stock_id < prices.len() {
pos.entry_price = prices[*stock_id];
pos.max_price = prices[*stock_id];
pos.cr = 1.0; pos.maxcr = 1.0; pos.previous_price = prices[*stock_id]; }
}
}
let positions_to_close: Vec<usize> = portfolio
.positions
.keys()
.filter(|&&id| id < target_weights.len() && target_weights[id] == 0.0)
.copied()
.collect();
for stock_id in positions_to_close {
if let Some(pos) = portfolio.positions.remove(&stock_id) {
let sell_value = pos.value * (1.0 - config.fee_ratio - config.tax_ratio);
portfolio.cash += sell_value;
}
}
let extra_positions: Vec<usize> = portfolio
.positions
.keys()
.filter(|&&id| id >= target_weights.len())
.copied()
.collect();
for stock_id in extra_positions {
if let Some(pos) = portfolio.positions.remove(&stock_id) {
let sell_value = pos.value * (1.0 - config.fee_ratio - config.tax_ratio);
portfolio.cash += sell_value;
}
}
let total_target_weight: f64 = target_weights.iter().sum();
if total_target_weight == 0.0 {
return;
}
let total_value = portfolio.balance();
for (stock_id, &target_weight) in target_weights.iter().enumerate() {
let target_value = total_value * target_weight;
let current_value = portfolio
.positions
.get(&stock_id)
.map(|p| p.value)
.unwrap_or(0.0);
let diff = target_value - current_value;
if diff < 0.0 {
let sell_amount = -diff;
if let Some(pos) = portfolio.positions.get_mut(&stock_id) {
if pos.value >= sell_amount - FLOAT_EPSILON {
let sell_value = sell_amount * (1.0 - config.fee_ratio - config.tax_ratio);
pos.value -= sell_amount;
portfolio.cash += sell_value;
if pos.value < FLOAT_EPSILON {
portfolio.positions.remove(&stock_id);
}
}
}
}
}
for (stock_id, &target_weight) in target_weights.iter().enumerate() {
if target_weight == 0.0 {
continue;
}
let target_allocation = total_value * target_weight;
let current_value = portfolio
.positions
.get(&stock_id)
.map(|p| p.value)
.unwrap_or(0.0);
let target_position = target_allocation * (1.0 - config.fee_ratio);
let diff = target_position - current_value;
if diff > FLOAT_EPSILON {
let spend_needed = diff / (1.0 - config.fee_ratio);
let actual_spend = spend_needed.min(portfolio.cash);
if actual_spend > FLOAT_EPSILON {
let position_value = actual_spend * (1.0 - config.fee_ratio);
portfolio.cash -= actual_spend;
let entry = portfolio.positions.entry(stock_id).or_insert(Position {
value: 0.0,
entry_price: prices[stock_id],
stop_entry_price: prices[stock_id],
max_price: prices[stock_id],
last_market_value: 0.0,
cr: 1.0,
maxcr: 1.0,
previous_price: prices[stock_id],
});
if entry.value < FLOAT_EPSILON {
entry.entry_price = prices[stock_id];
entry.stop_entry_price = prices[stock_id];
entry.max_price = prices[stock_id];
entry.cr = 1.0;
entry.maxcr = 1.0;
entry.previous_price = prices[stock_id];
}
entry.value += position_value;
entry.last_market_value = entry.value;
}
}
}
}
#[derive(Debug, Clone)]
pub struct PriceData<'a> {
pub close: &'a [Vec<f64>],
pub trade: &'a [Vec<f64>],
pub open: Option<&'a [Vec<f64>]>,
pub high: Option<&'a [Vec<f64>]>,
pub low: Option<&'a [Vec<f64>]>,
}
impl<'a> PriceData<'a> {
pub fn new(close: &'a [Vec<f64>], trade: &'a [Vec<f64>]) -> Self {
Self {
close,
trade,
open: None,
high: None,
low: None,
}
}
pub fn with_ohlc(
close: &'a [Vec<f64>],
trade: &'a [Vec<f64>],
open: &'a [Vec<f64>],
high: &'a [Vec<f64>],
low: &'a [Vec<f64>],
) -> Self {
Self {
close,
trade,
open: Some(open),
high: Some(high),
low: Some(low),
}
}
}
pub fn run_backtest_with_trades<S: IntoWeights>(
prices: &PriceData,
signals: &[S],
rebalance_indices: &[usize],
config: &BacktestConfig,
) -> WideBacktestResult {
let weights: Vec<Vec<f64>> = signals
.iter()
.map(|s| s.into_weights(&[], config.position_limit))
.collect();
let ohlc = match (prices.open, prices.high, prices.low) {
(Some(open), Some(high), Some(low)) => Some(OhlcPrices { open, high, low }),
_ => None,
};
let mut tracker = IndexTracker::new();
let creturn = simulate_backtest(
prices.close,
prices.trade,
&weights,
rebalance_indices,
config,
&mut tracker,
ohlc,
);
let mut trades = tracker.finalize(config.fee_ratio, config.tax_ratio);
let n_times = prices.close.len();
let n_assets = if n_times > 0 { prices.close[0].len() } else { 0 };
for trade in trades.iter_mut() {
if let (Some(entry_idx), Some(exit_idx)) = (trade.entry_index, trade.exit_index) {
let stock_id = trade.stock_id;
if stock_id < n_assets {
let close_series: Vec<f64> = prices.close.iter().map(|row| row[stock_id]).collect();
let trade_series: Vec<f64> = prices.trade.iter().map(|row| row[stock_id]).collect();
let is_long = trade.position_weight > 0.0;
let metrics = calculate_mae_mfe_at_exit(
&close_series,
&trade_series,
entry_idx,
exit_idx,
is_long,
true, true, config.fee_ratio,
config.tax_ratio,
);
trade.mae = Some(metrics.mae);
trade.gmfe = Some(metrics.gmfe);
trade.bmfe = Some(metrics.bmfe);
trade.mdd = Some(metrics.mdd);
trade.pdays = Some(metrics.pdays);
}
}
}
WideBacktestResult { creturn, trades }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_backtest() {
let prices = vec![
vec![100.0, 200.0], vec![102.0, 198.0], vec![105.0, 200.0], ];
let signals = vec![
vec![true, true],
];
let rebalance_indices = vec![0];
let config = BacktestConfig {
fee_ratio: 0.001425,
tax_ratio: 0.003,
..Default::default()
};
let creturn = run_backtest(&prices, &signals, &rebalance_indices, &config);
assert_eq!(creturn.len(), 3);
assert!((creturn[0] - 1.0).abs() < FLOAT_EPSILON, "Day 0 should be 1.0, got {}", creturn[0]);
assert!(creturn[1] > 0.9 && creturn[1] < 1.1, "Day 1 should be reasonable, got {}", creturn[1]);
assert!(creturn[2] > 0.0);
}
#[test]
fn test_no_positions() {
let prices = vec![
vec![100.0, 200.0],
vec![102.0, 198.0],
];
let signals = vec![
vec![false, false],
];
let rebalance_indices = vec![0];
let config = BacktestConfig {
fee_ratio: 0.001425,
tax_ratio: 0.003,
..Default::default()
};
let creturn = run_backtest(&prices, &signals, &rebalance_indices, &config);
assert_eq!(creturn.len(), 2);
assert!((creturn[0] - 1.0).abs() < FLOAT_EPSILON);
assert!((creturn[1] - 1.0).abs() < FLOAT_EPSILON);
}
#[test]
fn test_rebalancing() {
let prices = vec![
vec![100.0, 100.0], vec![100.0, 100.0], vec![110.0, 90.0], vec![110.0, 90.0], vec![120.0, 80.0], ];
let signals = vec![
vec![true, true], vec![true, false], ];
let rebalance_indices = vec![0, 3];
let config = BacktestConfig {
fee_ratio: 0.001425,
tax_ratio: 0.003,
..Default::default()
};
let creturn = run_backtest(&prices, &signals, &rebalance_indices, &config);
assert_eq!(creturn.len(), 5);
assert!(creturn[4] > 0.0);
}
#[test]
fn test_stop_loss() {
let prices = vec![
vec![100.0],
vec![95.0], vec![89.0], vec![85.0],
];
let signals = vec![vec![true]];
let rebalance_indices = vec![0];
let config = BacktestConfig {
fee_ratio: 0.001425,
tax_ratio: 0.003,
stop_loss: 0.10, ..Default::default()
};
let creturn = run_backtest(&prices, &signals, &rebalance_indices, &config);
assert_eq!(creturn.len(), 4);
}
#[test]
fn test_finlab_mode_stop_exit_uses_market_value() {
let prices = vec![
vec![100.0], vec![100.0], vec![125.0], vec![130.0], vec![140.0], ];
let signals = vec![vec![true]];
let rebalance_indices = vec![0];
let config = BacktestConfig {
fee_ratio: 0.01, tax_ratio: 0.0,
take_profit: 0.20,
finlab_mode: true,
..Default::default()
};
let creturn = run_backtest(&prices, &signals, &rebalance_indices, &config);
assert_eq!(creturn.len(), 5);
assert!(
(creturn[4] - creturn[3]).abs() < FLOAT_EPSILON,
"Portfolio should be flat after stop exit"
);
assert!(
creturn[4] > 1.2,
"Final value {} should reflect profit (>1.2)",
creturn[4]
);
}
#[test]
fn test_backtest_with_weights_basic() {
let prices = vec![
vec![100.0, 200.0], vec![102.0, 198.0], vec![105.0, 200.0], ];
let weights = vec![vec![0.5, 0.5]];
let rebalance_indices = vec![0];
let config = BacktestConfig {
fee_ratio: 0.001425,
tax_ratio: 0.003,
..Default::default()
};
let creturn = run_backtest(&prices, &weights, &rebalance_indices, &config);
assert_eq!(creturn.len(), 3);
assert!((creturn[0] - 1.0).abs() < FLOAT_EPSILON, "Day 0 should be 1.0, got {}", creturn[0]);
assert!(creturn[1] > 0.9 && creturn[1] < 1.1, "Day 1 should be reasonable, got {}", creturn[1]);
assert!(creturn[2] > 0.0);
}
#[test]
fn test_backtest_with_weights_unequal() {
let prices = vec![
vec![100.0, 100.0], vec![100.0, 100.0], vec![110.0, 100.0], ];
let weights = vec![vec![0.7, 0.3]];
let rebalance_indices = vec![0];
let config = BacktestConfig {
fee_ratio: 0.0,
tax_ratio: 0.0,
..Default::default()
};
let creturn = run_backtest(&prices, &weights, &rebalance_indices, &config);
assert_eq!(creturn.len(), 3);
assert!((creturn[0] - 1.0).abs() < FLOAT_EPSILON, "Day 0 should be 1.0, got {}", creturn[0]);
assert!((creturn[1] - 1.0).abs() < FLOAT_EPSILON, "Day 1 should be 1.0, got {}", creturn[1]);
let expected_day2 = 1.0 + 0.07;
assert!((creturn[2] - expected_day2).abs() < 0.001,
"Expected {}, got {}", expected_day2, creturn[2]);
}
#[test]
fn test_backtest_with_weights_overweight() {
let prices = vec![
vec![100.0, 100.0], vec![100.0, 100.0], vec![110.0, 100.0], ];
let weights = vec![vec![1.0, 0.5]]; let rebalance_indices = vec![0];
let config = BacktestConfig {
fee_ratio: 0.0,
tax_ratio: 0.0,
..Default::default()
};
let creturn = run_backtest(&prices, &weights, &rebalance_indices, &config);
assert_eq!(creturn.len(), 3);
assert!((creturn[0] - 1.0).abs() < FLOAT_EPSILON);
assert!((creturn[1] - 1.0).abs() < FLOAT_EPSILON);
let expected_day2 = 1.0 + (1.0 / 1.5) * 0.10;
assert!((creturn[2] - expected_day2).abs() < 0.001,
"Expected {}, got {}", expected_day2, creturn[2]);
}
#[test]
fn test_backtest_with_weights_underweight() {
let prices = vec![
vec![100.0, 100.0], vec![100.0, 100.0], vec![110.0, 110.0], ];
let weights = vec![vec![0.25, 0.25]]; let rebalance_indices = vec![0];
let config = BacktestConfig {
fee_ratio: 0.0,
tax_ratio: 0.0,
..Default::default()
};
let creturn = run_backtest(&prices, &weights, &rebalance_indices, &config);
assert_eq!(creturn.len(), 3);
assert!((creturn[0] - 1.0).abs() < FLOAT_EPSILON);
assert!((creturn[1] - 1.0).abs() < FLOAT_EPSILON);
let expected_day2 = 1.0 + 0.50 * 0.10;
assert!((creturn[2] - expected_day2).abs() < 0.001,
"Expected {}, got {}", expected_day2, creturn[2]);
}
#[test]
fn test_backtest_with_weights_position_limit() {
let prices = vec![
vec![100.0, 100.0], vec![100.0, 100.0], vec![110.0, 100.0], ];
let weights = vec![vec![0.8, 0.2]]; let rebalance_indices = vec![0];
let config = BacktestConfig {
fee_ratio: 0.0,
tax_ratio: 0.0,
position_limit: 0.4,
..Default::default()
};
let creturn = run_backtest(&prices, &weights, &rebalance_indices, &config);
assert_eq!(creturn.len(), 3);
assert!((creturn[0] - 1.0).abs() < FLOAT_EPSILON);
assert!((creturn[1] - 1.0).abs() < FLOAT_EPSILON);
let expected_day2 = 1.0 + 0.04;
assert!((creturn[2] - expected_day2).abs() < 0.001,
"Expected {}, got {}", expected_day2, creturn[2]);
}
#[test]
fn test_backtest_with_weights_matches_signals() {
let prices = vec![
vec![100.0, 100.0],
vec![110.0, 90.0], vec![115.0, 85.0],
];
let signals = vec![vec![true, true]];
let weights = vec![vec![0.5, 0.5]];
let rebalance_indices = vec![0];
let config = BacktestConfig {
fee_ratio: 0.001425,
tax_ratio: 0.003,
..Default::default()
};
let creturn_signals = run_backtest(&prices, &signals, &rebalance_indices, &config);
let creturn_weights = run_backtest(&prices, &weights, &rebalance_indices, &config);
assert_eq!(creturn_signals.len(), creturn_weights.len());
for (cs, cw) in creturn_signals.iter().zip(creturn_weights.iter()) {
assert!((cs - cw).abs() < FLOAT_EPSILON,
"Signal result {} != Weight result {}", cs, cw);
}
}
#[test]
fn test_backtest_with_weights_empty() {
let prices: Vec<Vec<f64>> = vec![];
let weights: Vec<Vec<f64>> = vec![];
let rebalance_indices: Vec<usize> = vec![];
let config = BacktestConfig::default();
let creturn = run_backtest(&prices, &weights, &rebalance_indices, &config);
assert!(creturn.is_empty());
}
#[test]
fn test_backtest_with_weights_all_zero() {
let prices = vec![
vec![100.0, 100.0],
vec![110.0, 110.0],
];
let weights = vec![vec![0.0, 0.0]];
let rebalance_indices = vec![0];
let config = BacktestConfig {
fee_ratio: 0.0,
tax_ratio: 0.0,
..Default::default()
};
let creturn = run_backtest(&prices, &weights, &rebalance_indices, &config);
assert!((creturn[0] - 1.0).abs() < FLOAT_EPSILON);
assert!((creturn[1] - 1.0).abs() < FLOAT_EPSILON);
}
#[test]
fn test_t1_execution_basic() {
let prices = vec![
vec![100.0], vec![100.0], vec![105.0], vec![110.0], ];
let signals = vec![vec![true]];
let rebalance_indices = vec![0];
let config = BacktestConfig {
fee_ratio: 0.0,
tax_ratio: 0.0,
..Default::default()
};
let creturn = run_backtest(&prices, &signals, &rebalance_indices, &config);
assert_eq!(creturn.len(), 4);
assert!((creturn[0] - 1.0).abs() < FLOAT_EPSILON, "Day 0 should be 1.0, got {}", creturn[0]);
assert!((creturn[1] - 1.0).abs() < FLOAT_EPSILON, "Day 1 should be 1.0, got {}", creturn[1]);
let expected_day2 = 1.0 * (105.0 / 100.0);
assert!((creturn[2] - expected_day2).abs() < FLOAT_EPSILON,
"Day 2: Expected {}, got {}", expected_day2, creturn[2]);
let expected_day3 = expected_day2 * (110.0 / 105.0);
assert!((creturn[3] - expected_day3).abs() < 0.001,
"Day 3: Expected {}, got {}", expected_day3, creturn[3]);
}
#[test]
fn test_t1_execution_with_fees() {
let prices = vec![
vec![100.0], vec![100.0], vec![100.0], ];
let signals = vec![vec![true]];
let rebalance_indices = vec![0];
let fee_ratio = 0.001425;
let config = BacktestConfig {
fee_ratio,
tax_ratio: 0.0,
..Default::default()
};
let creturn = run_backtest(&prices, &signals, &rebalance_indices, &config);
assert!((creturn[0] - 1.0).abs() < FLOAT_EPSILON, "Day 0 should be 1.0, got {}", creturn[0]);
let expected_day1 = 1.0 * (1.0 - fee_ratio);
assert!((creturn[1] - expected_day1).abs() < 1e-6,
"Day 1: Expected {}, got {}", expected_day1, creturn[1]);
}
#[test]
fn test_t1_execution_weights() {
let prices = vec![
vec![100.0, 100.0], vec![100.0, 100.0], vec![110.0, 100.0], vec![120.0, 100.0], ];
let weights = vec![vec![0.5, 0.5]]; let rebalance_indices = vec![0];
let config = BacktestConfig {
fee_ratio: 0.0,
tax_ratio: 0.0,
..Default::default()
};
let creturn = run_backtest(&prices, &weights, &rebalance_indices, &config);
assert_eq!(creturn.len(), 4);
assert!((creturn[0] - 1.0).abs() < FLOAT_EPSILON);
assert!((creturn[1] - 1.0).abs() < FLOAT_EPSILON);
let expected_day2 = 1.0 + 0.5 * 0.10;
assert!((creturn[2] - expected_day2).abs() < 0.001,
"Day 2: Expected {}, got {}", expected_day2, creturn[2]);
}
#[test]
fn test_t1_multiple_rebalances() {
let prices = vec![
vec![100.0, 100.0], vec![100.0, 100.0], vec![110.0, 100.0], vec![110.0, 100.0], vec![110.0, 110.0], ];
let signals = vec![
vec![true, false], vec![false, true], ];
let rebalance_indices = vec![0, 1];
let config = BacktestConfig {
fee_ratio: 0.0,
tax_ratio: 0.0,
..Default::default()
};
let creturn = run_backtest(&prices, &signals, &rebalance_indices, &config);
assert_eq!(creturn.len(), 5);
assert!((creturn[0] - 1.0).abs() < FLOAT_EPSILON, "Day 0 should be 1.0, got {}", creturn[0]);
assert!((creturn[1] - 1.0).abs() < FLOAT_EPSILON, "Day 1 should be 1.0, got {}", creturn[1]);
assert!((creturn[2] - 1.10).abs() < 0.001, "Day 2: Expected 1.10, got {}", creturn[2]);
assert!((creturn[3] - 1.10).abs() < 0.01, "Day 3 should be ~1.10, got {}", creturn[3]);
assert!((creturn[4] - 1.21).abs() < 0.01, "Day 4: Expected ~1.21, got {}", creturn[4]);
}
#[test]
fn test_mae_mfe_calculation() {
let prices = vec![
vec![100.0], vec![100.0], vec![95.0], vec![105.0], vec![110.0], ];
let signals = vec![
vec![true], vec![false], ];
let rebalance_indices = vec![0, 3];
let config = BacktestConfig {
fee_ratio: 0.0,
tax_ratio: 0.0,
..Default::default()
};
let price_data = PriceData {
close: &prices,
trade: &prices,
open: None,
high: None,
low: None,
};
let result = run_backtest_with_trades(&price_data, &signals, &rebalance_indices, &config);
let completed: Vec<_> = result.trades.iter()
.filter(|t| t.entry_index.is_some() && t.exit_index.is_some())
.collect();
assert_eq!(completed.len(), 1, "Should have 1 completed trade");
let trade = &completed[0];
assert!(trade.mae.is_some(), "MAE should be calculated");
assert!(trade.gmfe.is_some(), "GMFE should be calculated");
assert!(trade.mdd.is_some(), "MDD should be calculated");
assert!(trade.pdays.is_some(), "pdays should be calculated");
let mae = trade.mae.unwrap();
assert!(mae < 0.0, "MAE should be negative, got {}", mae);
assert!((mae - (-0.05)).abs() < 0.01, "MAE should be around -0.05, got {}", mae);
let gmfe = trade.gmfe.unwrap();
assert!(gmfe > 0.0, "GMFE should be positive, got {}", gmfe);
assert!(gmfe > 0.05, "GMFE should be at least 0.05, got {}", gmfe);
}
}