Files
vibe-invest/src/dashboard.rs
2026-02-10 15:34:49 +00:00

498 lines
20 KiB
Rust

//! 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<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">Total P&L</div>
<div class="stat-value" id="stat-total-pnl">$0.00</div>
</div>
<div class="stat-card">
<div class="stat-label">Today's P&L</div>
<div class="stat-value" id="stat-daily-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-total-pnl', data.total_pnl, true, true);
updateClass('stat-total-pnl', data.total_pnl);
updateText('stat-daily-pnl', data.daily_pnl, true, true);
updateClass('stat-daily-pnl', data.daily_pnl);
updateText('stat-positions', data.position_count);
} 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, 2000); // Refresh every 2 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,
daily_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();
let daily_pnl: f64 = positions
.iter()
.filter_map(|p| {
p.unrealized_intraday_pl
.as_ref()
.and_then(|s| s.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,
daily_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(())
}