transaction hystory
This commit is contained in:
@@ -96,6 +96,12 @@ pub struct Order {
|
|||||||
pub qty: String,
|
pub qty: String,
|
||||||
pub side: String,
|
pub side: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub filled_avg_price: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub filled_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AlpacaClient {
|
impl AlpacaClient {
|
||||||
@@ -415,6 +421,35 @@ impl AlpacaClient {
|
|||||||
response.json().await.context("Failed to parse order")
|
response.json().await.context("Failed to parse order")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get closed/filled orders (transaction history).
|
||||||
|
pub async fn get_orders(&self, limit: u32) -> Result<Vec<Order>> {
|
||||||
|
self.enforce_rate_limit().await;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/orders?status=closed&limit={}&direction=desc",
|
||||||
|
TRADING_BASE_URL, limit
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.http_client
|
||||||
|
.get(&url)
|
||||||
|
.headers(self.auth_headers())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to get orders")?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("API error {}: {}", status, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse orders response")
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if market is open.
|
/// Check if market is open.
|
||||||
pub async fn is_market_open(&self) -> Result<bool> {
|
pub async fn is_market_open(&self) -> Result<bool> {
|
||||||
let clock = self.get_clock().await?;
|
let clock = self.get_clock().await?;
|
||||||
|
|||||||
159
src/dashboard.rs
159
src/dashboard.rs
@@ -50,6 +50,16 @@ struct PositionResponse {
|
|||||||
change_today: f64,
|
change_today: 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>
|
const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -66,7 +76,7 @@ const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
.container { max-width: 1400px; margin: 0 auto; }
|
.container { max-width: 1800px; margin: 0 auto; }
|
||||||
h1 {
|
h1 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
@@ -93,6 +103,15 @@ const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
|
|||||||
.stat-value { font-size: 1.8rem; font-weight: 700; }
|
.stat-value { font-size: 1.8rem; font-weight: 700; }
|
||||||
.stat-value.positive { color: #00ff88; }
|
.stat-value.positive { color: #00ff88; }
|
||||||
.stat-value.negative { color: #ff4757; }
|
.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 {
|
.chart-container {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -101,6 +120,49 @@ const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
.chart-title { font-size: 1.3rem; margin-bottom: 20px; color: #fff; }
|
.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 {
|
.positions-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
@@ -170,16 +232,26 @@ const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
|
|||||||
<div class="stat-value" id="stat-positions">0</div>
|
<div class="stat-value" id="stat-positions">0</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-container">
|
<div class="main-layout">
|
||||||
<h2 class="chart-title">Portfolio Performance</h2>
|
<div class="left-column">
|
||||||
<canvas id="portfolioChart"></canvas>
|
<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>
|
||||||
<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>
|
||||||
<button class="refresh-btn" onclick="loadAllData()">Refresh</button>
|
<button class="refresh-btn" onclick="loadAllData()">Refresh</button>
|
||||||
<script>
|
<script>
|
||||||
@@ -297,12 +369,38 @@ const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
|
|||||||
} catch (error) { console.error('Error loading positions:', error); }
|
} 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() {
|
function updateTimestamp() {
|
||||||
document.getElementById('last-updated').textContent = 'Last updated: ' + new Date().toLocaleString();
|
document.getElementById('last-updated').textContent = 'Last updated: ' + new Date().toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAllData() {
|
async function loadAllData() {
|
||||||
await Promise.all([loadAccountStats(), loadEquityCurve(), loadPositions()]);
|
await Promise.all([loadAccountStats(), loadEquityCurve(), loadPositions(), loadOrders()]);
|
||||||
updateTimestamp();
|
updateTimestamp();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +575,44 @@ async fn api_positions(State(state): State<Arc<DashboardState>>) -> impl IntoRes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
/// Start the dashboard web server.
|
||||||
pub async fn start_dashboard(client: AlpacaClient, port: u16) -> anyhow::Result<()> {
|
pub async fn start_dashboard(client: AlpacaClient, port: u16) -> anyhow::Result<()> {
|
||||||
let state = Arc::new(DashboardState { client });
|
let state = Arc::new(DashboardState { client });
|
||||||
@@ -486,6 +622,7 @@ pub async fn start_dashboard(client: AlpacaClient, port: u16) -> anyhow::Result<
|
|||||||
.route("/api/account", get(api_account))
|
.route("/api/account", get(api_account))
|
||||||
.route("/api/equity", get(api_equity))
|
.route("/api/equity", get(api_equity))
|
||||||
.route("/api/positions", get(api_positions))
|
.route("/api/positions", get(api_positions))
|
||||||
|
.route("/api/orders", get(api_orders))
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user