Initial backtesting engine
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
//! Capital allocation strategies for portfolio management.
|
||||
|
||||
/// Allocation strategy for distributing capital across instruments.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AllocationStrategy {
|
||||
/// Equal weight across all instruments.
|
||||
EqualWeight,
|
||||
/// Fixed weight for each instrument.
|
||||
FixedWeight(Vec<f64>),
|
||||
/// Volatility-based weighting (inverse volatility).
|
||||
InverseVolatility,
|
||||
/// Risk parity (equal risk contribution).
|
||||
RiskParity,
|
||||
/// Maximum weight per instrument.
|
||||
MaxWeight(f64),
|
||||
/// Custom weights.
|
||||
Custom(Vec<(String, f64)>),
|
||||
}
|
||||
|
||||
impl Default for AllocationStrategy {
|
||||
fn default() -> Self {
|
||||
AllocationStrategy::EqualWeight
|
||||
}
|
||||
}
|
||||
|
||||
/// Capital allocator for managing position sizing and capital distribution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CapitalAllocator {
|
||||
/// Total capital.
|
||||
pub total_capital: f64,
|
||||
/// Available capital (not in positions).
|
||||
pub available_capital: f64,
|
||||
/// Allocation strategy.
|
||||
pub strategy: AllocationStrategy,
|
||||
/// Maximum position size as fraction of capital.
|
||||
pub max_position_size: f64,
|
||||
/// Minimum position size (absolute).
|
||||
pub min_position_size: f64,
|
||||
/// Reserve capital fraction (never allocate).
|
||||
pub reserve_fraction: f64,
|
||||
}
|
||||
|
||||
impl CapitalAllocator {
|
||||
/// Create a new capital allocator.
|
||||
pub fn new(total_capital: f64) -> Self {
|
||||
Self {
|
||||
total_capital,
|
||||
available_capital: total_capital,
|
||||
strategy: AllocationStrategy::EqualWeight,
|
||||
max_position_size: 1.0,
|
||||
min_position_size: 0.0,
|
||||
reserve_fraction: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set allocation strategy.
|
||||
pub fn with_strategy(mut self, strategy: AllocationStrategy) -> Self {
|
||||
self.strategy = strategy;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set maximum position size.
|
||||
pub fn with_max_position(mut self, max_fraction: f64) -> Self {
|
||||
self.max_position_size = max_fraction.clamp(0.0, 1.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set reserve fraction.
|
||||
pub fn with_reserve(mut self, reserve: f64) -> Self {
|
||||
self.reserve_fraction = reserve.clamp(0.0, 1.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// Calculate position size for a single instrument.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `price` - Entry price
|
||||
/// * `num_instruments` - Total number of instruments in portfolio
|
||||
/// * `instrument_weight` - Optional custom weight for this instrument
|
||||
///
|
||||
/// # Returns
|
||||
/// Position size in shares/contracts
|
||||
pub fn calculate_position_size(
|
||||
&self,
|
||||
price: f64,
|
||||
num_instruments: usize,
|
||||
instrument_weight: Option<f64>,
|
||||
) -> f64 {
|
||||
if price <= 0.0 || num_instruments == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Calculate allocatable capital
|
||||
let allocatable = self.available_capital * (1.0 - self.reserve_fraction);
|
||||
|
||||
// Calculate weight
|
||||
let weight = match &self.strategy {
|
||||
AllocationStrategy::EqualWeight => 1.0 / num_instruments as f64,
|
||||
AllocationStrategy::FixedWeight(weights) => {
|
||||
if weights.is_empty() {
|
||||
1.0 / num_instruments as f64
|
||||
} else {
|
||||
weights[0].min(self.max_position_size)
|
||||
}
|
||||
}
|
||||
AllocationStrategy::MaxWeight(max) => (*max).min(1.0 / num_instruments as f64),
|
||||
_ => instrument_weight.unwrap_or(1.0 / num_instruments as f64),
|
||||
};
|
||||
|
||||
// Calculate allocation
|
||||
let allocation = allocatable * weight.min(self.max_position_size);
|
||||
|
||||
// Convert to shares
|
||||
let shares = allocation / price;
|
||||
|
||||
// Apply minimum size constraint
|
||||
if shares * price < self.min_position_size {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
shares
|
||||
}
|
||||
|
||||
/// Calculate position sizes for multiple instruments.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `prices` - Entry prices for each instrument
|
||||
/// * `weights` - Optional weights for each instrument
|
||||
///
|
||||
/// # Returns
|
||||
/// Position sizes for each instrument
|
||||
pub fn calculate_portfolio_sizes(&self, prices: &[f64], weights: Option<&[f64]>) -> Vec<f64> {
|
||||
let n = prices.len();
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let allocatable = self.available_capital * (1.0 - self.reserve_fraction);
|
||||
|
||||
// Get weights
|
||||
let instrument_weights: Vec<f64> = match &self.strategy {
|
||||
AllocationStrategy::EqualWeight => vec![1.0 / n as f64; n],
|
||||
AllocationStrategy::FixedWeight(w) => {
|
||||
if w.len() == n {
|
||||
w.clone()
|
||||
} else {
|
||||
vec![1.0 / n as f64; n]
|
||||
}
|
||||
}
|
||||
AllocationStrategy::MaxWeight(max) => {
|
||||
let equal = 1.0 / n as f64;
|
||||
vec![equal.min(*max); n]
|
||||
}
|
||||
_ => weights
|
||||
.map(|w| w.to_vec())
|
||||
.unwrap_or_else(|| vec![1.0 / n as f64; n]),
|
||||
};
|
||||
|
||||
// Normalize weights
|
||||
let total_weight: f64 = instrument_weights.iter().sum();
|
||||
let normalized_weights: Vec<f64> = if total_weight > 0.0 {
|
||||
instrument_weights
|
||||
.iter()
|
||||
.map(|w| w / total_weight)
|
||||
.collect()
|
||||
} else {
|
||||
vec![1.0 / n as f64; n]
|
||||
};
|
||||
|
||||
// Calculate sizes
|
||||
prices
|
||||
.iter()
|
||||
.zip(normalized_weights.iter())
|
||||
.map(|(&price, &weight)| {
|
||||
if price <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let allocation = allocatable * weight.min(self.max_position_size);
|
||||
let shares = allocation / price;
|
||||
if shares * price < self.min_position_size {
|
||||
0.0
|
||||
} else {
|
||||
shares
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Calculate volatility-adjusted position size.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `price` - Entry price
|
||||
/// * `volatility` - Instrument volatility (e.g., ATR)
|
||||
/// * `risk_per_trade` - Risk per trade as fraction of capital
|
||||
///
|
||||
/// # Returns
|
||||
/// Position size
|
||||
pub fn calculate_volatility_sized(
|
||||
&self,
|
||||
price: f64,
|
||||
volatility: f64,
|
||||
risk_per_trade: f64,
|
||||
) -> f64 {
|
||||
if price <= 0.0 || volatility <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let risk_amount = self.available_capital * risk_per_trade;
|
||||
let size = risk_amount / volatility;
|
||||
|
||||
// Apply maximum constraint
|
||||
let max_allocation = self.available_capital * self.max_position_size;
|
||||
let max_shares = max_allocation / price;
|
||||
|
||||
size.min(max_shares)
|
||||
}
|
||||
|
||||
/// Allocate capital to a position.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `amount` - Amount to allocate
|
||||
///
|
||||
/// # Returns
|
||||
/// True if allocation succeeded
|
||||
pub fn allocate(&mut self, amount: f64) -> bool {
|
||||
if amount > self.available_capital {
|
||||
return false;
|
||||
}
|
||||
self.available_capital -= amount;
|
||||
true
|
||||
}
|
||||
|
||||
/// Release capital from a closed position.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `amount` - Amount to release (including P&L)
|
||||
pub fn release(&mut self, amount: f64) {
|
||||
self.available_capital += amount;
|
||||
}
|
||||
|
||||
/// Update total capital (e.g., after deposit/withdrawal or daily mark-to-market).
|
||||
pub fn update_capital(&mut self, new_capital: f64) {
|
||||
let diff = new_capital - self.total_capital;
|
||||
self.total_capital = new_capital;
|
||||
self.available_capital += diff;
|
||||
}
|
||||
|
||||
/// Get current utilization rate.
|
||||
pub fn utilization(&self) -> f64 {
|
||||
if self.total_capital <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
1.0 - (self.available_capital / self.total_capital)
|
||||
}
|
||||
|
||||
/// Reset allocator to initial state.
|
||||
pub fn reset(&mut self) {
|
||||
self.available_capital = self.total_capital;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_equal_weight() {
|
||||
let allocator = CapitalAllocator::new(100_000.0);
|
||||
|
||||
// 4 instruments, equal weight = 25% each
|
||||
let size = allocator.calculate_position_size(100.0, 4, None);
|
||||
|
||||
// Expected: 100000 * 0.25 / 100 = 250 shares
|
||||
assert!((size - 250.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_position() {
|
||||
let allocator = CapitalAllocator::new(100_000.0).with_max_position(0.1);
|
||||
|
||||
// Even with 1 instrument, max is 10%
|
||||
let size = allocator.calculate_position_size(100.0, 1, None);
|
||||
|
||||
// Expected: 100000 * 0.1 / 100 = 100 shares
|
||||
assert!((size - 100.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_portfolio_sizes() {
|
||||
let allocator = CapitalAllocator::new(100_000.0);
|
||||
|
||||
let prices = vec![100.0, 50.0, 200.0];
|
||||
let sizes = allocator.calculate_portfolio_sizes(&prices, None);
|
||||
|
||||
assert_eq!(sizes.len(), 3);
|
||||
|
||||
// Equal weight, each gets 1/3 of capital
|
||||
// Instrument 1: 33333 / 100 = 333.33
|
||||
// Instrument 2: 33333 / 50 = 666.66
|
||||
// Instrument 3: 33333 / 200 = 166.66
|
||||
assert!((sizes[0] - 333.33).abs() < 1.0);
|
||||
assert!((sizes[1] - 666.66).abs() < 1.0);
|
||||
assert!((sizes[2] - 166.66).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_release() {
|
||||
let mut allocator = CapitalAllocator::new(100_000.0);
|
||||
|
||||
// Allocate 30000
|
||||
assert!(allocator.allocate(30_000.0));
|
||||
assert!((allocator.available_capital - 70_000.0).abs() < 1e-10);
|
||||
|
||||
// Try to allocate more than available
|
||||
assert!(!allocator.allocate(80_000.0));
|
||||
|
||||
// Release with profit
|
||||
allocator.release(35_000.0);
|
||||
assert!((allocator.available_capital - 105_000.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_utilization() {
|
||||
let mut allocator = CapitalAllocator::new(100_000.0);
|
||||
|
||||
assert!((allocator.utilization() - 0.0).abs() < 1e-10);
|
||||
|
||||
allocator.allocate(50_000.0);
|
||||
assert!((allocator.utilization() - 0.5).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_volatility_sizing() {
|
||||
let allocator = CapitalAllocator::new(100_000.0).with_max_position(0.2);
|
||||
|
||||
// Risk 1% per trade with ATR of 2
|
||||
let size = allocator.calculate_volatility_sized(100.0, 2.0, 0.01);
|
||||
|
||||
// Risk amount: 100000 * 0.01 = 1000
|
||||
// Size: 1000 / 2 = 500 shares
|
||||
// Max: 100000 * 0.2 / 100 = 200 shares
|
||||
// Should be capped at max
|
||||
assert!((size - 200.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
//! Event-driven portfolio simulation engine.
|
||||
|
||||
use crate::core::types::{
|
||||
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, Direction, ExitReason,
|
||||
OhlcvData, Price, StopConfig, TargetConfig, Trade,
|
||||
};
|
||||
use crate::execution::{FeeModel, FillPrice, SlippageModel};
|
||||
use crate::indicators::volatility::atr;
|
||||
use crate::metrics::streaming::StreamingMetrics;
|
||||
use crate::portfolio::position::PositionManager;
|
||||
use crate::signals::processor::SignalProcessor;
|
||||
|
||||
/// Portfolio simulation engine.
|
||||
///
|
||||
/// Single-pass O(n) algorithm for simulating portfolio performance.
|
||||
#[derive(Debug)]
|
||||
pub struct PortfolioEngine {
|
||||
/// Configuration.
|
||||
pub config: BacktestConfig,
|
||||
/// Fee model.
|
||||
pub fee_model: FeeModel,
|
||||
/// Slippage model.
|
||||
pub slippage_model: SlippageModel,
|
||||
/// Fill price model.
|
||||
pub fill_price: FillPrice,
|
||||
/// Signal processor.
|
||||
pub signal_processor: SignalProcessor,
|
||||
}
|
||||
|
||||
impl Default for PortfolioEngine {
|
||||
fn default() -> Self {
|
||||
Self::new(BacktestConfig::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl PortfolioEngine {
|
||||
/// Create a new portfolio engine with the given configuration.
|
||||
pub fn new(config: BacktestConfig) -> Self {
|
||||
let fee_model = FeeModel::percentage(config.fees);
|
||||
let fill_price = if config.upon_bar_close {
|
||||
FillPrice::Close
|
||||
} else {
|
||||
FillPrice::Open
|
||||
};
|
||||
|
||||
Self {
|
||||
config,
|
||||
fee_model,
|
||||
slippage_model: SlippageModel::None,
|
||||
fill_price,
|
||||
signal_processor: SignalProcessor::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set fee model.
|
||||
pub fn with_fee_model(mut self, fee_model: FeeModel) -> Self {
|
||||
self.fee_model = fee_model;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set slippage model.
|
||||
pub fn with_slippage_model(mut self, slippage_model: SlippageModel) -> Self {
|
||||
self.slippage_model = slippage_model;
|
||||
self
|
||||
}
|
||||
|
||||
/// Run backtest on single instrument.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ohlcv` - OHLCV data
|
||||
/// * `signals` - Compiled trading signals
|
||||
///
|
||||
/// # Returns
|
||||
/// Backtest result
|
||||
pub fn run_single(&self, ohlcv: &OhlcvData, signals: &CompiledSignals) -> BacktestResult {
|
||||
let n = ohlcv.len();
|
||||
assert_eq!(n, signals.len(), "OHLCV and signals must have same length");
|
||||
|
||||
// Clean signals
|
||||
let (entries, exits) = self
|
||||
.signal_processor
|
||||
.clean_signals(&signals.entries, &signals.exits);
|
||||
|
||||
// Initialize state
|
||||
let mut position = PositionManager::new(signals.symbol.clone());
|
||||
let mut cash = self.config.initial_capital;
|
||||
let mut equity_curve = vec![cash; n];
|
||||
let mut drawdown_curve = vec![0.0; n];
|
||||
let mut returns = vec![0.0; n];
|
||||
let mut trades: Vec<Trade> = Vec::new();
|
||||
let mut streaming = StreamingMetrics::new();
|
||||
let mut peak_equity = cash;
|
||||
|
||||
// Pre-calculate ATR for ATR-based stops
|
||||
let atr_values = if matches!(self.config.stop, StopConfig::Atr { .. })
|
||||
|| matches!(self.config.target, TargetConfig::Atr { .. })
|
||||
{
|
||||
let period = match self.config.stop {
|
||||
StopConfig::Atr { period, .. } => period,
|
||||
_ => match self.config.target {
|
||||
TargetConfig::Atr { period, .. } => period,
|
||||
_ => 14,
|
||||
},
|
||||
};
|
||||
atr(&ohlcv.high, &ohlcv.low, &ohlcv.close, period).unwrap_or_else(|_| vec![0.0; n])
|
||||
} else {
|
||||
vec![0.0; n]
|
||||
};
|
||||
|
||||
// Main simulation loop
|
||||
for i in 0..n {
|
||||
let close = ohlcv.close[i];
|
||||
let high = ohlcv.high[i];
|
||||
let low = ohlcv.low[i];
|
||||
let timestamp = ohlcv.timestamps[i];
|
||||
|
||||
// Update position price tracking
|
||||
position.update_price(high, low);
|
||||
|
||||
// Check for exits first (stops and signals)
|
||||
if position.is_in_position() {
|
||||
let mut exit_reason: Option<ExitReason> = None;
|
||||
let mut exit_price = close;
|
||||
|
||||
// Check stop-loss
|
||||
if position.is_stop_hit(low, high) {
|
||||
exit_reason = Some(ExitReason::StopLoss);
|
||||
exit_price = position.position.stop_price.unwrap();
|
||||
|
||||
// Adjust for gap through stop
|
||||
match position.position.direction {
|
||||
Direction::Long => {
|
||||
if ohlcv.open[i] < exit_price {
|
||||
exit_price = ohlcv.open[i];
|
||||
}
|
||||
}
|
||||
Direction::Short => {
|
||||
if ohlcv.open[i] > exit_price {
|
||||
exit_price = ohlcv.open[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check take-profit
|
||||
if exit_reason.is_none() && position.is_target_hit(low, high) {
|
||||
exit_reason = Some(ExitReason::TakeProfit);
|
||||
exit_price = position.position.target_price.unwrap();
|
||||
}
|
||||
|
||||
// Check exit signal
|
||||
if exit_reason.is_none() && exits[i] {
|
||||
exit_reason = Some(ExitReason::Signal);
|
||||
exit_price = self.get_fill_price(ohlcv, i, signals.direction, false);
|
||||
}
|
||||
|
||||
// Execute exit
|
||||
if let Some(reason) = exit_reason {
|
||||
// Apply slippage
|
||||
exit_price = self.slippage_model.apply(
|
||||
exit_price,
|
||||
position.position.direction,
|
||||
false,
|
||||
Some(ohlcv.volume[i]),
|
||||
);
|
||||
|
||||
// Calculate fees
|
||||
let fees = self.fee_model.calculate(
|
||||
exit_price,
|
||||
position.position.size,
|
||||
position.position.direction,
|
||||
);
|
||||
|
||||
// Close position
|
||||
if let Some(trade) = position.close_position(
|
||||
i,
|
||||
timestamp,
|
||||
exit_price,
|
||||
ohlcv.timestamps[position.position.entry_idx],
|
||||
reason,
|
||||
fees,
|
||||
) {
|
||||
// Update cash
|
||||
let exit_value = exit_price * trade.size;
|
||||
cash += exit_value - fees;
|
||||
|
||||
// Track return for this trade
|
||||
streaming.update(trade.return_pct / 100.0);
|
||||
|
||||
trades.push(trade);
|
||||
}
|
||||
}
|
||||
|
||||
// Update trailing stop if position still open
|
||||
if position.is_in_position() {
|
||||
if let StopConfig::Trailing { percent } = self.config.stop {
|
||||
position.update_trailing_stop(percent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for entries
|
||||
if !position.is_in_position() && entries[i] {
|
||||
let entry_price = self.get_fill_price(ohlcv, i, signals.direction, true);
|
||||
|
||||
// Apply slippage
|
||||
let adjusted_price = self.slippage_model.apply(
|
||||
entry_price,
|
||||
signals.direction,
|
||||
true,
|
||||
Some(ohlcv.volume[i]),
|
||||
);
|
||||
|
||||
// Calculate position size
|
||||
// VectorBT formula: size = cash / (price * (1 + fees))
|
||||
// This ensures the position value plus entry fee equals available cash
|
||||
let fee_rate = self.config.fees;
|
||||
let size = if let Some(ref sizes) = signals.position_sizes {
|
||||
sizes[i] * cash / (adjusted_price * (1.0 + fee_rate))
|
||||
} else {
|
||||
cash / (adjusted_price * (1.0 + fee_rate))
|
||||
};
|
||||
|
||||
if size > 0.0 {
|
||||
// Calculate entry fees
|
||||
let entry_fees =
|
||||
self.fee_model
|
||||
.calculate(adjusted_price, size, signals.direction);
|
||||
|
||||
// Calculate stop and target prices
|
||||
let (stop_price, target_price) = self.calculate_stop_target(
|
||||
adjusted_price,
|
||||
signals.direction,
|
||||
&atr_values,
|
||||
i,
|
||||
);
|
||||
|
||||
// Open position (passing entry_fees for trade PnL tracking)
|
||||
position.open_position(
|
||||
i,
|
||||
timestamp,
|
||||
adjusted_price,
|
||||
size,
|
||||
signals.direction,
|
||||
stop_price,
|
||||
target_price,
|
||||
entry_fees,
|
||||
);
|
||||
|
||||
// Deduct cost
|
||||
cash -= adjusted_price * size + entry_fees;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate equity
|
||||
let position_value = if position.is_in_position() {
|
||||
close * position.position.size
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let equity = cash + position_value;
|
||||
equity_curve[i] = equity;
|
||||
|
||||
// Calculate drawdown
|
||||
if equity > peak_equity {
|
||||
peak_equity = equity;
|
||||
}
|
||||
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
|
||||
|
||||
// Calculate return
|
||||
if i > 0 {
|
||||
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Mark any open position at end of data (no exit fees, matching VectorBT behavior)
|
||||
if position.is_in_position() {
|
||||
let last_idx = n - 1;
|
||||
let exit_price = ohlcv.close[last_idx];
|
||||
// No exit fees for EndOfData - position is marked-to-market but not actually closed
|
||||
// This matches VectorBT's behavior for "Open" trades
|
||||
let exit_fees = 0.0;
|
||||
|
||||
if let Some(trade) = position.close_position(
|
||||
last_idx,
|
||||
ohlcv.timestamps[last_idx],
|
||||
exit_price,
|
||||
ohlcv.timestamps[position.position.entry_idx],
|
||||
ExitReason::EndOfData,
|
||||
exit_fees,
|
||||
) {
|
||||
streaming.update(trade.return_pct / 100.0);
|
||||
trades.push(trade);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate final metrics
|
||||
let metrics = self.calculate_metrics(
|
||||
&equity_curve,
|
||||
&drawdown_curve,
|
||||
&returns,
|
||||
&trades,
|
||||
&streaming,
|
||||
);
|
||||
|
||||
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
|
||||
}
|
||||
|
||||
/// Get fill price based on model.
|
||||
fn get_fill_price(
|
||||
&self,
|
||||
ohlcv: &OhlcvData,
|
||||
idx: usize,
|
||||
direction: Direction,
|
||||
is_entry: bool,
|
||||
) -> Price {
|
||||
self.fill_price.get_price_from_arrays(
|
||||
ohlcv.open[idx],
|
||||
ohlcv.high[idx],
|
||||
ohlcv.low[idx],
|
||||
ohlcv.close[idx],
|
||||
direction,
|
||||
is_entry,
|
||||
)
|
||||
}
|
||||
|
||||
/// Calculate stop and target prices.
|
||||
fn calculate_stop_target(
|
||||
&self,
|
||||
entry_price: Price,
|
||||
direction: Direction,
|
||||
atr_values: &[f64],
|
||||
idx: usize,
|
||||
) -> (Option<Price>, Option<Price>) {
|
||||
let multiplier = direction.multiplier();
|
||||
|
||||
// Calculate stop price
|
||||
let stop_price = match self.config.stop {
|
||||
StopConfig::None => None,
|
||||
StopConfig::Fixed { percent } => Some(entry_price * (1.0 - multiplier * percent)),
|
||||
StopConfig::Atr { multiplier: m, .. } => {
|
||||
let atr = atr_values.get(idx).copied().unwrap_or(0.0);
|
||||
if atr > 0.0 {
|
||||
Some(entry_price - multiplier * m * atr)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
StopConfig::Trailing { percent } => Some(entry_price * (1.0 - multiplier * percent)),
|
||||
};
|
||||
|
||||
// Calculate target price
|
||||
let target_price = match self.config.target {
|
||||
TargetConfig::None => None,
|
||||
TargetConfig::Fixed { percent } => Some(entry_price * (1.0 + multiplier * percent)),
|
||||
TargetConfig::Atr { multiplier: m, .. } => {
|
||||
let atr = atr_values.get(idx).copied().unwrap_or(0.0);
|
||||
if atr > 0.0 {
|
||||
Some(entry_price + multiplier * m * atr)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
TargetConfig::RiskReward { ratio } => {
|
||||
if let Some(stop) = stop_price {
|
||||
let risk = (entry_price - stop).abs();
|
||||
Some(entry_price + multiplier * risk * ratio)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
(stop_price, target_price)
|
||||
}
|
||||
|
||||
/// Calculate backtest metrics.
|
||||
fn calculate_metrics(
|
||||
&self,
|
||||
equity_curve: &[f64],
|
||||
drawdown_curve: &[f64],
|
||||
returns: &[f64],
|
||||
trades: &[Trade],
|
||||
_streaming: &StreamingMetrics,
|
||||
) -> BacktestMetrics {
|
||||
let start_value = self.config.initial_capital;
|
||||
let end_value = *equity_curve.last().unwrap_or(&start_value);
|
||||
|
||||
let total_return_pct = (end_value - start_value) / start_value * 100.0;
|
||||
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
|
||||
|
||||
// Calculate max drawdown duration
|
||||
let max_drawdown_duration = self.calculate_max_drawdown_duration(drawdown_curve);
|
||||
|
||||
// Trade statistics
|
||||
let total_trades = trades.len();
|
||||
|
||||
// Separate closed vs open trades (EndOfData means still open)
|
||||
let total_open_trades = trades
|
||||
.iter()
|
||||
.filter(|t| matches!(t.exit_reason, ExitReason::EndOfData))
|
||||
.count();
|
||||
let total_closed_trades = total_trades.saturating_sub(total_open_trades);
|
||||
|
||||
// Open trade PnL
|
||||
let open_trade_pnl: f64 = trades
|
||||
.iter()
|
||||
.filter(|t| matches!(t.exit_reason, ExitReason::EndOfData))
|
||||
.map(|t| t.pnl)
|
||||
.sum();
|
||||
|
||||
// Only count closed trades for win/loss statistics
|
||||
let closed_trades: Vec<_> = trades
|
||||
.iter()
|
||||
.filter(|t| !matches!(t.exit_reason, ExitReason::EndOfData))
|
||||
.collect();
|
||||
|
||||
let winning_trades = closed_trades.iter().filter(|t| t.pnl > 0.0).count();
|
||||
let losing_trades = closed_trades.iter().filter(|t| t.pnl < 0.0).count();
|
||||
|
||||
let win_rate_pct = if total_closed_trades > 0 {
|
||||
winning_trades as f64 / total_closed_trades as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Total fees paid
|
||||
let total_fees_paid: f64 = trades.iter().map(|t| t.fees).sum();
|
||||
|
||||
// Best and worst trade
|
||||
let best_trade_pct = trades
|
||||
.iter()
|
||||
.map(|t| t.return_pct)
|
||||
.fold(f64::NEG_INFINITY, |a, b| a.max(b));
|
||||
let best_trade_pct = if best_trade_pct.is_infinite() {
|
||||
0.0
|
||||
} else {
|
||||
best_trade_pct
|
||||
};
|
||||
|
||||
let worst_trade_pct = trades
|
||||
.iter()
|
||||
.map(|t| t.return_pct)
|
||||
.fold(f64::INFINITY, |a, b| a.min(b));
|
||||
let worst_trade_pct = if worst_trade_pct.is_infinite() {
|
||||
0.0
|
||||
} else {
|
||||
worst_trade_pct
|
||||
};
|
||||
|
||||
// Profit factor (based on closed trades)
|
||||
let gross_profit: f64 = closed_trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl > 0.0)
|
||||
.map(|t| t.pnl)
|
||||
.sum();
|
||||
let gross_loss: f64 = closed_trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.pnl.abs())
|
||||
.sum();
|
||||
let profit_factor = if gross_loss > 0.0 {
|
||||
gross_profit / gross_loss
|
||||
} else if gross_profit > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Expectancy = average trade PnL
|
||||
let expectancy = if total_closed_trades > 0 {
|
||||
closed_trades.iter().map(|t| t.pnl).sum::<f64>() / total_closed_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// SQN = (Expectancy / StdDev of trade PnL) * sqrt(total trades)
|
||||
let sqn = if total_closed_trades > 1 {
|
||||
let trade_pnls: Vec<f64> = closed_trades.iter().map(|t| t.pnl).collect();
|
||||
let mean = expectancy;
|
||||
let variance = trade_pnls.iter().map(|p| (p - mean).powi(2)).sum::<f64>()
|
||||
/ (total_closed_trades - 1) as f64;
|
||||
let std_dev = variance.sqrt();
|
||||
if std_dev > 0.0 {
|
||||
(mean / std_dev) * (total_closed_trades as f64).sqrt()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Average returns
|
||||
let avg_trade_return_pct = if total_trades > 0 {
|
||||
trades.iter().map(|t| t.return_pct).sum::<f64>() / total_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let avg_win_pct = if winning_trades > 0 {
|
||||
closed_trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl > 0.0)
|
||||
.map(|t| t.return_pct)
|
||||
.sum::<f64>()
|
||||
/ winning_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let avg_loss_pct = if losing_trades > 0 {
|
||||
closed_trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.return_pct)
|
||||
.sum::<f64>()
|
||||
/ losing_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Average winning/losing trade duration
|
||||
let avg_winning_duration = if winning_trades > 0 {
|
||||
closed_trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl > 0.0)
|
||||
.map(|t| t.holding_period() as f64)
|
||||
.sum::<f64>()
|
||||
/ winning_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let avg_losing_duration = if losing_trades > 0 {
|
||||
closed_trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.holding_period() as f64)
|
||||
.sum::<f64>()
|
||||
/ losing_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Consecutive wins/losses
|
||||
let (max_consecutive_wins, max_consecutive_losses) = self.calculate_consecutive(trades);
|
||||
|
||||
// Holding period
|
||||
let avg_holding_period = if total_trades > 0 {
|
||||
trades
|
||||
.iter()
|
||||
.map(|t| t.holding_period() as f64)
|
||||
.sum::<f64>()
|
||||
/ total_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Exposure (time in market)
|
||||
let bars_in_position: usize = trades.iter().map(|t| t.holding_period()).sum();
|
||||
let exposure_pct = if !equity_curve.is_empty() {
|
||||
bars_in_position as f64 / equity_curve.len() as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Risk-adjusted metrics (calculated from daily portfolio returns, not trade returns)
|
||||
// This matches VectorBT's calculation methodology
|
||||
let (sharpe_ratio, sortino_ratio, omega_ratio) = self.calculate_risk_metrics(returns);
|
||||
|
||||
// Calmar ratio: CAGR / max drawdown
|
||||
// VectorBT uses Compound Annual Growth Rate (CAGR)
|
||||
let num_periods = equity_curve.len().max(1) as f64;
|
||||
let years = num_periods / 365.25; // Convert to years using 365.25 days
|
||||
let total_return_frac = total_return_pct / 100.0;
|
||||
// CAGR = (end/start)^(1/years) - 1 = (1 + total_return)^(1/years) - 1
|
||||
let cagr = if years > 0.0 {
|
||||
(1.0 + total_return_frac).powf(1.0 / years) - 1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let calmar_ratio = if max_drawdown_pct > 0.0 {
|
||||
cagr / (max_drawdown_pct / 100.0) // Both as fractions
|
||||
} else if total_return_pct > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
BacktestMetrics {
|
||||
total_return_pct,
|
||||
sharpe_ratio,
|
||||
sortino_ratio,
|
||||
calmar_ratio,
|
||||
omega_ratio,
|
||||
max_drawdown_pct,
|
||||
max_drawdown_duration,
|
||||
win_rate_pct,
|
||||
profit_factor,
|
||||
expectancy,
|
||||
sqn,
|
||||
total_trades,
|
||||
total_closed_trades,
|
||||
total_open_trades,
|
||||
open_trade_pnl,
|
||||
winning_trades,
|
||||
losing_trades,
|
||||
start_value,
|
||||
end_value,
|
||||
total_fees_paid,
|
||||
best_trade_pct,
|
||||
worst_trade_pct,
|
||||
avg_trade_return_pct,
|
||||
avg_win_pct,
|
||||
avg_loss_pct,
|
||||
avg_winning_duration,
|
||||
avg_losing_duration,
|
||||
max_consecutive_wins,
|
||||
max_consecutive_losses,
|
||||
avg_holding_period,
|
||||
exposure_pct,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate max drawdown duration from drawdown curve.
|
||||
fn calculate_max_drawdown_duration(&self, drawdown_curve: &[f64]) -> usize {
|
||||
let mut max_duration = 0;
|
||||
let mut current_duration = 0;
|
||||
|
||||
for &dd in drawdown_curve {
|
||||
if dd > 0.0 {
|
||||
current_duration += 1;
|
||||
max_duration = max_duration.max(current_duration);
|
||||
} else {
|
||||
current_duration = 0;
|
||||
}
|
||||
}
|
||||
|
||||
max_duration
|
||||
}
|
||||
|
||||
/// Calculate max consecutive wins and losses.
|
||||
fn calculate_consecutive(&self, trades: &[Trade]) -> (usize, usize) {
|
||||
let mut max_wins = 0;
|
||||
let mut max_losses = 0;
|
||||
let mut current_wins = 0;
|
||||
let mut current_losses = 0;
|
||||
|
||||
for trade in trades {
|
||||
if trade.pnl > 0.0 {
|
||||
current_wins += 1;
|
||||
current_losses = 0;
|
||||
max_wins = max_wins.max(current_wins);
|
||||
} else if trade.pnl < 0.0 {
|
||||
current_losses += 1;
|
||||
current_wins = 0;
|
||||
max_losses = max_losses.max(current_losses);
|
||||
}
|
||||
}
|
||||
|
||||
(max_wins, max_losses)
|
||||
}
|
||||
|
||||
/// Calculate risk-adjusted metrics from daily portfolio returns.
|
||||
/// Returns (sharpe_ratio, sortino_ratio, omega_ratio).
|
||||
/// Uses 365 days for annualization to match VectorBT.
|
||||
fn calculate_risk_metrics(&self, returns: &[f64]) -> (f64, f64, f64) {
|
||||
if returns.len() < 2 {
|
||||
return (0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// VectorBT uses 365 days (calendar days) for annualization
|
||||
let periods_per_year: f64 = 365.0;
|
||||
let _n = returns.len() as f64;
|
||||
|
||||
// Filter out NaN values
|
||||
let valid_returns: Vec<f64> = returns.iter().filter(|r| !r.is_nan()).copied().collect();
|
||||
|
||||
if valid_returns.len() < 2 {
|
||||
return (0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
let n_valid = valid_returns.len() as f64;
|
||||
|
||||
// Calculate mean return
|
||||
let mean = valid_returns.iter().sum::<f64>() / n_valid;
|
||||
|
||||
// Calculate standard deviation
|
||||
let variance = valid_returns
|
||||
.iter()
|
||||
.map(|r| (r - mean).powi(2))
|
||||
.sum::<f64>()
|
||||
/ (n_valid - 1.0);
|
||||
let std_dev = variance.sqrt();
|
||||
|
||||
// Sharpe Ratio = (mean * periods_per_year) / (std_dev * sqrt(periods_per_year))
|
||||
// Simplified: Sharpe = mean / std_dev * sqrt(periods_per_year)
|
||||
let sharpe_ratio = if std_dev > 0.0 {
|
||||
(mean / std_dev) * periods_per_year.sqrt()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Sortino Ratio - uses downside deviation (only negative returns)
|
||||
let downside_returns: Vec<f64> = valid_returns
|
||||
.iter()
|
||||
.filter(|&&r| r < 0.0)
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
let downside_variance = if !downside_returns.is_empty() {
|
||||
downside_returns.iter().map(|r| r.powi(2)).sum::<f64>() / n_valid // Divide by total count, not downside count
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let downside_std = downside_variance.sqrt();
|
||||
|
||||
let sortino_ratio = if downside_std > 0.0 {
|
||||
(mean / downside_std) * periods_per_year.sqrt()
|
||||
} else if mean > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Omega Ratio = sum of returns above threshold / |sum of returns below threshold|
|
||||
// With threshold = 0
|
||||
let sum_positive: f64 = valid_returns.iter().filter(|&&r| r > 0.0).sum();
|
||||
let sum_negative: f64 = valid_returns
|
||||
.iter()
|
||||
.filter(|&&r| r < 0.0)
|
||||
.map(|r| r.abs())
|
||||
.sum();
|
||||
|
||||
let omega_ratio = if sum_negative > 0.0 {
|
||||
sum_positive / sum_negative
|
||||
} else if sum_positive > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
(sharpe_ratio, sortino_ratio, omega_ratio)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_ohlcv() -> OhlcvData {
|
||||
OhlcvData {
|
||||
timestamps: (0..20).map(|i| i as i64).collect(),
|
||||
open: vec![
|
||||
100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 104.0, 103.0, 102.0, 101.0, 100.0, 101.0,
|
||||
102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0,
|
||||
],
|
||||
high: vec![
|
||||
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 105.0, 104.0, 103.0, 102.0, 101.0, 102.0,
|
||||
103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0,
|
||||
],
|
||||
low: vec![
|
||||
99.0, 100.0, 101.0, 102.0, 103.0, 104.0, 103.0, 102.0, 101.0, 100.0, 99.0, 100.0,
|
||||
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0,
|
||||
],
|
||||
close: vec![
|
||||
100.5, 101.5, 102.5, 103.5, 104.5, 105.0, 104.0, 103.0, 102.0, 101.0, 100.5, 101.5,
|
||||
102.5, 103.5, 104.5, 105.5, 106.5, 107.5, 108.5, 109.5,
|
||||
],
|
||||
volume: vec![1000.0; 20],
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_signals() -> CompiledSignals {
|
||||
CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries: vec![
|
||||
false, true, false, false, false, false, false, false, false, false, false, true,
|
||||
false, false, false, false, false, false, false, false,
|
||||
],
|
||||
exits: vec![
|
||||
false, false, false, false, false, true, false, false, false, false, false, false,
|
||||
false, false, false, true, false, false, false, false,
|
||||
],
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_backtest() {
|
||||
let config = BacktestConfig {
|
||||
initial_capital: 100_000.0,
|
||||
fees: 0.0,
|
||||
slippage: 0.0,
|
||||
stop: StopConfig::None,
|
||||
target: TargetConfig::None,
|
||||
upon_bar_close: true,
|
||||
};
|
||||
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let ohlcv = sample_ohlcv();
|
||||
let signals = sample_signals();
|
||||
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Should have 2 trades
|
||||
assert_eq!(result.trades.len(), 2);
|
||||
|
||||
// First trade: entry at 101.5, exit at 105.0
|
||||
let trade1 = &result.trades[0];
|
||||
assert!((trade1.entry_price - 101.5).abs() < 1e-10);
|
||||
assert!((trade1.exit_price - 105.0).abs() < 1e-10);
|
||||
assert!(trade1.pnl > 0.0); // Profitable
|
||||
|
||||
// Equity curve should have correct length
|
||||
assert_eq!(result.equity_curve.len(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_fees() {
|
||||
let config = BacktestConfig {
|
||||
initial_capital: 100_000.0,
|
||||
fees: 0.001, // 0.1%
|
||||
slippage: 0.0,
|
||||
stop: StopConfig::None,
|
||||
target: TargetConfig::None,
|
||||
upon_bar_close: true,
|
||||
};
|
||||
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let ohlcv = sample_ohlcv();
|
||||
let signals = sample_signals();
|
||||
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Trades should have fees deducted
|
||||
for trade in &result.trades {
|
||||
assert!(trade.fees > 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_stop_loss() {
|
||||
let config = BacktestConfig {
|
||||
initial_capital: 100_000.0,
|
||||
fees: 0.0,
|
||||
slippage: 0.0,
|
||||
stop: StopConfig::Fixed { percent: 0.02 }, // 2% stop
|
||||
target: TargetConfig::None,
|
||||
upon_bar_close: true,
|
||||
};
|
||||
|
||||
let engine = PortfolioEngine::new(config);
|
||||
|
||||
// Create data where stop would be hit
|
||||
let mut ohlcv = sample_ohlcv();
|
||||
// Add a big drop after entry
|
||||
ohlcv.low[3] = 95.0; // Big drop
|
||||
ohlcv.close[3] = 96.0;
|
||||
|
||||
let signals = sample_signals();
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// First trade should exit on stop loss
|
||||
assert_eq!(result.trades[0].exit_reason, ExitReason::StopLoss);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Portfolio simulation engine for RaptorBT.
|
||||
|
||||
pub mod allocation;
|
||||
pub mod engine;
|
||||
pub mod position;
|
||||
|
||||
pub use allocation::{AllocationStrategy, CapitalAllocator};
|
||||
pub use engine::PortfolioEngine;
|
||||
pub use position::PositionManager;
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Position tracking for portfolio management.
|
||||
|
||||
use crate::core::types::{Direction, ExitReason, Position, Price, Timestamp, Trade};
|
||||
|
||||
/// Position manager for tracking open positions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PositionManager {
|
||||
/// Current position state.
|
||||
pub position: Position,
|
||||
/// Trade counter for generating unique IDs.
|
||||
trade_counter: u64,
|
||||
/// Symbol being traded.
|
||||
pub symbol: String,
|
||||
}
|
||||
|
||||
impl PositionManager {
|
||||
/// Create a new position manager.
|
||||
pub fn new(symbol: String) -> Self {
|
||||
Self {
|
||||
position: Position::new(),
|
||||
trade_counter: 0,
|
||||
symbol,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if currently in a position.
|
||||
#[inline]
|
||||
pub fn is_in_position(&self) -> bool {
|
||||
self.position.is_open
|
||||
}
|
||||
|
||||
/// Get current position direction.
|
||||
pub fn current_direction(&self) -> Option<Direction> {
|
||||
if self.position.is_open {
|
||||
Some(self.position.direction)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a new position.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `idx` - Bar index
|
||||
/// * `timestamp` - Entry timestamp
|
||||
/// * `price` - Entry price
|
||||
/// * `size` - Position size
|
||||
/// * `direction` - Trade direction
|
||||
/// * `stop_price` - Optional stop-loss price
|
||||
/// * `target_price` - Optional take-profit price
|
||||
/// * `entry_fees` - Entry fees (to track for PnL calculation)
|
||||
///
|
||||
/// # Returns
|
||||
/// True if position was opened, false if already in position
|
||||
pub fn open_position(
|
||||
&mut self,
|
||||
idx: usize,
|
||||
_timestamp: Timestamp,
|
||||
price: Price,
|
||||
size: f64,
|
||||
direction: Direction,
|
||||
stop_price: Option<Price>,
|
||||
target_price: Option<Price>,
|
||||
entry_fees: f64,
|
||||
) -> bool {
|
||||
if self.position.is_open {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.position.open(
|
||||
idx,
|
||||
price,
|
||||
size,
|
||||
direction,
|
||||
stop_price,
|
||||
target_price,
|
||||
entry_fees,
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Close current position and generate a trade record.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `idx` - Bar index
|
||||
/// * `timestamp` - Exit timestamp
|
||||
/// * `price` - Exit price
|
||||
/// * `entry_timestamp` - Entry timestamp (for trade record)
|
||||
/// * `exit_reason` - Reason for exit
|
||||
/// * `fees` - Transaction fees
|
||||
///
|
||||
/// # Returns
|
||||
/// Trade record if position was closed, None if no position
|
||||
pub fn close_position(
|
||||
&mut self,
|
||||
idx: usize,
|
||||
timestamp: Timestamp,
|
||||
price: Price,
|
||||
entry_timestamp: Timestamp,
|
||||
exit_reason: ExitReason,
|
||||
fees: f64,
|
||||
) -> Option<Trade> {
|
||||
if !self.position.is_open {
|
||||
return None;
|
||||
}
|
||||
|
||||
let trade = self.create_trade(idx, timestamp, price, entry_timestamp, exit_reason, fees);
|
||||
self.position.close();
|
||||
self.trade_counter += 1;
|
||||
|
||||
Some(trade)
|
||||
}
|
||||
|
||||
/// Create a trade record from current position.
|
||||
fn create_trade(
|
||||
&self,
|
||||
exit_idx: usize,
|
||||
exit_timestamp: Timestamp,
|
||||
exit_price: Price,
|
||||
entry_timestamp: Timestamp,
|
||||
exit_reason: ExitReason,
|
||||
exit_fees: f64,
|
||||
) -> Trade {
|
||||
let pos = &self.position;
|
||||
let multiplier = pos.direction.multiplier();
|
||||
|
||||
// Calculate P&L (matching VectorBT: gross - entry_fees - exit_fees)
|
||||
let gross_pnl = (exit_price - pos.entry_price) * pos.size * multiplier;
|
||||
let total_fees = pos.entry_fees + exit_fees;
|
||||
let pnl = gross_pnl - total_fees;
|
||||
|
||||
// Calculate return percentage
|
||||
let cost_basis = pos.entry_price * pos.size;
|
||||
let return_pct = if cost_basis > 0.0 {
|
||||
pnl / cost_basis * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
Trade {
|
||||
id: self.trade_counter,
|
||||
symbol: self.symbol.clone(),
|
||||
entry_idx: pos.entry_idx,
|
||||
exit_idx,
|
||||
entry_price: pos.entry_price,
|
||||
exit_price,
|
||||
size: pos.size,
|
||||
direction: pos.direction,
|
||||
pnl,
|
||||
return_pct,
|
||||
entry_time: entry_timestamp,
|
||||
exit_time: exit_timestamp,
|
||||
fees: total_fees,
|
||||
exit_reason,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update position with new price data (for trailing stops).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` - Current bar high
|
||||
/// * `low` - Current bar low
|
||||
pub fn update_price(&mut self, high: Price, low: Price) {
|
||||
if self.position.is_open {
|
||||
self.position.update_extremes(high, low);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate unrealized P&L at current price.
|
||||
pub fn unrealized_pnl(&self, current_price: Price) -> f64 {
|
||||
self.position.unrealized_pnl(current_price)
|
||||
}
|
||||
|
||||
/// Get current position value (market value of position).
|
||||
pub fn position_value(&self, current_price: Price) -> f64 {
|
||||
if !self.position.is_open {
|
||||
return 0.0;
|
||||
}
|
||||
current_price * self.position.size
|
||||
}
|
||||
|
||||
/// Calculate position exposure (notional value as fraction of given capital).
|
||||
pub fn exposure(&self, current_price: Price, capital: f64) -> f64 {
|
||||
if capital <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.position_value(current_price) / capital
|
||||
}
|
||||
|
||||
/// Check if stop-loss is hit.
|
||||
pub fn is_stop_hit(&self, low: Price, high: Price) -> bool {
|
||||
if !self.position.is_open {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(stop) = self.position.stop_price {
|
||||
match self.position.direction {
|
||||
Direction::Long => low <= stop,
|
||||
Direction::Short => high >= stop,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if take-profit is hit.
|
||||
pub fn is_target_hit(&self, low: Price, high: Price) -> bool {
|
||||
if !self.position.is_open {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(target) = self.position.target_price {
|
||||
match self.position.direction {
|
||||
Direction::Long => high >= target,
|
||||
Direction::Short => low <= target,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Update trailing stop.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `trail_percent` - Trailing stop percentage
|
||||
pub fn update_trailing_stop(&mut self, trail_percent: f64) {
|
||||
if !self.position.is_open {
|
||||
return;
|
||||
}
|
||||
|
||||
match self.position.direction {
|
||||
Direction::Long => {
|
||||
// Trail below highest price since entry
|
||||
let new_stop = self.position.highest_since_entry * (1.0 - trail_percent);
|
||||
if let Some(current_stop) = self.position.stop_price {
|
||||
if new_stop > current_stop {
|
||||
self.position.stop_price = Some(new_stop);
|
||||
}
|
||||
} else {
|
||||
self.position.stop_price = Some(new_stop);
|
||||
}
|
||||
}
|
||||
Direction::Short => {
|
||||
// Trail above lowest price since entry
|
||||
let new_stop = self.position.lowest_since_entry * (1.0 + trail_percent);
|
||||
if let Some(current_stop) = self.position.stop_price {
|
||||
if new_stop < current_stop {
|
||||
self.position.stop_price = Some(new_stop);
|
||||
}
|
||||
} else {
|
||||
self.position.stop_price = Some(new_stop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset position manager for new backtest.
|
||||
pub fn reset(&mut self) {
|
||||
self.position = Position::new();
|
||||
self.trade_counter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_open_close_position() {
|
||||
let mut pm = PositionManager::new("TEST".to_string());
|
||||
|
||||
// Open position
|
||||
assert!(pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None));
|
||||
assert!(pm.is_in_position());
|
||||
|
||||
// Try to open another - should fail
|
||||
assert!(!pm.open_position(1, 1001, 101.0, 10.0, Direction::Long, None, None));
|
||||
|
||||
// Close position with profit
|
||||
let trade = pm
|
||||
.close_position(5, 1005, 110.0, 1000, ExitReason::Signal, 2.0)
|
||||
.unwrap();
|
||||
|
||||
assert!(!pm.is_in_position());
|
||||
assert_eq!(trade.entry_idx, 0);
|
||||
assert_eq!(trade.exit_idx, 5);
|
||||
assert!((trade.entry_price - 100.0).abs() < 1e-10);
|
||||
assert!((trade.exit_price - 110.0).abs() < 1e-10);
|
||||
|
||||
// P&L: (110 - 100) * 10 - 2 = 98
|
||||
assert!((trade.pnl - 98.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_position() {
|
||||
let mut pm = PositionManager::new("TEST".to_string());
|
||||
|
||||
pm.open_position(0, 1000, 100.0, 10.0, Direction::Short, None, None);
|
||||
|
||||
// Close with profit (price went down)
|
||||
let trade = pm
|
||||
.close_position(5, 1005, 90.0, 1000, ExitReason::Signal, 2.0)
|
||||
.unwrap();
|
||||
|
||||
// P&L: (100 - 90) * 10 * -(-1) - 2 = 98
|
||||
// For short: (entry - exit) * size = (100 - 90) * 10 = 100 gross, minus 2 fees = 98
|
||||
assert!((trade.pnl - 98.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_loss() {
|
||||
let mut pm = PositionManager::new("TEST".to_string());
|
||||
|
||||
pm.open_position(
|
||||
0,
|
||||
1000,
|
||||
100.0,
|
||||
10.0,
|
||||
Direction::Long,
|
||||
Some(95.0), // Stop at 95
|
||||
None,
|
||||
);
|
||||
|
||||
// Check stop not hit
|
||||
assert!(!pm.is_stop_hit(96.0, 102.0));
|
||||
|
||||
// Check stop hit
|
||||
assert!(pm.is_stop_hit(94.0, 102.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trailing_stop() {
|
||||
let mut pm = PositionManager::new("TEST".to_string());
|
||||
|
||||
pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None);
|
||||
|
||||
// Update with higher price
|
||||
pm.update_price(110.0, 98.0);
|
||||
pm.update_trailing_stop(0.05); // 5% trail
|
||||
|
||||
// Stop should be at 110 * 0.95 = 104.5
|
||||
assert!((pm.position.stop_price.unwrap() - 104.5).abs() < 1e-10);
|
||||
|
||||
// Update with even higher price
|
||||
pm.update_price(120.0, 108.0);
|
||||
pm.update_trailing_stop(0.05);
|
||||
|
||||
// Stop should move up to 120 * 0.95 = 114
|
||||
assert!((pm.position.stop_price.unwrap() - 114.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unrealized_pnl() {
|
||||
let mut pm = PositionManager::new("TEST".to_string());
|
||||
|
||||
pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None);
|
||||
|
||||
// Price up
|
||||
let pnl = pm.unrealized_pnl(110.0);
|
||||
assert!((pnl - 100.0).abs() < 1e-10); // (110 - 100) * 10 = 100
|
||||
|
||||
// Price down
|
||||
let pnl = pm.unrealized_pnl(95.0);
|
||||
assert!((pnl - (-50.0)).abs() < 1e-10); // (95 - 100) * 10 = -50
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user