it works buty its not good\

This commit is contained in:
zastian-dev
2026-02-11 18:00:12 +00:00
parent c53fb1f7b5
commit 189694cc09
14 changed files with 1380 additions and 308 deletions

View File

@@ -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(&current_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(())
}
}

View File

@@ -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;

View File

@@ -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()

View File

@@ -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 },
}
}

View File

@@ -21,6 +21,7 @@ mod config;
mod dashboard;
mod indicators;
mod paths;
mod strategy;
mod types;
use anyhow::{Context, Result};

View File

@@ -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
View 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()
}
}

View File

@@ -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.