transaction hystory

This commit is contained in:
zastian-dev
2026-02-10 16:45:42 +00:00
parent a19906560a
commit 5bb6f2d686
2 changed files with 183 additions and 11 deletions

View File

@@ -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?;

View File

@@ -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);