RayMelius Claude Sonnet 4.6 commited on
Commit
f3da511
·
1 Parent(s): 7db3fdc

Merge Trade Chart and Price History into unified Price Chart panel

Browse files

- Remove standalone Trade Chart panel from the grid
- Rename Price History panel to Price Chart (full-width, below grid)
- Add "Live" as default period option: shows last 100 trades as a line chart
that auto-refreshes on every SSE trade event
- Historical periods (1H/8H/1D/1W/1M) now also use a consistent line chart
style (close price line + gradient fill + volume bars) instead of candlesticks
- Unified drawChart() function renders both live trades and OHLCV candles with
the same visual: green line, gradient fill, blue volume bars, time axis
- Remove renderPriceChart() and dead #chart-symbol dropdown references

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. dashboard/templates/index.html +113 -203
dashboard/templates/index.html CHANGED
@@ -290,15 +290,6 @@
290
  </div>
291
  </div>
292
 
293
- <div class="panel">
294
- <h2>Trade Chart
295
- <select id="chart-symbol" onchange="renderPriceChart()" style="margin-left:10px; padding:2px 5px;">
296
- <option value="">All Symbols</option>
297
- </select>
298
- </h2>
299
- <canvas id="price-chart" style="width:100%; flex-grow:1;"></canvas>
300
- </div>
301
-
302
  <div class="panel">
303
  <h2>Order Book
304
  <select id="bbo-symbol-select" onchange="renderOrderBookPanel()" style="margin-left:10px; padding:2px 5px;">
@@ -337,16 +328,17 @@
337
 
338
  </div>
339
 
340
- <!-- Price History panel (full width) -->
341
  <div class="panel-full">
342
- <h2>Price History
343
  <select id="history-symbol" onchange="loadHistory()" style="margin-left:10px; padding:2px 5px;">
344
  <option value="">Select symbol...</option>
345
  </select>
346
  <select id="history-period" onchange="loadHistory()" style="margin-left:5px; padding:2px 5px;">
 
347
  <option value="1h">1 Hour</option>
348
  <option value="8h">8 Hours</option>
349
- <option value="1d" selected>1 Day</option>
350
  <option value="1w">1 Week</option>
351
  <option value="1m">1 Month</option>
352
  </select>
@@ -674,20 +666,6 @@
674
  renderOrderBookPanel();
675
  }
676
 
677
- // Update chart symbol select
678
- const chartSel = document.getElementById("chart-symbol");
679
- const chartCurrent = chartSel.value;
680
- const existingOpts = Array.from(chartSel.options).map(o => o.value);
681
- symbols.forEach(sym => {
682
- if (!existingOpts.includes(sym)) {
683
- const opt = document.createElement("option");
684
- opt.value = sym;
685
- opt.textContent = sym;
686
- chartSel.appendChild(opt);
687
- }
688
- });
689
- if (chartCurrent) chartSel.value = chartCurrent;
690
-
691
  // Update history symbol select
692
  const histSel = document.getElementById("history-symbol");
693
  const histCurrent = histSel.value;
@@ -768,103 +746,113 @@
768
  // Alias for backward compatibility
769
  function renderFullBBO() { renderOrderBookPanel(); }
770
 
771
- function renderPriceChart() {
772
- const canvas = document.getElementById("price-chart");
773
- const ctx = canvas.getContext("2d");
774
- const selectedSymbol = document.getElementById("chart-symbol").value;
775
-
776
- // Filter trades
777
- let trades = state.trades.filter(t => !selectedSymbol || t.symbol === selectedSymbol);
778
- trades = trades.slice(0, 100).reverse(); // Last 100, oldest first
779
-
780
- if (trades.length === 0) {
781
- ctx.clearRect(0, 0, canvas.width, canvas.height);
782
- ctx.fillStyle = "#666";
783
- ctx.font = "14px Arial";
784
- ctx.textAlign = "center";
785
- ctx.fillText("No trade data available", canvas.width / 2, canvas.height / 2);
786
  return;
787
  }
788
 
789
- // Set canvas size
790
- canvas.width = canvas.offsetWidth || 400;
791
- canvas.height = canvas.offsetHeight || 300;
792
-
793
- const padding = { top: 20, right: 60, bottom: 50, left: 60 };
794
- const chartWidth = canvas.width - padding.left - padding.right;
795
- const chartHeight = canvas.height - padding.top - padding.bottom;
 
796
 
797
- // Calculate ranges
798
- const prices = trades.map(t => t.price);
799
- const volumes = trades.map(t => t.quantity || t.qty || 0);
800
- const minPrice = Math.min(...prices) * 0.995;
801
- const maxPrice = Math.max(...prices) * 1.005;
802
- const maxVolume = Math.max(...volumes);
803
 
804
- ctx.clearRect(0, 0, canvas.width, canvas.height);
805
 
806
- // Draw grid
807
- ctx.strokeStyle = "#eee";
808
- ctx.lineWidth = 1;
809
  for (let i = 0; i <= 5; i++) {
810
- const y = padding.top + (chartHeight * i / 5);
811
- ctx.beginPath();
812
- ctx.moveTo(padding.left, y);
813
- ctx.lineTo(canvas.width - padding.right, y);
814
- ctx.stroke();
815
  }
816
 
817
- // Draw volume bars
818
- const barWidth = Math.max(2, chartWidth / trades.length - 2);
819
- ctx.fillStyle = "rgba(33, 150, 243, 0.3)";
820
- trades.forEach((t, i) => {
821
- const x = padding.left + (i / trades.length) * chartWidth;
822
- const vol = t.quantity || t.qty || 0;
823
- const barHeight = maxVolume > 0 ? (vol / maxVolume) * (chartHeight * 0.3) : 0;
824
- ctx.fillRect(x, canvas.height - padding.bottom - barHeight, barWidth, barHeight);
 
 
 
 
 
 
825
  });
826
 
827
- // Draw price line
828
- ctx.strokeStyle = "#4CAF50";
829
- ctx.lineWidth = 2;
 
 
 
 
 
 
 
 
 
 
 
 
830
  ctx.beginPath();
831
- trades.forEach((t, i) => {
832
- const x = padding.left + (i / trades.length) * chartWidth + barWidth / 2;
833
- const y = padding.top + chartHeight - ((t.price - minPrice) / (maxPrice - minPrice)) * chartHeight;
834
- if (i === 0) ctx.moveTo(x, y);
835
- else ctx.lineTo(x, y);
836
  });
837
  ctx.stroke();
838
 
839
- // Draw price points
840
- ctx.fillStyle = "#4CAF50";
841
- trades.forEach((t, i) => {
842
- const x = padding.left + (i / trades.length) * chartWidth + barWidth / 2;
843
- const y = padding.top + chartHeight - ((t.price - minPrice) / (maxPrice - minPrice)) * chartHeight;
844
- ctx.beginPath();
845
- ctx.arc(x, y, 3, 0, Math.PI * 2);
846
- ctx.fill();
847
- });
848
 
849
- // Y-axis labels (price)
850
- ctx.fillStyle = "#333";
851
- ctx.font = "11px Arial";
852
- ctx.textAlign = "right";
853
- for (let i = 0; i <= 5; i++) {
854
- const price = minPrice + (maxPrice - minPrice) * (5 - i) / 5;
855
- const y = padding.top + (chartHeight * i / 5);
856
- ctx.fillText(price.toFixed(2), padding.left - 5, y + 4);
 
857
  }
858
 
859
  // Legend
860
- ctx.font = "12px Arial";
861
- ctx.textAlign = "left";
862
- ctx.fillStyle = "#4CAF50";
863
- ctx.fillText("Price", padding.left, 12);
864
- ctx.fillStyle = "rgba(33, 150, 243, 0.6)";
865
- ctx.fillText("Volume", padding.left + 60, 12);
866
- ctx.fillStyle = "#666";
867
- ctx.fillText(`(${trades.length} trades${selectedSymbol ? " for " + selectedSymbol : ""})`, padding.left + 130, 12);
868
  }
869
 
870
  function renderOrderBook(book) {
@@ -1000,7 +988,7 @@
1000
  renderTrades();
1001
  renderBBOs();
1002
  renderStats();
1003
- renderPriceChart();
1004
  });
1005
 
1006
  eventSource.addEventListener("order", (e) => {
@@ -1016,7 +1004,7 @@
1016
  if (state.trades.length > 200) state.trades.pop();
1017
  renderTrades();
1018
  renderStats();
1019
- renderPriceChart();
1020
  });
1021
 
1022
  eventSource.addEventListener("snapshot", (e) => {
@@ -1052,7 +1040,7 @@
1052
  renderTrades();
1053
  renderBBOs();
1054
  renderStats();
1055
- renderPriceChart();
1056
  } catch (e) {
1057
  console.error("Fetch data failed:", e);
1058
  }
@@ -1137,111 +1125,33 @@
1137
  const period = document.getElementById("history-period").value;
1138
  const statusEl = document.getElementById("history-status");
1139
  if (!sym) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1140
  statusEl.textContent = "Loading...";
1141
  try {
1142
  const r = await fetch(`/history/${sym}?period=${period}`);
1143
  const data = await r.json();
1144
- drawHistoryChart(data.candles || []);
1145
- statusEl.textContent = (data.candles && data.candles.length)
1146
- ? `${data.candles.length} candles`
1147
- : "No data yet";
1148
  } catch (e) {
1149
  statusEl.textContent = "Error: " + e.message;
1150
  }
1151
  }
1152
 
1153
- function drawHistoryChart(candles) {
1154
- const canvas = document.getElementById("history-chart");
1155
- const ctx = canvas.getContext("2d");
1156
- canvas.width = canvas.offsetWidth || 900;
1157
- canvas.height = 350;
1158
-
1159
- ctx.clearRect(0, 0, canvas.width, canvas.height);
1160
-
1161
- if (!candles || candles.length === 0) {
1162
- ctx.fillStyle = "#999";
1163
- ctx.font = "14px Arial";
1164
- ctx.textAlign = "center";
1165
- ctx.fillText("No history data for this period", canvas.width / 2, canvas.height / 2);
1166
- return;
1167
- }
1168
-
1169
- const pad = { top: 25, right: 20, bottom: 40, left: 65 };
1170
- const W = canvas.width - pad.left - pad.right;
1171
- const H = canvas.height - pad.top - pad.bottom;
1172
- const priceH = H * 0.70;
1173
- const gapH = H * 0.05;
1174
- const volH = H * 0.25;
1175
- const priceY = pad.top;
1176
- const volY = pad.top + priceH + gapH;
1177
-
1178
- const maxP = Math.max(...candles.map(c => c.h)) * 1.002;
1179
- const minP = Math.min(...candles.map(c => c.l)) * 0.998;
1180
- const maxV = Math.max(...candles.map(c => c.v), 1);
1181
-
1182
- // Price grid lines
1183
- ctx.strokeStyle = "#eee"; ctx.lineWidth = 1;
1184
- for (let i = 0; i <= 5; i++) {
1185
- const y = priceY + (priceH * i / 5);
1186
- ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(canvas.width - pad.right, y); ctx.stroke();
1187
- const p = maxP - (maxP - minP) * i / 5;
1188
- ctx.fillStyle = "#666"; ctx.font = "10px Arial"; ctx.textAlign = "right";
1189
- ctx.fillText(p.toFixed(2), pad.left - 5, y + 4);
1190
- }
1191
-
1192
- // Divider between price and volume areas
1193
- ctx.strokeStyle = "#ddd"; ctx.lineWidth = 1;
1194
- ctx.beginPath(); ctx.moveTo(pad.left, volY); ctx.lineTo(canvas.width - pad.right, volY); ctx.stroke();
1195
-
1196
- const n = candles.length;
1197
- const slotW = W / n;
1198
- const barW = Math.max(1, Math.floor(slotW * 0.7));
1199
-
1200
- candles.forEach((c, i) => {
1201
- const x = pad.left + i * slotW + (slotW - barW) / 2;
1202
- const isUp = c.c >= c.o;
1203
- const color = isUp ? "#26a69a" : "#ef5350";
1204
- const toY = p => priceY + priceH - ((p - minP) / (maxP - minP)) * priceH;
1205
-
1206
- // Wick
1207
- const midX = x + barW / 2;
1208
- ctx.strokeStyle = color; ctx.lineWidth = 1;
1209
- ctx.beginPath(); ctx.moveTo(midX, toY(c.h)); ctx.lineTo(midX, toY(c.l)); ctx.stroke();
1210
-
1211
- // Candle body
1212
- const bodyTop = Math.min(toY(c.o), toY(c.c));
1213
- const bodyH = Math.max(1, Math.abs(toY(c.c) - toY(c.o)));
1214
- ctx.fillStyle = color;
1215
- ctx.fillRect(x, bodyTop, barW, bodyH);
1216
-
1217
- // Volume bar
1218
- const vH = (c.v / maxV) * volH;
1219
- ctx.fillStyle = isUp ? "rgba(38,166,154,0.5)" : "rgba(239,83,80,0.5)";
1220
- ctx.fillRect(x, volY + volH - vH, barW, vH);
1221
- });
1222
-
1223
- // Time axis labels (up to 6 evenly spaced)
1224
- ctx.fillStyle = "#666"; ctx.font = "10px Arial"; ctx.textAlign = "center";
1225
- const step = Math.max(1, Math.floor(n / 6));
1226
- for (let i = 0; i < n; i += step) {
1227
- const dt = new Date(candles[i].t * 1000);
1228
- const lbl = (dt.getMonth()+1) + "/" + dt.getDate() + " "
1229
- + dt.getHours().toString().padStart(2,"0") + ":"
1230
- + dt.getMinutes().toString().padStart(2,"0");
1231
- const x = pad.left + i * slotW + slotW / 2;
1232
- ctx.fillText(lbl, x, canvas.height - pad.bottom + 15);
1233
- }
1234
-
1235
- // VOL label rotated
1236
- ctx.save();
1237
- ctx.translate(10, volY + volH / 2);
1238
- ctx.rotate(-Math.PI / 2);
1239
- ctx.textAlign = "center";
1240
- ctx.fillStyle = "#aaa"; ctx.font = "9px Arial";
1241
- ctx.fillText("VOL", 0, 0);
1242
- ctx.restore();
1243
- }
1244
-
1245
  // Initial load: fetch data via REST, then connect SSE
1246
  async function init() {
1247
  await fetchData();
 
290
  </div>
291
  </div>
292
 
 
 
 
 
 
 
 
 
 
293
  <div class="panel">
294
  <h2>Order Book
295
  <select id="bbo-symbol-select" onchange="renderOrderBookPanel()" style="margin-left:10px; padding:2px 5px;">
 
328
 
329
  </div>
330
 
331
+ <!-- Unified Price Chart panel (full width) -->
332
  <div class="panel-full">
333
+ <h2>Price Chart
334
  <select id="history-symbol" onchange="loadHistory()" style="margin-left:10px; padding:2px 5px;">
335
  <option value="">Select symbol...</option>
336
  </select>
337
  <select id="history-period" onchange="loadHistory()" style="margin-left:5px; padding:2px 5px;">
338
+ <option value="live" selected>Live</option>
339
  <option value="1h">1 Hour</option>
340
  <option value="8h">8 Hours</option>
341
+ <option value="1d">1 Day</option>
342
  <option value="1w">1 Week</option>
343
  <option value="1m">1 Month</option>
344
  </select>
 
666
  renderOrderBookPanel();
667
  }
668
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
669
  // Update history symbol select
670
  const histSel = document.getElementById("history-symbol");
671
  const histCurrent = histSel.value;
 
746
  // Alias for backward compatibility
747
  function renderFullBBO() { renderOrderBookPanel(); }
748
 
749
+ // ── Unified price chart (live trades or OHLCV history) ─────────────────────
750
+ // points: [{price, volume, ts}] – normalised by caller
751
+ function drawChart(points, labelSuffix) {
752
+ const canvas = document.getElementById("history-chart");
753
+ const ctx = canvas.getContext("2d");
754
+ canvas.width = canvas.offsetWidth || 900;
755
+ canvas.height = parseInt(canvas.style.height) || 350;
756
+
757
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
758
+
759
+ if (!points || points.length === 0) {
760
+ ctx.fillStyle = "#999"; ctx.font = "14px Arial"; ctx.textAlign = "center";
761
+ ctx.fillText("No data for this period", canvas.width / 2, canvas.height / 2);
 
 
762
  return;
763
  }
764
 
765
+ const pad = { top: 25, right: 20, bottom: 40, left: 65 };
766
+ const W = canvas.width - pad.left - pad.right;
767
+ const H = canvas.height - pad.top - pad.bottom;
768
+ const priceH = H * 0.72;
769
+ const gapH = H * 0.03;
770
+ const volH = H * 0.25;
771
+ const priceY = pad.top;
772
+ const volY = pad.top + priceH + gapH;
773
 
774
+ const prices = points.map(p => p.price);
775
+ const volumes = points.map(p => p.volume);
776
+ const minP = Math.min(...prices) * 0.997;
777
+ const maxP = Math.max(...prices) * 1.003;
778
+ const maxV = Math.max(...volumes, 1);
 
779
 
780
+ const toY = p => priceY + priceH - ((p - minP) / (maxP - minP)) * priceH;
781
 
782
+ // Grid lines
783
+ ctx.strokeStyle = "#eee"; ctx.lineWidth = 1;
 
784
  for (let i = 0; i <= 5; i++) {
785
+ const y = priceY + priceH * i / 5;
786
+ ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(canvas.width - pad.right, y); ctx.stroke();
787
+ ctx.fillStyle = "#666"; ctx.font = "10px Arial"; ctx.textAlign = "right";
788
+ const p = maxP - (maxP - minP) * i / 5;
789
+ ctx.fillText(p.toFixed(2), pad.left - 5, y + 4);
790
  }
791
 
792
+ // Volume / price area divider
793
+ ctx.strokeStyle = "#ddd";
794
+ ctx.beginPath(); ctx.moveTo(pad.left, volY); ctx.lineTo(canvas.width - pad.right, volY); ctx.stroke();
795
+
796
+ const n = points.length;
797
+ const slotW = W / n;
798
+ const barW = Math.max(2, Math.floor(slotW * 0.7));
799
+
800
+ // Volume bars
801
+ points.forEach((p, i) => {
802
+ const x = pad.left + i * slotW + (slotW - barW) / 2;
803
+ const bh = (p.volume / maxV) * volH;
804
+ ctx.fillStyle = "rgba(33,150,243,0.35)";
805
+ ctx.fillRect(x, volY + volH - bh, barW, bh);
806
  });
807
 
808
+ // Gradient fill under line
809
+ const grad = ctx.createLinearGradient(0, priceY, 0, priceY + priceH);
810
+ grad.addColorStop(0, "rgba(76,175,80,0.18)");
811
+ grad.addColorStop(1, "rgba(76,175,80,0)");
812
+ ctx.beginPath();
813
+ ctx.moveTo(pad.left + slotW / 2, toY(points[0].price));
814
+ points.forEach((p, i) => ctx.lineTo(pad.left + i * slotW + slotW / 2, toY(p.price)));
815
+ ctx.lineTo(pad.left + (n - 1) * slotW + slotW / 2, priceY + priceH);
816
+ ctx.lineTo(pad.left + slotW / 2, priceY + priceH);
817
+ ctx.closePath();
818
+ ctx.fillStyle = grad;
819
+ ctx.fill();
820
+
821
+ // Price line
822
+ ctx.strokeStyle = "#4CAF50"; ctx.lineWidth = 2;
823
  ctx.beginPath();
824
+ points.forEach((p, i) => {
825
+ const x = pad.left + i * slotW + slotW / 2;
826
+ const y = toY(p.price);
827
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
 
828
  });
829
  ctx.stroke();
830
 
831
+ // Dots (only when few points)
832
+ if (n <= 60) {
833
+ ctx.fillStyle = "#4CAF50";
834
+ points.forEach((p, i) => {
835
+ const x = pad.left + i * slotW + slotW / 2;
836
+ ctx.beginPath(); ctx.arc(x, toY(p.price), 3, 0, Math.PI * 2); ctx.fill();
837
+ });
838
+ }
 
839
 
840
+ // Time axis labels (up to 6 evenly spaced)
841
+ ctx.fillStyle = "#666"; ctx.font = "10px Arial"; ctx.textAlign = "center";
842
+ const step = Math.max(1, Math.floor(n / 6));
843
+ for (let i = 0; i < n; i += step) {
844
+ const dt = new Date(points[i].ts * 1000);
845
+ const lbl = (dt.getMonth()+1) + "/" + dt.getDate() + " "
846
+ + dt.getHours().toString().padStart(2,"0") + ":"
847
+ + dt.getMinutes().toString().padStart(2,"0");
848
+ ctx.fillText(lbl, pad.left + i * slotW + slotW / 2, canvas.height - pad.bottom + 15);
849
  }
850
 
851
  // Legend
852
+ ctx.font = "11px Arial"; ctx.textAlign = "left";
853
+ ctx.fillStyle = "#4CAF50"; ctx.fillText("● Price", pad.left, 16);
854
+ ctx.fillStyle = "rgba(33,150,243,0.7)"; ctx.fillText("■ Volume", pad.left + 65, 16);
855
+ if (labelSuffix) { ctx.fillStyle = "#999"; ctx.fillText(labelSuffix, pad.left + 145, 16); }
 
 
 
 
856
  }
857
 
858
  function renderOrderBook(book) {
 
988
  renderTrades();
989
  renderBBOs();
990
  renderStats();
991
+ loadHistory();
992
  });
993
 
994
  eventSource.addEventListener("order", (e) => {
 
1004
  if (state.trades.length > 200) state.trades.pop();
1005
  renderTrades();
1006
  renderStats();
1007
+ if (document.getElementById("history-period").value === "live") loadHistory();
1008
  });
1009
 
1010
  eventSource.addEventListener("snapshot", (e) => {
 
1040
  renderTrades();
1041
  renderBBOs();
1042
  renderStats();
1043
+ loadHistory();
1044
  } catch (e) {
1045
  console.error("Fetch data failed:", e);
1046
  }
 
1125
  const period = document.getElementById("history-period").value;
1126
  const statusEl = document.getElementById("history-status");
1127
  if (!sym) return;
1128
+
1129
+ if (period === "live") {
1130
+ let trades = state.trades.filter(t => t.symbol === sym);
1131
+ trades = trades.slice(0, 100).reverse();
1132
+ const points = trades.map(t => ({
1133
+ price: t.price || 0,
1134
+ volume: t.quantity || t.qty || 0,
1135
+ ts: t.timestamp || Date.now() / 1000,
1136
+ }));
1137
+ drawChart(points, `${trades.length} trades`);
1138
+ statusEl.textContent = trades.length ? `${trades.length} trades` : "No trades yet";
1139
+ return;
1140
+ }
1141
+
1142
  statusEl.textContent = "Loading...";
1143
  try {
1144
  const r = await fetch(`/history/${sym}?period=${period}`);
1145
  const data = await r.json();
1146
+ const candles = data.candles || [];
1147
+ const points = candles.map(c => ({ price: c.c, volume: c.v, ts: c.t }));
1148
+ drawChart(points, `${candles.length} candles`);
1149
+ statusEl.textContent = candles.length ? `${candles.length} candles` : "No data yet";
1150
  } catch (e) {
1151
  statusEl.textContent = "Error: " + e.message;
1152
  }
1153
  }
1154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1155
  // Initial load: fetch data via REST, then connect SSE
1156
  async function init() {
1157
  await fetchData();