it works buty its not good\
This commit is contained in:
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;
|
||||
|
||||
Reference in New Issue
Block a user