682 lines
27 KiB
Rust
682 lines
27 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_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<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 (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::<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(())
|
|
}
|