| <!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-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; } |
| |
| .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-row { cursor: pointer; } |
| .selectable-row:hover { background: #f5f5f5; } |
| .selectable-row.selected { background: #e3f2fd !important; } |
| |
| |
| .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; } |
| |
| |
| .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; } |
| |
| |
| .panel-full { |
| background: #fff; |
| border-radius: 8px; |
| padding: 10px; |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1); |
| margin-top: 20px; |
| } |
| |
| |
| @media (max-width: 768px) { |
| body { margin: 10px; } |
| h1 { font-size: 18px; flex-wrap: wrap; gap: 8px; } |
| |
| h1 a { margin-left: 0 !important; } |
| .container { grid-template-columns: 1fr; } |
| .panel { height: 300px; } |
| |
| .panel > div { overflow-x: auto; } |
| .ticker-item { font-size: 11px; padding: 0 10px; } |
| #history-chart { height: 220px !important; } |
| |
| #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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
| |
| <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> |
| |
| const state = { |
| orders: [], |
| trades: [], |
| bbos: {}, |
| connected: false, |
| lastPrices: {} |
| }; |
| |
| 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}`; |
| } |
| |
| |
| let selectedOrder = null; |
| |
| |
| const LLM_MODELS = { auto: [], groq: [], hf: [], ollama: [] }; |
| |
| async function loadAIConfig() { |
| try { |
| const r = await fetch("/ai/config"); |
| const cfg = await r.json(); |
| |
| for (const [p, info] of Object.entries(cfg.providers || {})) { |
| if (info.models && info.models.length) LLM_MODELS[p] = info.models; |
| } |
| |
| 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(); |
| 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) {} |
| |
| |
| let elapsed = 0; |
| const tick = setInterval(() => { |
| elapsed += 1; |
| status.textContent = `generating⦠${elapsed}s`; |
| }, 1000); |
| |
| |
| setTimeout(() => { |
| clearInterval(tick); |
| btn.disabled = false; |
| btn.textContent = "β¨ Generate Now"; |
| status.textContent = "timed out β check HF token / try again"; |
| }, 95000); |
| |
| |
| 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) { |
| |
| if (selectedOrder && selectedOrder.index === index) { |
| selectedOrder = null; |
| } else { |
| selectedOrder = { index, orderId, symbol, qty, price }; |
| } |
| |
| 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; |
| } |
| |
| |
| 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]; |
| |
| |
| let direction = "neutral"; |
| let arrow = "β"; |
| if (lastPrice !== undefined) { |
| if (price > lastPrice) { |
| direction = "up"; |
| arrow = "β²"; |
| } else if (price < lastPrice) { |
| direction = "down"; |
| arrow = "βΌ"; |
| } |
| } |
| |
| |
| 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> |
| `; |
| } |
| |
| |
| ticker.innerHTML = tickerHTML + tickerHTML; |
| } |
| |
| |
| 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(); |
| |
| |
| 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(); |
| } |
| |
| |
| 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(); |
| } 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>`; |
| } |
| } |
| |
| |
| function renderFullBBO() { renderOrderBookPanel(); } |
| |
| |
| |
| 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)); |
| |
| |
| 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); |
| } |
| |
| |
| ctx.strokeStyle = "#e0e0e0"; |
| ctx.beginPath(); ctx.moveTo(pad.left, volY); ctx.lineTo(canvas.width - pad.right, volY); ctx.stroke(); |
| |
| |
| 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); |
| }); |
| |
| |
| 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); |
| }); |
| |
| |
| 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"; |
| |
| |
| ctx.strokeStyle = color; ctx.lineWidth = 1; |
| ctx.beginPath(); ctx.moveTo(midX, toY(p.high)); ctx.lineTo(midX, toY(p.low)); ctx.stroke(); |
| |
| }); |
| |
| |
| 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(); |
| }); |
| |
| |
| 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); |
| } |
| |
| |
| 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() { |
| |
| 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; |
| } |
| |
| |
| 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); |
| } |
| |
| |
| 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() { |
| |
| renderStats(); |
| } |
| |
| |
| 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(); |
| |
| reconnectTimeout = setTimeout(connectSSE, 3000); |
| }; |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| |
| 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; |
| } 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; |
| } 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; |
| } |
| } |
| |
| 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); |
| } |
| } |
| |
| |
| |
| const CHART_COLORS = [ |
| "#2196F3","#4CAF50","#FF9800","#E91E63","#9C27B0", |
| "#00BCD4","#FF5722","#8BC34A","#607D8B","#F44336" |
| ]; |
| |
| |
| 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; |
| |
| |
| 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; |
| |
| |
| 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; |
| |
| |
| 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); |
| } |
| |
| |
| 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([]); |
| } |
| |
| |
| 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(); |
| }); |
| |
| |
| 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); |
| |
| |
| ctx.strokeStyle = "#e0e0e0"; ctx.lineWidth = 1; |
| ctx.beginPath(); ctx.moveTo(pad.left, volY); ctx.lineTo(canvas.width - pad.right, volY); ctx.stroke(); |
| |
| |
| 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); |
| }); |
| |
| |
| 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); |
| }); |
| } |
| |
| |
| 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); |
| } |
| |
| |
| 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(); |
| |
| 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; |
| } |
| } |
| |
| |
| |
| (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(); |
| |
| |
| 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); |
| } |
| }); |
| |
| if (data.symbols && data.symbols.length > 0 && !document.getElementById("history-period").value) { |
| document.getElementById("history-period").value = "1w"; |
| } |
| loadHistory(); |
| } catch(e) { } |
| |
| |
| setInterval(() => { |
| const sym = document.getElementById("bbo-symbol-select").value; |
| if (sym) renderOrderBookPanel(); |
| }, 3000); |
| |
| |
| setInterval(fetchData, 5000); |
| } |
| |
| init(); |
| </script> |
| </body> |
| </html> |
|
|