//! 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, daily_pnl: f64, position_count: usize, } #[derive(Serialize)] struct EquityResponse { dates: Vec, values: Vec, 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#" Trading Bot Dashboard

Trading Bot Dashboard (Rust)

Portfolio Value
$0.00
Cash
$0.00
Buying Power
$0.00
Total P&L
$0.00
Today's P&L
$0.00
Open Positions
0

Portfolio Performance

Current Positions

Loading...

"#; async fn index() -> Html<&'static str> { Html(HTML_TEMPLATE) } async fn api_account(State(state): State>) -> 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, daily_pnl: 0.0, position_count: 0, }), ) .into_response() } } } async fn get_account_data(client: &AlpacaClient) -> anyhow::Result { 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::().ok()) .sum(); let daily_pnl: f64 = positions .iter() .filter_map(|p| { p.unrealized_intraday_pl .as_ref() .and_then(|s| s.parse::().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, daily_pnl, position_count: positions.len(), }) } async fn api_equity() -> Json { // 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::>(&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 = data_slice .iter() .map(|s| { if s.timestamp.len() >= 16 { s.timestamp[5..16].replace("T", " ") } else { s.timestamp.clone() } }) .collect(); let values: Vec = 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::() { 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>) -> impl IntoResponse { match state.client.get_positions().await { Ok(positions) => { let mut result: Vec = 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::().unwrap_or(0.0) * 100.0, change_today: p .change_today .as_ref() .and_then(|s| s.parse::().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::::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(()) }