Files
vibe-invest/src/dashboard.rs
2026-02-26 17:05:57 +00:00

700 lines
28 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,
config::{
ATR_STOP_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER,
BREAKEVEN_ACTIVATION_PCT, BREAKEVEN_MAX_LOSS_PCT,
EARLY_TRAIL_ACTIVATION_MULTIPLIER, EARLY_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<String, f64>,
pub high_water_marks: HashMap<String, f64>,
}
/// 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<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,
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#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vibe Invest</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: 1800px; 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; }
.main-layout {
display: grid;
grid-template-columns: 1fr 380px;
gap: 20px;
margin-bottom: 30px;
}
@media (max-width: 1000px) {
.main-layout { grid-template-columns: 1fr; }
}
.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; }
.transactions-panel {
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
max-height: calc(100vh - 220px);
}
.transactions-list {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.2) transparent;
}
.transactions-list::-webkit-scrollbar { width: 6px; }
.transactions-list::-webkit-scrollbar-track { background: transparent; }
.transactions-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
.tx-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.tx-row:last-child { border-bottom: none; }
.tx-left { display: flex; align-items: center; gap: 10px; }
.tx-side {
font-size: 0.7rem;
font-weight: 700;
padding: 3px 8px;
border-radius: 4px;
text-transform: uppercase;
min-width: 36px;
text-align: center;
}
.tx-side.buy { background: rgba(0,255,136,0.2); color: #00ff88; }
.tx-side.sell { background: rgba(255,71,87,0.2); color: #ff4757; }
.tx-symbol { font-weight: 600; font-size: 0.95rem; }
.tx-qty { color: #888; font-size: 0.8rem; }
.tx-right { text-align: right; }
.tx-price { font-size: 0.9rem; font-weight: 500; }
.tx-time { font-size: 0.7rem; color: #666; margin-top: 2px; }
.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>Vibe Invest</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="main-layout">
<div class="left-column">
<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>
<div class="transactions-panel">
<h2 class="chart-title">Transaction History</h2>
<div class="transactions-list" id="transactions-list">
<div class="loading">Loading...</div>
</div>
</div>
</div>
</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: 20 } },
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 class="position-detail"><div class="position-detail-label">Trail Status</div><div class="position-detail-value">${pos.trail_status}</div></div>
<div class="position-detail"><div class="position-detail-label">Stop Loss</div><div class="position-detail-value">${formatCurrency(pos.stop_loss_price)}</div></div>
</div>
</div>`;
}).join('');
} catch (error) { console.error('Error loading positions:', error); }
}
async function loadOrders() {
try {
const response = await fetch('/api/orders');
const orders = await response.json();
const list = document.getElementById('transactions-list');
if (orders.length === 0) {
list.innerHTML = '<div class="loading">No transactions yet</div>';
return;
}
list.innerHTML = orders.map(o => {
const sideClass = o.side === 'buy' ? 'buy' : 'sell';
return `<div class="tx-row">
<div class="tx-left">
<span class="tx-side ${sideClass}">${o.side}</span>
<span class="tx-symbol">${o.symbol}</span>
<span class="tx-qty">x${o.qty}</span>
</div>
<div class="tx-right">
<div class="tx-price">${formatCurrency(o.filled_price)}</div>
<div class="tx-time">${o.filled_at}</div>
</div>
</div>`;
}).join('');
} catch (error) { console.error('Error loading orders:', error); }
}
function updateTimestamp() {
document.getElementById('last-updated').textContent = 'Last updated: ' + new Date().toLocaleString();
}
async function loadAllData() {
await Promise.all([loadAccountStats(), loadEquityCurve(), loadPositions(), loadOrders()]);
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 = 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<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| {
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 best_pnl = (high_water_mark - entry_price) / entry_price;
let big_activation = if entry_atr > 0.0 {
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
} else { 0.0 };
let small_activation = if entry_atr > 0.0 {
(EARLY_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
} else { 0.0 };
let (trail_status, stop_loss_price) = if best_pnl >= BREAKEVEN_ACTIVATION_PCT && pnl_pct <= -BREAKEVEN_MAX_LOSS_PCT {
("Breakeven!".to_string(), entry_price * (1.0 - BREAKEVEN_MAX_LOSS_PCT))
} else if entry_atr > 0.0 && best_pnl >= big_activation {
let trail_distance = ATR_TRAIL_MULTIPLIER * entry_atr;
let stop_price = high_water_mark - trail_distance;
("Wide Trail".to_string(), stop_price)
} else if entry_atr > 0.0 && pnl_pct >= small_activation {
let trail_distance = EARLY_TRAIL_MULTIPLIER * entry_atr;
let stop_price = high_water_mark - trail_distance;
("Tight Trail".to_string(), stop_price)
} else {
("Inactive".to_string(), entry_price - ATR_STOP_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::<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,
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::<PositionResponse>::new()).into_response()
}
}
}
async fn api_orders(State(state): State<Arc<DashboardState>>) -> impl IntoResponse {
match state.client.get_orders(100).await {
Ok(orders) => {
let result: Vec<OrderHistoryResponse> = 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::<OrderHistoryResponse>::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(())
}