it works buty its not good\
This commit is contained in:
@@ -6,13 +6,17 @@ use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use crate::alpaca::{fetch_backtest_data, AlpacaClient};
|
||||
use crate::config::{
|
||||
get_all_symbols, IndicatorParams, Timeframe, HOURS_PER_DAY, MAX_POSITION_SIZE,
|
||||
MIN_CASH_RESERVE, STOP_LOSS_PCT, TAKE_PROFIT_PCT, TOP_MOMENTUM_COUNT,
|
||||
TRADING_DAYS_PER_YEAR, TRAILING_STOP_ACTIVATION, TRAILING_STOP_DISTANCE,
|
||||
get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER,
|
||||
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, DRAWDOWN_HALT_BARS, HOURS_PER_DAY,
|
||||
MAX_CONCURRENT_POSITIONS, MAX_DRAWDOWN_HALT, MAX_LOSS_PCT, MAX_POSITION_SIZE,
|
||||
MAX_SECTOR_POSITIONS, MIN_CASH_RESERVE, RAMPUP_PERIOD_BARS,
|
||||
REENTRY_COOLDOWN_BARS, SLIPPAGE_BPS, TIME_EXIT_BARS,
|
||||
TOP_MOMENTUM_COUNT, TRADING_DAYS_PER_YEAR,
|
||||
};
|
||||
use crate::indicators::{calculate_all_indicators, generate_signal};
|
||||
use crate::strategy::Strategy;
|
||||
use crate::types::{
|
||||
BacktestPosition, BacktestResult, EquityPoint, IndicatorRow, Signal, Trade,
|
||||
BacktestPosition, BacktestResult, EquityPoint, IndicatorRow, Signal, Trade, TradeSignal,
|
||||
};
|
||||
|
||||
/// Backtesting engine for the trading strategy.
|
||||
@@ -22,10 +26,18 @@ pub struct Backtester {
|
||||
positions: HashMap<String, BacktestPosition>,
|
||||
trades: Vec<Trade>,
|
||||
equity_history: Vec<EquityPoint>,
|
||||
entry_prices: HashMap<String, f64>,
|
||||
high_water_marks: HashMap<String, f64>,
|
||||
params: IndicatorParams,
|
||||
peak_portfolio_value: f64,
|
||||
drawdown_halt: bool,
|
||||
/// Bar index when drawdown halt started (for time-based resume)
|
||||
drawdown_halt_start: Option<usize>,
|
||||
strategy: Strategy,
|
||||
timeframe: Timeframe,
|
||||
/// Current bar index in the simulation
|
||||
current_bar: usize,
|
||||
/// Tracks when each symbol can be re-entered after stop-loss (bar index)
|
||||
cooldown_timers: HashMap<String, usize>,
|
||||
/// Tracks new positions opened in current bar (for gradual ramp-up)
|
||||
new_positions_this_bar: usize,
|
||||
}
|
||||
|
||||
impl Backtester {
|
||||
@@ -37,10 +49,24 @@ impl Backtester {
|
||||
positions: HashMap::new(),
|
||||
trades: Vec::new(),
|
||||
equity_history: Vec::new(),
|
||||
entry_prices: HashMap::new(),
|
||||
high_water_marks: HashMap::new(),
|
||||
params: timeframe.params(),
|
||||
peak_portfolio_value: initial_capital,
|
||||
drawdown_halt: false,
|
||||
drawdown_halt_start: None,
|
||||
strategy: Strategy::new(timeframe),
|
||||
timeframe,
|
||||
current_bar: 0,
|
||||
cooldown_timers: HashMap::new(),
|
||||
new_positions_this_bar: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply slippage to a price (buy = slightly higher, sell = slightly lower).
|
||||
fn apply_slippage(price: f64, side: &str) -> f64 {
|
||||
let slip = SLIPPAGE_BPS / 10_000.0;
|
||||
if side == "buy" {
|
||||
price * (1.0 + slip)
|
||||
} else {
|
||||
price * (1.0 - slip)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,37 +80,96 @@ impl Backtester {
|
||||
self.cash + positions_value
|
||||
}
|
||||
|
||||
/// Calculate position size based on risk management.
|
||||
fn calculate_position_size(&self, price: f64, portfolio_value: f64) -> u64 {
|
||||
let max_allocation = portfolio_value * MAX_POSITION_SIZE;
|
||||
let available_cash = self.cash - (portfolio_value * MIN_CASH_RESERVE);
|
||||
|
||||
if available_cash <= 0.0 {
|
||||
return 0;
|
||||
/// Update drawdown circuit breaker state.
|
||||
/// Uses time-based halt: pause for DRAWDOWN_HALT_BARS after trigger, then auto-resume.
|
||||
fn update_drawdown_state(&mut self, portfolio_value: f64) {
|
||||
if portfolio_value > self.peak_portfolio_value {
|
||||
self.peak_portfolio_value = portfolio_value;
|
||||
}
|
||||
|
||||
let position_value = max_allocation.min(available_cash);
|
||||
(position_value / price).floor() as u64
|
||||
let drawdown_pct = (self.peak_portfolio_value - portfolio_value) / self.peak_portfolio_value;
|
||||
|
||||
// Trigger halt if drawdown exceeds threshold
|
||||
if drawdown_pct >= MAX_DRAWDOWN_HALT && !self.drawdown_halt {
|
||||
tracing::warn!(
|
||||
"DRAWDOWN CIRCUIT BREAKER: {:.2}% drawdown exceeds {:.0}% limit. Halting for {} bars.",
|
||||
drawdown_pct * 100.0,
|
||||
MAX_DRAWDOWN_HALT * 100.0,
|
||||
DRAWDOWN_HALT_BARS
|
||||
);
|
||||
self.drawdown_halt = true;
|
||||
self.drawdown_halt_start = Some(self.current_bar);
|
||||
}
|
||||
|
||||
// Auto-resume after time-based cooldown
|
||||
if self.drawdown_halt {
|
||||
if let Some(halt_start) = self.drawdown_halt_start {
|
||||
if self.current_bar >= halt_start + DRAWDOWN_HALT_BARS {
|
||||
tracing::info!(
|
||||
"Drawdown halt expired after {} bars. Resuming trading at {:.2}% drawdown.",
|
||||
DRAWDOWN_HALT_BARS,
|
||||
drawdown_pct * 100.0
|
||||
);
|
||||
self.drawdown_halt = false;
|
||||
self.drawdown_halt_start = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a simulated buy order.
|
||||
/// Execute a simulated buy order with slippage.
|
||||
fn execute_buy(
|
||||
&mut self,
|
||||
symbol: &str,
|
||||
price: f64,
|
||||
timestamp: DateTime<Utc>,
|
||||
portfolio_value: f64,
|
||||
signal: &TradeSignal,
|
||||
) -> bool {
|
||||
if self.positions.contains_key(symbol) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let shares = self.calculate_position_size(price, portfolio_value);
|
||||
// Cooldown guard: prevent whipsaw re-entry after stop-loss
|
||||
if let Some(&cooldown_until) = self.cooldown_timers.get(symbol) {
|
||||
if self.current_bar < cooldown_until {
|
||||
return false; // Still in cooldown period
|
||||
}
|
||||
}
|
||||
|
||||
// Portfolio-level guards
|
||||
if self.drawdown_halt {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.positions.len() >= MAX_CONCURRENT_POSITIONS {
|
||||
return false;
|
||||
}
|
||||
|
||||
let sector = get_sector(symbol);
|
||||
if self
|
||||
.strategy
|
||||
.sector_position_count(sector, self.positions.keys())
|
||||
>= MAX_SECTOR_POSITIONS
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Gradual ramp-up: limit new positions during initial period
|
||||
if self.current_bar < RAMPUP_PERIOD_BARS && self.new_positions_this_bar >= 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let available_cash = self.cash - (portfolio_value * MIN_CASH_RESERVE);
|
||||
let shares =
|
||||
self.strategy
|
||||
.calculate_position_size(price, portfolio_value, available_cash, signal);
|
||||
if shares == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let cost = shares as f64 * price;
|
||||
let fill_price = Self::apply_slippage(price, "buy");
|
||||
let cost = shares as f64 * fill_price;
|
||||
if cost > self.cash {
|
||||
return false;
|
||||
}
|
||||
@@ -95,18 +180,22 @@ impl Backtester {
|
||||
BacktestPosition {
|
||||
symbol: symbol.to_string(),
|
||||
shares: shares as f64,
|
||||
entry_price: price,
|
||||
entry_price: fill_price,
|
||||
entry_time: timestamp,
|
||||
entry_atr: signal.atr,
|
||||
bars_held: 0,
|
||||
},
|
||||
);
|
||||
self.entry_prices.insert(symbol.to_string(), price);
|
||||
self.high_water_marks.insert(symbol.to_string(), price);
|
||||
self.strategy.entry_prices.insert(symbol.to_string(), fill_price);
|
||||
self.strategy.entry_atrs.insert(symbol.to_string(), signal.atr);
|
||||
self.strategy.high_water_marks.insert(symbol.to_string(), fill_price);
|
||||
self.new_positions_this_bar += 1;
|
||||
|
||||
self.trades.push(Trade {
|
||||
symbol: symbol.to_string(),
|
||||
side: "BUY".to_string(),
|
||||
shares: shares as f64,
|
||||
price,
|
||||
price: fill_price,
|
||||
timestamp,
|
||||
pnl: 0.0,
|
||||
pnl_pct: 0.0,
|
||||
@@ -115,72 +204,75 @@ impl Backtester {
|
||||
true
|
||||
}
|
||||
|
||||
/// Execute a simulated sell order.
|
||||
fn execute_sell(&mut self, symbol: &str, price: f64, timestamp: DateTime<Utc>) -> bool {
|
||||
/// Execute a simulated full sell order with slippage.
|
||||
fn execute_sell(
|
||||
&mut self,
|
||||
symbol: &str,
|
||||
price: f64,
|
||||
timestamp: DateTime<Utc>,
|
||||
was_stop_loss: bool,
|
||||
) -> bool {
|
||||
let position = match self.positions.remove(symbol) {
|
||||
Some(p) => p,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let proceeds = position.shares * price;
|
||||
let fill_price = Self::apply_slippage(price, "sell");
|
||||
let proceeds = position.shares * fill_price;
|
||||
self.cash += proceeds;
|
||||
|
||||
let pnl = proceeds - (position.shares * position.entry_price);
|
||||
let pnl_pct = (price - position.entry_price) / position.entry_price;
|
||||
let pnl_pct = (fill_price - position.entry_price) / position.entry_price;
|
||||
|
||||
self.trades.push(Trade {
|
||||
symbol: symbol.to_string(),
|
||||
side: "SELL".to_string(),
|
||||
shares: position.shares,
|
||||
price,
|
||||
price: fill_price,
|
||||
timestamp,
|
||||
pnl,
|
||||
pnl_pct,
|
||||
});
|
||||
|
||||
self.entry_prices.remove(symbol);
|
||||
self.high_water_marks.remove(symbol);
|
||||
self.strategy.entry_prices.remove(symbol);
|
||||
self.strategy.entry_atrs.remove(symbol);
|
||||
self.strategy.high_water_marks.remove(symbol);
|
||||
|
||||
// Record cooldown if this was a stop-loss exit
|
||||
if was_stop_loss {
|
||||
self.cooldown_timers.insert(
|
||||
symbol.to_string(),
|
||||
self.current_bar + REENTRY_COOLDOWN_BARS,
|
||||
);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if stop-loss, take-profit, or trailing stop should trigger.
|
||||
// Partial exits removed: they systematically halve winning trade size
|
||||
// while losing trades remain at full size, creating an asymmetric
|
||||
// avg_win < avg_loss profile. The trailing stop alone provides adequate
|
||||
// profit protection without splitting winners into smaller fragments.
|
||||
|
||||
/// Check if stop-loss, trailing stop, or time exit should trigger.
|
||||
///
|
||||
/// Exit priority (checked in order):
|
||||
/// 1. Hard max-loss cap (MAX_LOSS_PCT) -- absolute worst-case protection
|
||||
/// 2. ATR-based stop-loss (ATR_STOP_MULTIPLIER * ATR) -- primary risk control
|
||||
/// 3. Fixed % stop-loss (STOP_LOSS_PCT) -- fallback when ATR unavailable
|
||||
/// 4. Time-based exit (TIME_EXIT_BARS) -- capital efficiency
|
||||
/// 5. Trailing stop (ATR_TRAIL_MULTIPLIER * ATR) -- profit protection
|
||||
///
|
||||
/// Note: Take-profit removed intentionally. Capping winners reduces avg win
|
||||
/// and hurts the win/loss ratio. The trailing stop naturally captures profits
|
||||
/// while allowing trends to continue (per Trend Following literature, Covel 2004).
|
||||
fn check_stop_loss_take_profit(&mut self, symbol: &str, current_price: f64) -> Option<Signal> {
|
||||
let entry_price = match self.entry_prices.get(symbol) {
|
||||
Some(&p) => p,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
let pnl_pct = (current_price - entry_price) / entry_price;
|
||||
|
||||
// Update high water mark
|
||||
if let Some(hwm) = self.high_water_marks.get_mut(symbol) {
|
||||
if current_price > *hwm {
|
||||
*hwm = current_price;
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed stop loss
|
||||
if pnl_pct <= -STOP_LOSS_PCT {
|
||||
return Some(Signal::StrongSell);
|
||||
}
|
||||
|
||||
// Take profit
|
||||
if pnl_pct >= TAKE_PROFIT_PCT {
|
||||
return Some(Signal::Sell);
|
||||
}
|
||||
|
||||
// Trailing stop (only after activation threshold)
|
||||
if pnl_pct >= TRAILING_STOP_ACTIVATION {
|
||||
if let Some(&high_water) = self.high_water_marks.get(symbol) {
|
||||
let trailing_stop_price = high_water * (1.0 - TRAILING_STOP_DISTANCE);
|
||||
if current_price <= trailing_stop_price {
|
||||
return Some(Signal::Sell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
let bars_held = self
|
||||
.positions
|
||||
.get(symbol)
|
||||
.map_or(0, |p| p.bars_held);
|
||||
self.strategy
|
||||
.check_stop_loss_take_profit(symbol, current_price, bars_held)
|
||||
}
|
||||
|
||||
/// Run the backtest simulation.
|
||||
@@ -188,7 +280,7 @@ impl Backtester {
|
||||
let symbols = get_all_symbols();
|
||||
|
||||
// Calculate warmup period
|
||||
let warmup_period = self.params.min_bars() + 10;
|
||||
let warmup_period = self.strategy.params.min_bars() + 10;
|
||||
let warmup_calendar_days = if self.timeframe == Timeframe::Hourly {
|
||||
(warmup_period as f64 / HOURS_PER_DAY as f64 * 1.5) as i64
|
||||
} else {
|
||||
@@ -200,12 +292,19 @@ impl Backtester {
|
||||
tracing::info!("Initial Capital: ${:.2}", self.initial_capital);
|
||||
tracing::info!("Period: {:.2} years ({:.1} months)", years, years * 12.0);
|
||||
tracing::info!("Timeframe: {:?} bars", self.timeframe);
|
||||
tracing::info!(
|
||||
"Risk: ATR stops ({}x), trail ({}x after {}x gain), max {}% pos, {} max pos, {} max/sector, {} bar cooldown",
|
||||
ATR_STOP_MULTIPLIER, ATR_TRAIL_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER,
|
||||
MAX_POSITION_SIZE * 100.0, MAX_CONCURRENT_POSITIONS, MAX_SECTOR_POSITIONS,
|
||||
REENTRY_COOLDOWN_BARS
|
||||
);
|
||||
tracing::info!("Slippage: {} bps per trade", SLIPPAGE_BPS);
|
||||
if self.timeframe == Timeframe::Hourly {
|
||||
tracing::info!(
|
||||
"Parameters scaled {}x (e.g., RSI: {}, EMA_TREND: {})",
|
||||
HOURS_PER_DAY,
|
||||
self.params.rsi_period,
|
||||
self.params.ema_trend
|
||||
self.strategy.params.rsi_period,
|
||||
self.strategy.params.ema_trend
|
||||
);
|
||||
}
|
||||
tracing::info!("{}", "=".repeat(70));
|
||||
@@ -227,7 +326,7 @@ impl Backtester {
|
||||
// Calculate indicators for all symbols
|
||||
let mut data: HashMap<String, Vec<IndicatorRow>> = HashMap::new();
|
||||
for (symbol, bars) in &raw_data {
|
||||
let min_bars = self.params.min_bars();
|
||||
let min_bars = self.strategy.params.min_bars();
|
||||
if bars.len() < min_bars {
|
||||
tracing::warn!(
|
||||
"{}: Only {} bars, need {}. Skipping.",
|
||||
@@ -237,7 +336,7 @@ impl Backtester {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let indicators = calculate_all_indicators(bars, &self.params);
|
||||
let indicators = calculate_all_indicators(bars, &self.strategy.params);
|
||||
data.insert(symbol.clone(), indicators);
|
||||
}
|
||||
|
||||
@@ -285,8 +384,7 @@ impl Backtester {
|
||||
|
||||
if trading_dates.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No trading days available after warmup. \
|
||||
Try a longer backtest period (at least 4 months recommended)."
|
||||
"No trading days available after warmup. \n Try a longer backtest period (at least 4 months recommended)."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,12 +406,17 @@ impl Backtester {
|
||||
|
||||
// Main simulation loop
|
||||
for (day_num, current_date) in trading_dates.iter().enumerate() {
|
||||
self.current_bar = day_num;
|
||||
self.new_positions_this_bar = 0; // Reset counter for each bar
|
||||
|
||||
// Get current prices and momentum for all symbols
|
||||
let mut current_prices: HashMap<String, f64> = HashMap::new();
|
||||
let mut momentum_scores: HashMap<String, f64> = HashMap::new();
|
||||
|
||||
for (symbol, rows) in &data {
|
||||
if let Some(&idx) = symbol_date_index.get(symbol).and_then(|m| m.get(current_date)) {
|
||||
if let Some(&idx) =
|
||||
symbol_date_index.get(symbol).and_then(|m| m.get(current_date))
|
||||
{
|
||||
let row = &rows[idx];
|
||||
current_prices.insert(symbol.clone(), row.close);
|
||||
if !row.momentum.is_nan() {
|
||||
@@ -324,6 +427,14 @@ impl Backtester {
|
||||
|
||||
let portfolio_value = self.get_portfolio_value(¤t_prices);
|
||||
|
||||
// Update drawdown circuit breaker
|
||||
self.update_drawdown_state(portfolio_value);
|
||||
|
||||
// Increment bars_held for all positions
|
||||
for pos in self.positions.values_mut() {
|
||||
pos.bars_held += 1;
|
||||
}
|
||||
|
||||
// Momentum ranking: sort symbols by momentum
|
||||
let mut ranked_symbols: Vec<String> = momentum_scores.keys().cloned().collect();
|
||||
ranked_symbols.sort_by(|a, b| {
|
||||
@@ -331,10 +442,13 @@ impl Backtester {
|
||||
let mb = momentum_scores.get(b).unwrap_or(&0.0);
|
||||
mb.partial_cmp(ma).unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
let top_momentum_symbols: HashSet<String> =
|
||||
ranked_symbols.iter().take(TOP_MOMENTUM_COUNT).cloned().collect();
|
||||
let top_momentum_symbols: HashSet<String> = ranked_symbols
|
||||
.iter()
|
||||
.take(TOP_MOMENTUM_COUNT)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Process sells first (for all symbols with positions)
|
||||
// Phase 1: Process sells (stop-loss, trailing stop, time exit, signals)
|
||||
let position_symbols: Vec<String> = self.positions.keys().cloned().collect();
|
||||
for symbol in position_symbols {
|
||||
let rows = match data.get(&symbol) {
|
||||
@@ -342,7 +456,10 @@ impl Backtester {
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let idx = match symbol_date_index.get(&symbol).and_then(|m| m.get(current_date)) {
|
||||
let idx = match symbol_date_index
|
||||
.get(&symbol)
|
||||
.and_then(|m| m.get(current_date))
|
||||
{
|
||||
Some(&i) => i,
|
||||
None => continue,
|
||||
};
|
||||
@@ -360,19 +477,21 @@ impl Backtester {
|
||||
|
||||
let mut signal = generate_signal(&symbol, current_row, previous_row);
|
||||
|
||||
// Check stop-loss/take-profit/trailing stop
|
||||
if let Some(sl_tp) = self.check_stop_loss_take_profit(&symbol, signal.current_price)
|
||||
// Check stop-loss/take-profit/trailing stop/time exit
|
||||
if let Some(sl_tp) =
|
||||
self.check_stop_loss_take_profit(&symbol, signal.current_price)
|
||||
{
|
||||
signal.signal = sl_tp;
|
||||
}
|
||||
|
||||
// Execute sells
|
||||
if signal.signal.is_sell() {
|
||||
self.execute_sell(&symbol, signal.current_price, *current_date);
|
||||
let was_stop_loss = matches!(signal.signal, Signal::StrongSell);
|
||||
self.execute_sell(&symbol, signal.current_price, *current_date, was_stop_loss);
|
||||
}
|
||||
}
|
||||
|
||||
// Process buys (only for top momentum stocks)
|
||||
// Phase 2: Process buys (only for top momentum stocks)
|
||||
for symbol in &ranked_symbols {
|
||||
let rows = match data.get(symbol) {
|
||||
Some(r) => r,
|
||||
@@ -384,7 +503,10 @@ impl Backtester {
|
||||
continue;
|
||||
}
|
||||
|
||||
let idx = match symbol_date_index.get(symbol).and_then(|m| m.get(current_date)) {
|
||||
let idx = match symbol_date_index
|
||||
.get(symbol)
|
||||
.and_then(|m| m.get(current_date))
|
||||
{
|
||||
Some(&i) => i,
|
||||
None => continue,
|
||||
};
|
||||
@@ -404,7 +526,13 @@ impl Backtester {
|
||||
|
||||
// Execute buys
|
||||
if signal.signal.is_buy() {
|
||||
self.execute_buy(symbol, signal.current_price, *current_date, portfolio_value);
|
||||
self.execute_buy(
|
||||
symbol,
|
||||
signal.current_price,
|
||||
*current_date,
|
||||
portfolio_value,
|
||||
&signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,10 +547,14 @@ impl Backtester {
|
||||
// Progress update
|
||||
if (day_num + 1) % 100 == 0 {
|
||||
tracing::info!(
|
||||
" Processed {}/{} days... Portfolio: ${:.2}",
|
||||
" Processed {}/{} days... Portfolio: ${:.2} (positions: {})",
|
||||
day_num + 1,
|
||||
trading_dates.len(),
|
||||
self.equity_history.last().map(|e| e.portfolio_value).unwrap_or(0.0)
|
||||
self.equity_history
|
||||
.last()
|
||||
.map(|e| e.portfolio_value)
|
||||
.unwrap_or(0.0),
|
||||
self.positions.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -434,7 +566,7 @@ impl Backtester {
|
||||
for symbol in position_symbols {
|
||||
if let Some(rows) = data.get(&symbol) {
|
||||
if let Some(last_row) = rows.last() {
|
||||
self.execute_sell(&symbol, last_row.close, final_date);
|
||||
self.execute_sell(&symbol, last_row.close, final_date, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,8 +584,7 @@ impl Backtester {
|
||||
fn calculate_results(&self, years: f64) -> Result<BacktestResult> {
|
||||
if self.equity_history.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No trading days after indicator warmup period. \
|
||||
Try a longer backtest period (at least 4 months recommended)."
|
||||
"No trading days after indicator warmup period. \n Try a longer backtest period (at least 4 months recommended)."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -480,11 +611,15 @@ impl Backtester {
|
||||
|
||||
// Sharpe Ratio (assuming 252 trading days, risk-free rate ~5%)
|
||||
let risk_free_daily = 0.05 / TRADING_DAYS_PER_YEAR as f64;
|
||||
let excess_returns: Vec<f64> = daily_returns.iter().map(|r| r - risk_free_daily).collect();
|
||||
let excess_returns: Vec<f64> =
|
||||
daily_returns.iter().map(|r| r - risk_free_daily).collect();
|
||||
|
||||
let sharpe = if !excess_returns.is_empty() {
|
||||
let mean = excess_returns.iter().sum::<f64>() / excess_returns.len() as f64;
|
||||
let variance: f64 = excess_returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>()
|
||||
let variance: f64 = excess_returns
|
||||
.iter()
|
||||
.map(|r| (r - mean).powi(2))
|
||||
.sum::<f64>()
|
||||
/ excess_returns.len() as f64;
|
||||
let std = variance.sqrt();
|
||||
if std > 0.0 {
|
||||
@@ -497,11 +632,15 @@ impl Backtester {
|
||||
};
|
||||
|
||||
// Sortino Ratio (downside deviation)
|
||||
let negative_returns: Vec<f64> = daily_returns.iter().filter(|&&r| r < 0.0).copied().collect();
|
||||
let negative_returns: Vec<f64> = daily_returns
|
||||
.iter()
|
||||
.filter(|&&r| r < 0.0)
|
||||
.copied()
|
||||
.collect();
|
||||
let sortino = if !negative_returns.is_empty() && !daily_returns.is_empty() {
|
||||
let mean = daily_returns.iter().sum::<f64>() / daily_returns.len() as f64;
|
||||
let neg_variance: f64 =
|
||||
negative_returns.iter().map(|r| r.powi(2)).sum::<f64>() / negative_returns.len() as f64;
|
||||
let neg_variance: f64 = negative_returns.iter().map(|r| r.powi(2)).sum::<f64>()
|
||||
/ negative_returns.len() as f64;
|
||||
let neg_std = neg_variance.sqrt();
|
||||
if neg_std > 0.0 {
|
||||
(mean / neg_std) * (TRADING_DAYS_PER_YEAR as f64).sqrt()
|
||||
@@ -531,8 +670,10 @@ impl Backtester {
|
||||
|
||||
// Trade statistics
|
||||
let sell_trades: Vec<&Trade> = self.trades.iter().filter(|t| t.side == "SELL").collect();
|
||||
let winning_trades: Vec<&Trade> = sell_trades.iter().filter(|t| t.pnl > 0.0).copied().collect();
|
||||
let losing_trades: Vec<&Trade> = sell_trades.iter().filter(|t| t.pnl <= 0.0).copied().collect();
|
||||
let winning_trades: Vec<&Trade> =
|
||||
sell_trades.iter().filter(|t| t.pnl > 0.0).copied().collect();
|
||||
let losing_trades: Vec<&Trade> =
|
||||
sell_trades.iter().filter(|t| t.pnl <= 0.0).copied().collect();
|
||||
|
||||
let total_trades = sell_trades.len();
|
||||
let win_count = winning_trades.len();
|
||||
@@ -621,6 +762,46 @@ impl Backtester {
|
||||
println!(" Avg Win: ${:>15.2}", result.avg_win);
|
||||
println!(" Avg Loss: ${:>15.2}", result.avg_loss);
|
||||
println!(" Profit Factor: {:>15.2}", result.profit_factor);
|
||||
println!();
|
||||
println!("{:^70}", "STRATEGY PARAMETERS");
|
||||
println!("{}", "-".repeat(70));
|
||||
println!(
|
||||
" Slippage: {:>15} bps",
|
||||
SLIPPAGE_BPS as i64
|
||||
);
|
||||
println!(
|
||||
" ATR Stop: {:>15.1}x",
|
||||
ATR_STOP_MULTIPLIER
|
||||
);
|
||||
println!(
|
||||
" ATR Trail: {:>15.1}x (after {:.1}x gain)",
|
||||
ATR_TRAIL_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER
|
||||
);
|
||||
println!(
|
||||
" Max Positions: {:>15}",
|
||||
MAX_CONCURRENT_POSITIONS
|
||||
);
|
||||
println!(
|
||||
" Max Per Sector: {:>15}",
|
||||
MAX_SECTOR_POSITIONS
|
||||
);
|
||||
println!(
|
||||
" Drawdown Halt: {:>14.0}% ({} bar cooldown)",
|
||||
MAX_DRAWDOWN_HALT * 100.0,
|
||||
DRAWDOWN_HALT_BARS
|
||||
);
|
||||
println!(
|
||||
" Time Exit: {:>13} bars",
|
||||
TIME_EXIT_BARS
|
||||
);
|
||||
println!(
|
||||
" Max Loss Cap: {:>14.1}%",
|
||||
MAX_LOSS_PCT * 100.0
|
||||
);
|
||||
println!(
|
||||
" Re-entry Cooldown: {:>13} bars",
|
||||
REENTRY_COOLDOWN_BARS
|
||||
);
|
||||
println!("{}", "=".repeat(70));
|
||||
|
||||
// Show recent trades
|
||||
@@ -674,8 +855,8 @@ pub fn save_backtest_results(result: &BacktestResult) -> Result<()> {
|
||||
|
||||
// Save trades
|
||||
if !result.trades.is_empty() {
|
||||
let mut wtr =
|
||||
csv::Writer::from_path("backtest_trades.csv").context("Failed to create trades CSV")?;
|
||||
let mut wtr = csv::Writer::from_path("backtest_trades.csv")
|
||||
.context("Failed to create trades CSV")?;
|
||||
|
||||
wtr.write_record(["timestamp", "symbol", "side", "shares", "price", "pnl", "pnl_pct"])?;
|
||||
|
||||
@@ -696,4 +877,4 @@ pub fn save_backtest_results(result: &BacktestResult) -> Result<()> {
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user