498 lines
20 KiB
Rust
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(())
|
|
}
|