first comit
This commit is contained in:
479
src/dashboard.rs
Normal file
479
src/dashboard.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user