StockEx / dashboard /templates /index.html
RayMelius's picture
Seed dashboard price chart with real OHLCV history from Yahoo Finance
9209065
Raw
History Blame Contribute Delete
66.2 kB
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Trading Dashboard</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; background: #f7f7f7; }
h1 { display: flex; align-items: center; gap: 15px; }
h2 { margin: 5px 0; }
.container { display: grid; grid-template-columns: 1fr 1fr; grid-gap: 20px; }
.panel {
background: #fff;
border-radius: 8px;
padding: 10px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
height: 400px;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.panel pre {
flex-grow: 1;
overflow-y: scroll;
background: #fafafa;
padding: 10px;
margin: 0;
border-radius: 6px;
font-size: 13px;
white-space: pre;
font-family: monospace;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th, td {
border: 1px solid #ccc;
padding: 4px;
text-align: center;
}
.updated {
animation: flash 1s ease-in-out;
}
@keyframes flash {
from { background: yellow; }
to { background: transparent; }
}
/* AI Insights panel */
.ai-panel {
background: #fff;
border-radius: 8px;
padding: 12px 16px;
box-shadow: 0 2px 6px rgba(92,107,192,0.18);
border-top: 3px solid #5c6bc0;
margin-top: 20px;
}
.insight-card {
padding: 9px 12px;
border-left: 3px solid #5c6bc0;
margin-bottom: 8px;
background: #f8f9ff;
border-radius: 0 4px 4px 0;
font-size: 13px;
line-height: 1.6;
}
.insight-time { font-size: 11px; color: #999; margin-bottom: 3px; }
@keyframes fadeInDown {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
.insight-new { animation: fadeInDown 0.4s ease; }
/* Connection status indicator */
.status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
}
.status .dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.status.connected { background: #d4edda; color: #155724; }
.status.connected .dot { background: #28a745; }
.status.disconnected { background: #f8d7da; color: #721c24; }
.status.disconnected .dot { background: #dc3545; }
.status.connecting { background: #fff3cd; color: #856404; }
.status.connecting .dot { background: #ffc107; animation: pulse 1s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.new-item {
animation: highlight 2s ease-out;
}
@keyframes highlight {
from { background: #c8e6c9; }
to { background: transparent; }
}
/* Selectable rows */
.selectable-row { cursor: pointer; }
.selectable-row:hover { background: #f5f5f5; }
.selectable-row.selected { background: #e3f2fd !important; }
/* Ticker tape */
.ticker-container {
background: #1a1a2e;
overflow: hidden;
padding: 8px 0;
border-radius: 4px;
margin-top: 10px;
}
.ticker-tape {
display: flex;
animation: ticker-scroll 30s linear infinite;
white-space: nowrap;
}
.ticker-tape:hover {
animation-play-state: paused;
}
@keyframes ticker-scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.ticker-item {
display: inline-flex;
align-items: center;
padding: 0 20px;
font-size: 13px;
font-weight: bold;
}
.ticker-symbol {
color: #fff;
margin-right: 8px;
}
.ticker-price {
margin-right: 5px;
}
.ticker-price.up { color: #4caf50; }
.ticker-price.down { color: #f44336; }
.ticker-price.neutral { color: #ffc107; }
.ticker-change {
font-size: 11px;
}
.ticker-change.up { color: #4caf50; }
.ticker-change.down { color: #f44336; }
.ticker-arrow { margin-right: 3px; }
/* Start/End of Day buttons */
.btn-day {
padding: 4px 14px;
border: none;
border-radius: 20px;
cursor: pointer;
font-size: 12px;
font-weight: bold;
}
.btn-start { background: #28a745; color: #fff; }
.btn-start:hover { background: #218838; }
.btn-end { background: #ff9800; color: #fff; }
.btn-end:hover { background: #e68900; }
.btn-suspend { background: #607d8b; color: #fff; }
.btn-suspend:hover { background: #455a64; }
.btn-resume { background: #4caf50; color: #fff; }
.btn-resume:hover { background: #388e3c; }
.btn-manual { background: #607d8b; color: #fff; }
.btn-manual:hover { background: #455a64; }
.btn-automatic { background: #1565c0; color: #fff; }
.btn-automatic:hover { background: #0d47a1; }
.btn-day:disabled { background: #ccc !important; color: #999; cursor: not-allowed; }
.status.active { background: #d4edda; color: #155724; }
.status.active .dot { background: #28a745; }
.status.suspended { background: #fff3cd; color: #856404; }
.status.suspended .dot { background: #ffc107; animation: pulse 1s infinite; }
.status.idle { background: #e8e8e8; color: #666; }
.status.idle .dot { background: #9e9e9e; }
/* Price History panel */
.panel-full {
background: #fff;
border-radius: 8px;
padding: 10px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-top: 20px;
}
/* ── Mobile responsive ───────────────────────────────────────────────────── */
@media (max-width: 768px) {
body { margin: 10px; }
h1 { font-size: 18px; flex-wrap: wrap; gap: 8px; }
/* FIX UI link: don't push to far right, wrap naturally */
h1 a { margin-left: 0 !important; }
.container { grid-template-columns: 1fr; }
.panel { height: 300px; }
/* Allow tables to scroll horizontally inside panels */
.panel > div { overflow-x: auto; }
.ticker-item { font-size: 11px; padding: 0 10px; }
#history-chart { height: 220px !important; }
/* Modal: full width on small screens */
#order-modal > div {
width: 90% !important;
min-width: unset !important;
}
}
@media (max-width: 480px) {
body { margin: 6px; }
h1 { font-size: 14px; }
table { font-size: 11px; }
th, td { padding: 3px 2px; }
.btn-day { font-size: 10px; padding: 3px 8px; }
.status { font-size: 10px; padding: 3px 8px; }
}
</style>
</head>
<body>
<h1>
Trading Dashboard
<span id="status" class="status connecting"><span class="dot"></span><span id="status-text">Connecting...</span></span>
<span id="session-badge" class="status idle"><span class="dot"></span><span id="session-text">IDLE</span></span>
<span id="local-clock" style="font-size:26px; font-weight:bold; color:#333; background:#f0f0f0; padding:3px 12px; border-radius:12px; font-variant-numeric:tabular-nums; letter-spacing:1px;"></span>
<button id="day-btn" onclick="toggleDay()" class="btn-day btn-start" disabled>Start of Day</button>
<button id="suspend-btn" onclick="toggleSuspend()" class="btn-day btn-suspend" disabled>Suspend</button>
<button id="mode-btn" onclick="toggleMode()" class="btn-day btn-automatic">Automatic</button>
{% if frontend_url %}<a href="{{ frontend_url }}" target="_blank" style="margin-left:auto; padding:4px 14px; background:#5c6bc0; color:#fff; border-radius:20px; font-size:12px; font-weight:bold; text-decoration:none;">Frontend</a>{% endif %}
<a href="/ch/" target="_blank" style="padding:4px 14px; background:#1a7f37; color:#fff; border-radius:20px; font-size:12px; font-weight:bold; text-decoration:none;">Clearing House</a>
<a href="/fix/" style="{% if not frontend_url %}margin-left:auto; {% endif %}padding:4px 14px; background:#6c757d; color:#fff; border-radius:20px; font-size:12px; font-weight:bold; text-decoration:none;">FIX UI</a>
</h1>
<div class="container">
<div class="panel">
<h2>Orders <span id="order-count" style="font-size:22px;color:#333;font-weight:bold;"></span>
<button id="edit-selected-btn" onclick="editSelectedOrder()" style="display:none; margin-left:10px; padding:4px 10px; background:#2196F3; color:#fff; border:none; border-radius:4px; cursor:pointer;">Edit</button>
<button id="cancel-selected-btn" onclick="cancelSelectedOrder()" style="display:none; margin-left:5px; padding:4px 10px; background:#f44336; color:#fff; border:none; border-radius:4px; cursor:pointer;">Cancel</button>
</h2>
<div style="flex-grow:1; overflow-y:auto;">
<table id="orders-table">
<thead>
<tr style="background:#f0f0f0;">
<th>Symbol</th>
<th>Side</th>
<th>Qty</th>
<th>Price</th>
<th>Source</th>
<th>Time</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="orders-body"></tbody>
</table>
</div>
</div>
<!-- Order Edit Modal -->
<div id="order-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); z-index:1000;">
<div style="position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); background:#fff; padding:20px; border-radius:8px; min-width:300px;">
<h3 style="margin:0 0 15px;">Edit Order</h3>
<input type="hidden" id="edit-order-id">
<input type="hidden" id="edit-order-symbol">
<div style="margin-bottom:10px;">
<label style="display:block; font-size:12px; color:#666;">Quantity:</label>
<input type="number" id="edit-qty" style="width:100%; padding:8px; border:1px solid #ccc; border-radius:4px;">
</div>
<div style="margin-bottom:15px;">
<label style="display:block; font-size:12px; color:#666;">Price:</label>
<input type="number" step="0.01" id="edit-price" style="width:100%; padding:8px; border:1px solid #ccc; border-radius:4px;">
</div>
<div style="display:flex; gap:10px;">
<button onclick="submitAmend()" style="flex:1; padding:8px; background:#2196F3; color:#fff; border:none; border-radius:4px; cursor:pointer;">Update</button>
<button onclick="closeModal()" style="flex:1; padding:8px; background:#ccc; border:none; border-radius:4px; cursor:pointer;">Cancel</button>
</div>
</div>
</div>
<div class="panel">
<h2>Market Snapshot</h2>
<div style="flex-grow:1; overflow-y:auto;">
<table id="bbos-table">
<thead>
<tr>
<th>Symbol</th>
<th>Best Bid</th>
<th>Best Ask</th>
<th>Spread</th>
<th>Mid</th>
<th>Updated</th>
</tr>
</thead>
<tbody id="bbos-body"></tbody>
</table>
</div>
<div class="ticker-container">
<div class="ticker-tape" id="ticker-tape">
<span class="ticker-item"><span class="ticker-symbol">Loading trades...</span></span>
</div>
</div>
</div>
<div class="panel">
<h2>Trades <span id="trade-count" style="font-size:22px;color:#333;font-weight:bold;"></span></h2>
<div style="flex-grow:1; overflow-y:auto;">
<table id="trades-table">
<thead>
<tr style="background:#f0f0f0;">
<th>Symbol</th>
<th>Qty</th>
<th>Price</th>
<th>Value</th>
<th>Time</th>
</tr>
</thead>
<tbody id="trades-body"></tbody>
</table>
</div>
</div>
<div class="panel">
<h2>Order Book
<select id="bbo-symbol-select" onchange="renderOrderBookPanel()" style="margin-left:10px; padding:2px 5px;">
</select>
<button onclick="renderOrderBookPanel()" style="margin-left:5px; padding:2px 8px; cursor:pointer;">Refresh</button>
</h2>
<div id="full-bbo-container" style="flex-grow:1; overflow-y:auto; padding:5px;">
<p style="color:#666; text-align:center;">Select a symbol</p>
</div>
</div>
<div class="panel">
<h2>Trading Statistics</h2>
<div id="stats-container" style="flex-grow:1; overflow-y:auto; padding: 10px;">
<table id="stats-table" style="width:100%; margin-bottom:10px; font-size:12px;">
<thead>
<tr style="background:#f0f0f0;">
<th>Symbol</th>
<th>Trades</th>
<th>Volume</th>
<th>Value</th>
<th>Start</th>
<th>Last</th>
<th>VWAP</th>
</tr>
</thead>
<tbody id="stats-body"></tbody>
</table>
<div style="display:flex; gap:4px; justify-content:center; margin-top:5px;">
<div style="font-size:10px; color:#4CAF50; margin-right:10px;">β–  Volume</div>
<div style="font-size:10px; color:#2196F3;">β–  Value</div>
</div>
<div id="stats-chart" style="display:flex; align-items:flex-end; justify-content:center; gap:15px; height:120px; margin-top:5px;"></div>
</div>
</div>
<div class="panel">
<h2 style="flex-shrink:0;">Price Chart
<select id="history-symbol" onchange="loadHistory()" style="margin-left:8px; padding:2px 4px; font-size:11px;">
<option value="">All Symbols</option>
</select>
<select id="history-period" onchange="loadHistory()" style="margin-left:4px; padding:2px 4px; font-size:11px;">
<option value="live" selected>Live</option>
<option value="1h">1H</option>
<option value="8h">8H</option>
<option value="1d">1D</option>
<option value="1w">1W</option>
<option value="1m">1M</option>
</select>
<span id="history-status" style="font-size:10px; color:#999; margin-left:6px;"></span>
</h2>
<canvas id="history-chart" style="width:100%; flex-grow:1; min-height:0;"></canvas>
</div>
</div>
<!-- AI Analyst panel (full width) -->
<div class="ai-panel">
<h2 style="margin:0 0 8px; font-size:15px; display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
AI Analyst
<button id="ai-generate-btn" onclick="triggerAIInsight()"
style="padding:5px 14px; background:#5c6bc0; color:#fff; border:none; border-radius:12px;
font-size:12px; font-weight:bold; cursor:pointer; flex-shrink:0;">
✨ Generate Now
</button>
<!-- LLM selector -->
<select id="ai-provider-select" onchange="onProviderChange()"
style="padding:4px 8px; border:1px solid #c5cae9; border-radius:8px; font-size:12px;
background:#f8f9ff; color:#3949ab; cursor:pointer; flex-shrink:0;">
<option value="auto">Auto</option>
<option value="groq">Groq</option>
<option value="hf">HuggingFace</option>
<option value="ollama">Ollama</option>
</select>
<select id="ai-model-select" onchange="onModelChange()"
style="padding:4px 8px; border:1px solid #c5cae9; border-radius:8px; font-size:12px;
background:#f8f9ff; color:#3949ab; cursor:pointer; flex-shrink:0; max-width:220px;">
<option value="">β€” model β€”</option>
</select>
<span id="ai-llm-badge" style="font-size:10px; color:#7986cb; background:#e8eaf6; padding:2px 8px;
border-radius:10px; white-space:nowrap; flex-shrink:0;"></span>
<span id="ai-status" style="font-size:11px; color:#999; font-weight:normal; margin-left:auto;">waiting for first insight…</span>
</h2>
<div id="ai-insights-list" style="max-height:220px; overflow-y:auto;">
<div class="insight-card" style="color:#bbb; border-left-color:#ddd; background:#fafafa;" id="ai-placeholder">
No insights yet β€” insights are generated every 30 min when the session is active.
</div>
</div>
</div>
<script>
// State
const state = {
orders: [],
trades: [],
bbos: {},
connected: false,
lastPrices: {} // Track last price per symbol for ticker
};
function setStatus(status, text) {
const el = document.getElementById("status");
const textEl = document.getElementById("status-text");
el.className = "status " + status;
textEl.textContent = text;
}
function fmtOrder(o) {
const sym = (o.symbol ?? "?").padEnd(6);
const side = (o.type ?? o.side ?? "?").padEnd(4);
const price = o.price !== undefined ? Number(o.price).toFixed(2).padStart(8) : " ? ";
const qty = String(o.quantity ?? o.qty ?? "?").padStart(6);
const src = (o.source ?? "?").padEnd(8);
const ts = o.timestamp ? new Date(o.timestamp * 1000).toLocaleTimeString() : "-";
return `${sym} ${side} ${qty} @ ${price} | ${src} | ${ts}`;
}
function fmtTrade(t) {
const sym = (t.symbol ?? "?").padEnd(6);
const qty = String(t.quantity ?? t.qty ?? "-").padStart(6);
const price = t.price !== undefined ? Number(t.price).toFixed(2).padStart(8) : " - ";
const ts = t.timestamp ? new Date(t.timestamp * 1000).toLocaleTimeString() : "-";
return `${sym} ${qty} x ${price} | ${ts}`;
}
// Selected order state
let selectedOrder = null;
// ── LLM selector ────────────────────────────────────────────────────────────
const LLM_MODELS = { auto: [], groq: [], hf: [], ollama: [] };
async function loadAIConfig() {
try {
const r = await fetch("/ai/config");
const cfg = await r.json();
// Populate model lists from server
for (const [p, info] of Object.entries(cfg.providers || {})) {
if (info.models && info.models.length) LLM_MODELS[p] = info.models;
}
// Restore active selection
const prov = cfg.active_provider || "auto";
document.getElementById("ai-provider-select").value = prov;
populateModelDropdown(prov, cfg.active_model);
updateLLMBadge(prov, cfg.active_model);
} catch(e) { console.warn("Could not load AI config:", e); }
}
function populateModelDropdown(provider, selectedModel) {
const sel = document.getElementById("ai-model-select");
const models = LLM_MODELS[provider] || [];
sel.innerHTML = "";
if (provider === "auto" || models.length === 0) {
sel.style.display = "none";
return;
}
sel.style.display = "";
for (const m of models) {
const opt = document.createElement("option");
opt.value = m;
opt.textContent = m.split("/").pop(); // show only model name, not org prefix
if (m === selectedModel) opt.selected = true;
sel.appendChild(opt);
}
if (!selectedModel) sel.selectedIndex = 0;
}
function updateLLMBadge(provider, model) {
const badge = document.getElementById("ai-llm-badge");
if (provider === "auto") {
badge.textContent = "auto";
} else {
const name = model ? model.split("/").pop() : "default";
badge.textContent = `${provider} / ${name}`;
}
}
async function sendLLMSelection(provider, model) {
try {
await fetch("/ai/select", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, model: model || null }),
});
} catch(e) { console.warn("Could not update LLM selection:", e); }
}
function onProviderChange() {
const provider = document.getElementById("ai-provider-select").value;
populateModelDropdown(provider, null);
const model = LLM_MODELS[provider]?.[0] || null;
updateLLMBadge(provider, model);
sendLLMSelection(provider, model);
}
function onModelChange() {
const provider = document.getElementById("ai-provider-select").value;
const model = document.getElementById("ai-model-select").value || null;
updateLLMBadge(provider, model);
sendLLMSelection(provider, model);
}
// ────────────────────────────────────────────────────────────────────────────
async function triggerAIInsight() {
const btn = document.getElementById("ai-generate-btn");
const status = document.getElementById("ai-status");
btn.disabled = true;
btn.textContent = "Generating…";
status.textContent = "contacting LLM…";
try {
await fetch("/session/ai_insight", { method: "POST" });
} catch(e) {}
// Countdown timer β€” LLM (especially HF cold-start) can take up to 2 min
let elapsed = 0;
const tick = setInterval(() => {
elapsed += 1;
status.textContent = `generating… ${elapsed}s`;
}, 1000);
// Hard cap: re-enable after 95s (matches 90s server timeout + buffer)
setTimeout(() => {
clearInterval(tick);
btn.disabled = false;
btn.textContent = "✨ Generate Now";
status.textContent = "timed out β€” check HF token / try again";
}, 95000);
// Store tick handle so SSE success can clear it
btn._tick = tick;
}
function addInsight(insight) {
const list = document.getElementById("ai-insights-list");
const ph = document.getElementById("ai-placeholder");
if (ph) ph.remove();
const div = document.createElement("div");
const isErr = insight.source === "error" || insight.source === "config"
|| (insight.text || "").startsWith("⚠️");
div.className = "insight-card insight-new";
if (isErr) div.style.cssText = "border-left-color:#e53935; background:#fff5f5;";
const t = new Date(insight.timestamp * 1000).toLocaleTimeString();
const srcBadge = insight.source && insight.source !== "error" && insight.source !== "config"
? `<span style="font-size:10px; background:#e8eaf6; color:#5c6bc0; padding:1px 6px; border-radius:8px; margin-left:6px;">${insight.source}</span>`
: "";
div.innerHTML = `<div class="insight-time">${t}${srcBadge}</div><div>${insight.text}</div>`;
list.prepend(div);
while (list.children.length > 10) list.removeChild(list.lastChild);
}
function renderOrders() {
const tbody = document.getElementById("orders-body");
tbody.innerHTML = "";
for (let i = 0; i < state.orders.length; i++) {
const o = state.orders[i];
const row = document.createElement("tr");
const side = o.type ?? o.side ?? "?";
const isBuy = side.toLowerCase().includes("buy");
const orderId = o.cl_ord_id || o.order_id || o.id || "";
row.className = "selectable-row";
row.dataset.index = i;
row.dataset.orderId = orderId;
row.dataset.symbol = o.symbol || "";
row.dataset.qty = o.quantity || o.qty || 0;
row.dataset.price = o.price || 0;
if (selectedOrder && selectedOrder.index === i) {
row.classList.add("selected");
}
row.onclick = () => selectOrder(i, orderId, o.symbol, o.quantity || o.qty || 0, o.price || 0);
row.innerHTML = `
<td><strong>${o.symbol ?? "?"}</strong></td>
<td style="color:${isBuy ? '#2e7d32' : '#c62828'}; font-weight:bold;">${side}</td>
<td>${o.quantity ?? o.qty ?? "-"}</td>
<td>${o.price !== undefined ? Number(o.price).toFixed(2) : "-"}</td>
<td style="font-size:11px;">${o.source ?? "-"}</td>
<td style="font-size:11px;">${o.timestamp ? new Date(o.timestamp * 1000).toLocaleTimeString() : "-"}</td>
<td>
${orderId ? `
<button onclick="event.stopPropagation(); editOrder('${orderId}', '${o.symbol}', ${o.quantity || o.qty || 0}, ${o.price || 0})"
style="padding:2px 6px; font-size:11px; cursor:pointer; background:#2196F3; color:#fff; border:none; border-radius:3px; margin-right:3px;">Edit</button>
<button onclick="event.stopPropagation(); cancelOrder('${orderId}', '${o.symbol}')"
style="padding:2px 6px; font-size:11px; cursor:pointer; background:#f44336; color:#fff; border:none; border-radius:3px;">Cancel</button>
` : '<span style="color:#999; font-size:10px;">N/A</span>'}
</td>
`;
tbody.appendChild(row);
}
document.getElementById("order-count").textContent = `(${state.orders.length})`;
updateSelectionButtons();
}
function selectOrder(index, orderId, symbol, qty, price) {
// Toggle selection
if (selectedOrder && selectedOrder.index === index) {
selectedOrder = null;
} else {
selectedOrder = { index, orderId, symbol, qty, price };
}
// Update row highlighting
document.querySelectorAll("#orders-body tr").forEach((row, i) => {
row.classList.toggle("selected", selectedOrder && selectedOrder.index === i);
});
updateSelectionButtons();
}
function updateSelectionButtons() {
const editBtn = document.getElementById("edit-selected-btn");
const cancelBtn = document.getElementById("cancel-selected-btn");
if (selectedOrder && selectedOrder.orderId) {
editBtn.style.display = "inline-block";
cancelBtn.style.display = "inline-block";
} else {
editBtn.style.display = "none";
cancelBtn.style.display = "none";
}
}
function editSelectedOrder() {
if (selectedOrder && selectedOrder.orderId) {
editOrder(selectedOrder.orderId, selectedOrder.symbol, selectedOrder.qty, selectedOrder.price);
}
}
function cancelSelectedOrder() {
if (selectedOrder && selectedOrder.orderId) {
cancelOrder(selectedOrder.orderId, selectedOrder.symbol);
}
}
function renderTrades() {
const tbody = document.getElementById("trades-body");
tbody.innerHTML = "";
for (const t of state.trades) {
const row = document.createElement("tr");
const qty = t.quantity ?? t.qty ?? 0;
const price = t.price ?? 0;
const value = (qty * price).toFixed(2);
row.innerHTML = `
<td><strong>${t.symbol ?? "?"}</strong></td>
<td>${qty}</td>
<td>${price.toFixed(2)}</td>
<td style="color:#666;">${value}</td>
<td style="font-size:11px;">${t.timestamp ? new Date(t.timestamp * 1000).toLocaleTimeString() : "-"}</td>
`;
tbody.appendChild(row);
}
document.getElementById("trade-count").textContent = `(${state.trades.length})`;
renderTicker();
}
function renderTicker() {
const ticker = document.getElementById("ticker-tape");
if (state.trades.length === 0) {
ticker.innerHTML = '<span class="ticker-item"><span class="ticker-symbol">Waiting for trades...</span></span>';
return;
}
// Get last 20 trades and build ticker items
const recentTrades = state.trades.slice(0, 20);
let tickerHTML = "";
for (const t of recentTrades) {
const symbol = t.symbol || "?";
const price = t.price || 0;
const qty = t.quantity || t.qty || 0;
const lastPrice = state.lastPrices[symbol];
// Determine direction
let direction = "neutral";
let arrow = "●";
if (lastPrice !== undefined) {
if (price > lastPrice) {
direction = "up";
arrow = "β–²";
} else if (price < lastPrice) {
direction = "down";
arrow = "β–Ό";
}
}
// Update last price
state.lastPrices[symbol] = price;
tickerHTML += `
<span class="ticker-item">
<span class="ticker-symbol">${symbol}</span>
<span class="ticker-price ${direction}">${price.toFixed(2)}</span>
<span class="ticker-change ${direction}">
<span class="ticker-arrow">${arrow}</span>${qty}
</span>
</span>
`;
}
// Duplicate for seamless scroll
ticker.innerHTML = tickerHTML + tickerHTML;
}
// Order management functions
function editOrder(orderId, symbol, qty, price) {
document.getElementById("edit-order-id").value = orderId;
document.getElementById("edit-order-symbol").value = symbol;
document.getElementById("edit-qty").value = qty;
document.getElementById("edit-price").value = price;
document.getElementById("order-modal").style.display = "block";
}
function closeModal() {
document.getElementById("order-modal").style.display = "none";
}
async function submitAmend() {
const orderId = document.getElementById("edit-order-id").value;
const symbol = document.getElementById("edit-order-symbol").value;
const qty = parseInt(document.getElementById("edit-qty").value);
const price = parseFloat(document.getElementById("edit-price").value);
try {
const r = await fetch("/order/amend", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
orig_cl_ord_id: orderId,
symbol: symbol,
quantity: qty,
price: price
})
});
const result = await r.json();
if (result.status === "ok") {
alert("Order amended successfully");
closeModal();
renderOrderBookPanel();
} else {
alert("Amend failed: " + (result.error || "Unknown error"));
}
} catch (e) {
alert("Error: " + e.message);
}
}
async function cancelOrder(orderId, symbol) {
if (!confirm(`Cancel order ${orderId}?`)) return;
try {
const r = await fetch("/order/cancel", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
orig_cl_ord_id: orderId,
symbol: symbol
})
});
const result = await r.json();
if (result.status === "ok") {
alert("Order cancelled");
renderOrderBookPanel();
} else {
alert("Cancel failed: " + (result.error || "Unknown error"));
}
} catch (e) {
alert("Error: " + e.message);
}
}
function renderBBOs() {
const tbody = document.getElementById("bbos-body");
tbody.innerHTML = "";
for (const [symbol, snap] of Object.entries(state.bbos).sort()) {
const row = document.createElement("tr");
row.id = "bbo-" + symbol;
const spread = (snap.best_ask && snap.best_bid) ? (snap.best_ask - snap.best_bid).toFixed(2) : "-";
const mid = (snap.best_ask && snap.best_bid) ? ((snap.best_ask + snap.best_bid) / 2).toFixed(2) : "-";
row.innerHTML = `
<td><strong>${symbol}</strong></td>
<td style="color:green;">${snap.best_bid !== null ? Number(snap.best_bid).toFixed(2) : "-"}</td>
<td style="color:red;">${snap.best_ask !== null ? Number(snap.best_ask).toFixed(2) : "-"}</td>
<td>${spread}</td>
<td>${mid}</td>
<td>${snap.timestamp ? new Date(snap.timestamp*1000).toLocaleTimeString() : "-"}</td>
`;
tbody.appendChild(row);
}
updateSymbolDropdown();
}
function updateBBO(symbol, data) {
state.bbos[symbol] = data;
const row = document.getElementById("bbo-" + symbol);
if (row) {
const spread = (data.best_ask && data.best_bid) ? (data.best_ask - data.best_bid).toFixed(2) : "-";
const mid = (data.best_ask && data.best_bid) ? ((data.best_ask + data.best_bid) / 2).toFixed(2) : "-";
row.innerHTML = `
<td><strong>${symbol}</strong></td>
<td style="color:green;">${data.best_bid !== null ? Number(data.best_bid).toFixed(2) : "-"}</td>
<td style="color:red;">${data.best_ask !== null ? Number(data.best_ask).toFixed(2) : "-"}</td>
<td>${spread}</td>
<td>${mid}</td>
<td>${data.timestamp ? new Date(data.timestamp*1000).toLocaleTimeString() : "-"}</td>
`;
row.classList.add("updated");
setTimeout(() => row.classList.remove("updated"), 1000);
} else {
renderBBOs();
}
}
function updateSymbolDropdown() {
const symbols = Object.keys(state.bbos).sort();
// Update BBO symbol select
const bboSel = document.getElementById("bbo-symbol-select");
const bboCurrent = bboSel.value;
if (symbols.length > 0 && bboSel.options.length !== symbols.length) {
bboSel.innerHTML = "";
symbols.forEach(sym => {
const opt = document.createElement("option");
opt.value = sym;
opt.textContent = sym;
bboSel.appendChild(opt);
});
if (bboCurrent && symbols.includes(bboCurrent)) {
bboSel.value = bboCurrent;
}
renderOrderBookPanel();
}
// Update history symbol select
const histSel = document.getElementById("history-symbol");
const histCurrent = histSel.value;
const histOpts = Array.from(histSel.options).map(o => o.value).filter(v => v !== "");
symbols.forEach(sym => {
if (!histOpts.includes(sym)) {
const opt = document.createElement("option");
opt.value = sym;
opt.textContent = sym;
histSel.appendChild(opt);
}
});
if (!histCurrent) {
loadHistory(); // draws all-symbols multi-chart
} else {
histSel.value = histCurrent;
}
}
async function renderOrderBookPanel() {
const sel = document.getElementById("bbo-symbol-select");
const symbol = sel.value;
const container = document.getElementById("full-bbo-container");
if (!symbol) {
container.innerHTML = "<p style='color:#666; text-align:center;'>Select a symbol</p>";
return;
}
container.innerHTML = "<p style='color:#666; text-align:center;'>Loading...</p>";
try {
const r = await fetch(`/orderbook/${symbol}`);
const book = await r.json();
const bids = (book.bids || []).sort((a,b) => (b.price || 0) - (a.price || 0));
const asks = (book.asks || []).sort((a,b) => (a.price || 0) - (b.price || 0));
let html = `<div style="text-align:center; margin-bottom:10px; font-size:12px; color:#666;">
${bids.length} bids | ${asks.length} asks
</div>`;
html += `<table style="width:100%; font-size:12px; border-collapse:collapse;">
<thead>
<tr style="background:#f0f0f0;">
<th style="padding:6px; border:1px solid #ddd; color:#2e7d32;">Bid Qty</th>
<th style="padding:6px; border:1px solid #ddd; color:#2e7d32;">Bid Price</th>
<th style="padding:6px; border:1px solid #ddd; color:#c62828;">Ask Price</th>
<th style="padding:6px; border:1px solid #ddd; color:#c62828;">Ask Qty</th>
</tr>
</thead>
<tbody>`;
const maxRows = Math.max(bids.length, asks.length, 1);
for (let i = 0; i < Math.min(maxRows, 20); i++) {
const b = bids[i] || {};
const a = asks[i] || {};
html += `<tr>
<td style="padding:4px 6px; border:1px solid #eee; color:#2e7d32; font-weight:bold; text-align:right;">${b.quantity ?? ""}</td>
<td style="padding:4px 6px; border:1px solid #eee; color:#2e7d32; text-align:right;">${b.price !== undefined ? Number(b.price).toFixed(2) : ""}</td>
<td style="padding:4px 6px; border:1px solid #eee; color:#c62828; text-align:left;">${a.price !== undefined ? Number(a.price).toFixed(2) : ""}</td>
<td style="padding:4px 6px; border:1px solid #eee; color:#c62828; font-weight:bold; text-align:left;">${a.quantity ?? ""}</td>
</tr>`;
}
html += `</tbody></table>`;
if (bids.length === 0 && asks.length === 0) {
html = "<p style='color:#666; text-align:center; margin-top:20px;'>No resting orders</p>";
}
container.innerHTML = html;
} catch (e) {
container.innerHTML = `<p style='color:red; text-align:center;'>Error: ${e.message}</p>`;
}
}
// Alias for backward compatibility
function renderFullBBO() { renderOrderBookPanel(); }
// ── Unified price chart: candlesticks + close line + volume ────────────────
// points: [{open, high, low, close, volume, ts}]
function drawChart(points, labelSuffix) {
const canvas = document.getElementById("history-chart");
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth || 500;
canvas.height = canvas.offsetHeight || 300;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!points || points.length === 0) {
ctx.fillStyle = "#999"; ctx.font = "13px Arial"; ctx.textAlign = "center";
ctx.fillText("No data for this period", canvas.width / 2, canvas.height / 2);
return;
}
const pad = { top: 18, right: 46, bottom: 32, left: 55 };
const W = canvas.width - pad.left - pad.right;
const H = canvas.height - pad.top - pad.bottom;
const priceH = H * 0.70;
const gapH = H * 0.03;
const volH = H * 0.27;
const priceY = pad.top;
const volY = pad.top + priceH + gapH;
const maxP = Math.max(...points.map(p => p.high)) * 1.002;
const minP = Math.min(...points.map(p => p.low)) * 0.998;
const maxV = Math.max(...points.map(p => p.volume), 1);
const toY = p => priceY + priceH - ((p - minP) / (maxP - minP)) * priceH;
const n = points.length;
const slotW = W / n;
const bodyW = Math.max(1, Math.floor(slotW * 0.65));
const volBarW = Math.max(1, Math.min(Math.floor(slotW * 0.25), 5));
// Price grid
ctx.strokeStyle = "#eee"; ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = priceY + priceH * i / 4;
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(canvas.width - pad.right, y); ctx.stroke();
ctx.fillStyle = "#888"; ctx.font = "9px Arial"; ctx.textAlign = "right";
ctx.fillText((maxP - (maxP - minP) * i / 4).toFixed(2), pad.left - 3, y + 3);
}
// Volume / price divider
ctx.strokeStyle = "#e0e0e0";
ctx.beginPath(); ctx.moveTo(pad.left, volY); ctx.lineTo(canvas.width - pad.right, volY); ctx.stroke();
// Volume bars
points.forEach((p, i) => {
const x = pad.left + i * slotW + (slotW - volBarW) / 2;
const bh = (p.volume / maxV) * volH;
ctx.fillStyle = p.close >= p.open ? "rgba(38,166,154,0.4)" : "rgba(239,83,80,0.4)";
ctx.fillRect(x, volY + volH - bh, volBarW, bh);
});
// Volume Y-axis labels (right side)
ctx.fillStyle = "#888"; ctx.font = "9px Arial"; ctx.textAlign = "left";
[1.0, 0.5, 0.0].forEach(frac => {
const val = Math.round(maxV * frac);
const y = volY + volH * (1 - frac);
const lbl = val >= 1000 ? (val / 1000).toFixed(1) + "k" : val.toString();
ctx.fillText(lbl, canvas.width - pad.right + 3, y + 3);
});
// Candlestick wicks + bodies
points.forEach((p, i) => {
const midX = pad.left + i * slotW + slotW / 2;
const x = pad.left + i * slotW + (slotW - bodyW) / 2;
const isUp = p.close >= p.open;
const color = isUp ? "#26a69a" : "#ef5350";
// Wick
ctx.strokeStyle = color; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(midX, toY(p.high)); ctx.lineTo(midX, toY(p.low)); ctx.stroke();
});
// Close-price line + dots (orange)
ctx.strokeStyle = "rgba(255,152,0,0.85)";
ctx.lineWidth = 1.5;
ctx.beginPath();
points.forEach((p, i) => {
const x = pad.left + i * slotW + slotW / 2;
i === 0 ? ctx.moveTo(x, toY(p.close)) : ctx.lineTo(x, toY(p.close));
});
ctx.stroke();
ctx.fillStyle = "rgba(255,152,0,0.9)";
points.forEach((p, i) => {
const x = pad.left + i * slotW + slotW / 2;
ctx.beginPath();
ctx.arc(x, toY(p.close), 2, 0, Math.PI * 2);
ctx.fill();
});
// Time labels (up to 5 evenly spaced)
ctx.fillStyle = "#888"; ctx.font = "9px Arial"; ctx.textAlign = "center";
const step = Math.max(1, Math.floor(n / 5));
for (let i = 0; i < n; i += step) {
const dt = new Date(points[i].ts * 1000);
const lbl = (dt.getMonth()+1) + "/" + dt.getDate() + " "
+ dt.getHours().toString().padStart(2,"0") + ":"
+ dt.getMinutes().toString().padStart(2,"0");
ctx.fillText(lbl, pad.left + i * slotW + slotW / 2, canvas.height - pad.bottom + 12);
}
// Legend
ctx.font = "9px Arial"; ctx.textAlign = "left";
ctx.fillStyle = "#26a69a"; ctx.fillText("β–²", pad.left, 12);
ctx.fillStyle = "#ef5350"; ctx.fillText("β–Ό", pad.left + 14, 12);
ctx.fillStyle = "#888"; ctx.fillText("candle", pad.left + 24, 12);
ctx.fillStyle = "rgba(255,152,0,0.9)";
ctx.beginPath(); ctx.arc(pad.left + 72, 9, 2.5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#ff9800"; ctx.fillText("close", pad.left + 80, 12);
if (labelSuffix) { ctx.fillStyle = "#bbb"; ctx.fillText(labelSuffix, pad.left + 114, 12); }
}
function renderOrderBook(book) {
const bids = (book.bids || []).sort((a,b) => (b.price || 0) - (a.price || 0));
const asks = (book.asks || []).sort((a,b) => (a.price || 0) - (b.price || 0));
const maxRows = Math.max(bids.length, asks.length, 1);
let html = "<table><tr><th>Bid Qty</th><th>Bid Price</th><th>Ask Price</th><th>Ask Qty</th></tr>";
for (let i = 0; i < maxRows; i++) {
const b = bids[i] || {};
const a = asks[i] || {};
html += `<tr>
<td style="color:green;">${b.quantity ?? ""}</td>
<td style="color:green;">${b.price !== undefined ? Number(b.price).toFixed(2) : ""}</td>
<td style="color:red;">${a.price !== undefined ? Number(a.price).toFixed(2) : ""}</td>
<td style="color:red;">${a.quantity ?? ""}</td>
</tr>`;
}
html += "</table>";
if (bids.length === 0 && asks.length === 0) {
html = "<p style='color:#666;text-align:center;margin-top:20px;'>No orders in book</p>";
}
return html;
}
function renderStats() {
// Calculate statistics from trades
const stats = {};
let maxVolume = 0;
let maxValue = 0;
for (const trade of state.trades) {
const sym = trade.symbol || "?";
if (!stats[sym]) {
stats[sym] = { trades: 0, volume: 0, totalValue: 0, startPrice: null, lastPrice: 0 };
}
const qty = trade.quantity || trade.qty || 0;
const price = trade.price || 0;
stats[sym].trades++;
stats[sym].volume += qty;
stats[sym].totalValue += qty * price;
if (stats[sym].startPrice === null) stats[sym].startPrice = price;
stats[sym].lastPrice = price;
if (stats[sym].volume > maxVolume) maxVolume = stats[sym].volume;
if (stats[sym].totalValue > maxValue) maxValue = stats[sym].totalValue;
}
// Render stats table
const tbody = document.getElementById("stats-body");
tbody.innerHTML = "";
const symbols = Object.keys(stats).sort();
for (const sym of symbols) {
const s = stats[sym];
const vwap = s.volume > 0 ? (s.totalValue / s.volume).toFixed(2) : "-";
const row = document.createElement("tr");
row.innerHTML = `
<td><strong>${sym}</strong></td>
<td>${s.trades}</td>
<td>${s.volume.toLocaleString()}</td>
<td>${s.totalValue.toLocaleString(undefined, {minimumFractionDigits:0, maximumFractionDigits:0})}</td>
<td>${s.startPrice !== null ? s.startPrice.toFixed(2) : "-"}</td>
<td>${s.lastPrice.toFixed(2)}</td>
<td>${vwap}</td>
`;
tbody.appendChild(row);
}
// Render grouped bar chart (Volume + Value side by side per symbol)
const statsChart = document.getElementById("stats-chart");
statsChart.innerHTML = "";
if (symbols.length === 0) {
statsChart.innerHTML = "<p style='color:#666; text-align:center; font-size:11px;'>No data</p>";
return;
}
for (const sym of symbols) {
const s = stats[sym];
const volPct = maxVolume > 0 ? (s.volume / maxVolume * 100) : 0;
const valPct = maxValue > 0 ? (s.totalValue / maxValue * 100) : 0;
const group = document.createElement("div");
group.style.cssText = "display:flex; flex-direction:column; align-items:center; gap:2px;";
group.innerHTML = `
<div style="display:flex; gap:2px; align-items:flex-end; height:80px;">
<div style="display:flex; flex-direction:column; align-items:center;">
<span style="font-size:9px; color:#4CAF50;">${s.volume >= 1000 ? (s.volume/1000).toFixed(1)+'k' : s.volume}</span>
<div style="width:20px; height:70px; background:#eee; border-radius:2px; display:flex; align-items:flex-end; overflow:hidden;">
<div style="width:100%; height:${volPct}%; background:linear-gradient(0deg, #4CAF50, #8BC34A);"></div>
</div>
</div>
<div style="display:flex; flex-direction:column; align-items:center;">
<span style="font-size:9px; color:#2196F3;">${s.totalValue >= 1000 ? (s.totalValue/1000).toFixed(1)+'k' : s.totalValue.toFixed(0)}</span>
<div style="width:20px; height:70px; background:#eee; border-radius:2px; display:flex; align-items:flex-end; overflow:hidden;">
<div style="width:100%; height:${valPct}%; background:linear-gradient(0deg, #2196F3, #64B5F6);"></div>
</div>
</div>
</div>
<span style="font-size:11px; font-weight:bold;">${sym}</span>
`;
statsChart.appendChild(group);
}
}
async function fetchOrderBook() {
// Deprecated - now using stats panel
renderStats();
}
// SSE Connection
let eventSource = null;
let reconnectTimeout = null;
function connectSSE() {
if (eventSource) {
eventSource.close();
}
setStatus("connecting", "Connecting...");
eventSource = new EventSource("/stream");
eventSource.addEventListener("connected", (e) => {
setStatus("connected", "Live");
state.connected = true;
});
eventSource.addEventListener("init", (e) => {
const data = JSON.parse(e.data);
state.orders = data.orders || [];
state.bbos = data.bbos || {};
state.trades = data.trades || [];
renderOrders();
renderTrades();
renderBBOs();
renderStats();
loadHistory();
});
eventSource.addEventListener("order", (e) => {
const order = JSON.parse(e.data);
state.orders.unshift(order);
if (state.orders.length > 50) state.orders.pop();
renderOrders();
});
eventSource.addEventListener("trade", (e) => {
const trade = JSON.parse(e.data);
state.trades.unshift(trade);
if (state.trades.length > 200) state.trades.pop();
renderTrades();
renderStats();
if (document.getElementById("history-period").value === "live") loadHistory();
});
eventSource.addEventListener("snapshot", (e) => {
const snap = JSON.parse(e.data);
updateBBO(snap.symbol, snap);
});
eventSource.addEventListener("session", (e) => {
const data = JSON.parse(e.data);
const active = data.status === "started" || data.status === "active" || data.status === "suspended";
const suspended = data.status === "suspended";
updateSessionBadge(active, suspended);
});
eventSource.addEventListener("mode", (e) => {
const data = JSON.parse(e.data);
updateModeBtn(data.mode);
});
eventSource.addEventListener("ai_insights_init", (e) => {
const insights = JSON.parse(e.data);
insights.forEach(addInsight);
});
eventSource.addEventListener("llm_config", (e) => {
const cfg = JSON.parse(e.data);
const prov = cfg.provider || "auto";
const model = cfg.model || null;
document.getElementById("ai-provider-select").value = prov;
populateModelDropdown(prov, model);
updateLLMBadge(prov, model);
});
eventSource.addEventListener("ai_insight", (e) => {
const insight = JSON.parse(e.data);
addInsight(insight);
const isErr = insight.source === "error" || insight.source === "config"
|| (insight.text || "").startsWith("⚠️");
const statusEl = document.getElementById("ai-status");
statusEl.textContent = isErr ? "error β€” see below" : "Last update: " + new Date().toLocaleTimeString();
statusEl.style.color = isErr ? "#e53935" : "#999";
const btn = document.getElementById("ai-generate-btn");
if (btn._tick) { clearInterval(btn._tick); btn._tick = null; }
btn.disabled = false;
btn.textContent = "✨ Generate Now";
});
eventSource.onerror = () => {
setStatus("disconnected", "Disconnected");
state.connected = false;
eventSource.close();
// Reconnect after 3 seconds
reconnectTimeout = setTimeout(connectSSE, 3000);
};
}
// Fetch data from REST API
async function fetchData() {
try {
const r = await fetch("/data");
const data = await r.json();
state.orders = data.orders || [];
state.trades = data.trades || [];
state.bbos = data.bbos || {};
renderOrders();
renderTrades();
renderBBOs();
renderStats();
loadHistory();
} catch (e) {
console.error("Fetch data failed:", e);
}
}
// ── Session control ────────────────────────────────────────────────────────
let _sessionActive = false;
let _sessionSuspended = false;
let _sessionMode = "automatic";
function updateSessionBadge(active, suspended) {
const badge = document.getElementById("session-badge");
const text = document.getElementById("session-text");
const btn = document.getElementById("suspend-btn");
const dayBtn = document.getElementById("day-btn");
_sessionActive = !!active;
_sessionSuspended = !!suspended;
const isAuto = _sessionMode === "automatic";
if (!active) {
badge.className = "status idle";
text.textContent = "IDLE";
btn.disabled = true;
btn.textContent = "Suspend";
btn.className = "btn-day btn-suspend";
dayBtn.textContent = "Start of Day";
dayBtn.className = "btn-day btn-start";
dayBtn.disabled = isAuto; // auto handles start; manual can't
} else if (suspended) {
badge.className = "status suspended";
text.textContent = "PAUSED";
btn.disabled = false;
btn.textContent = "Resume";
btn.className = "btn-day btn-resume";
dayBtn.textContent = "End of Day";
dayBtn.className = "btn-day btn-end";
dayBtn.disabled = false; // always allow manual end
} else {
badge.className = "status active";
text.textContent = "ACTIVE";
btn.disabled = false;
btn.textContent = "Suspend";
btn.className = "btn-day btn-suspend";
dayBtn.textContent = "End of Day";
dayBtn.className = "btn-day btn-end";
dayBtn.disabled = false; // always allow manual end
}
}
function updateModeBtn(mode) {
const modeBtn = document.getElementById("mode-btn");
_sessionMode = mode;
if (mode === "automatic") {
modeBtn.textContent = "Automatic";
modeBtn.className = "btn-day btn-automatic";
} else {
modeBtn.textContent = "Manual";
modeBtn.className = "btn-day btn-manual";
}
updateSessionBadge(_sessionActive, _sessionSuspended);
}
async function toggleDay() {
if (_sessionActive) {
if (!confirm("End of Day: stop simulation and save closing prices?")) return;
try {
const r = await fetch("/session/end", { method: "POST" });
const result = await r.json();
if (result.status !== "ok") alert("Error: " + (result.error || "Unknown error"));
} catch (e) {
alert("Error: " + e.message);
}
} else {
if (!confirm("Start of Day: reset all security prices to their opening values and begin simulation?")) return;
try {
const r = await fetch("/session/start", { method: "POST" });
const result = await r.json();
if (result.status !== "ok") alert("Error: " + (result.error || "Unknown error"));
} catch (e) {
alert("Error: " + e.message);
}
}
}
async function toggleSuspend() {
try {
const url = _sessionSuspended ? "/session/resume" : "/session/suspend";
const r = await fetch(url, { method: "POST" });
const result = await r.json();
if (result.status !== "ok") alert("Error: " + (result.error || "Unknown error"));
} catch (e) {
alert("Error: " + e.message);
}
}
async function toggleMode() {
try {
const r = await fetch("/session/mode", { method: "POST" });
const result = await r.json();
if (result.status !== "ok") alert("Error: " + (result.error || "Unknown error"));
} catch (e) {
alert("Error: " + e.message);
}
}
// ── Price History chart ────────────────────────────────────────────────────
const CHART_COLORS = [
"#2196F3","#4CAF50","#FF9800","#E91E63","#9C27B0",
"#00BCD4","#FF5722","#8BC34A","#607D8B","#F44336"
];
// ── Multi-symbol normalised % chart ────────────────────────────────────────
function drawMultiChart(series, labelSuffix) {
const canvas = document.getElementById("history-chart");
const ctx = canvas.getContext("2d");
canvas.width = canvas.offsetWidth || 500;
canvas.height = canvas.offsetHeight || 300;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const active = series.filter(s => s.points.length > 0);
if (!active.length) {
ctx.fillStyle = "#999"; ctx.font = "13px Arial"; ctx.textAlign = "center";
ctx.fillText("No data for this period", canvas.width / 2, canvas.height / 2);
return;
}
const hasVolume = active.some(s => s.points.some(p => (p.volume || 0) > 0));
const pad = { top: 18, right: 48, bottom: 32, left: 46 };
const W = canvas.width - pad.left - pad.right;
const H = canvas.height - pad.top - pad.bottom;
const priceH = hasVolume ? H * 0.70 : H;
const gapH = hasVolume ? H * 0.03 : 0;
const volH = hasVolume ? H * 0.27 : 0;
const priceY = pad.top;
const volY = pad.top + priceH + gapH;
// Normalise each series to % change from its first point
const norm = active.map(s => {
const base = s.points[0].price || 1;
return { ...s, pcts: s.points.map(p => (p.price - base) / base * 100) };
});
const allPcts = norm.flatMap(s => s.pcts);
let minPct = Math.min(...allPcts);
let maxPct = Math.max(...allPcts);
const rng = maxPct - minPct || 1;
minPct -= rng * 0.06; maxPct += rng * 0.06;
// Global ts range
const allTs = active.flatMap(s => s.points.map(p => p.ts));
const minTs = Math.min(...allTs);
const maxTs = Math.max(...allTs);
const tsRng = maxTs - minTs || 1;
const toX = ts => pad.left + ((ts - minTs) / tsRng) * W;
const toY = pct => priceY + priceH - ((pct - minPct) / (maxPct - minPct)) * priceH;
// Price grid + Y labels
ctx.strokeStyle = "#eee"; ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const pct = minPct + (maxPct - minPct) * i / 4;
const y = toY(pct);
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + W, y); ctx.stroke();
ctx.fillStyle = "#888"; ctx.font = "9px Arial"; ctx.textAlign = "right";
ctx.fillText(pct.toFixed(1) + "%", pad.left - 3, y + 3);
}
// Zero line
if (minPct < 0 && maxPct > 0) {
const y0 = toY(0);
ctx.strokeStyle = "#ccc"; ctx.lineWidth = 1; ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.moveTo(pad.left, y0); ctx.lineTo(pad.left + W, y0); ctx.stroke();
ctx.setLineDash([]);
}
// Series lines
norm.forEach((s, idx) => {
const color = CHART_COLORS[idx % CHART_COLORS.length];
ctx.strokeStyle = color; ctx.lineWidth = 1.5;
ctx.beginPath();
s.points.forEach((p, i) => {
const x = toX(p.ts);
const y = toY(s.pcts[i]);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
});
// Volume section β€” aggregate all symbols by time bucket
if (hasVolume) {
const volMap = {};
active.forEach(s => {
s.points.forEach(p => {
const bucket = Math.round(p.ts / 60) * 60;
volMap[bucket] = (volMap[bucket] || 0) + (p.volume || 0);
});
});
const volPoints = Object.entries(volMap)
.map(([ts, vol]) => ({ ts: +ts, volume: vol }))
.sort((a, b) => a.ts - b.ts);
const maxVol = Math.max(...volPoints.map(v => v.volume), 1);
// Divider
ctx.strokeStyle = "#e0e0e0"; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(pad.left, volY); ctx.lineTo(canvas.width - pad.right, volY); ctx.stroke();
// Bars
const volSlotW = W / Math.max(volPoints.length, 1);
const volBarW = Math.max(1, Math.min(Math.floor(volSlotW * 0.25), 5));
volPoints.forEach(v => {
const x = toX(v.ts) - volBarW / 2;
const bh = (v.volume / maxVol) * volH;
ctx.fillStyle = "rgba(96,125,139,0.5)";
ctx.fillRect(x, volY + volH - bh, volBarW, bh);
});
// Volume Y-axis labels (right side)
ctx.fillStyle = "#888"; ctx.font = "9px Arial"; ctx.textAlign = "left";
[1.0, 0.5, 0.0].forEach(frac => {
const val = Math.round(maxVol * frac);
const y = volY + volH * (1 - frac);
const lbl = val >= 1000 ? (val / 1000).toFixed(1) + "k" : val.toString();
ctx.fillText(lbl, canvas.width - pad.right + 3, y + 3);
});
}
// Time labels (up to 4)
ctx.fillStyle = "#888"; ctx.font = "9px Arial"; ctx.textAlign = "center";
for (let i = 0; i <= 3; i++) {
const ts = minTs + tsRng * i / 3;
const dt = new Date(ts * 1000);
const lbl = (dt.getMonth()+1)+"/"+dt.getDate()+" "
+ dt.getHours().toString().padStart(2,"0")+":"
+ dt.getMinutes().toString().padStart(2,"0");
ctx.fillText(lbl, toX(ts), canvas.height - pad.bottom + 12);
}
// Right-side vertical legend
ctx.font = "9px Arial"; ctx.textAlign = "left";
norm.forEach((s, idx) => {
const color = CHART_COLORS[idx % CHART_COLORS.length];
const ly = pad.top + idx * 13;
ctx.fillStyle = color;
ctx.fillRect(canvas.width - pad.right + 2, ly - 1, 10, 3);
ctx.fillText(s.symbol, canvas.width - pad.right + 14, ly + 3);
});
if (labelSuffix) {
ctx.fillStyle = "#bbb"; ctx.textAlign = "right";
ctx.fillText(labelSuffix, canvas.width - pad.right - 2, 12);
}
}
async function loadAllHistory() {
const period = document.getElementById("history-period").value;
const statusEl = document.getElementById("history-status");
const symbols = Object.keys(state.bbos).sort();
if (!symbols.length) {
drawMultiChart([], "");
statusEl.textContent = "No symbols yet";
return;
}
if (period === "live") {
const series = symbols.map(sym => {
let trades = state.trades.filter(t => t.symbol === sym).slice(0, 100).reverse();
return { symbol: sym, points: trades.map(t => ({ price: t.price || 0, ts: t.timestamp || Date.now()/1000, volume: t.quantity || t.qty || 0 })) };
});
drawMultiChart(series, `${symbols.length} symbols`);
statusEl.textContent = `${symbols.length} symbols`;
return;
}
statusEl.textContent = "Loading...";
try {
const results = await Promise.all(
symbols.map(sym => fetch(`/history/${sym}?period=${period}`).then(r => r.json()))
);
const series = symbols.map((sym, i) => ({
symbol: sym,
points: (results[i].candles || []).map(c => ({ price: c.c, ts: c.t, volume: c.v || 0 }))
}));
const filled = series.filter(s => s.points.length > 0);
drawMultiChart(series, `${filled.length} symbols`);
statusEl.textContent = `${filled.length} symbols`;
} catch (e) {
statusEl.textContent = "Error: " + e.message;
}
}
async function loadHistory() {
const sym = document.getElementById("history-symbol").value;
const period = document.getElementById("history-period").value;
const statusEl = document.getElementById("history-status");
if (!sym) { await loadAllHistory(); return; }
if (period === "live") {
let trades = state.trades.filter(t => t.symbol === sym);
trades = trades.slice(0, 100).reverse();
// Individual trades have no spread: treat each tick as o=h=l=c
const points = trades.map(t => {
const p = t.price || 0;
return { open: p, high: p, low: p, close: p,
volume: t.quantity || t.qty || 0,
ts: t.timestamp || Date.now() / 1000 };
});
drawChart(points, `${trades.length} ticks`);
statusEl.textContent = trades.length ? `${trades.length} ticks` : "No trades yet";
return;
}
statusEl.textContent = "Loading...";
try {
const r = await fetch(`/history/${sym}?period=${period}`);
const data = await r.json();
const candles = data.candles || [];
const points = candles.map(c => ({
open: c.o, high: c.h, low: c.l, close: c.c, volume: c.v, ts: c.t
}));
drawChart(points, `${candles.length} candles`);
statusEl.textContent = candles.length ? `${candles.length} candles` : "No data yet";
} catch (e) {
statusEl.textContent = "Error: " + e.message;
}
}
// Initial load: fetch data via REST, then connect SSE
// ── Local clock ───────────────────────────────────────────────────────────
(function tickClock() {
const el = document.getElementById("local-clock");
if (el) {
const now = new Date();
const h = now.getHours() .toString().padStart(2, "0");
const m = now.getMinutes().toString().padStart(2, "0");
const s = now.getSeconds().toString().padStart(2, "0");
const sep = now.getSeconds() % 2 === 0
? '<span style="opacity:1">:</span>'
: '<span style="opacity:0.2">:</span>';
el.innerHTML = h + sep + m + sep + s;
}
setTimeout(tickClock, 1000);
})();
async function init() {
await fetchData();
connectSSE();
loadAIConfig();
// Pre-populate history symbol dropdown from DB (shows chart before session starts)
try {
const resp = await fetch("/history/symbols");
const data = await resp.json();
const histSel = document.getElementById("history-symbol");
const existing = new Set(Array.from(histSel.options).map(o => o.value));
(data.symbols || []).forEach(sym => {
if (!existing.has(sym)) {
const opt = document.createElement("option");
opt.value = sym; opt.textContent = sym;
histSel.appendChild(opt);
}
});
// Default to 1W view if there's historical data and no live session
if (data.symbols && data.symbols.length > 0 && !document.getElementById("history-period").value) {
document.getElementById("history-period").value = "1w";
}
loadHistory();
} catch(e) { /* ignore */ }
// Refresh order book panel every 3 seconds
setInterval(() => {
const sym = document.getElementById("bbo-symbol-select").value;
if (sym) renderOrderBookPanel();
}, 3000);
// Fallback: poll data every 5 seconds in case SSE fails
setInterval(fetchData, 5000);
}
init();
</script>
</body>
</html>