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(())
|
||||
}
|
||||
}
|
||||
526
src/bot.rs
526
src/bot.rs
@@ -2,27 +2,49 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tokio::time::{sleep, Duration as TokioDuration};
|
||||
|
||||
use crate::alpaca::AlpacaClient;
|
||||
use crate::config::{
|
||||
get_all_symbols, IndicatorParams, Timeframe, BOT_CHECK_INTERVAL_SECONDS, HOURS_PER_DAY,
|
||||
MAX_POSITION_SIZE, MIN_CASH_RESERVE, STOP_LOSS_PCT, TAKE_PROFIT_PCT,
|
||||
TOP_MOMENTUM_COUNT, TRAILING_STOP_ACTIVATION, TRAILING_STOP_DISTANCE,
|
||||
get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER,
|
||||
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, BOT_CHECK_INTERVAL_SECONDS,
|
||||
DRAWDOWN_HALT_BARS, HOURS_PER_DAY, MAX_CONCURRENT_POSITIONS, MAX_DRAWDOWN_HALT,
|
||||
MAX_POSITION_SIZE, MAX_SECTOR_POSITIONS, MIN_CASH_RESERVE, RAMPUP_PERIOD_BARS,
|
||||
REENTRY_COOLDOWN_BARS, TOP_MOMENTUM_COUNT,
|
||||
};
|
||||
use crate::indicators::{calculate_all_indicators, generate_signal};
|
||||
use crate::paths::{LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE, LIVE_POSITIONS_FILE};
|
||||
use crate::paths::{
|
||||
LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE, LIVE_POSITIONS_FILE,
|
||||
LIVE_POSITION_META_FILE,
|
||||
};
|
||||
use crate::strategy::Strategy;
|
||||
use crate::types::{EquitySnapshot, PositionInfo, Signal, TradeSignal};
|
||||
|
||||
/// Per-position metadata persisted to disk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct PositionMeta {
|
||||
bars_held: usize,
|
||||
}
|
||||
|
||||
/// Live trading bot for paper trading.
|
||||
pub struct TradingBot {
|
||||
client: AlpacaClient,
|
||||
params: IndicatorParams,
|
||||
strategy: Strategy,
|
||||
timeframe: Timeframe,
|
||||
entry_prices: HashMap<String, f64>,
|
||||
high_water_marks: HashMap<String, f64>,
|
||||
position_meta: HashMap<String, PositionMeta>,
|
||||
equity_history: Vec<EquitySnapshot>,
|
||||
peak_portfolio_value: f64,
|
||||
drawdown_halt: bool,
|
||||
/// Cycle count when drawdown halt started (for time-based resume)
|
||||
drawdown_halt_start: Option<usize>,
|
||||
/// Current trading cycle count
|
||||
trading_cycle_count: usize,
|
||||
/// Tracks when each symbol can be re-entered after stop-loss (cycle index)
|
||||
cooldown_timers: HashMap<String, usize>,
|
||||
/// Tracks new positions opened in current cycle (for gradual ramp-up)
|
||||
new_positions_this_cycle: usize,
|
||||
}
|
||||
|
||||
impl TradingBot {
|
||||
@@ -36,16 +58,24 @@ impl TradingBot {
|
||||
|
||||
let mut bot = Self {
|
||||
client,
|
||||
params: timeframe.params(),
|
||||
strategy: Strategy::new(timeframe),
|
||||
timeframe,
|
||||
entry_prices: HashMap::new(),
|
||||
high_water_marks: HashMap::new(),
|
||||
position_meta: HashMap::new(),
|
||||
equity_history: Vec::new(),
|
||||
peak_portfolio_value: 0.0,
|
||||
drawdown_halt: false,
|
||||
drawdown_halt_start: None,
|
||||
trading_cycle_count: 0,
|
||||
cooldown_timers: HashMap::new(),
|
||||
new_positions_this_cycle: 0,
|
||||
};
|
||||
|
||||
// Load persisted state
|
||||
bot.load_entry_prices();
|
||||
bot.load_high_water_marks();
|
||||
bot.load_entry_atrs();
|
||||
bot.load_position_meta();
|
||||
bot.load_cooldown_timers();
|
||||
bot.load_equity_history();
|
||||
|
||||
// Log account info
|
||||
@@ -56,71 +86,101 @@ impl TradingBot {
|
||||
Ok(bot)
|
||||
}
|
||||
|
||||
/// Load entry prices from file.
|
||||
// ── Persistence helpers ──────────────────────────────────────────
|
||||
|
||||
fn load_json_map<V: serde::de::DeserializeOwned>(
|
||||
path: &std::path::Path,
|
||||
label: &str,
|
||||
) -> HashMap<String, V> {
|
||||
if path.exists() {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(content) if !content.is_empty() => {
|
||||
match serde_json::from_str(&content) {
|
||||
Ok(map) => return map,
|
||||
Err(e) => tracing::error!("Error parsing {} file: {}", label, e),
|
||||
}
|
||||
}
|
||||
Ok(_) => {} // Empty file is valid, return empty map
|
||||
Err(e) => tracing::error!("Error loading {} file: {}", label, e),
|
||||
}
|
||||
}
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
fn save_json_map<V: serde::Serialize>(map: &HashMap<String, V>, path: &std::path::Path, label: &str) {
|
||||
match serde_json::to_string_pretty(map) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(path, json) {
|
||||
tracing::error!("Error saving {} file: {}", label, e);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Error serializing {}: {}", label, e),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_entry_prices(&mut self) {
|
||||
if LIVE_POSITIONS_FILE.exists() {
|
||||
match std::fs::read_to_string(&*LIVE_POSITIONS_FILE) {
|
||||
Ok(content) => {
|
||||
if !content.is_empty() {
|
||||
match serde_json::from_str::<HashMap<String, f64>>(&content) {
|
||||
Ok(prices) => {
|
||||
tracing::info!("Loaded entry prices for {} positions.", prices.len());
|
||||
self.entry_prices = prices;
|
||||
}
|
||||
Err(e) => tracing::error!("Error parsing positions file: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Error loading positions file: {}", e),
|
||||
}
|
||||
self.strategy.entry_prices = Self::load_json_map(&LIVE_POSITIONS_FILE, "positions");
|
||||
if !self.strategy.entry_prices.is_empty() {
|
||||
tracing::info!("Loaded entry prices for {} positions.", self.strategy.entry_prices.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Save entry prices to file.
|
||||
fn save_entry_prices(&self) {
|
||||
match serde_json::to_string_pretty(&self.entry_prices) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(&*LIVE_POSITIONS_FILE, json) {
|
||||
tracing::error!("Error saving positions file: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Error serializing positions: {}", e),
|
||||
}
|
||||
Self::save_json_map(&self.strategy.entry_prices, &LIVE_POSITIONS_FILE, "positions");
|
||||
}
|
||||
|
||||
/// Load high water marks from file.
|
||||
fn load_high_water_marks(&mut self) {
|
||||
if LIVE_HIGH_WATER_MARKS_FILE.exists() {
|
||||
match std::fs::read_to_string(&*LIVE_HIGH_WATER_MARKS_FILE) {
|
||||
Ok(content) => {
|
||||
if !content.is_empty() {
|
||||
match serde_json::from_str::<HashMap<String, f64>>(&content) {
|
||||
Ok(marks) => {
|
||||
tracing::info!("Loaded high water marks for {} positions.", marks.len());
|
||||
self.high_water_marks = marks;
|
||||
}
|
||||
Err(e) => tracing::error!("Error parsing high water marks file: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Error loading high water marks file: {}", e),
|
||||
}
|
||||
self.strategy.high_water_marks = Self::load_json_map(&LIVE_HIGH_WATER_MARKS_FILE, "high water marks");
|
||||
if !self.strategy.high_water_marks.is_empty() {
|
||||
tracing::info!("Loaded high water marks for {} positions.", self.strategy.high_water_marks.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Save high water marks to file.
|
||||
fn save_high_water_marks(&self) {
|
||||
match serde_json::to_string_pretty(&self.high_water_marks) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(&*LIVE_HIGH_WATER_MARKS_FILE, json) {
|
||||
tracing::error!("Error saving high water marks file: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Error serializing high water marks: {}", e),
|
||||
Self::save_json_map(&self.strategy.high_water_marks, &LIVE_HIGH_WATER_MARKS_FILE, "high water marks");
|
||||
}
|
||||
|
||||
fn load_entry_atrs(&mut self) {
|
||||
self.strategy.entry_atrs = Self::load_json_map(&LIVE_ENTRY_ATRS_FILE, "entry ATRs");
|
||||
if !self.strategy.entry_atrs.is_empty() {
|
||||
tracing::info!("Loaded entry ATRs for {} positions.", self.strategy.entry_atrs.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn save_entry_atrs(&self) {
|
||||
Self::save_json_map(&self.strategy.entry_atrs, &LIVE_ENTRY_ATRS_FILE, "entry ATRs");
|
||||
}
|
||||
|
||||
fn load_position_meta(&mut self) {
|
||||
self.position_meta = Self::load_json_map(&LIVE_POSITION_META_FILE, "position meta");
|
||||
if !self.position_meta.is_empty() {
|
||||
tracing::info!("Loaded position meta for {} positions.", self.position_meta.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn save_position_meta(&self) {
|
||||
Self::save_json_map(&self.position_meta, &LIVE_POSITION_META_FILE, "position meta");
|
||||
}
|
||||
|
||||
fn load_cooldown_timers(&mut self) {
|
||||
if let Ok(path_str) = std::env::var("HOME") {
|
||||
let path = std::path::PathBuf::from(path_str)
|
||||
.join(".local/share/invest-bot/cooldown_timers.json");
|
||||
self.cooldown_timers = Self::load_json_map(&path, "cooldown timers");
|
||||
if !self.cooldown_timers.is_empty() {
|
||||
tracing::info!("Loaded cooldown timers for {} symbols.", self.cooldown_timers.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save_cooldown_timers(&self) {
|
||||
if let Ok(path_str) = std::env::var("HOME") {
|
||||
let path = std::path::PathBuf::from(path_str)
|
||||
.join(".local/share/invest-bot/cooldown_timers.json");
|
||||
Self::save_json_map(&self.cooldown_timers, &path, "cooldown timers");
|
||||
}
|
||||
}
|
||||
|
||||
/// Load equity history from file.
|
||||
fn load_equity_history(&mut self) {
|
||||
if LIVE_EQUITY_FILE.exists() {
|
||||
match std::fs::read_to_string(&*LIVE_EQUITY_FILE) {
|
||||
@@ -129,6 +189,11 @@ impl TradingBot {
|
||||
match serde_json::from_str::<Vec<EquitySnapshot>>(&content) {
|
||||
Ok(history) => {
|
||||
tracing::info!("Loaded {} equity data points.", history.len());
|
||||
// Restore peak from history
|
||||
self.peak_portfolio_value = history
|
||||
.iter()
|
||||
.map(|s| s.portfolio_value)
|
||||
.fold(0.0_f64, f64::max);
|
||||
self.equity_history = history;
|
||||
}
|
||||
Err(e) => tracing::error!("Error parsing equity history: {}", e),
|
||||
@@ -156,14 +221,55 @@ impl TradingBot {
|
||||
current_price: pos.current_price.parse().unwrap_or(0.0),
|
||||
unrealized_pnl: pos.unrealized_pl.parse().unwrap_or(0.0),
|
||||
pnl_pct: pos.unrealized_plpc.parse::<f64>().unwrap_or(0.0) * 100.0,
|
||||
change_today: pos.change_today.as_ref().and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0) * 100.0,
|
||||
change_today:
|
||||
pos.change_today.as_ref().and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0) * 100.0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let portfolio_value = account.portfolio_value.parse().unwrap_or(0.0);
|
||||
|
||||
// Update peak and drawdown halt status
|
||||
if portfolio_value > self.peak_portfolio_value {
|
||||
self.peak_portfolio_value = portfolio_value;
|
||||
}
|
||||
|
||||
let drawdown_pct = if self.peak_portfolio_value > 0.0 {
|
||||
(self.peak_portfolio_value - portfolio_value) / self.peak_portfolio_value
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// 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 {} cycles.",
|
||||
drawdown_pct * 100.0,
|
||||
MAX_DRAWDOWN_HALT * 100.0,
|
||||
DRAWDOWN_HALT_BARS
|
||||
);
|
||||
self.drawdown_halt = true;
|
||||
self.drawdown_halt_start = Some(self.trading_cycle_count);
|
||||
}
|
||||
|
||||
// Auto-resume after time-based cooldown
|
||||
if self.drawdown_halt {
|
||||
if let Some(halt_start) = self.drawdown_halt_start {
|
||||
if self.trading_cycle_count >= halt_start + DRAWDOWN_HALT_BARS {
|
||||
tracing::info!(
|
||||
"Drawdown halt expired after {} cycles. Resuming trading at {:.2}% drawdown.",
|
||||
DRAWDOWN_HALT_BARS,
|
||||
drawdown_pct * 100.0
|
||||
);
|
||||
self.drawdown_halt = false;
|
||||
self.drawdown_halt_start = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot = EquitySnapshot {
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
portfolio_value: account.portfolio_value.parse().unwrap_or(0.0),
|
||||
portfolio_value,
|
||||
cash: account.cash.parse().unwrap_or(0.0),
|
||||
buying_power: account.buying_power.parse().unwrap_or(0.0),
|
||||
positions_count: positions.len(),
|
||||
@@ -172,11 +278,12 @@ impl TradingBot {
|
||||
|
||||
self.equity_history.push(snapshot.clone());
|
||||
|
||||
// Keep last 7 trading days of equity data (4 snapshots per minute at 15s intervals).
|
||||
// Keep last 7 trading days of equity data
|
||||
const SNAPSHOTS_PER_MINUTE: usize = 4;
|
||||
const MINUTES_PER_HOUR: usize = 60;
|
||||
const DAYS_TO_KEEP: usize = 7;
|
||||
const MAX_SNAPSHOTS: usize = DAYS_TO_KEEP * HOURS_PER_DAY * MINUTES_PER_HOUR * SNAPSHOTS_PER_MINUTE;
|
||||
const MAX_SNAPSHOTS:
|
||||
usize = DAYS_TO_KEEP * HOURS_PER_DAY * MINUTES_PER_HOUR * SNAPSHOTS_PER_MINUTE;
|
||||
|
||||
if self.equity_history.len() > MAX_SNAPSHOTS {
|
||||
let start = self.equity_history.len() - MAX_SNAPSHOTS;
|
||||
@@ -198,7 +305,8 @@ impl TradingBot {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Log current account information.
|
||||
// ── Account helpers ──────────────────────────────────────────────
|
||||
|
||||
async fn log_account_info(&self) {
|
||||
match self.client.get_account().await {
|
||||
Ok(account) => {
|
||||
@@ -215,7 +323,6 @@ impl TradingBot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get position quantity for a symbol.
|
||||
async fn get_position(&self, symbol: &str) -> Option<f64> {
|
||||
match self.client.get_position(symbol).await {
|
||||
Ok(Some(pos)) => pos.qty.parse().ok(),
|
||||
@@ -227,8 +334,9 @@ impl TradingBot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate position size based on risk management.
|
||||
async fn calculate_position_size(&self, price: f64) -> u64 {
|
||||
// ── Volatility-adjusted position sizing ──────────────────────────
|
||||
|
||||
async fn calculate_position_size(&self, signal: &TradeSignal) -> u64 {
|
||||
let account = match self.client.get_account().await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
@@ -239,65 +347,42 @@ impl TradingBot {
|
||||
|
||||
let portfolio_value: f64 = account.portfolio_value.parse().unwrap_or(0.0);
|
||||
let cash: f64 = account.cash.parse().unwrap_or(0.0);
|
||||
|
||||
let max_allocation = portfolio_value * MAX_POSITION_SIZE;
|
||||
let available_funds = cash - (portfolio_value * MIN_CASH_RESERVE);
|
||||
|
||||
if available_funds <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let position_value = max_allocation.min(available_funds);
|
||||
(position_value / price).floor() as u64
|
||||
self.strategy.calculate_position_size(
|
||||
signal.current_price,
|
||||
portfolio_value,
|
||||
available_funds,
|
||||
signal,
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if stop-loss, take-profit, or trailing stop should trigger.
|
||||
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,
|
||||
};
|
||||
// ── ATR-based stop/trailing logic ────────────────────────────────
|
||||
|
||||
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;
|
||||
self.save_high_water_marks();
|
||||
}
|
||||
fn check_stop_loss_take_profit(
|
||||
&mut self,
|
||||
symbol: &str,
|
||||
current_price: f64,
|
||||
) -> Option<Signal> {
|
||||
let bars_held = self.position_meta.get(symbol).map_or(0, |m| m.bars_held);
|
||||
let signal = self
|
||||
.strategy
|
||||
.check_stop_loss_take_profit(symbol, current_price, bars_held);
|
||||
if self.strategy.high_water_marks.contains_key(symbol) {
|
||||
self.save_high_water_marks();
|
||||
}
|
||||
|
||||
// Fixed stop loss
|
||||
if pnl_pct <= -STOP_LOSS_PCT {
|
||||
tracing::warn!("{}: Stop-loss triggered at {:.2}% loss", symbol, pnl_pct * 100.0);
|
||||
return Some(Signal::StrongSell);
|
||||
}
|
||||
|
||||
// Take profit
|
||||
if pnl_pct >= TAKE_PROFIT_PCT {
|
||||
tracing::info!("{}: Take-profit triggered at {:.2}% gain", symbol, pnl_pct * 100.0);
|
||||
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 {
|
||||
tracing::info!(
|
||||
"{}: Trailing stop triggered at ${:.2} (peak: ${:.2}, stop: ${:.2})",
|
||||
symbol, current_price, high_water, trailing_stop_price
|
||||
);
|
||||
return Some(Signal::Sell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
signal
|
||||
}
|
||||
|
||||
/// Execute a buy order.
|
||||
// ── Sector concentration check ───────────────────────────────────
|
||||
|
||||
fn sector_position_count(&self, sector: &str) -> usize {
|
||||
self.strategy
|
||||
.sector_position_count(sector, self.strategy.entry_prices.keys())
|
||||
}
|
||||
|
||||
// ── Order execution ──────────────────────────────────────────────
|
||||
|
||||
async fn execute_buy(&mut self, symbol: &str, signal: &TradeSignal) -> bool {
|
||||
// Check if already holding
|
||||
if let Some(qty) = self.get_position(symbol).await {
|
||||
@@ -307,7 +392,55 @@ impl TradingBot {
|
||||
}
|
||||
}
|
||||
|
||||
let shares = self.calculate_position_size(signal.current_price).await;
|
||||
// Cooldown guard: prevent whipsaw re-entry after stop-loss
|
||||
if let Some(&cooldown_until) = self.cooldown_timers.get(symbol) {
|
||||
if self.trading_cycle_count < cooldown_until {
|
||||
tracing::info!(
|
||||
"{}: In cooldown period until cycle {} (currently {})",
|
||||
symbol,
|
||||
cooldown_until,
|
||||
self.trading_cycle_count
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Portfolio-level guards
|
||||
if self.drawdown_halt {
|
||||
tracing::info!("{}: Skipping buy — drawdown circuit breaker active", symbol);
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.strategy.entry_prices.len() >= MAX_CONCURRENT_POSITIONS {
|
||||
tracing::info!(
|
||||
"{}: Skipping buy — at max {} concurrent positions",
|
||||
symbol,
|
||||
MAX_CONCURRENT_POSITIONS
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let sector = get_sector(symbol);
|
||||
if self.sector_position_count(sector) >= MAX_SECTOR_POSITIONS {
|
||||
tracing::info!(
|
||||
"{}: Skipping buy — sector '{}' at max {} positions",
|
||||
symbol, sector, MAX_SECTOR_POSITIONS
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Gradual ramp-up: limit new positions during initial period
|
||||
if self.trading_cycle_count < RAMPUP_PERIOD_BARS && self.new_positions_this_cycle >= 1 {
|
||||
tracing::info!(
|
||||
"{}: Ramp-up period (cycle {}/{}) — already opened 1 position this cycle",
|
||||
symbol,
|
||||
self.trading_cycle_count,
|
||||
RAMPUP_PERIOD_BARS
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let shares = self.calculate_position_size(signal).await;
|
||||
if shares == 0 {
|
||||
tracing::info!("{}: Insufficient funds for purchase", symbol);
|
||||
return false;
|
||||
@@ -318,21 +451,35 @@ impl TradingBot {
|
||||
.submit_market_order(symbol, shares as f64, "buy")
|
||||
.await
|
||||
{
|
||||
Ok(_order) => {
|
||||
self.entry_prices.insert(symbol.to_string(), signal.current_price);
|
||||
self.high_water_marks.insert(symbol.to_string(), signal.current_price);
|
||||
Ok(order) => {
|
||||
// Use filled price if available, otherwise signal price
|
||||
let fill_price = order
|
||||
.filled_avg_price
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or(signal.current_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.position_meta.insert(
|
||||
symbol.to_string(),
|
||||
PositionMeta {
|
||||
bars_held: 0,
|
||||
},
|
||||
);
|
||||
|
||||
self.save_entry_prices();
|
||||
self.save_entry_atrs();
|
||||
self.save_high_water_marks();
|
||||
self.save_position_meta();
|
||||
|
||||
self.new_positions_this_cycle += 1;
|
||||
|
||||
tracing::info!(
|
||||
"BUY ORDER EXECUTED: {} - {} shares @ ~${:.2} \
|
||||
(RSI: {:.1}, MACD: {:.3}, Confidence: {:.2})",
|
||||
symbol,
|
||||
shares,
|
||||
signal.current_price,
|
||||
signal.rsi,
|
||||
signal.macd_histogram,
|
||||
signal.confidence
|
||||
"BUY ORDER EXECUTED: {} - {} shares @ ~${:.2} \n (RSI: {:.1}, MACD: {:.3}, ATR: ${:.2}, Confidence: {:.2})",
|
||||
symbol, shares, fill_price, signal.rsi, signal.macd_histogram,
|
||||
signal.atr, signal.confidence
|
||||
);
|
||||
|
||||
true
|
||||
@@ -344,8 +491,7 @@ impl TradingBot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a sell order.
|
||||
async fn execute_sell(&mut self, symbol: &str, signal: &TradeSignal) -> bool {
|
||||
async fn execute_sell(&mut self, symbol: &str, signal: &TradeSignal, was_stop_loss: bool) -> bool {
|
||||
let current_position = match self.get_position(symbol).await {
|
||||
Some(qty) if qty > 0.0 => qty,
|
||||
_ => {
|
||||
@@ -360,22 +506,36 @@ impl TradingBot {
|
||||
.await
|
||||
{
|
||||
Ok(_order) => {
|
||||
if let Some(entry) = self.entry_prices.remove(symbol) {
|
||||
if let Some(entry) = self.strategy.entry_prices.remove(symbol) {
|
||||
let pnl_pct = (signal.current_price - entry) / entry;
|
||||
tracing::info!("{}: Realized P&L: {:.2}%", symbol, pnl_pct * 100.0);
|
||||
self.save_entry_prices();
|
||||
}
|
||||
self.high_water_marks.remove(symbol);
|
||||
self.strategy.high_water_marks.remove(symbol);
|
||||
self.strategy.entry_atrs.remove(symbol);
|
||||
self.position_meta.remove(symbol);
|
||||
self.save_high_water_marks();
|
||||
self.save_entry_atrs();
|
||||
self.save_position_meta();
|
||||
|
||||
// Record cooldown if this was a stop-loss exit
|
||||
if was_stop_loss {
|
||||
self.cooldown_timers.insert(
|
||||
symbol.to_string(),
|
||||
self.trading_cycle_count + REENTRY_COOLDOWN_BARS,
|
||||
);
|
||||
self.save_cooldown_timers();
|
||||
tracing::info!(
|
||||
"{}: Stop-loss exit — cooldown until cycle {}",
|
||||
symbol,
|
||||
self.trading_cycle_count + REENTRY_COOLDOWN_BARS
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"SELL ORDER EXECUTED: {} - {} shares @ ~${:.2} \
|
||||
(RSI: {:.1}, MACD: {:.3})",
|
||||
symbol,
|
||||
current_position,
|
||||
signal.current_price,
|
||||
signal.rsi,
|
||||
signal.macd_histogram
|
||||
"SELL ORDER EXECUTED: {} - {} shares @ ~${:.2} \n (RSI: {:.1}, MACD: {:.3})",
|
||||
symbol, current_position, signal.current_price,
|
||||
signal.rsi, signal.macd_histogram
|
||||
);
|
||||
|
||||
true
|
||||
@@ -387,11 +547,14 @@ impl TradingBot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Analyze a symbol and generate trading signal (without stop-loss check).
|
||||
async fn analyze_symbol(&self, symbol: &str) -> Option<TradeSignal> {
|
||||
let min_bars = self.params.min_bars();
|
||||
// Partial exits removed: they systematically halve winning trade size
|
||||
// while losing trades remain at full size, creating unfavorable avg win/loss ratio.
|
||||
|
||||
// ── Analysis ─────────────────────────────────────────────────────
|
||||
|
||||
async fn analyze_symbol(&self, symbol: &str) -> Option<TradeSignal> {
|
||||
let min_bars = self.strategy.params.min_bars();
|
||||
|
||||
// Calculate days needed for data
|
||||
let days = if self.timeframe == Timeframe::Hourly {
|
||||
(min_bars as f64 / HOURS_PER_DAY as f64 * 1.5) as i64 + 10
|
||||
} else {
|
||||
@@ -401,7 +564,11 @@ impl TradingBot {
|
||||
let end = Utc::now();
|
||||
let start = end - Duration::days(days);
|
||||
|
||||
let bars = match self.client.get_historical_bars(symbol, self.timeframe, start, end).await {
|
||||
let bars = match self
|
||||
.client
|
||||
.get_historical_bars(symbol, self.timeframe, start, end)
|
||||
.await
|
||||
{
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!("{}: Failed to get historical data: {}", symbol, e);
|
||||
@@ -419,7 +586,7 @@ impl TradingBot {
|
||||
return None;
|
||||
}
|
||||
|
||||
let indicators = calculate_all_indicators(&bars, &self.params);
|
||||
let indicators = calculate_all_indicators(&bars, &self.strategy.params);
|
||||
|
||||
if indicators.len() < 2 {
|
||||
return None;
|
||||
@@ -435,12 +602,20 @@ impl TradingBot {
|
||||
Some(generate_signal(symbol, current, previous))
|
||||
}
|
||||
|
||||
/// Execute one complete trading cycle.
|
||||
// ── Trading cycle ────────────────────────────────────────────────
|
||||
|
||||
async fn run_trading_cycle(&mut self) {
|
||||
self.trading_cycle_count += 1;
|
||||
self.new_positions_this_cycle = 0; // Reset counter for each cycle
|
||||
tracing::info!("{}", "=".repeat(60));
|
||||
tracing::info!("Starting trading cycle...");
|
||||
tracing::info!("Starting trading cycle #{}...", self.trading_cycle_count);
|
||||
self.log_account_info().await;
|
||||
|
||||
// Increment bars_held once per trading cycle (matches backtester's per-bar increment)
|
||||
for meta in self.position_meta.values_mut() {
|
||||
meta.bars_held += 1;
|
||||
}
|
||||
|
||||
let symbols = get_all_symbols();
|
||||
|
||||
// Analyze all symbols first
|
||||
@@ -457,13 +632,13 @@ impl TradingBot {
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"{}: Signal={}, RSI={:.1}, MACD Hist={:.3}, Momentum={:.2}%, \
|
||||
Price=${:.2}, Confidence={:.2}",
|
||||
"{}: Signal={}, RSI={:.1}, MACD Hist={:.3}, Momentum={:.2}%, \n ATR=${:.2}, Price=${:.2}, Confidence={:.2}",
|
||||
signal.symbol,
|
||||
signal.signal.as_str(),
|
||||
signal.rsi,
|
||||
signal.macd_histogram,
|
||||
signal.momentum,
|
||||
signal.atr,
|
||||
signal.current_price,
|
||||
signal.confidence
|
||||
);
|
||||
@@ -474,27 +649,32 @@ impl TradingBot {
|
||||
sleep(TokioDuration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
// Phase 1: Process all sells first (free up cash before buying)
|
||||
// Phase 1: Process all sells (stop-loss, trailing stop, time exit, signals)
|
||||
for signal in &signals {
|
||||
let mut effective_signal = signal.clone();
|
||||
|
||||
// Check stop-loss/take-profit/trailing stop
|
||||
if let Some(sl_tp) = self.check_stop_loss_take_profit(&signal.symbol, signal.current_price) {
|
||||
// Check stop-loss/take-profit/trailing stop/time exit
|
||||
if let Some(sl_tp) =
|
||||
self.check_stop_loss_take_profit(&signal.symbol, signal.current_price)
|
||||
{
|
||||
effective_signal.signal = sl_tp;
|
||||
}
|
||||
|
||||
if effective_signal.signal.is_sell() {
|
||||
self.execute_sell(&signal.symbol, &effective_signal).await;
|
||||
let was_stop_loss = matches!(effective_signal.signal, Signal::StrongSell);
|
||||
self.execute_sell(&signal.symbol, &effective_signal, was_stop_loss).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Momentum ranking - only buy top N momentum stocks
|
||||
// Phase 2: Momentum ranking — only buy top N momentum stocks
|
||||
let mut ranked_signals: Vec<&TradeSignal> = signals
|
||||
.iter()
|
||||
.filter(|s| !s.momentum.is_nan())
|
||||
.collect();
|
||||
ranked_signals.sort_by(|a, b| {
|
||||
b.momentum.partial_cmp(&a.momentum).unwrap_or(std::cmp::Ordering::Equal)
|
||||
b.momentum
|
||||
.partial_cmp(&a.momentum)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
let top_momentum_symbols: std::collections::HashSet<String> = ranked_signals
|
||||
@@ -520,7 +700,8 @@ impl TradingBot {
|
||||
}
|
||||
}
|
||||
|
||||
// Save equity snapshot for dashboard
|
||||
// Save equity snapshot and persist metadata
|
||||
self.save_position_meta();
|
||||
if let Err(e) = self.save_equity_snapshot().await {
|
||||
tracing::error!("Failed to save equity snapshot: {}", e);
|
||||
}
|
||||
@@ -529,7 +710,7 @@ impl TradingBot {
|
||||
tracing::info!("{}", "=".repeat(60));
|
||||
}
|
||||
|
||||
/// Main bot loop - runs continuously during market hours.
|
||||
/// Main bot loop — runs continuously during market hours.
|
||||
pub async fn run(&mut self) -> Result<()> {
|
||||
let symbols = get_all_symbols();
|
||||
|
||||
@@ -540,17 +721,23 @@ impl TradingBot {
|
||||
tracing::info!(
|
||||
"Parameters scaled {}x (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!("Symbols: {}", symbols.join(", "));
|
||||
tracing::info!(
|
||||
"Strategy: RSI({}) + MACD({},{},{}) + Momentum",
|
||||
self.params.rsi_period,
|
||||
self.params.macd_fast,
|
||||
self.params.macd_slow,
|
||||
self.params.macd_signal
|
||||
"Strategy: RSI({}) + MACD({},{},{}) + Momentum({})",
|
||||
self.strategy.params.rsi_period,
|
||||
self.strategy.params.macd_fast,
|
||||
self.strategy.params.macd_slow,
|
||||
self.strategy.params.macd_signal,
|
||||
self.strategy.params.momentum_period
|
||||
);
|
||||
tracing::info!(
|
||||
"Risk: ATR stops ({}x), trailing ({}x after {}x gain), max {}% position, {} max positions",
|
||||
ATR_STOP_MULTIPLIER, ATR_TRAIL_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER,
|
||||
MAX_POSITION_SIZE * 100.0, MAX_CONCURRENT_POSITIONS
|
||||
);
|
||||
tracing::info!("Bot Check Interval: {} seconds", BOT_CHECK_INTERVAL_SECONDS);
|
||||
tracing::info!("{}", "=".repeat(60));
|
||||
@@ -571,7 +758,10 @@ impl TradingBot {
|
||||
Ok(next_open) => {
|
||||
let wait_seconds = (next_open - Utc::now()).num_seconds().max(0);
|
||||
tracing::info!("Market closed. Next open: {}", next_open);
|
||||
tracing::info!("Waiting {:.1} hours...", wait_seconds as f64 / 3600.0);
|
||||
tracing::info!(
|
||||
"Waiting {:.1} hours...",
|
||||
wait_seconds as f64 / 3600.0
|
||||
);
|
||||
|
||||
let sleep_time = (wait_seconds as u64).min(300).max(60);
|
||||
sleep(TokioDuration::from_secs(sleep_time)).await;
|
||||
|
||||
123
src/config.rs
123
src/config.rs
@@ -37,7 +37,7 @@ pub const MACD_FAST: usize = 12;
|
||||
pub const MACD_SLOW: usize = 26;
|
||||
pub const MACD_SIGNAL: usize = 9;
|
||||
|
||||
pub const MOMENTUM_PERIOD: usize = 5;
|
||||
pub const MOMENTUM_PERIOD: usize = 63;
|
||||
|
||||
pub const EMA_SHORT: usize = 9;
|
||||
pub const EMA_LONG: usize = 21;
|
||||
@@ -45,7 +45,7 @@ pub const EMA_TREND: usize = 50;
|
||||
|
||||
// ADX - Trend Strength
|
||||
pub const ADX_PERIOD: usize = 14;
|
||||
pub const ADX_THRESHOLD: f64 = 25.0;
|
||||
pub const ADX_THRESHOLD: f64 = 20.0;
|
||||
pub const ADX_STRONG: f64 = 35.0;
|
||||
|
||||
// Bollinger Bands
|
||||
@@ -54,8 +54,7 @@ pub const BB_STD: f64 = 2.0;
|
||||
|
||||
// ATR for volatility-based stops
|
||||
pub const ATR_PERIOD: usize = 14;
|
||||
pub const ATR_MULTIPLIER_STOP: f64 = 1.5;
|
||||
pub const ATR_MULTIPLIER_TRAIL: f64 = 2.5;
|
||||
pub const MIN_ATR_PCT: f64 = 0.005; // 0.5% floor to prevent extreme position sizing
|
||||
|
||||
// Volume filter
|
||||
pub const VOLUME_MA_PERIOD: usize = 20;
|
||||
@@ -66,11 +65,56 @@ pub const TOP_MOMENTUM_COUNT: usize = 8;
|
||||
|
||||
// Risk Management
|
||||
pub const MAX_POSITION_SIZE: f64 = 0.22;
|
||||
pub const MIN_CASH_RESERVE: f64 = 0.01;
|
||||
pub const STOP_LOSS_PCT: f64 = 0.025;
|
||||
pub const TAKE_PROFIT_PCT: f64 = 0.40;
|
||||
pub const TRAILING_STOP_ACTIVATION: f64 = 0.12;
|
||||
pub const TRAILING_STOP_DISTANCE: f64 = 0.07;
|
||||
pub const MIN_CASH_RESERVE: f64 = 0.05;
|
||||
pub const STOP_LOSS_PCT: f64 = 0.025; // fixed % fallback when no ATR
|
||||
pub const MAX_LOSS_PCT: f64 = 0.04; // hard cap: no trade loses more than 4% regardless of ATR
|
||||
pub const TRAILING_STOP_ACTIVATION: f64 = 0.08; // fixed % fallback for trailing activation
|
||||
pub const TRAILING_STOP_DISTANCE: f64 = 0.05; // fixed % fallback for trailing distance
|
||||
|
||||
// ATR-based risk management (overrides fixed % when ATR is available)
|
||||
/// Risk budget per trade as fraction of portfolio. Used with ATR for position sizing:
|
||||
/// position_value = (portfolio * RISK_PER_TRADE) / (ATR_STOP_MULTIPLIER * atr_pct).
|
||||
/// Reduced to 0.75% for hourly trading to account for more frequent trades and higher transaction costs.
|
||||
pub const RISK_PER_TRADE: f64 = 0.0075; // 0.75% of portfolio risk per trade
|
||||
/// Initial stop-loss distance in ATR multiples. At 2.5x ATR for hourly bars, provides
|
||||
/// adequate room for intraday volatility while maintaining risk control.
|
||||
/// Hourly ATR is noisier than daily, requiring wider stops to avoid premature exits.
|
||||
pub const ATR_STOP_MULTIPLIER: f64 = 2.5;
|
||||
/// Trailing stop distance in ATR multiples once activated.
|
||||
/// At 1.5x ATR (same as initial stop), we lock in gains without giving back too much.
|
||||
pub const ATR_TRAIL_MULTIPLIER: f64 = 1.5;
|
||||
/// Trailing stop activates after this many ATR of unrealized gain.
|
||||
/// At 1.5x ATR, activates once the trade has earned its risk budget.
|
||||
pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 1.5;
|
||||
|
||||
// Portfolio-level controls
|
||||
/// Max concurrent positions reduced to 5 for hourly trading to limit correlation risk
|
||||
/// with faster rebalancing and more frequent signals.
|
||||
pub const MAX_CONCURRENT_POSITIONS: usize = 5;
|
||||
pub const MAX_SECTOR_POSITIONS: usize = 2;
|
||||
pub const MAX_DRAWDOWN_HALT: f64 = 0.10; // trigger circuit breaker at 10% drawdown
|
||||
pub const DRAWDOWN_HALT_BARS: usize = 35; // halt for 35 bars (~5 trading days on hourly), then auto-resume
|
||||
|
||||
// Time-based exit
|
||||
/// Stale position exit threshold (in bars). Positions that haven't reached
|
||||
/// trailing stop activation after this many bars are closed to free capital.
|
||||
/// 30 hourly bars ~ 4.3 trading days. Gives positions enough time to work
|
||||
/// without tying up capital in dead trades indefinitely.
|
||||
pub const TIME_EXIT_BARS: usize = 30;
|
||||
|
||||
/// Re-entry cooldown period (in bars) after a stop-loss exit.
|
||||
/// Prevents whipsaw churning where a stock is sold at stop-loss then
|
||||
/// immediately re-bought on the same bar. 7 bars = 1 trading day on hourly.
|
||||
/// This single parameter prevents the majority of same-day round-trip losses.
|
||||
pub const REENTRY_COOLDOWN_BARS: usize = 7;
|
||||
|
||||
/// Gradual ramp-up period (in bars) at backtest start.
|
||||
/// Limits new positions to 1 per bar during this initial period to prevent
|
||||
/// flash-deployment of full capital. 30 bars = ~4.3 trading days on hourly.
|
||||
pub const RAMPUP_PERIOD_BARS: usize = 30;
|
||||
|
||||
// Backtester slippage
|
||||
pub const SLIPPAGE_BPS: f64 = 10.0; // 10 basis points per trade
|
||||
|
||||
// Trading intervals
|
||||
pub const BOT_CHECK_INTERVAL_SECONDS: u64 = 15;
|
||||
@@ -83,6 +127,31 @@ pub const TRADING_DAYS_PER_YEAR: usize = 252;
|
||||
// Hours per trading day (for scaling parameters)
|
||||
pub const HOURS_PER_DAY: usize = 7;
|
||||
|
||||
/// Get the sector for a given symbol.
|
||||
pub fn get_sector(symbol: &str) -> &'static str {
|
||||
if MAG7.contains(&symbol) {
|
||||
"mag7"
|
||||
} else if SEMIS.contains(&symbol) {
|
||||
"semis"
|
||||
} else if GROWTH_TECH.contains(&symbol) {
|
||||
"growth_tech"
|
||||
} else if HEALTHCARE.contains(&symbol) {
|
||||
"healthcare"
|
||||
} else if FINTECH_VOLATILE.contains(&symbol) {
|
||||
"fintech_volatile"
|
||||
} else if SP500_FINANCIALS.contains(&symbol) {
|
||||
"financials"
|
||||
} else if SP500_INDUSTRIALS.contains(&symbol) {
|
||||
"industrials"
|
||||
} else if SP500_CONSUMER.contains(&symbol) {
|
||||
"consumer"
|
||||
} else if SP500_ENERGY.contains(&symbol) {
|
||||
"energy"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicator parameters that can be scaled for different timeframes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IndicatorParams {
|
||||
@@ -119,33 +188,35 @@ impl IndicatorParams {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create parameters for hourly timeframe (scaled by HOURS_PER_DAY).
|
||||
/// Create parameters for hourly timeframe.
|
||||
/// Uses standard textbook periods appropriate for hourly bars.
|
||||
/// Research shows indicator periods work on bar counts, not calendar time.
|
||||
pub fn hourly() -> Self {
|
||||
let scale = HOURS_PER_DAY;
|
||||
Self {
|
||||
rsi_period: RSI_PERIOD * scale,
|
||||
macd_fast: MACD_FAST * scale,
|
||||
macd_slow: MACD_SLOW * scale,
|
||||
macd_signal: MACD_SIGNAL * scale,
|
||||
momentum_period: MOMENTUM_PERIOD * scale,
|
||||
ema_short: EMA_SHORT * scale,
|
||||
ema_long: EMA_LONG * scale,
|
||||
ema_trend: EMA_TREND * scale,
|
||||
adx_period: ADX_PERIOD * scale,
|
||||
bb_period: BB_PERIOD * scale,
|
||||
atr_period: ATR_PERIOD * scale,
|
||||
volume_ma_period: VOLUME_MA_PERIOD * scale,
|
||||
rsi_period: 14, // Standard RSI-14 (works on any timeframe)
|
||||
macd_fast: 12, // Standard MACD
|
||||
macd_slow: 26, // Standard MACD
|
||||
macd_signal: 9, // Standard MACD
|
||||
momentum_period: 63, // ~9 trading days on hourly (tactical momentum)
|
||||
ema_short: 20, // ~3 trading days
|
||||
ema_long: 50, // ~7 trading days
|
||||
ema_trend: 100, // ~14 trading days
|
||||
adx_period: 14, // Standard ADX-14
|
||||
bb_period: 20, // Standard BB-20
|
||||
atr_period: 14, // Standard ATR-14
|
||||
volume_ma_period: 20, // Standard 20-bar volume MA
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the minimum number of bars required for indicator calculation.
|
||||
pub fn min_bars(&self) -> usize {
|
||||
*[
|
||||
self.macd_slow,
|
||||
self.rsi_period,
|
||||
self.macd_slow + self.macd_signal, // MACD needs slow + signal periods
|
||||
self.rsi_period + 1, // RSI needs period + 1
|
||||
self.ema_trend,
|
||||
self.adx_period,
|
||||
self.adx_period * 2, // ADX needs 2x period (DI smoothing + ADX smoothing)
|
||||
self.bb_period,
|
||||
self.momentum_period,
|
||||
]
|
||||
.iter()
|
||||
.max()
|
||||
|
||||
@@ -504,7 +504,7 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
|
||||
}
|
||||
}
|
||||
|
||||
// PULLBACK ENTRY
|
||||
// PULLBACK ENTRY (buy-side)
|
||||
if trend_bullish && ema_bullish {
|
||||
if !rsi.is_nan() && rsi > RSI_PULLBACK_LOW && rsi < RSI_PULLBACK_HIGH {
|
||||
buy_score += 3.0;
|
||||
@@ -517,7 +517,20 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
|
||||
}
|
||||
}
|
||||
|
||||
// OVERSOLD/OVERBOUGHT
|
||||
// PULLBACK EXIT (sell-side symmetry — bearish trend with RSI bounce)
|
||||
if !trend_bullish && !ema_bullish {
|
||||
if !rsi.is_nan() && rsi > (100.0 - RSI_PULLBACK_HIGH) && rsi < (100.0 - RSI_PULLBACK_LOW) {
|
||||
sell_score += 3.0;
|
||||
}
|
||||
if ema_distance < 0.0 && ema_distance > -0.03 {
|
||||
sell_score += 1.5;
|
||||
}
|
||||
if bb_pct > 0.7 {
|
||||
sell_score += 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
// OVERSOLD/OVERBOUGHT (symmetrized)
|
||||
if !rsi.is_nan() {
|
||||
if rsi < RSI_OVERSOLD {
|
||||
if trend_bullish {
|
||||
@@ -526,11 +539,15 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
|
||||
buy_score += 2.0;
|
||||
}
|
||||
} else if rsi > RSI_OVERBOUGHT {
|
||||
sell_score += 3.0;
|
||||
if !trend_bullish {
|
||||
sell_score += 4.0;
|
||||
} else {
|
||||
sell_score += 2.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MACD MOMENTUM
|
||||
// MACD MOMENTUM (symmetrized)
|
||||
if macd_crossed_up {
|
||||
buy_score += 2.5;
|
||||
if strong_trend && trend_up {
|
||||
@@ -538,6 +555,9 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
|
||||
}
|
||||
} else if macd_crossed_down {
|
||||
sell_score += 2.5;
|
||||
if strong_trend && !trend_up {
|
||||
sell_score += 1.0;
|
||||
}
|
||||
} else if !macd_hist.is_nan() {
|
||||
if macd_hist > 0.0 {
|
||||
buy_score += 0.5;
|
||||
@@ -574,14 +594,12 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
|
||||
sell_score += 0.5;
|
||||
}
|
||||
|
||||
// VOLUME CONFIRMATION
|
||||
// VOLUME GATE — require minimum volume for signal to be actionable
|
||||
let has_volume = volume_ratio >= VOLUME_THRESHOLD;
|
||||
if has_volume && volume_ratio > 1.5 {
|
||||
if buy_score > sell_score {
|
||||
buy_score += 1.0;
|
||||
} else if sell_score > buy_score {
|
||||
sell_score += 1.0;
|
||||
}
|
||||
if !has_volume {
|
||||
// Dampen scores when volume is too low
|
||||
buy_score *= 0.5;
|
||||
sell_score *= 0.5;
|
||||
}
|
||||
|
||||
// DETERMINE SIGNAL
|
||||
@@ -589,7 +607,7 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
|
||||
|
||||
let signal = if total_score >= 6.0 {
|
||||
Signal::StrongBuy
|
||||
} else if total_score >= 3.5 {
|
||||
} else if total_score >= 4.5 {
|
||||
Signal::Buy
|
||||
} else if total_score <= -6.0 {
|
||||
Signal::StrongSell
|
||||
@@ -617,5 +635,7 @@ pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &Indicato
|
||||
ema_long: if ema_long.is_nan() { 0.0 } else { ema_long },
|
||||
current_price,
|
||||
confidence,
|
||||
atr: if current.atr.is_nan() { 0.0 } else { current.atr },
|
||||
atr_pct: if current.atr_pct.is_nan() { 0.0 } else { current.atr_pct },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ mod config;
|
||||
mod dashboard;
|
||||
mod indicators;
|
||||
mod paths;
|
||||
mod strategy;
|
||||
mod types;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
14
src/paths.rs
14
src/paths.rs
@@ -37,6 +37,20 @@ lazy_static! {
|
||||
path
|
||||
};
|
||||
|
||||
/// Path to the live entry ATR values JSON file.
|
||||
pub static ref LIVE_ENTRY_ATRS_FILE: PathBuf = {
|
||||
let mut path = DATA_DIR.clone();
|
||||
path.push("live_entry_atrs.json");
|
||||
path
|
||||
};
|
||||
|
||||
/// Path to the live position metadata JSON file (bars held, partial exit status).
|
||||
pub static ref LIVE_POSITION_META_FILE: PathBuf = {
|
||||
let mut path = DATA_DIR.clone();
|
||||
path.push("live_position_meta.json");
|
||||
path
|
||||
};
|
||||
|
||||
/// Path to the trading log file.
|
||||
pub static ref LOG_FILE: PathBuf = {
|
||||
let mut path = DATA_DIR.clone();
|
||||
|
||||
140
src/strategy.rs
Normal file
140
src/strategy.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
//! Common trading strategy logic used by both the live bot and the backtester.
|
||||
use std::collections::HashMap;
|
||||
use crate::config::{
|
||||
get_sector, IndicatorParams, Timeframe, ATR_STOP_MULTIPLIER,
|
||||
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, MAX_LOSS_PCT, MAX_POSITION_SIZE,
|
||||
MIN_ATR_PCT, RISK_PER_TRADE, STOP_LOSS_PCT, TIME_EXIT_BARS,
|
||||
TRAILING_STOP_ACTIVATION, TRAILING_STOP_DISTANCE,
|
||||
};
|
||||
use crate::types::{Signal, TradeSignal};
|
||||
|
||||
/// Contains the core trading strategy logic.
|
||||
pub struct Strategy {
|
||||
pub params: IndicatorParams,
|
||||
pub high_water_marks: HashMap<String, f64>,
|
||||
pub entry_atrs: HashMap<String, f64>,
|
||||
pub entry_prices: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
impl Strategy {
|
||||
pub fn new(timeframe: Timeframe) -> Self {
|
||||
Self {
|
||||
params: timeframe.params(),
|
||||
high_water_marks: HashMap::new(),
|
||||
entry_atrs: HashMap::new(),
|
||||
entry_prices: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Volatility-adjusted position sizing using ATR.
|
||||
pub fn calculate_position_size(
|
||||
&self,
|
||||
price: f64,
|
||||
portfolio_value: f64,
|
||||
available_cash: f64,
|
||||
signal: &TradeSignal,
|
||||
) -> u64 {
|
||||
if available_cash <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let position_value = if signal.atr_pct > MIN_ATR_PCT {
|
||||
let atr_stop_pct = signal.atr_pct * ATR_STOP_MULTIPLIER;
|
||||
let risk_amount = portfolio_value * RISK_PER_TRADE;
|
||||
let vol_adjusted = risk_amount / atr_stop_pct;
|
||||
// Scale by confidence
|
||||
let confidence_scale = 0.7 + 0.3 * signal.confidence;
|
||||
let sized = vol_adjusted * confidence_scale;
|
||||
sized.min(portfolio_value * MAX_POSITION_SIZE)
|
||||
} else {
|
||||
portfolio_value * MAX_POSITION_SIZE
|
||||
};
|
||||
|
||||
let position_value = position_value.min(available_cash);
|
||||
(position_value / price).floor() as u64
|
||||
}
|
||||
|
||||
/// Check if stop-loss, trailing stop, or time exit should trigger.
|
||||
pub fn check_stop_loss_take_profit(
|
||||
&mut self,
|
||||
symbol: &str,
|
||||
current_price: f64,
|
||||
bars_held: usize,
|
||||
) -> 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;
|
||||
let entry_atr = self.entry_atrs.get(symbol).copied().unwrap_or(0.0);
|
||||
|
||||
// Update high water mark
|
||||
if let Some(hwm) = self.high_water_marks.get_mut(symbol) {
|
||||
if current_price > *hwm {
|
||||
*hwm = current_price;
|
||||
}
|
||||
}
|
||||
|
||||
// Hard max-loss cap
|
||||
if pnl_pct <= -MAX_LOSS_PCT {
|
||||
return Some(Signal::StrongSell);
|
||||
}
|
||||
|
||||
// ATR-based stop loss
|
||||
if entry_atr > 0.0 {
|
||||
let atr_stop_price = entry_price - ATR_STOP_MULTIPLIER * entry_atr;
|
||||
if current_price <= atr_stop_price {
|
||||
return Some(Signal::StrongSell);
|
||||
}
|
||||
} else if pnl_pct <= -STOP_LOSS_PCT {
|
||||
return Some(Signal::StrongSell);
|
||||
}
|
||||
|
||||
// Time-based exit
|
||||
if bars_held >= TIME_EXIT_BARS {
|
||||
let activation = if entry_atr > 0.0 {
|
||||
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
|
||||
} else {
|
||||
TRAILING_STOP_ACTIVATION
|
||||
};
|
||||
if pnl_pct < activation {
|
||||
return Some(Signal::Sell);
|
||||
}
|
||||
}
|
||||
|
||||
// ATR-based trailing stop
|
||||
let activation_gain = if entry_atr > 0.0 {
|
||||
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
|
||||
} else {
|
||||
TRAILING_STOP_ACTIVATION
|
||||
};
|
||||
|
||||
if pnl_pct >= activation_gain {
|
||||
if let Some(&high_water) = self.high_water_marks.get(symbol) {
|
||||
let trail_distance = if entry_atr > 0.0 {
|
||||
ATR_TRAIL_MULTIPLIER * entry_atr
|
||||
} else {
|
||||
high_water * TRAILING_STOP_DISTANCE
|
||||
};
|
||||
let trailing_stop_price = high_water - trail_distance;
|
||||
if current_price <= trailing_stop_price {
|
||||
return Some(Signal::Sell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Count positions in a given sector.
|
||||
pub fn sector_position_count<'a, I>(&self, sector: &str, positions: I) -> usize
|
||||
where
|
||||
I: IntoIterator<Item = &'a String>,
|
||||
{
|
||||
positions
|
||||
.into_iter()
|
||||
.filter(|sym| get_sector(sym) == sector)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ pub struct TradeSignal {
|
||||
pub ema_long: f64,
|
||||
pub current_price: f64,
|
||||
pub confidence: f64,
|
||||
pub atr: f64,
|
||||
pub atr_pct: f64,
|
||||
}
|
||||
|
||||
/// Represents a completed trade for tracking.
|
||||
@@ -71,6 +73,8 @@ pub struct BacktestPosition {
|
||||
pub shares: f64,
|
||||
pub entry_price: f64,
|
||||
pub entry_time: DateTime<Utc>,
|
||||
pub entry_atr: f64,
|
||||
pub bars_held: usize,
|
||||
}
|
||||
|
||||
/// Results from a backtest run.
|
||||
|
||||
Reference in New Issue
Block a user