first comit

This commit is contained in:
zastian-dev
2026-02-09 19:20:47 +00:00
commit 79625743bd
24 changed files with 8726 additions and 0 deletions

476
src/alpaca.rs Normal file
View File

@@ -0,0 +1,476 @@
//! Alpaca API client for market data and trading.
use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Utc};
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{sleep, Duration as TokioDuration};
use crate::config::Timeframe;
use crate::types::Bar;
const DATA_BASE_URL: &str = "https://data.alpaca.markets/v2";
const TRADING_BASE_URL: &str = "https://paper-api.alpaca.markets/v2";
const RATE_LIMIT_REQUESTS_PER_MINUTE: u32 = 200;
/// Alpaca API client.
pub struct AlpacaClient {
http_client: reqwest::Client,
api_key: String,
api_secret: String,
last_request_time: Arc<Mutex<std::time::Instant>>,
}
// API Response types
#[derive(Debug, Deserialize)]
struct BarsResponse {
bars: HashMap<String, Vec<AlpacaBar>>,
next_page_token: Option<String>,
}
// Single-symbol bars response (different format from multi-symbol)
#[derive(Debug, Deserialize)]
struct SingleBarsResponse {
bars: Vec<AlpacaBar>,
symbol: String,
next_page_token: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AlpacaBar {
t: DateTime<Utc>,
o: f64,
h: f64,
l: f64,
c: f64,
v: f64,
vw: Option<f64>,
}
#[derive(Debug, Deserialize)]
pub struct Account {
pub id: String,
pub status: String,
pub buying_power: String,
pub portfolio_value: String,
pub cash: String,
}
#[derive(Debug, Deserialize)]
pub struct Position {
pub symbol: String,
pub qty: String,
pub market_value: String,
pub avg_entry_price: String,
pub current_price: String,
pub unrealized_pl: String,
pub unrealized_plpc: String,
pub change_today: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct Clock {
pub is_open: bool,
pub next_open: DateTime<Utc>,
pub next_close: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
struct OrderRequest {
symbol: String,
qty: String,
side: String,
#[serde(rename = "type")]
order_type: String,
time_in_force: String,
}
#[derive(Debug, Deserialize)]
pub struct Order {
pub id: String,
pub symbol: String,
pub qty: String,
pub side: String,
pub status: String,
}
impl AlpacaClient {
/// Create a new Alpaca client.
pub fn new(api_key: String, api_secret: String) -> Result<Self> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let http_client = reqwest::Client::builder()
.default_headers(headers)
.build()
.context("Failed to create HTTP client")?;
Ok(Self {
http_client,
api_key,
api_secret,
last_request_time: Arc::new(Mutex::new(std::time::Instant::now())),
})
}
/// Enforce rate limiting.
async fn enforce_rate_limit(&self) {
let min_interval =
TokioDuration::from_secs_f64(60.0 / RATE_LIMIT_REQUESTS_PER_MINUTE as f64);
let mut last_time = self.last_request_time.lock().await;
let elapsed = last_time.elapsed();
if elapsed < min_interval {
sleep(min_interval - elapsed).await;
}
*last_time = std::time::Instant::now();
}
/// Add authentication headers to a request.
fn auth_headers(&self) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
"APCA-API-KEY-ID",
HeaderValue::from_str(&self.api_key).unwrap(),
);
headers.insert(
"APCA-API-SECRET-KEY",
HeaderValue::from_str(&self.api_secret).unwrap(),
);
headers
}
/// Fetch historical bar data for a symbol with pagination support.
pub async fn get_historical_bars(
&self,
symbol: &str,
timeframe: Timeframe,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<Vec<Bar>> {
let tf_str = match timeframe {
Timeframe::Daily => "1Day",
Timeframe::Hourly => "1Hour",
};
let mut all_bars = Vec::new();
let mut page_token: Option<String> = None;
loop {
self.enforce_rate_limit().await;
let mut url = format!(
"{}/stocks/{}/bars?timeframe={}&start={}&end={}&feed=iex&limit=10000",
DATA_BASE_URL,
symbol,
tf_str,
start.format("%Y-%m-%dT%H:%M:%SZ"),
end.format("%Y-%m-%dT%H:%M:%SZ"),
);
if let Some(ref token) = page_token {
url.push_str(&format!("&page_token={}", token));
}
let response = self
.http_client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to fetch bars")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("API error {}: {}", status, text);
}
// Single-symbol endpoint returns a different format
let data: SingleBarsResponse = response.json().await.context("Failed to parse bars response")?;
for b in &data.bars {
all_bars.push(Bar {
timestamp: b.t,
open: b.o,
high: b.h,
low: b.l,
close: b.c,
volume: b.v,
vwap: b.vw,
});
}
// Check for more pages
if let Some(token) = data.next_page_token {
if !token.is_empty() {
page_token = Some(token);
continue;
}
}
break;
}
Ok(all_bars)
}
/// Fetch historical bars for multiple symbols.
pub async fn get_multi_historical_bars(
&self,
symbols: &[&str],
timeframe: Timeframe,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<HashMap<String, Vec<Bar>>> {
self.enforce_rate_limit().await;
let tf_str = match timeframe {
Timeframe::Daily => "1Day",
Timeframe::Hourly => "1Hour",
};
let symbols_str = symbols.join(",");
let url = format!(
"{}/stocks/bars?symbols={}&timeframe={}&start={}&end={}&feed=iex&limit=10000",
DATA_BASE_URL,
symbols_str,
tf_str,
start.format("%Y-%m-%dT%H:%M:%SZ"),
end.format("%Y-%m-%dT%H:%M:%SZ"),
);
let response = self
.http_client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to fetch bars")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("API error {}: {}", status, text);
}
let data: BarsResponse = response.json().await.context("Failed to parse bars response")?;
let mut result = HashMap::new();
for (symbol, bars) in data.bars {
let converted: Vec<Bar> = bars
.iter()
.map(|b| Bar {
timestamp: b.t,
open: b.o,
high: b.h,
low: b.l,
close: b.c,
volume: b.v,
vwap: b.vw,
})
.collect();
result.insert(symbol, converted);
}
Ok(result)
}
/// Get account information.
pub async fn get_account(&self) -> Result<Account> {
self.enforce_rate_limit().await;
let url = format!("{}/account", TRADING_BASE_URL);
let response = self
.http_client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to get account")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("API error {}: {}", status, text);
}
response.json().await.context("Failed to parse account")
}
/// Get all positions.
pub async fn get_positions(&self) -> Result<Vec<Position>> {
self.enforce_rate_limit().await;
let url = format!("{}/positions", TRADING_BASE_URL);
let response = self
.http_client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to get positions")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("API error {}: {}", status, text);
}
response.json().await.context("Failed to parse positions")
}
/// Get position for a specific symbol.
pub async fn get_position(&self, symbol: &str) -> Result<Option<Position>> {
self.enforce_rate_limit().await;
let url = format!("{}/positions/{}", TRADING_BASE_URL, symbol);
let response = self
.http_client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to get position")?;
if response.status().as_u16() == 404 {
return Ok(None);
}
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("API error {}: {}", status, text);
}
let position: Position = response.json().await.context("Failed to parse position")?;
Ok(Some(position))
}
/// Get market clock.
pub async fn get_clock(&self) -> Result<Clock> {
self.enforce_rate_limit().await;
let url = format!("{}/clock", TRADING_BASE_URL);
let response = self
.http_client
.get(&url)
.headers(self.auth_headers())
.send()
.await
.context("Failed to get clock")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("API error {}: {}", status, text);
}
response.json().await.context("Failed to parse clock")
}
/// Submit a market order.
pub async fn submit_market_order(
&self,
symbol: &str,
qty: f64,
side: &str,
) -> Result<Order> {
self.enforce_rate_limit().await;
let url = format!("{}/orders", TRADING_BASE_URL);
let order_request = OrderRequest {
symbol: symbol.to_string(),
qty: qty.to_string(),
side: side.to_string(),
order_type: "market".to_string(),
time_in_force: "day".to_string(),
};
let response = self
.http_client
.post(&url)
.headers(self.auth_headers())
.json(&order_request)
.send()
.await
.context("Failed to submit order")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("API error {}: {}", status, text);
}
response.json().await.context("Failed to parse order")
}
/// Check if market is open.
pub async fn is_market_open(&self) -> Result<bool> {
let clock = self.get_clock().await?;
Ok(clock.is_open)
}
/// Get next market open time.
pub async fn get_next_market_open(&self) -> Result<DateTime<Utc>> {
let clock = self.get_clock().await?;
Ok(clock.next_open)
}
}
/// Helper to fetch bars for backtesting with proper date handling.
/// Fetches each symbol individually to avoid API limits on multi-symbol requests.
pub async fn fetch_backtest_data(
client: &AlpacaClient,
symbols: &[&str],
years: f64,
timeframe: Timeframe,
warmup_days: i64,
) -> Result<HashMap<String, Vec<Bar>>> {
let end = Utc::now();
let days = (years * 365.0) as i64 + warmup_days + 30;
let start = end - Duration::days(days);
tracing::info!(
"Fetching {:.2} years of data ({} to {})...",
years,
start.format("%Y-%m-%d"),
end.format("%Y-%m-%d")
);
let mut all_data = HashMap::new();
// Fetch each symbol individually like Python does
// The multi-symbol endpoint has a 10000 bar limit across ALL symbols
for symbol in symbols {
tracing::info!(" Fetching {}...", symbol);
match client
.get_historical_bars(symbol, timeframe, start, end)
.await
{
Ok(bars) => {
if !bars.is_empty() {
tracing::info!(" {}: {} bars loaded", symbol, bars.len());
all_data.insert(symbol.to_string(), bars);
} else {
tracing::warn!(" {}: No data", symbol);
}
}
Err(e) => {
tracing::error!(" Failed to fetch {}: {}", symbol, e);
}
}
}
Ok(all_data)
}

699
src/backtester.rs Normal file
View File

@@ -0,0 +1,699 @@
//! Backtesting engine for the trading strategy.
use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Utc};
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,
};
use crate::indicators::{calculate_all_indicators, generate_signal};
use crate::types::{
BacktestPosition, BacktestResult, EquityPoint, IndicatorRow, Signal, Trade,
};
/// Backtesting engine for the trading strategy.
pub struct Backtester {
initial_capital: f64,
cash: f64,
positions: HashMap<String, BacktestPosition>,
trades: Vec<Trade>,
equity_history: Vec<EquityPoint>,
entry_prices: HashMap<String, f64>,
high_water_marks: HashMap<String, f64>,
params: IndicatorParams,
timeframe: Timeframe,
}
impl Backtester {
/// Create a new backtester.
pub fn new(initial_capital: f64, timeframe: Timeframe) -> Self {
Self {
initial_capital,
cash: initial_capital,
positions: HashMap::new(),
trades: Vec::new(),
equity_history: Vec::new(),
entry_prices: HashMap::new(),
high_water_marks: HashMap::new(),
params: timeframe.params(),
timeframe,
}
}
/// Calculate current portfolio value.
fn get_portfolio_value(&self, prices: &HashMap<String, f64>) -> f64 {
let positions_value: f64 = self
.positions
.iter()
.map(|(symbol, pos)| pos.shares * prices.get(symbol).unwrap_or(&pos.entry_price))
.sum();
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;
}
let position_value = max_allocation.min(available_cash);
(position_value / price).floor() as u64
}
/// Execute a simulated buy order.
fn execute_buy(
&mut self,
symbol: &str,
price: f64,
timestamp: DateTime<Utc>,
portfolio_value: f64,
) -> bool {
if self.positions.contains_key(symbol) {
return false;
}
let shares = self.calculate_position_size(price, portfolio_value);
if shares == 0 {
return false;
}
let cost = shares as f64 * price;
if cost > self.cash {
return false;
}
self.cash -= cost;
self.positions.insert(
symbol.to_string(),
BacktestPosition {
symbol: symbol.to_string(),
shares: shares as f64,
entry_price: price,
entry_time: timestamp,
},
);
self.entry_prices.insert(symbol.to_string(), price);
self.high_water_marks.insert(symbol.to_string(), price);
self.trades.push(Trade {
symbol: symbol.to_string(),
side: "BUY".to_string(),
shares: shares as f64,
price,
timestamp,
pnl: 0.0,
pnl_pct: 0.0,
});
true
}
/// Execute a simulated sell order.
fn execute_sell(&mut self, symbol: &str, price: f64, timestamp: DateTime<Utc>) -> bool {
let position = match self.positions.remove(symbol) {
Some(p) => p,
None => return false,
};
let proceeds = position.shares * price;
self.cash += proceeds;
let pnl = proceeds - (position.shares * position.entry_price);
let pnl_pct = (price - position.entry_price) / position.entry_price;
self.trades.push(Trade {
symbol: symbol.to_string(),
side: "SELL".to_string(),
shares: position.shares,
price,
timestamp,
pnl,
pnl_pct,
});
self.entry_prices.remove(symbol);
self.high_water_marks.remove(symbol);
true
}
/// 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,
};
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
}
/// Run the backtest simulation.
pub async fn run(&mut self, client: &AlpacaClient, years: f64) -> Result<BacktestResult> {
let symbols = get_all_symbols();
// Calculate warmup period
let warmup_period = self.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 {
(warmup_period as f64 * 1.5) as i64
};
tracing::info!("{}", "=".repeat(70));
tracing::info!("STARTING BACKTEST");
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);
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
);
}
tracing::info!("{}", "=".repeat(70));
// Fetch historical data
let raw_data = fetch_backtest_data(
client,
&symbols.iter().map(|s| *s).collect::<Vec<_>>(),
years,
self.timeframe,
warmup_calendar_days,
)
.await?;
if raw_data.is_empty() {
anyhow::bail!("No historical data available for backtesting");
}
// 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();
if bars.len() < min_bars {
tracing::warn!(
"{}: Only {} bars, need {}. Skipping.",
symbol,
bars.len(),
min_bars
);
continue;
}
let indicators = calculate_all_indicators(bars, &self.params);
data.insert(symbol.clone(), indicators);
}
// Get common date range
let mut all_dates: BTreeMap<DateTime<Utc>, HashSet<String>> = BTreeMap::new();
for (symbol, rows) in &data {
for row in rows {
all_dates
.entry(row.timestamp)
.or_insert_with(HashSet::new)
.insert(symbol.clone());
}
}
let all_dates: Vec<DateTime<Utc>> = all_dates.keys().copied().collect();
// Calculate trading start date
let end_date = Utc::now();
let trading_start_date = end_date - Duration::days((years * 365.0) as i64);
// Filter to only trade on requested period
let trading_dates: Vec<DateTime<Utc>> = all_dates
.iter()
.filter(|&&d| d >= trading_start_date)
.copied()
.collect();
// Ensure we have enough warmup
let trading_dates = if !trading_dates.is_empty() {
let first_trading_idx = all_dates
.iter()
.position(|&d| d == trading_dates[0])
.unwrap_or(0);
if first_trading_idx < warmup_period {
trading_dates
.into_iter()
.skip(warmup_period - first_trading_idx)
.collect()
} else {
trading_dates
}
} else {
trading_dates
};
if trading_dates.is_empty() {
anyhow::bail!(
"No trading days available after warmup. \
Try a longer backtest period (at least 4 months recommended)."
);
}
tracing::info!(
"\nSimulating {} trading days (after {}-day warmup)...",
trading_dates.len(),
warmup_period
);
// Build index lookup for each symbol's data
let mut symbol_date_index: HashMap<String, HashMap<DateTime<Utc>, usize>> = HashMap::new();
for (symbol, rows) in &data {
let mut idx_map = HashMap::new();
for (i, row) in rows.iter().enumerate() {
idx_map.insert(row.timestamp, i);
}
symbol_date_index.insert(symbol.clone(), idx_map);
}
// Main simulation loop
for (day_num, current_date) in trading_dates.iter().enumerate() {
// 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)) {
let row = &rows[idx];
current_prices.insert(symbol.clone(), row.close);
if !row.momentum.is_nan() {
momentum_scores.insert(symbol.clone(), row.momentum);
}
}
}
let portfolio_value = self.get_portfolio_value(&current_prices);
// Momentum ranking: sort symbols by momentum
let mut ranked_symbols: Vec<String> = momentum_scores.keys().cloned().collect();
ranked_symbols.sort_by(|a, b| {
let ma = momentum_scores.get(a).unwrap_or(&0.0);
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();
// Process sells first (for all symbols with positions)
let position_symbols: Vec<String> = self.positions.keys().cloned().collect();
for symbol in position_symbols {
let rows = match data.get(&symbol) {
Some(r) => r,
None => continue,
};
let idx = match symbol_date_index.get(&symbol).and_then(|m| m.get(current_date)) {
Some(&i) => i,
None => continue,
};
if idx < 1 {
continue;
}
let current_row = &rows[idx];
let previous_row = &rows[idx - 1];
if current_row.rsi.is_nan() || current_row.macd.is_nan() {
continue;
}
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)
{
signal.signal = sl_tp;
}
// Execute sells
if signal.signal.is_sell() {
self.execute_sell(&symbol, signal.current_price, *current_date);
}
}
// Process buys (only for top momentum stocks)
for symbol in &ranked_symbols {
let rows = match data.get(symbol) {
Some(r) => r,
None => continue,
};
// Only buy top momentum stocks
if !top_momentum_symbols.contains(symbol) {
continue;
}
let idx = match symbol_date_index.get(symbol).and_then(|m| m.get(current_date)) {
Some(&i) => i,
None => continue,
};
if idx < 1 {
continue;
}
let current_row = &rows[idx];
let previous_row = &rows[idx - 1];
if current_row.rsi.is_nan() || current_row.macd.is_nan() {
continue;
}
let signal = generate_signal(symbol, current_row, previous_row);
// Execute buys
if signal.signal.is_buy() {
self.execute_buy(symbol, signal.current_price, *current_date, portfolio_value);
}
}
// Record equity
self.equity_history.push(EquityPoint {
date: *current_date,
portfolio_value: self.get_portfolio_value(&current_prices),
cash: self.cash,
positions_count: self.positions.len(),
});
// Progress update
if (day_num + 1) % 100 == 0 {
tracing::info!(
" Processed {}/{} days... Portfolio: ${:.2}",
day_num + 1,
trading_dates.len(),
self.equity_history.last().map(|e| e.portfolio_value).unwrap_or(0.0)
);
}
}
// Close all remaining positions at final prices
let final_date = trading_dates.last().copied().unwrap_or_else(Utc::now);
let position_symbols: Vec<String> = self.positions.keys().cloned().collect();
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);
}
}
}
// Calculate results
let result = self.calculate_results(years)?;
// Print summary
self.print_summary(&result);
Ok(result)
}
/// Calculate performance metrics from backtest.
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)."
);
}
let final_value = self.cash;
let total_return = final_value - self.initial_capital;
let total_return_pct = total_return / self.initial_capital;
// CAGR
let cagr = if years > 0.0 {
(final_value / self.initial_capital).powf(1.0 / years) - 1.0
} else {
0.0
};
// Calculate daily returns
let mut daily_returns: Vec<f64> = Vec::new();
for i in 1..self.equity_history.len() {
let prev = self.equity_history[i - 1].portfolio_value;
let curr = self.equity_history[i].portfolio_value;
if prev > 0.0 {
daily_returns.push((curr - prev) / prev);
}
}
// 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 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>()
/ excess_returns.len() as f64;
let std = variance.sqrt();
if std > 0.0 {
(mean / std) * (TRADING_DAYS_PER_YEAR as f64).sqrt()
} else {
0.0
}
} else {
0.0
};
// Sortino Ratio (downside deviation)
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_std = neg_variance.sqrt();
if neg_std > 0.0 {
(mean / neg_std) * (TRADING_DAYS_PER_YEAR as f64).sqrt()
} else {
0.0
}
} else {
0.0
};
// Maximum Drawdown
let mut max_drawdown = 0.0;
let mut max_drawdown_pct = 0.0;
let mut peak = self.initial_capital;
for point in &self.equity_history {
if point.portfolio_value > peak {
peak = point.portfolio_value;
}
let drawdown = point.portfolio_value - peak;
let drawdown_pct = drawdown / peak;
if drawdown < max_drawdown {
max_drawdown = drawdown;
max_drawdown_pct = drawdown_pct;
}
}
// 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 total_trades = sell_trades.len();
let win_count = winning_trades.len();
let lose_count = losing_trades.len();
let win_rate = if total_trades > 0 {
win_count as f64 / total_trades as f64
} else {
0.0
};
let avg_win = if !winning_trades.is_empty() {
winning_trades.iter().map(|t| t.pnl).sum::<f64>() / winning_trades.len() as f64
} else {
0.0
};
let avg_loss = if !losing_trades.is_empty() {
losing_trades.iter().map(|t| t.pnl).sum::<f64>() / losing_trades.len() as f64
} else {
0.0
};
let total_wins: f64 = winning_trades.iter().map(|t| t.pnl).sum();
let total_losses: f64 = losing_trades.iter().map(|t| t.pnl.abs()).sum();
let profit_factor = if total_losses > 0.0 {
total_wins / total_losses
} else {
f64::INFINITY
};
Ok(BacktestResult {
initial_capital: self.initial_capital,
final_value,
total_return,
total_return_pct,
cagr,
sharpe_ratio: sharpe,
sortino_ratio: sortino,
max_drawdown,
max_drawdown_pct,
total_trades,
winning_trades: win_count,
losing_trades: lose_count,
win_rate,
avg_win,
avg_loss,
profit_factor,
trades: self.trades.clone(),
equity_curve: self.equity_history.clone(),
})
}
/// Print backtest summary.
fn print_summary(&self, result: &BacktestResult) {
println!("\n");
println!("{}", "=".repeat(70));
println!("{:^70}", "BACKTEST RESULTS");
println!("{}", "=".repeat(70));
println!("\n{:^70}", "PORTFOLIO PERFORMANCE");
println!("{}", "-".repeat(70));
println!(" Initial Capital: ${:>15.2}", result.initial_capital);
println!(" Final Value: ${:>15.2}", result.final_value);
println!(
" Total Return: ${:>15.2} ({:>+.2}%)",
result.total_return,
result.total_return_pct * 100.0
);
println!(" CAGR: {:>15.2}%", result.cagr * 100.0);
println!();
println!("{:^70}", "RISK METRICS");
println!("{}", "-".repeat(70));
println!(" Sharpe Ratio: {:>15.2}", result.sharpe_ratio);
println!(" Sortino Ratio: {:>15.2}", result.sortino_ratio);
println!(
" Max Drawdown: ${:>15.2} ({:.2}%)",
result.max_drawdown,
result.max_drawdown_pct * 100.0
);
println!();
println!("{:^70}", "TRADE STATISTICS");
println!("{}", "-".repeat(70));
println!(" Total Trades: {:>15}", result.total_trades);
println!(" Winning Trades: {:>15}", result.winning_trades);
println!(" Losing Trades: {:>15}", result.losing_trades);
println!(" Win Rate: {:>15.2}%", result.win_rate * 100.0);
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!("{}", "=".repeat(70));
// Show recent trades
if !result.trades.is_empty() {
println!("\n{:^70}", "RECENT TRADES (Last 10)");
println!("{}", "-".repeat(70));
println!(
" {:12} {:8} {:6} {:8} {:12} {:12}",
"Date", "Symbol", "Side", "Shares", "Price", "P&L"
);
println!("{}", "-".repeat(70));
for trade in result.trades.iter().rev().take(10).rev() {
let date_str = trade.timestamp.format("%Y-%m-%d").to_string();
let pnl_str = if trade.side == "SELL" {
format!("${:.2}", trade.pnl)
} else {
"-".to_string()
};
println!(
" {:12} {:8} {:6} {:8.0} ${:11.2} {:12}",
date_str, trade.symbol, trade.side, trade.shares, trade.price, pnl_str
);
}
println!("{}", "=".repeat(70));
}
}
}
/// Save backtest results to CSV files.
pub fn save_backtest_results(result: &BacktestResult) -> Result<()> {
// Save equity curve
if !result.equity_curve.is_empty() {
let mut wtr = csv::Writer::from_path("backtest_equity_curve.csv")
.context("Failed to create equity curve CSV")?;
wtr.write_record(["date", "portfolio_value", "cash", "positions_count"])?;
for point in &result.equity_curve {
wtr.write_record(&[
point.date.to_rfc3339(),
point.portfolio_value.to_string(),
point.cash.to_string(),
point.positions_count.to_string(),
])?;
}
wtr.flush()?;
tracing::info!("Equity curve saved to: backtest_equity_curve.csv");
}
// Save trades
if !result.trades.is_empty() {
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"])?;
for trade in &result.trades {
wtr.write_record(&[
trade.timestamp.to_rfc3339(),
trade.symbol.clone(),
trade.side.clone(),
trade.shares.to_string(),
trade.price.to_string(),
trade.pnl.to_string(),
trade.pnl_pct.to_string(),
])?;
}
wtr.flush()?;
tracing::info!("Trades saved to: backtest_trades.csv");
}
Ok(())
}

490
src/bot.rs Normal file
View File

@@ -0,0 +1,490 @@
//! Live trading bot using Alpaca API.
use anyhow::Result;
use chrono::{Duration, Utc};
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,
};
use crate::indicators::{calculate_all_indicators, generate_signal};
use crate::paths::{LIVE_EQUITY_FILE, LIVE_POSITIONS_FILE};
use crate::types::{EquitySnapshot, PositionInfo, Signal, TradeSignal};
/// Live trading bot for paper trading.
pub struct TradingBot {
client: AlpacaClient,
params: IndicatorParams,
timeframe: Timeframe,
entry_prices: HashMap<String, f64>,
equity_history: Vec<EquitySnapshot>,
}
impl TradingBot {
/// Create a new trading bot.
pub async fn new(
api_key: String,
api_secret: String,
timeframe: Timeframe,
) -> Result<Self> {
let client = AlpacaClient::new(api_key, api_secret)?;
let mut bot = Self {
client,
params: timeframe.params(),
timeframe,
entry_prices: HashMap::new(),
equity_history: Vec::new(),
};
// Load persisted state
bot.load_entry_prices();
bot.load_equity_history();
// Log account info
bot.log_account_info().await;
tracing::info!("Trading bot initialized successfully (Paper Trading Mode)");
Ok(bot)
}
/// Load entry prices from file.
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),
}
}
}
/// 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),
}
}
/// 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) {
Ok(content) => {
if !content.is_empty() {
match serde_json::from_str::<Vec<EquitySnapshot>>(&content) {
Ok(history) => {
tracing::info!("Loaded {} equity data points.", history.len());
self.equity_history = history;
}
Err(e) => tracing::error!("Error parsing equity history: {}", e),
}
}
}
Err(e) => tracing::error!("Error loading equity history: {}", e),
}
}
}
/// Save equity snapshot.
async fn save_equity_snapshot(&mut self) -> Result<()> {
let account = self.client.get_account().await?;
let positions = self.client.get_positions().await?;
let mut positions_map = HashMap::new();
for pos in &positions {
positions_map.insert(
pos.symbol.clone(),
PositionInfo {
qty: pos.qty.parse().unwrap_or(0.0),
market_value: pos.market_value.parse().unwrap_or(0.0),
avg_entry_price: pos.avg_entry_price.parse().unwrap_or(0.0),
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,
},
);
}
let snapshot = EquitySnapshot {
timestamp: Utc::now().to_rfc3339(),
portfolio_value: account.portfolio_value.parse().unwrap_or(0.0),
cash: account.cash.parse().unwrap_or(0.0),
buying_power: account.buying_power.parse().unwrap_or(0.0),
positions_count: positions.len(),
positions: positions_map,
};
self.equity_history.push(snapshot.clone());
// Keep last 7 trading days of equity data (1 snapshot per minute).
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;
if self.equity_history.len() > MAX_SNAPSHOTS {
let start = self.equity_history.len() - MAX_SNAPSHOTS;
self.equity_history = self.equity_history[start..].to_vec();
}
// Save to file
match serde_json::to_string_pretty(&self.equity_history) {
Ok(json) => {
if let Err(e) = std::fs::write(&*LIVE_EQUITY_FILE, json) {
tracing::error!("Error saving equity history: {}", e);
}
}
Err(e) => tracing::error!("Error serializing equity history: {}", e),
}
tracing::info!("Saved equity snapshot: ${:.2}", snapshot.portfolio_value);
Ok(())
}
/// Log current account information.
async fn log_account_info(&self) {
match self.client.get_account().await {
Ok(account) => {
let portfolio_value: f64 = account.portfolio_value.parse().unwrap_or(0.0);
let buying_power: f64 = account.buying_power.parse().unwrap_or(0.0);
let cash: f64 = account.cash.parse().unwrap_or(0.0);
tracing::info!("Account Status: {}", account.status);
tracing::info!("Buying Power: ${:.2}", buying_power);
tracing::info!("Portfolio Value: ${:.2}", portfolio_value);
tracing::info!("Cash: ${:.2}", cash);
}
Err(e) => tracing::error!("Failed to get account info: {}", e),
}
}
/// 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(),
Ok(None) => None,
Err(e) => {
tracing::error!("Failed to get position for {}: {}", symbol, e);
None
}
}
}
/// Calculate position size based on risk management.
async fn calculate_position_size(&self, price: f64) -> u64 {
let account = match self.client.get_account().await {
Ok(a) => a,
Err(e) => {
tracing::error!("Failed to get account: {}", e);
return 0;
}
};
let portfolio_value: f64 = account.portfolio_value.parse().unwrap_or(0.0);
let buying_power: f64 = account.buying_power.parse().unwrap_or(0.0);
let max_allocation = portfolio_value * MAX_POSITION_SIZE;
let available_funds = buying_power - (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
}
/// Check if stop-loss or take-profit should trigger.
fn check_stop_loss_take_profit(&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;
if pnl_pct <= -STOP_LOSS_PCT {
tracing::warn!("{}: Stop-loss triggered at {:.2}% loss", symbol, pnl_pct * 100.0);
return Some(Signal::StrongSell);
}
if pnl_pct >= TAKE_PROFIT_PCT {
tracing::info!("{}: Take-profit triggered at {:.2}% gain", symbol, pnl_pct * 100.0);
return Some(Signal::Sell);
}
None
}
/// Execute a buy order.
async fn execute_buy(&mut self, symbol: &str, signal: &TradeSignal) -> bool {
// Check if already holding
if let Some(qty) = self.get_position(symbol).await {
if qty > 0.0 {
tracing::info!("{}: Already holding {} shares, skipping buy", symbol, qty);
return false;
}
}
let shares = self.calculate_position_size(signal.current_price).await;
if shares == 0 {
tracing::info!("{}: Insufficient funds for purchase", symbol);
return false;
}
match self
.client
.submit_market_order(symbol, shares as f64, "buy")
.await
{
Ok(_order) => {
self.entry_prices.insert(symbol.to_string(), signal.current_price);
self.save_entry_prices();
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
);
true
}
Err(e) => {
tracing::error!("Failed to execute buy for {}: {}", symbol, e);
false
}
}
}
/// Execute a sell order.
async fn execute_sell(&mut self, symbol: &str, signal: &TradeSignal) -> bool {
let current_position = match self.get_position(symbol).await {
Some(qty) if qty > 0.0 => qty,
_ => {
tracing::info!("{}: No position to sell", symbol);
return false;
}
};
match self
.client
.submit_market_order(symbol, current_position, "sell")
.await
{
Ok(_order) => {
if let Some(entry) = self.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();
}
tracing::info!(
"SELL ORDER EXECUTED: {} - {} shares @ ~${:.2} \
(RSI: {:.1}, MACD: {:.3})",
symbol,
current_position,
signal.current_price,
signal.rsi,
signal.macd_histogram
);
true
}
Err(e) => {
tracing::error!("Failed to execute sell for {}: {}", symbol, e);
false
}
}
}
/// Analyze a symbol and generate trading signal.
async fn analyze_symbol(&self, symbol: &str) -> Option<TradeSignal> {
let min_bars = self.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 {
(min_bars as f64 * 1.5) as i64 + 30
};
let end = Utc::now();
let start = end - Duration::days(days);
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);
return None;
}
};
if bars.len() < min_bars {
tracing::warn!(
"{}: Only {} bars, need {} for indicators",
symbol,
bars.len(),
min_bars
);
return None;
}
let indicators = calculate_all_indicators(&bars, &self.params);
if indicators.len() < 2 {
return None;
}
let current = &indicators[indicators.len() - 1];
let previous = &indicators[indicators.len() - 2];
let mut signal = generate_signal(symbol, current, previous);
// Check stop-loss/take-profit
if let Some(sl_tp) = self.check_stop_loss_take_profit(symbol, signal.current_price) {
signal.signal = sl_tp;
}
Some(signal)
}
/// Execute one complete trading cycle.
async fn run_trading_cycle(&mut self) {
tracing::info!("{}", "=".repeat(60));
tracing::info!("Starting trading cycle...");
self.log_account_info().await;
let symbols = get_all_symbols();
for symbol in symbols {
tracing::info!("\nAnalyzing {}...", symbol);
let signal = match self.analyze_symbol(symbol).await {
Some(s) => s,
None => {
tracing::warn!("{}: Analysis failed, skipping", symbol);
continue;
}
};
tracing::info!(
"{}: Signal={}, RSI={:.1}, MACD Hist={:.3}, Momentum={:.2}%, \
Price=${:.2}, Confidence={:.2}",
symbol,
signal.signal.as_str(),
signal.rsi,
signal.macd_histogram,
signal.momentum,
signal.current_price,
signal.confidence
);
if signal.signal.is_buy() {
self.execute_buy(symbol, &signal).await;
} else if signal.signal.is_sell() {
self.execute_sell(symbol, &signal).await;
} else {
tracing::info!("{}: Holding position (no action)", symbol);
}
// Small delay between symbols
sleep(TokioDuration::from_millis(500)).await;
}
// Save equity snapshot for dashboard
if let Err(e) = self.save_equity_snapshot().await {
tracing::error!("Failed to save equity snapshot: {}", e);
}
tracing::info!("Trading cycle complete");
tracing::info!("{}", "=".repeat(60));
}
/// Main bot loop - runs continuously during market hours.
pub async fn run(&mut self) -> Result<()> {
let symbols = get_all_symbols();
tracing::info!("{}", "=".repeat(60));
tracing::info!("TECH GIANTS TRADING BOT STARTED");
tracing::info!("Timeframe: {:?} bars", self.timeframe);
if self.timeframe == Timeframe::Hourly {
tracing::info!(
"Parameters scaled {}x (RSI: {}, EMA_TREND: {})",
HOURS_PER_DAY,
self.params.rsi_period,
self.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
);
tracing::info!("Bot Check Interval: {} seconds", BOT_CHECK_INTERVAL_SECONDS);
tracing::info!("{}", "=".repeat(60));
loop {
match self.client.is_market_open().await {
Ok(true) => {
self.run_trading_cycle().await;
tracing::info!(
"Next signal check in {} seconds...",
BOT_CHECK_INTERVAL_SECONDS
);
sleep(TokioDuration::from_secs(BOT_CHECK_INTERVAL_SECONDS)).await;
}
Ok(false) => {
match self.client.get_next_market_open().await {
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);
let sleep_time = (wait_seconds as u64).min(300).max(60);
sleep(TokioDuration::from_secs(sleep_time)).await;
}
Err(e) => {
tracing::error!("Failed to get next market open: {}", e);
tracing::info!("Market closed. Checking again in 5 minutes...");
sleep(TokioDuration::from_secs(300)).await;
}
}
}
Err(e) => {
tracing::error!("Failed to check market status: {}", e);
tracing::info!("Retrying in 60 seconds...");
sleep(TokioDuration::from_secs(60)).await;
}
}
}
}
}

171
src/config.rs Normal file
View File

@@ -0,0 +1,171 @@
//! Configuration constants for the trading bot.
// Stock Universe
pub const MAG7: &[&str] = &["AAPL", "MSFT", "GOOGL", "AMZN", "META", "NVDA", "TSLA"];
pub const SEMIS: &[&str] = &["AVGO", "AMD", "ASML", "QCOM", "MU"];
pub const GROWTH_TECH: &[&str] = &["NFLX", "CRM", "NOW", "UBER", "SNOW"];
pub const HEALTHCARE: &[&str] = &["LLY", "UNH", "ISRG", "VRTX", "ABBV", "MRK", "PFE"];
pub const FINTECH_VOLATILE: &[&str] = &["V", "MA", "COIN", "PLTR", "MSTR"];
pub const SP500_FINANCIALS: &[&str] = &["JPM", "GS", "MS", "BLK", "AXP", "C"];
pub const SP500_INDUSTRIALS: &[&str] = &["CAT", "GE", "HON", "BA", "RTX", "LMT", "DE"];
pub const SP500_CONSUMER: &[&str] = &["COST", "WMT", "HD", "NKE", "SBUX", "MCD", "DIS"];
pub const SP500_ENERGY: &[&str] = &["XOM", "CVX", "COP", "SLB", "OXY"];
/// Get all symbols in the trading universe (50 stocks).
pub fn get_all_symbols() -> Vec<&'static str> {
let mut symbols = Vec::new();
symbols.extend_from_slice(MAG7);
symbols.extend_from_slice(SEMIS);
symbols.extend_from_slice(GROWTH_TECH);
symbols.extend_from_slice(HEALTHCARE);
symbols.extend_from_slice(FINTECH_VOLATILE);
symbols.extend_from_slice(SP500_FINANCIALS);
symbols.extend_from_slice(SP500_INDUSTRIALS);
symbols.extend_from_slice(SP500_CONSUMER);
symbols.extend_from_slice(SP500_ENERGY);
symbols
}
// Strategy Parameters
pub const RSI_PERIOD: usize = 14;
pub const RSI_OVERSOLD: f64 = 30.0;
pub const RSI_OVERBOUGHT: f64 = 70.0;
pub const RSI_PULLBACK_LOW: f64 = 35.0;
pub const RSI_PULLBACK_HIGH: f64 = 60.0;
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 EMA_SHORT: usize = 9;
pub const EMA_LONG: usize = 21;
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_STRONG: f64 = 35.0;
// Bollinger Bands
pub const BB_PERIOD: usize = 20;
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;
// Volume filter
pub const VOLUME_MA_PERIOD: usize = 20;
pub const VOLUME_THRESHOLD: f64 = 0.8;
// Momentum Ranking
pub const TOP_MOMENTUM_COUNT: usize = 4;
// 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;
// Trading intervals
pub const BOT_CHECK_INTERVAL_SECONDS: u64 = 60;
pub const BARS_LOOKBACK: usize = 100;
// Backtest defaults
pub const DEFAULT_INITIAL_CAPITAL: f64 = 100_000.0;
pub const TRADING_DAYS_PER_YEAR: usize = 252;
// Hours per trading day (for scaling parameters)
pub const HOURS_PER_DAY: usize = 7;
/// Indicator parameters that can be scaled for different timeframes.
#[derive(Debug, Clone)]
pub struct IndicatorParams {
pub rsi_period: usize,
pub macd_fast: usize,
pub macd_slow: usize,
pub macd_signal: usize,
pub momentum_period: usize,
pub ema_short: usize,
pub ema_long: usize,
pub ema_trend: usize,
pub adx_period: usize,
pub bb_period: usize,
pub atr_period: usize,
pub volume_ma_period: usize,
}
impl IndicatorParams {
/// Create parameters for daily timeframe.
pub fn daily() -> Self {
Self {
rsi_period: RSI_PERIOD,
macd_fast: MACD_FAST,
macd_slow: MACD_SLOW,
macd_signal: MACD_SIGNAL,
momentum_period: MOMENTUM_PERIOD,
ema_short: EMA_SHORT,
ema_long: EMA_LONG,
ema_trend: EMA_TREND,
adx_period: ADX_PERIOD,
bb_period: BB_PERIOD,
atr_period: ATR_PERIOD,
volume_ma_period: VOLUME_MA_PERIOD,
}
}
/// Create parameters for hourly timeframe (scaled by HOURS_PER_DAY).
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,
}
}
/// Get the minimum number of bars required for indicator calculation.
pub fn min_bars(&self) -> usize {
*[
self.macd_slow,
self.rsi_period,
self.ema_trend,
self.adx_period,
self.bb_period,
]
.iter()
.max()
.unwrap()
+ 5
}
}
/// Timeframe for trading data.
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum Timeframe {
Daily,
Hourly,
}
impl Timeframe {
pub fn params(&self) -> IndicatorParams {
match self {
Timeframe::Daily => IndicatorParams::daily(),
Timeframe::Hourly => IndicatorParams::hourly(),
}
}
}

479
src/dashboard.rs Normal file
View File

@@ -0,0 +1,479 @@
//! Web dashboard for monitoring portfolio performance.
use axum::{
extract::State,
http::StatusCode,
response::{Html, IntoResponse, Json},
routing::get,
Router,
};
use serde::Serialize;
use std::path::Path;
use std::sync::Arc;
use tower_http::cors::CorsLayer;
use crate::alpaca::AlpacaClient;
use crate::paths::LIVE_EQUITY_FILE;
use crate::types::EquitySnapshot;
/// Shared state for the dashboard.
pub struct DashboardState {
pub client: AlpacaClient,
}
#[derive(Serialize)]
struct AccountResponse {
portfolio_value: f64,
cash: f64,
buying_power: f64,
total_pnl: f64,
position_count: usize,
}
#[derive(Serialize)]
struct EquityResponse {
dates: Vec<String>,
values: Vec<f64>,
source: String,
}
#[derive(Serialize)]
struct PositionResponse {
symbol: String,
qty: f64,
market_value: f64,
avg_entry_price: f64,
current_price: f64,
unrealized_pnl: f64,
pnl_pct: f64,
change_today: f64,
}
const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trading Bot Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 1400px; margin: 0 auto; }
h1 {
text-align: center;
margin-bottom: 30px;
font-size: 2.5rem;
background: linear-gradient(90deg, #00d4ff, #7b2cbf);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 24px;
text-align: center;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.stat-label { font-size: 0.85rem; color: #888; text-transform: uppercase; margin-bottom: 8px; }
.stat-value { font-size: 1.8rem; font-weight: 700; }
.stat-value.positive { color: #00ff88; }
.stat-value.negative { color: #ff4757; }
.chart-container {
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 24px;
margin-bottom: 30px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.chart-title { font-size: 1.3rem; margin-bottom: 20px; color: #fff; }
.positions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.position-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.position-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.position-symbol { font-size: 1.4rem; font-weight: 700; }
.position-pnl { font-size: 1.1rem; font-weight: 600; padding: 4px 12px; border-radius: 20px; }
.position-pnl.positive { background: rgba(0, 255, 136, 0.2); color: #00ff88; }
.position-pnl.negative { background: rgba(255, 71, 87, 0.2); color: #ff4757; }
.position-details { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.position-detail { padding: 8px 0; }
.position-detail-label { font-size: 0.75rem; color: #888; text-transform: uppercase; }
.position-detail-value { font-size: 1rem; font-weight: 500; margin-top: 2px; }
.loading { text-align: center; padding: 40px; color: #888; }
.refresh-btn {
position: fixed;
bottom: 30px;
right: 30px;
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
border: none;
color: white;
padding: 15px 25px;
border-radius: 50px;
font-size: 1rem;
cursor: pointer;
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.4);
transition: transform 0.2s ease;
}
.refresh-btn:hover { transform: scale(1.05); }
.last-updated { text-align: center; color: #666; font-size: 0.85rem; margin-top: 20px; }
.data-source { text-align: center; color: #888; font-size: 0.8rem; margin-top: 5px; }
</style>
</head>
<body>
<div class="container">
<h1>Trading Bot Dashboard (Rust)</h1>
<div class="stats-grid" id="stats-grid">
<div class="stat-card">
<div class="stat-label">Portfolio Value</div>
<div class="stat-value" id="stat-portfolio-value">$0.00</div>
</div>
<div class="stat-card">
<div class="stat-label">Cash</div>
<div class="stat-value" id="stat-cash">$0.00</div>
</div>
<div class="stat-card">
<div class="stat-label">Buying Power</div>
<div class="stat-value" id="stat-buying-power">$0.00</div>
</div>
<div class="stat-card">
<div class="stat-label">Today's P&L</div>
<div class="stat-value" id="stat-pnl">$0.00</div>
</div>
<div class="stat-card">
<div class="stat-label">Open Positions</div>
<div class="stat-value" id="stat-positions">0</div>
</div>
</div>
<div class="chart-container">
<h2 class="chart-title">Portfolio Performance</h2>
<canvas id="portfolioChart"></canvas>
</div>
<div class="chart-container">
<h2 class="chart-title">Current Positions</h2>
<div class="positions-grid" id="positions-grid"><div class="loading">Loading...</div></div>
</div>
<p class="last-updated" id="last-updated"></p>
<p class="data-source" id="data-source"></p>
</div>
<button class="refresh-btn" onclick="loadAllData()">Refresh</button>
<script>
let portfolioChart = null;
function formatCurrency(value, sign = false) {
const signChar = value >= 0 ? (sign ? '+' : '') : '-';
return `${signChar}$${Math.abs(value).toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
}
function updateText(elementId, value, isCurrency = false, sign = false) {
const el = document.getElementById(elementId);
if (el) el.textContent = isCurrency ? formatCurrency(value, sign) : value;
}
function updateClass(elementId, value) {
const el = document.getElementById(elementId);
if (el) {
el.classList.toggle('positive', value >= 0);
el.classList.toggle('negative', value < 0);
}
}
async function loadAccountStats() {
try {
const response = await fetch('/api/account');
const data = await response.json();
updateText('stat-portfolio-value', data.portfolio_value, true);
updateText('stat-cash', data.cash, true);
updateText('stat-buying-power', data.buying_power, true);
updateText('stat-pnl', data.total_pnl, true, true);
updateText('stat-positions', data.position_count);
updateClass('stat-pnl', data.total_pnl);
} catch (error) { console.error('Error loading account stats:', error); }
}
async function loadEquityCurve() {
try {
const response = await fetch('/api/equity');
const data = await response.json();
const sourceEl = document.getElementById('data-source');
if (data.source === 'live') {
sourceEl.innerHTML = '<span style="color: #00ff88;">Live Data</span>';
} else if (data.source === 'backtest') {
sourceEl.innerHTML = '<span style="color: #ffaa00;">Backtest Data</span>';
} else {
sourceEl.innerHTML = '<span style="color: #888;">No data</span>';
}
if (!portfolioChart) {
const ctx = document.getElementById('portfolioChart').getContext('2d');
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, 'rgba(0, 212, 255, 0.3)');
gradient.addColorStop(1, 'rgba(0, 212, 255, 0)');
portfolioChart = new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: [{
label: 'Portfolio Value', data: [], borderColor: '#00d4ff',
backgroundColor: gradient, fill: true, tension: 0.4,
pointRadius: 0, borderWidth: 2
}]},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#888', maxTicksLimit: 10 } },
y: { grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#888', callback: v => '$' + v.toLocaleString() } }
}
}
});
}
portfolioChart.data.labels = data.dates;
portfolioChart.data.datasets[0].data = data.values;
portfolioChart.update('none'); // 'none' for no animation
} catch (error) { console.error('Error loading equity curve:', error); }
}
async function loadPositions() {
try {
const response = await fetch('/api/positions');
const positions = await response.json();
const grid = document.getElementById('positions-grid');
if (positions.length === 0) {
grid.innerHTML = '<div class="loading">No open positions</div>';
return;
}
grid.innerHTML = positions.map(pos => {
const pnlClass = pos.pnl_pct >= 0 ? 'positive' : 'negative';
const pnlSign = pos.pnl_pct >= 0 ? '+' : '';
const changeClass = pos.change_today >= 0 ? 'positive' : 'negative';
const changeSign = pos.change_today >= 0 ? '+' : '';
return `<div class="position-card">
<div class="position-header">
<span class="position-symbol">${pos.symbol}</span>
<span class="position-pnl ${pnlClass}">${pnlSign}${pos.pnl_pct.toFixed(2)}%</span>
</div>
<div class="position-details">
<div class="position-detail"><div class="position-detail-label">Shares</div><div class="position-detail-value">${pos.qty}</div></div>
<div class="position-detail"><div class="position-detail-label">Market Value</div><div class="position-detail-value">${formatCurrency(pos.market_value)}</div></div>
<div class="position-detail"><div class="position-detail-label">Avg Cost</div><div class="position-detail-value">${formatCurrency(pos.avg_entry_price)}</div></div>
<div class="position-detail"><div class="position-detail-label">Current</div><div class="position-detail-value">${formatCurrency(pos.current_price)}</div></div>
<div class="position-detail"><div class="position-detail-label">P&L</div><div class="position-detail-value ${pnlClass}">${formatCurrency(pos.unrealized_pnl, true)}</div></div>
<div class="position-detail"><div class="position-detail-label">Today</div><div class="position-detail-value ${changeClass}">${changeSign}${pos.change_today.toFixed(2)}%</div></div>
</div>
</div>`;
}).join('');
} catch (error) { console.error('Error loading positions:', error); }
}
function updateTimestamp() {
document.getElementById('last-updated').textContent = 'Last updated: ' + new Date().toLocaleString();
}
async function loadAllData() {
await Promise.all([loadAccountStats(), loadEquityCurve(), loadPositions()]);
updateTimestamp();
}
// Initial load
document.addEventListener('DOMContentLoaded', () => {
loadAllData();
setInterval(loadAllData, 10000); // Refresh every 10 seconds
});
</script>
</body>
</html>"#;
async fn index() -> Html<&'static str> {
Html(HTML_TEMPLATE)
}
async fn api_account(State(state): State<Arc<DashboardState>>) -> impl IntoResponse {
match get_account_data(&state.client).await {
Ok(data) => Json(data).into_response(),
Err(e) => {
tracing::error!("Failed to get account: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(AccountResponse {
portfolio_value: 0.0,
cash: 0.0,
buying_power: 0.0,
total_pnl: 0.0,
position_count: 0,
}),
)
.into_response()
}
}
}
async fn get_account_data(client: &AlpacaClient) -> anyhow::Result<AccountResponse> {
let account = client.get_account().await?;
let positions = client.get_positions().await?;
let total_pnl: f64 = positions
.iter()
.filter_map(|p| p.unrealized_pl.parse::<f64>().ok())
.sum();
Ok(AccountResponse {
portfolio_value: account.portfolio_value.parse().unwrap_or(0.0),
cash: account.cash.parse().unwrap_or(0.0),
buying_power: account.buying_power.parse().unwrap_or(0.0),
total_pnl,
position_count: positions.len(),
})
}
async fn api_equity() -> Json<EquityResponse> {
// Try live equity data first
if LIVE_EQUITY_FILE.exists() {
if let Ok(content) = std::fs::read_to_string(&*LIVE_EQUITY_FILE) {
if let Ok(data) = serde_json::from_str::<Vec<EquitySnapshot>>(&content) {
if !data.is_empty() {
const MAX_DATAPOINTS_TO_SHOW: usize = 240; // 4 hours of data (1 per minute)
let start_index = if data.len() > MAX_DATAPOINTS_TO_SHOW {
data.len() - MAX_DATAPOINTS_TO_SHOW
} else {
0
};
let data_slice = &data[start_index..];
let dates: Vec<String> = data_slice
.iter()
.map(|s| {
if s.timestamp.len() >= 16 {
s.timestamp[5..16].replace("T", " ")
} else {
s.timestamp.clone()
}
})
.collect();
let values: Vec<f64> = data_slice.iter().map(|s| s.portfolio_value).collect();
return Json(EquityResponse {
dates,
values,
source: "live".to_string(),
});
}
}
}
}
// Fall back to backtest data
if Path::new("backtest_equity_curve.csv").exists() {
if let Ok(mut rdr) = csv::Reader::from_path("backtest_equity_curve.csv") {
let mut dates = Vec::new();
let mut values = Vec::new();
for result in rdr.records() {
if let Ok(record) = result {
if let Some(date) = record.get(0) {
let formatted = if date.len() >= 16 {
date[5..16].replace("T", " ")
} else {
date.to_string()
};
dates.push(formatted);
}
if let Some(value) = record.get(1) {
if let Ok(v) = value.parse::<f64>() {
values.push(v);
}
}
}
}
if !dates.is_empty() {
return Json(EquityResponse {
dates,
values,
source: "backtest".to_string(),
});
}
}
}
Json(EquityResponse {
dates: vec![],
values: vec![],
source: "none".to_string(),
})
}
async fn api_positions(State(state): State<Arc<DashboardState>>) -> impl IntoResponse {
match state.client.get_positions().await {
Ok(positions) => {
let mut result: Vec<PositionResponse> = positions
.iter()
.map(|p| PositionResponse {
symbol: p.symbol.clone(),
qty: p.qty.parse().unwrap_or(0.0),
market_value: p.market_value.parse().unwrap_or(0.0),
avg_entry_price: p.avg_entry_price.parse().unwrap_or(0.0),
current_price: p.current_price.parse().unwrap_or(0.0),
unrealized_pnl: p.unrealized_pl.parse().unwrap_or(0.0),
pnl_pct: p.unrealized_plpc.parse::<f64>().unwrap_or(0.0) * 100.0,
change_today: p
.change_today
.as_ref()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0)
* 100.0,
})
.collect();
result.sort_by(|a, b| b.market_value.partial_cmp(&a.market_value).unwrap());
Json(result).into_response()
}
Err(e) => {
tracing::error!("Failed to get positions: {}", e);
Json(Vec::<PositionResponse>::new()).into_response()
}
}
}
/// Start the dashboard web server.
pub async fn start_dashboard(client: AlpacaClient, port: u16) -> anyhow::Result<()> {
let state = Arc::new(DashboardState { client });
let app = Router::new()
.route("/", get(index))
.route("/api/account", get(api_account))
.route("/api/equity", get(api_equity))
.route("/api/positions", get(api_positions))
.layer(CorsLayer::permissive())
.with_state(state);
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
tracing::info!("Dashboard running on http://localhost:{}", port);
axum::serve(listener, app).await?;
Ok(())
}

621
src/indicators.rs Normal file
View File

@@ -0,0 +1,621 @@
//! Technical indicator calculations.
use crate::config::{
IndicatorParams, ADX_STRONG, ADX_THRESHOLD, BB_STD, RSI_OVERBOUGHT, RSI_OVERSOLD,
RSI_PULLBACK_HIGH, RSI_PULLBACK_LOW, VOLUME_THRESHOLD,
};
use crate::types::{Bar, IndicatorRow, Signal, TradeSignal};
/// Calculate Exponential Moving Average (EMA).
pub fn calculate_ema(data: &[f64], period: usize) -> Vec<f64> {
if data.is_empty() || period == 0 {
return vec![];
}
let mut ema = vec![f64::NAN; data.len()];
let multiplier = 2.0 / (period as f64 + 1.0);
// Start with SMA for the first period values
if data.len() >= period {
let sma: f64 = data[..period].iter().sum::<f64>() / period as f64;
ema[period - 1] = sma;
for i in period..data.len() {
ema[i] = (data[i] - ema[i - 1]) * multiplier + ema[i - 1];
}
}
ema
}
/// Calculate Simple Moving Average (SMA).
pub fn calculate_sma(data: &[f64], period: usize) -> Vec<f64> {
if data.is_empty() || period == 0 {
return vec![];
}
let mut sma = vec![f64::NAN; data.len()];
for i in (period - 1)..data.len() {
let sum: f64 = data[(i + 1 - period)..=i].iter().sum();
sma[i] = sum / period as f64;
}
sma
}
/// Calculate standard deviation over a rolling window.
pub fn calculate_rolling_std(data: &[f64], period: usize) -> Vec<f64> {
if data.is_empty() || period == 0 {
return vec![];
}
let mut std = vec![f64::NAN; data.len()];
for i in (period - 1)..data.len() {
let window = &data[(i + 1 - period)..=i];
let mean: f64 = window.iter().sum::<f64>() / period as f64;
let variance: f64 = window.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
std[i] = variance.sqrt();
}
std
}
/// Calculate Relative Strength Index (RSI).
pub fn calculate_rsi(closes: &[f64], period: usize) -> Vec<f64> {
if closes.len() < 2 || period == 0 {
return vec![f64::NAN; closes.len()];
}
let mut rsi = vec![f64::NAN; closes.len()];
// Calculate price changes
let mut gains = vec![0.0; closes.len()];
let mut losses = vec![0.0; closes.len()];
for i in 1..closes.len() {
let change = closes[i] - closes[i - 1];
if change > 0.0 {
gains[i] = change;
} else {
losses[i] = -change;
}
}
// Calculate initial average gain/loss
if closes.len() > period {
let mut avg_gain: f64 = gains[1..=period].iter().sum::<f64>() / period as f64;
let mut avg_loss: f64 = losses[1..=period].iter().sum::<f64>() / period as f64;
if avg_loss == 0.0 {
rsi[period] = 100.0;
} else {
let rs = avg_gain / avg_loss;
rsi[period] = 100.0 - (100.0 / (1.0 + rs));
}
// Smoothed RSI calculation
for i in (period + 1)..closes.len() {
avg_gain = (avg_gain * (period - 1) as f64 + gains[i]) / period as f64;
avg_loss = (avg_loss * (period - 1) as f64 + losses[i]) / period as f64;
if avg_loss == 0.0 {
rsi[i] = 100.0;
} else {
let rs = avg_gain / avg_loss;
rsi[i] = 100.0 - (100.0 / (1.0 + rs));
}
}
}
rsi
}
/// Calculate MACD (Moving Average Convergence Divergence).
pub fn calculate_macd(
closes: &[f64],
fast_period: usize,
slow_period: usize,
signal_period: usize,
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let fast_ema = calculate_ema(closes, fast_period);
let slow_ema = calculate_ema(closes, slow_period);
let mut macd_line = vec![f64::NAN; closes.len()];
for i in 0..closes.len() {
if !fast_ema[i].is_nan() && !slow_ema[i].is_nan() {
macd_line[i] = fast_ema[i] - slow_ema[i];
}
}
// Calculate signal line from MACD line (excluding NaN values)
let valid_macd: Vec<f64> = macd_line.iter().copied().filter(|x| !x.is_nan()).collect();
let signal_ema = calculate_ema(&valid_macd, signal_period);
// Map signal EMA back to original indices
let mut signal_line = vec![f64::NAN; closes.len()];
let mut valid_idx = 0;
for i in 0..closes.len() {
if !macd_line[i].is_nan() {
if valid_idx < signal_ema.len() {
signal_line[i] = signal_ema[valid_idx];
}
valid_idx += 1;
}
}
// Calculate histogram
let mut histogram = vec![f64::NAN; closes.len()];
for i in 0..closes.len() {
if !macd_line[i].is_nan() && !signal_line[i].is_nan() {
histogram[i] = macd_line[i] - signal_line[i];
}
}
(macd_line, signal_line, histogram)
}
/// Calculate Rate of Change (Momentum).
pub fn calculate_roc(closes: &[f64], period: usize) -> Vec<f64> {
if closes.is_empty() || period == 0 {
return vec![];
}
let mut roc = vec![f64::NAN; closes.len()];
for i in period..closes.len() {
if closes[i - period] != 0.0 {
roc[i] = ((closes[i] - closes[i - period]) / closes[i - period]) * 100.0;
}
}
roc
}
/// Calculate True Range.
fn calculate_true_range(highs: &[f64], lows: &[f64], closes: &[f64]) -> Vec<f64> {
let mut tr = vec![f64::NAN; highs.len()];
if !highs.is_empty() {
tr[0] = highs[0] - lows[0];
}
for i in 1..highs.len() {
let hl = highs[i] - lows[i];
let hc = (highs[i] - closes[i - 1]).abs();
let lc = (lows[i] - closes[i - 1]).abs();
tr[i] = hl.max(hc).max(lc);
}
tr
}
/// Calculate Average True Range (ATR).
pub fn calculate_atr(highs: &[f64], lows: &[f64], closes: &[f64], period: usize) -> Vec<f64> {
let tr = calculate_true_range(highs, lows, closes);
// Use Wilder's smoothing (similar to EMA but with different multiplier)
let mut atr = vec![f64::NAN; tr.len()];
if tr.len() >= period {
// First ATR is simple average
let first_atr: f64 = tr[..period].iter().filter(|x| !x.is_nan()).sum::<f64>() / period as f64;
atr[period - 1] = first_atr;
// Subsequent ATR values use smoothing
for i in period..tr.len() {
atr[i] = (atr[i - 1] * (period - 1) as f64 + tr[i]) / period as f64;
}
}
atr
}
/// Calculate ADX (Average Directional Index) along with DI+ and DI-.
pub fn calculate_adx(
highs: &[f64],
lows: &[f64],
closes: &[f64],
period: usize,
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let len = highs.len();
if len < 2 {
return (vec![f64::NAN; len], vec![f64::NAN; len], vec![f64::NAN; len]);
}
let mut plus_dm = vec![0.0; len];
let mut minus_dm = vec![0.0; len];
// Calculate +DM and -DM
for i in 1..len {
let up_move = highs[i] - highs[i - 1];
let down_move = lows[i - 1] - lows[i];
if up_move > down_move && up_move > 0.0 {
plus_dm[i] = up_move;
}
if down_move > up_move && down_move > 0.0 {
minus_dm[i] = down_move;
}
}
let tr = calculate_true_range(highs, lows, closes);
// Smooth the values using Wilder's smoothing
let mut smoothed_plus_dm = vec![f64::NAN; len];
let mut smoothed_minus_dm = vec![f64::NAN; len];
let mut smoothed_tr = vec![f64::NAN; len];
if len >= period {
smoothed_plus_dm[period - 1] = plus_dm[..period].iter().sum();
smoothed_minus_dm[period - 1] = minus_dm[..period].iter().sum();
smoothed_tr[period - 1] = tr[..period].iter().filter(|x| !x.is_nan()).sum();
for i in period..len {
smoothed_plus_dm[i] =
smoothed_plus_dm[i - 1] - (smoothed_plus_dm[i - 1] / period as f64) + plus_dm[i];
smoothed_minus_dm[i] =
smoothed_minus_dm[i - 1] - (smoothed_minus_dm[i - 1] / period as f64) + minus_dm[i];
smoothed_tr[i] =
smoothed_tr[i - 1] - (smoothed_tr[i - 1] / period as f64) + tr[i];
}
}
// Calculate DI+ and DI-
let mut di_plus = vec![f64::NAN; len];
let mut di_minus = vec![f64::NAN; len];
for i in 0..len {
if !smoothed_tr[i].is_nan() && smoothed_tr[i] != 0.0 {
di_plus[i] = (smoothed_plus_dm[i] / smoothed_tr[i]) * 100.0;
di_minus[i] = (smoothed_minus_dm[i] / smoothed_tr[i]) * 100.0;
}
}
// Calculate DX
let mut dx = vec![f64::NAN; len];
for i in 0..len {
if !di_plus[i].is_nan() && !di_minus[i].is_nan() {
let di_sum = di_plus[i] + di_minus[i];
if di_sum != 0.0 {
dx[i] = ((di_plus[i] - di_minus[i]).abs() / di_sum) * 100.0;
}
}
}
// Calculate ADX (smoothed DX)
let mut adx = vec![f64::NAN; len];
let adx_start = period * 2 - 1;
if len > adx_start {
// First ADX is simple average of DX
let first_adx: f64 = dx[(period - 1)..adx_start]
.iter()
.filter(|x| !x.is_nan())
.sum::<f64>()
/ period as f64;
adx[adx_start - 1] = first_adx;
for i in adx_start..len {
if !dx[i].is_nan() {
adx[i] = (adx[i - 1] * (period - 1) as f64 + dx[i]) / period as f64;
}
}
}
(adx, di_plus, di_minus)
}
/// Calculate Bollinger Bands.
pub fn calculate_bollinger_bands(
closes: &[f64],
period: usize,
std_dev: f64,
) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
let middle = calculate_sma(closes, period);
let std = calculate_rolling_std(closes, period);
let mut upper = vec![f64::NAN; closes.len()];
let mut lower = vec![f64::NAN; closes.len()];
let mut pct_b = vec![f64::NAN; closes.len()];
for i in 0..closes.len() {
if !middle[i].is_nan() && !std[i].is_nan() {
upper[i] = middle[i] + std_dev * std[i];
lower[i] = middle[i] - std_dev * std[i];
let band_width = upper[i] - lower[i];
if band_width != 0.0 {
pct_b[i] = (closes[i] - lower[i]) / band_width;
}
}
}
(upper, middle, lower, pct_b)
}
/// Calculate all technical indicators for a series of bars.
pub fn calculate_all_indicators(bars: &[Bar], params: &IndicatorParams) -> Vec<IndicatorRow> {
if bars.is_empty() {
return vec![];
}
let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
let highs: Vec<f64> = bars.iter().map(|b| b.high).collect();
let lows: Vec<f64> = bars.iter().map(|b| b.low).collect();
let volumes: Vec<f64> = bars.iter().map(|b| b.volume).collect();
// Calculate all indicators
let rsi = calculate_rsi(&closes, params.rsi_period);
let (macd, macd_signal, macd_histogram) =
calculate_macd(&closes, params.macd_fast, params.macd_slow, params.macd_signal);
let momentum = calculate_roc(&closes, params.momentum_period);
let ema_short = calculate_ema(&closes, params.ema_short);
let ema_long = calculate_ema(&closes, params.ema_long);
let ema_trend = calculate_ema(&closes, params.ema_trend);
let atr = calculate_atr(&highs, &lows, &closes, params.atr_period);
let (adx, di_plus, di_minus) = calculate_adx(&highs, &lows, &closes, params.adx_period);
let (bb_upper, bb_middle, bb_lower, bb_pct) =
calculate_bollinger_bands(&closes, params.bb_period, BB_STD);
let volume_ma = calculate_sma(&volumes, params.volume_ma_period);
// Build indicator rows
let mut rows = Vec::with_capacity(bars.len());
for i in 0..bars.len() {
let bar = &bars[i];
let vol_ratio = if !volume_ma[i].is_nan() && volume_ma[i] != 0.0 {
bar.volume / volume_ma[i]
} else {
f64::NAN
};
let ema_dist = if !ema_trend[i].is_nan() && ema_trend[i] != 0.0 {
(bar.close - ema_trend[i]) / ema_trend[i]
} else {
f64::NAN
};
let atr_pct_val = if !atr[i].is_nan() && bar.close != 0.0 {
atr[i] / bar.close
} else {
f64::NAN
};
rows.push(IndicatorRow {
timestamp: bar.timestamp,
open: bar.open,
high: bar.high,
low: bar.low,
close: bar.close,
volume: bar.volume,
rsi: rsi[i],
macd: macd[i],
macd_signal: macd_signal[i],
macd_histogram: macd_histogram[i],
momentum: momentum[i],
ema_short: ema_short[i],
ema_long: ema_long[i],
ema_trend: ema_trend[i],
ema_bullish: !ema_short[i].is_nan()
&& !ema_long[i].is_nan()
&& ema_short[i] > ema_long[i],
trend_bullish: !ema_trend[i].is_nan() && bar.close > ema_trend[i],
atr: atr[i],
atr_pct: atr_pct_val,
adx: adx[i],
di_plus: di_plus[i],
di_minus: di_minus[i],
bb_upper: bb_upper[i],
bb_middle: bb_middle[i],
bb_lower: bb_lower[i],
bb_pct: bb_pct[i],
volume_ma: volume_ma[i],
volume_ratio: vol_ratio,
ema_distance: ema_dist,
});
}
rows
}
/// Generate trading signal from current and previous indicator rows.
pub fn generate_signal(symbol: &str, current: &IndicatorRow, previous: &IndicatorRow) -> TradeSignal {
let rsi = current.rsi;
let macd = current.macd;
let macd_signal_val = current.macd_signal;
let macd_hist = current.macd_histogram;
let momentum = current.momentum;
let ema_short = current.ema_short;
let ema_long = current.ema_long;
let current_price = current.close;
// Advanced indicators
let trend_bullish = current.trend_bullish;
let volume_ratio = if current.volume_ratio.is_nan() {
1.0
} else {
current.volume_ratio
};
let adx = if current.adx.is_nan() { 25.0 } else { current.adx };
let di_plus = if current.di_plus.is_nan() {
25.0
} else {
current.di_plus
};
let di_minus = if current.di_minus.is_nan() {
25.0
} else {
current.di_minus
};
let bb_pct = if current.bb_pct.is_nan() {
0.5
} else {
current.bb_pct
};
let ema_distance = if current.ema_distance.is_nan() {
0.0
} else {
current.ema_distance
};
// MACD crossover detection
let macd_crossed_up = !previous.macd.is_nan()
&& !previous.macd_signal.is_nan()
&& !macd.is_nan()
&& !macd_signal_val.is_nan()
&& previous.macd < previous.macd_signal
&& macd > macd_signal_val;
let macd_crossed_down = !previous.macd.is_nan()
&& !previous.macd_signal.is_nan()
&& !macd.is_nan()
&& !macd_signal_val.is_nan()
&& previous.macd > previous.macd_signal
&& macd < macd_signal_val;
// EMA trend
let ema_bullish = !ema_short.is_nan() && !ema_long.is_nan() && ema_short > ema_long;
// ADX trend strength
let is_trending = adx > ADX_THRESHOLD;
let strong_trend = adx > ADX_STRONG;
let trend_up = di_plus > di_minus;
// Calculate scores
let mut buy_score: f64 = 0.0;
let mut sell_score: f64 = 0.0;
// TREND STRENGTH FILTER
if is_trending {
if trend_up && trend_bullish {
buy_score += 3.0;
} else if !trend_up && !trend_bullish {
sell_score += 3.0;
}
} else {
// Ranging market - use mean reversion
if bb_pct < 0.1 {
buy_score += 2.0;
} else if bb_pct > 0.9 {
sell_score += 2.0;
}
}
// PULLBACK ENTRY
if trend_bullish && ema_bullish {
if !rsi.is_nan() && rsi > RSI_PULLBACK_LOW && rsi < RSI_PULLBACK_HIGH {
buy_score += 3.0;
}
if ema_distance > 0.0 && ema_distance < 0.03 {
buy_score += 1.5;
}
if bb_pct < 0.3 {
buy_score += 2.0;
}
}
// OVERSOLD/OVERBOUGHT
if !rsi.is_nan() {
if rsi < RSI_OVERSOLD {
if trend_bullish {
buy_score += 4.0;
} else {
buy_score += 2.0;
}
} else if rsi > RSI_OVERBOUGHT {
sell_score += 3.0;
}
}
// MACD MOMENTUM
if macd_crossed_up {
buy_score += 2.5;
if strong_trend && trend_up {
buy_score += 1.0;
}
} else if macd_crossed_down {
sell_score += 2.5;
} else if !macd_hist.is_nan() {
if macd_hist > 0.0 {
buy_score += 0.5;
} else if macd_hist < 0.0 {
sell_score += 0.5;
}
}
// MOMENTUM
if !momentum.is_nan() {
if momentum > 5.0 {
buy_score += 2.0;
} else if momentum > 2.0 {
buy_score += 1.0;
} else if momentum < -5.0 {
sell_score += 2.0;
} else if momentum < -2.0 {
sell_score += 1.0;
}
}
// EMA CROSSOVER
let prev_ema_bullish = !previous.ema_short.is_nan()
&& !previous.ema_long.is_nan()
&& previous.ema_short > previous.ema_long;
if ema_bullish && !prev_ema_bullish {
buy_score += 2.0;
} else if !ema_bullish && prev_ema_bullish {
sell_score += 2.0;
} else if ema_bullish {
buy_score += 0.5;
} else {
sell_score += 0.5;
}
// VOLUME CONFIRMATION
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;
}
}
// DETERMINE SIGNAL
let total_score = buy_score - sell_score;
let signal = if total_score >= 6.0 {
Signal::StrongBuy
} else if total_score >= 3.5 {
Signal::Buy
} else if total_score <= -6.0 {
Signal::StrongSell
} else if total_score <= -3.5 {
Signal::Sell
} else {
Signal::Hold
};
let confidence = (total_score.abs() / 10.0).min(1.0);
TradeSignal {
symbol: symbol.to_string(),
signal,
rsi: if rsi.is_nan() { 0.0 } else { rsi },
macd: if macd.is_nan() { 0.0 } else { macd },
macd_signal: if macd_signal_val.is_nan() {
0.0
} else {
macd_signal_val
},
macd_histogram: if macd_hist.is_nan() { 0.0 } else { macd_hist },
momentum: if momentum.is_nan() { 0.0 } else { momentum },
ema_short: if ema_short.is_nan() { 0.0 } else { ema_short },
ema_long: if ema_long.is_nan() { 0.0 } else { ema_long },
current_price,
confidence,
}
}

207
src/main.rs Normal file
View File

@@ -0,0 +1,207 @@
//! Tech Giants Trading Bot - Algorithmic trading system using Alpaca API.
//!
//! A momentum + mean-reversion hybrid strategy for 50 stocks across multiple sectors.
//! Uses RSI, MACD, EMA, ADX, Bollinger Bands, and momentum indicators for trade signals.
//!
//! # Usage
//!
//! ```bash
//! # Live paper trading
//! invest-bot
//!
//! # Backtest with historical data
//! invest-bot --backtest --years 3
//! invest-bot --backtest --years 5 --capital 50000
//! ```
mod alpaca;
mod backtester;
mod bot;
mod config;
mod dashboard;
mod indicators;
mod paths;
mod types;
use anyhow::{Context, Result};
use clap::Parser;
use std::env;
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use crate::alpaca::AlpacaClient;
use crate::backtester::{save_backtest_results, Backtester};
use crate::bot::TradingBot;
use crate::config::{Timeframe, DEFAULT_INITIAL_CAPITAL};
/// Vibe Invest - Trade with Alpaca
#[derive(Parser, Debug)]
#[command(name = "vibe-invest")]
#[command(author = "Vibe Invest Team")]
#[command(version = "0.1.0")]
#[command(about = "Tech Giants Trading Bot - Algorithmic trading system using Alpaca API")]
#[command(
long_about = "A momentum + mean-reversion hybrid strategy for 50 stocks across multiple sectors.\n\
Uses RSI, MACD, EMA, ADX, Bollinger Bands, and momentum indicators for trade signals.\n\n\
Examples:\n \
Live trading: invest-bot\n \
Live daily bars: invest-bot --timeframe daily\n \
Backtest 3 years: invest-bot --backtest --years 3\n \
Backtest 6 months: invest-bot --backtest --months 6\n \
Backtest 1y 6m: invest-bot --backtest --years 1 --months 6\n \
Custom capital: invest-bot --backtest --years 5 --capital 50000\n \
Hourly backtest: invest-bot --backtest --years 1 --timeframe hourly"
)]
struct Args {
/// Run in backtest mode instead of live trading
#[arg(short, long)]
backtest: bool,
/// Number of years to backtest (default: 1 if --months not specified)
#[arg(short, long, default_value_t = 0.0)]
years: f64,
/// Number of months to backtest (can combine with --years)
#[arg(short, long, default_value_t = 0.0)]
months: f64,
/// Initial capital for backtesting
#[arg(short, long, default_value_t = DEFAULT_INITIAL_CAPITAL)]
capital: f64,
/// Timeframe for bars
#[arg(short, long, value_enum, default_value_t = Timeframe::Daily)]
timeframe: Timeframe,
}
fn setup_logging(backtest_mode: bool) {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info"));
if backtest_mode {
// Console only for backtest
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.init();
} else {
// Console + file for live trading
let log_path = &paths::LOG_FILE;
let log_dir = log_path.parent().unwrap();
let log_file_name = log_path.file_name().unwrap();
let file_appender = tracing_appender::rolling::never(log_dir, log_file_name);
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.with(fmt::layer().with_writer(non_blocking).with_ansi(false))
.init();
}
}
fn print_banner(backtest: bool) {
if backtest {
println!(
r#"
+---------------------------------------------------------+
| TECH GIANTS TRADING BOT - BACKTEST MODE |
+---------------------------------------------------------+
| Symbols: AAPL, MSFT, GOOGL, AMZN, META, NVDA, TSLA |
| Strategy: Momentum + RSI Mean Reversion + MACD |
+---------------------------------------------------------+
"#
);
} else {
let dashboard_port = env::var("DASHBOARD_PORT").unwrap_or_else(|_| "5000".to_string());
println!(
r#"
+---------------------------------------------------------+
| TECH GIANTS TRADING BOT - PAPER TRADING |
+---------------------------------------------------------+
| Symbols: AAPL, MSFT, GOOGL, AMZN, META, NVDA, TSLA |
| Strategy: Momentum + RSI Mean Reversion + MACD |
| Mode: Alpaca Paper Trading |
| Dashboard: http://localhost:{:<29}|
+---------------------------------------------------------+
"#,
dashboard_port
);
}
}
fn get_credentials() -> Result<(String, String)> {
let api_key = env::var("ALPACA_API_KEY").context(
"Missing ALPACA_API_KEY. Please set it in your .env file or environment.",
)?;
let api_secret = env::var("ALPACA_SECRET_KEY").context(
"Missing ALPACA_SECRET_KEY. Please set it in your .env file or environment.",
)?;
Ok((api_key, api_secret))
}
#[tokio::main]
async fn main() -> Result<()> {
// Load .env file if present
dotenvy::dotenv().ok();
let args = Args::parse();
setup_logging(args.backtest);
print_banner(args.backtest);
let (api_key, api_secret) = match get_credentials() {
Ok(creds) => creds,
Err(e) => {
eprintln!("\nConfiguration Error: {}", e);
eprintln!("\nPlease create a .env file with your Alpaca credentials:");
eprintln!("ALPACA_API_KEY=your_api_key");
eprintln!("ALPACA_SECRET_KEY=your_secret_key");
eprintln!("\nGet your free paper trading API keys at: https://alpaca.markets/");
std::process::exit(1);
}
};
if args.backtest {
run_backtest(api_key, api_secret, args).await
} else {
run_live_trading(api_key, api_secret, args).await
}
}
async fn run_backtest(api_key: String, api_secret: String, args: Args) -> Result<()> {
// Combine years and months (default to 1 year if neither specified)
let total_years = args.years + (args.months / 12.0);
let total_years = if total_years <= 0.0 { 1.0 } else { total_years };
let client = AlpacaClient::new(api_key, api_secret)?;
let mut backtester = Backtester::new(args.capital, args.timeframe);
let result = backtester.run(&client, total_years).await?;
// Save results to CSV
save_backtest_results(&result)?;
Ok(())
}
async fn run_live_trading(api_key: String, api_secret: String, args: Args) -> Result<()> {
let dashboard_port: u16 = env::var("DASHBOARD_PORT")
.unwrap_or_else(|_| "5000".to_string())
.parse()
.unwrap_or(5000);
// Create a separate client for the dashboard
let dashboard_client = AlpacaClient::new(api_key.clone(), api_secret.clone())?;
// Spawn dashboard in background
tokio::spawn(async move {
if let Err(e) = dashboard::start_dashboard(dashboard_client, dashboard_port).await {
tracing::error!("Dashboard error: {}", e);
}
});
// Run the trading bot
let mut bot = TradingBot::new(api_key, api_secret, args.timeframe).await?;
bot.run().await
}

35
src/paths.rs Normal file
View File

@@ -0,0 +1,35 @@
//! App paths and file locations.
use lazy_static::lazy_static;
use std::path::PathBuf;
lazy_static! {
/// Base data directory for the application, using XDG standards.
pub static ref DATA_DIR: PathBuf = {
let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
path.push("invest-bot");
std::fs::create_dir_all(&path).expect("Failed to create data directory");
path
};
/// Path to the live positions JSON file.
pub static ref LIVE_POSITIONS_FILE: PathBuf = {
let mut path = DATA_DIR.clone();
path.push("live_positions.json");
path
};
/// Path to the live equity history JSON file.
pub static ref LIVE_EQUITY_FILE: PathBuf = {
let mut path = DATA_DIR.clone();
path.push("live_equity.json");
path
};
/// Path to the trading log file.
pub static ref LOG_FILE: PathBuf = {
let mut path = DATA_DIR.clone();
path.push("trading_bot.log");
path
};
}

227
src/types.rs Normal file
View File

@@ -0,0 +1,227 @@
//! Data types and structures for the trading bot.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Trading signal types.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Signal {
StrongBuy,
Buy,
Hold,
Sell,
StrongSell,
}
impl Signal {
pub fn as_str(&self) -> &'static str {
match self {
Signal::StrongBuy => "strong_buy",
Signal::Buy => "buy",
Signal::Hold => "hold",
Signal::Sell => "sell",
Signal::StrongSell => "strong_sell",
}
}
pub fn is_buy(&self) -> bool {
matches!(self, Signal::Buy | Signal::StrongBuy)
}
pub fn is_sell(&self) -> bool {
matches!(self, Signal::Sell | Signal::StrongSell)
}
}
/// Represents a trading signal with all relevant data.
#[derive(Debug, Clone)]
pub struct TradeSignal {
pub symbol: String,
pub signal: Signal,
pub rsi: f64,
pub macd: f64,
pub macd_signal: f64,
pub macd_histogram: f64,
pub momentum: f64,
pub ema_short: f64,
pub ema_long: f64,
pub current_price: f64,
pub confidence: f64,
}
/// Represents a completed trade for tracking.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trade {
pub symbol: String,
pub side: String,
pub shares: f64,
pub price: f64,
pub timestamp: DateTime<Utc>,
#[serde(default)]
pub pnl: f64,
#[serde(default)]
pub pnl_pct: f64,
}
/// Tracks a position during backtesting.
#[derive(Debug, Clone)]
pub struct BacktestPosition {
pub symbol: String,
pub shares: f64,
pub entry_price: f64,
pub entry_time: DateTime<Utc>,
}
/// Results from a backtest run.
#[derive(Debug, Clone)]
pub struct BacktestResult {
pub initial_capital: f64,
pub final_value: f64,
pub total_return: f64,
pub total_return_pct: f64,
pub cagr: f64,
pub sharpe_ratio: f64,
pub sortino_ratio: f64,
pub max_drawdown: f64,
pub max_drawdown_pct: f64,
pub total_trades: usize,
pub winning_trades: usize,
pub losing_trades: usize,
pub win_rate: f64,
pub avg_win: f64,
pub avg_loss: f64,
pub profit_factor: f64,
pub trades: Vec<Trade>,
pub equity_curve: Vec<EquityPoint>,
}
/// A point on the equity curve.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EquityPoint {
pub date: DateTime<Utc>,
pub portfolio_value: f64,
pub cash: f64,
pub positions_count: usize,
}
/// OHLCV bar data.
#[derive(Debug, Clone)]
pub struct Bar {
pub timestamp: DateTime<Utc>,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
pub vwap: Option<f64>,
}
/// A row of data with all calculated indicators.
#[derive(Debug, Clone)]
pub struct IndicatorRow {
pub timestamp: DateTime<Utc>,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
// RSI
pub rsi: f64,
// MACD
pub macd: f64,
pub macd_signal: f64,
pub macd_histogram: f64,
// Momentum
pub momentum: f64,
// EMAs
pub ema_short: f64,
pub ema_long: f64,
pub ema_trend: f64,
pub ema_bullish: bool,
pub trend_bullish: bool,
// ATR
pub atr: f64,
pub atr_pct: f64,
// ADX
pub adx: f64,
pub di_plus: f64,
pub di_minus: f64,
// Bollinger Bands
pub bb_upper: f64,
pub bb_middle: f64,
pub bb_lower: f64,
pub bb_pct: f64,
// Volume
pub volume_ma: f64,
pub volume_ratio: f64,
// EMA distance
pub ema_distance: f64,
}
impl Default for IndicatorRow {
fn default() -> Self {
Self {
timestamp: Utc::now(),
open: 0.0,
high: 0.0,
low: 0.0,
close: 0.0,
volume: 0.0,
rsi: 0.0,
macd: 0.0,
macd_signal: 0.0,
macd_histogram: 0.0,
momentum: 0.0,
ema_short: 0.0,
ema_long: 0.0,
ema_trend: 0.0,
ema_bullish: false,
trend_bullish: false,
atr: 0.0,
atr_pct: 0.0,
adx: 0.0,
di_plus: 0.0,
di_minus: 0.0,
bb_upper: 0.0,
bb_middle: 0.0,
bb_lower: 0.0,
bb_pct: 0.0,
volume_ma: 0.0,
volume_ratio: 0.0,
ema_distance: 0.0,
}
}
}
/// Live equity snapshot for dashboard.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EquitySnapshot {
pub timestamp: String,
pub portfolio_value: f64,
pub cash: f64,
pub buying_power: f64,
pub positions_count: usize,
pub positions: std::collections::HashMap<String, PositionInfo>,
}
/// Position information for dashboard.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PositionInfo {
pub qty: f64,
pub market_value: f64,
pub avg_entry_price: f64,
pub current_price: f64,
pub unrealized_pnl: f64,
pub pnl_pct: f64,
pub change_today: f64,
}