//! 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, config::{ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER}, paths::{LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE}, types::EquitySnapshot, }; use std::collections::HashMap; pub struct DashboardInitData { pub entry_atrs: HashMap, pub high_water_marks: HashMap, } /// Shared state for the dashboard. pub struct DashboardState { pub client: AlpacaClient, pub init_data: DashboardInitData, } #[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, trail_status: String, stop_loss_price: f64, } #[derive(Serialize)] struct OrderHistoryResponse { symbol: String, side: String, qty: f64, filled_price: f64, filled_at: String, status: String, } const HTML_TEMPLATE: &str = r#" Vibe Invest

Vibe Invest

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

Transaction History

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 = 960; // 4 hours of data (4 per minute at 15s intervals) 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| { let entry_price = p.avg_entry_price.parse().unwrap_or(0.0); let current_price = p.current_price.parse().unwrap_or(0.0); let pnl_pct = if entry_price > 0.0 { (current_price - entry_price) / entry_price } else { 0.0 }; let entry_atr = state.init_data.entry_atrs.get(&p.symbol).copied().unwrap_or(0.0); let high_water_mark = state.init_data.high_water_marks.get(&p.symbol).copied().unwrap_or(entry_price); let activation_gain = if entry_atr > 0.0 { (ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price } else { 0.0 }; let (trail_status, stop_loss_price) = if pnl_pct >= activation_gain && entry_atr > 0.0 { let trail_distance = ATR_TRAIL_MULTIPLIER * entry_atr; let stop_price = high_water_mark - trail_distance; ("Active".to_string(), stop_price) } else { ("Inactive".to_string(), entry_price - ATR_TRAIL_MULTIPLIER * entry_atr) }; 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: entry_price, current_price, 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, trail_status, stop_loss_price, } }) .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() } } } async fn api_orders(State(state): State>) -> impl IntoResponse { match state.client.get_orders(100).await { Ok(orders) => { let result: Vec = orders .into_iter() .filter(|o| o.status == "filled") .map(|o| { let filled_at = o .filled_at .or(o.created_at) .unwrap_or_default(); let display_time = if filled_at.len() >= 16 { filled_at[..16].replace("T", " ") } else { filled_at }; OrderHistoryResponse { symbol: o.symbol, side: o.side, qty: o.qty.parse().unwrap_or(0.0), filled_price: o .filled_avg_price .and_then(|s| s.parse().ok()) .unwrap_or(0.0), filled_at: display_time, status: o.status, } }) .collect(); Json(result).into_response() } Err(e) => { tracing::error!("Failed to get orders: {}", e); Json(Vec::::new()).into_response() } } } /// Start the dashboard web server. pub async fn start_dashboard( client: AlpacaClient, port: u16, init_data: DashboardInitData, ) -> anyhow::Result<()> { let state = Arc::new(DashboardState { client, init_data }); 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)) .route("/api/orders", get(api_orders)) .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(()) }