Move Price Chart into grid beside Trading Statistics; traditional chart style
Browse files- Price Chart moves from full-width panel to grid panel (row 3, col 2)
- Traditional chart: candlestick bodies + wicks (teal/red), orange close-price
line overlay, colour-matched volume bars at bottom (27% height)
- Live mode: individual trades rendered as single-tick candles (o=h=l=c=price)
+ orange close line connecting ticks + volume bars
- Historical modes: full OHLCV candles (open/high/low/close) passed to drawChart
- Canvas resizes dynamically to fill panel height (flex-grow:1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- dashboard/templates/index.html +89 -91
dashboard/templates/index.html
CHANGED
|
@@ -326,25 +326,24 @@
|
|
| 326 |
</div>
|
| 327 |
</div>
|
| 328 |
|
| 329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
| 345 |
-
<span id="history-status" style="font-size:12px; color:#999; margin-left:12px;"></span>
|
| 346 |
-
</h2>
|
| 347 |
-
<canvas id="history-chart" style="width:100%; height:350px; display:block;"></canvas>
|
| 348 |
</div>
|
| 349 |
|
| 350 |
<script>
|
|
@@ -746,113 +745,108 @@
|
|
| 746 |
// Alias for backward compatibility
|
| 747 |
function renderFullBBO() { renderOrderBookPanel(); }
|
| 748 |
|
| 749 |
-
// ββ Unified price chart
|
| 750 |
-
// points: [{
|
| 751 |
function drawChart(points, labelSuffix) {
|
| 752 |
const canvas = document.getElementById("history-chart");
|
| 753 |
const ctx = canvas.getContext("2d");
|
| 754 |
-
canvas.width = canvas.offsetWidth
|
| 755 |
-
canvas.height =
|
| 756 |
|
| 757 |
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 758 |
|
| 759 |
if (!points || points.length === 0) {
|
| 760 |
-
ctx.fillStyle = "#999"; ctx.font = "
|
| 761 |
ctx.fillText("No data for this period", canvas.width / 2, canvas.height / 2);
|
| 762 |
return;
|
| 763 |
}
|
| 764 |
|
| 765 |
-
const pad
|
| 766 |
-
const W
|
| 767 |
-
const H
|
| 768 |
-
const priceH = H * 0.
|
| 769 |
const gapH = H * 0.03;
|
| 770 |
-
const volH = H * 0.
|
| 771 |
const priceY = pad.top;
|
| 772 |
const volY = pad.top + priceH + gapH;
|
| 773 |
|
| 774 |
-
const
|
| 775 |
-
const
|
| 776 |
-
const
|
| 777 |
-
const
|
| 778 |
-
const maxV = Math.max(...volumes, 1);
|
| 779 |
|
| 780 |
-
const
|
|
|
|
|
|
|
| 781 |
|
| 782 |
-
//
|
| 783 |
ctx.strokeStyle = "#eee"; ctx.lineWidth = 1;
|
| 784 |
-
for (let i = 0; i <=
|
| 785 |
-
const y = priceY + priceH * i /
|
| 786 |
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(canvas.width - pad.right, y); ctx.stroke();
|
| 787 |
-
ctx.fillStyle = "#
|
| 788 |
-
|
| 789 |
-
ctx.fillText(p.toFixed(2), pad.left - 5, y + 4);
|
| 790 |
}
|
| 791 |
|
| 792 |
-
// Volume / price
|
| 793 |
-
ctx.strokeStyle = "#
|
| 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 -
|
| 803 |
const bh = (p.volume / maxV) * volH;
|
| 804 |
-
ctx.fillStyle = "rgba(
|
| 805 |
-
ctx.fillRect(x, volY + volH - bh,
|
| 806 |
});
|
| 807 |
|
| 808 |
-
//
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 823 |
ctx.beginPath();
|
| 824 |
points.forEach((p, i) => {
|
| 825 |
const x = pad.left + i * slotW + slotW / 2;
|
| 826 |
-
|
| 827 |
-
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
| 828 |
});
|
| 829 |
ctx.stroke();
|
| 830 |
|
| 831 |
-
//
|
| 832 |
-
|
| 833 |
-
|
| 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 +
|
| 849 |
}
|
| 850 |
|
| 851 |
// Legend
|
| 852 |
-
ctx.font = "
|
| 853 |
-
ctx.fillStyle = "#
|
| 854 |
-
ctx.fillStyle = "
|
| 855 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 856 |
}
|
| 857 |
|
| 858 |
function renderOrderBook(book) {
|
|
@@ -1129,13 +1123,15 @@
|
|
| 1129 |
if (period === "live") {
|
| 1130 |
let trades = state.trades.filter(t => t.symbol === sym);
|
| 1131 |
trades = trades.slice(0, 100).reverse();
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
|
|
|
|
|
|
| 1139 |
return;
|
| 1140 |
}
|
| 1141 |
|
|
@@ -1144,7 +1140,9 @@
|
|
| 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 => ({
|
|
|
|
|
|
|
| 1148 |
drawChart(points, `${candles.length} candles`);
|
| 1149 |
statusEl.textContent = candles.length ? `${candles.length} candles` : "No data yet";
|
| 1150 |
} catch (e) {
|
|
|
|
| 326 |
</div>
|
| 327 |
</div>
|
| 328 |
|
| 329 |
+
<div class="panel">
|
| 330 |
+
<h2 style="flex-shrink:0;">Price Chart
|
| 331 |
+
<select id="history-symbol" onchange="loadHistory()" style="margin-left:8px; padding:2px 4px; font-size:11px;">
|
| 332 |
+
<option value="">Symbol...</option>
|
| 333 |
+
</select>
|
| 334 |
+
<select id="history-period" onchange="loadHistory()" style="margin-left:4px; padding:2px 4px; font-size:11px;">
|
| 335 |
+
<option value="live" selected>Live</option>
|
| 336 |
+
<option value="1h">1H</option>
|
| 337 |
+
<option value="8h">8H</option>
|
| 338 |
+
<option value="1d">1D</option>
|
| 339 |
+
<option value="1w">1W</option>
|
| 340 |
+
<option value="1m">1M</option>
|
| 341 |
+
</select>
|
| 342 |
+
<span id="history-status" style="font-size:10px; color:#999; margin-left:6px;"></span>
|
| 343 |
+
</h2>
|
| 344 |
+
<canvas id="history-chart" style="width:100%; flex-grow:1; min-height:0;"></canvas>
|
| 345 |
+
</div>
|
| 346 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
</div>
|
| 348 |
|
| 349 |
<script>
|
|
|
|
| 745 |
// Alias for backward compatibility
|
| 746 |
function renderFullBBO() { renderOrderBookPanel(); }
|
| 747 |
|
| 748 |
+
// ββ Unified price chart: candlesticks + close line + volume ββββββββββββββββ
|
| 749 |
+
// points: [{open, high, low, close, volume, ts}]
|
| 750 |
function drawChart(points, labelSuffix) {
|
| 751 |
const canvas = document.getElementById("history-chart");
|
| 752 |
const ctx = canvas.getContext("2d");
|
| 753 |
+
canvas.width = canvas.offsetWidth || 500;
|
| 754 |
+
canvas.height = canvas.offsetHeight || 300;
|
| 755 |
|
| 756 |
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 757 |
|
| 758 |
if (!points || points.length === 0) {
|
| 759 |
+
ctx.fillStyle = "#999"; ctx.font = "13px Arial"; ctx.textAlign = "center";
|
| 760 |
ctx.fillText("No data for this period", canvas.width / 2, canvas.height / 2);
|
| 761 |
return;
|
| 762 |
}
|
| 763 |
|
| 764 |
+
const pad = { top: 18, right: 12, bottom: 32, left: 55 };
|
| 765 |
+
const W = canvas.width - pad.left - pad.right;
|
| 766 |
+
const H = canvas.height - pad.top - pad.bottom;
|
| 767 |
+
const priceH = H * 0.70;
|
| 768 |
const gapH = H * 0.03;
|
| 769 |
+
const volH = H * 0.27;
|
| 770 |
const priceY = pad.top;
|
| 771 |
const volY = pad.top + priceH + gapH;
|
| 772 |
|
| 773 |
+
const maxP = Math.max(...points.map(p => p.high)) * 1.002;
|
| 774 |
+
const minP = Math.min(...points.map(p => p.low)) * 0.998;
|
| 775 |
+
const maxV = Math.max(...points.map(p => p.volume), 1);
|
| 776 |
+
const toY = p => priceY + priceH - ((p - minP) / (maxP - minP)) * priceH;
|
|
|
|
| 777 |
|
| 778 |
+
const n = points.length;
|
| 779 |
+
const slotW = W / n;
|
| 780 |
+
const bodyW = Math.max(1, Math.floor(slotW * 0.65));
|
| 781 |
|
| 782 |
+
// Price grid
|
| 783 |
ctx.strokeStyle = "#eee"; ctx.lineWidth = 1;
|
| 784 |
+
for (let i = 0; i <= 4; i++) {
|
| 785 |
+
const y = priceY + priceH * i / 4;
|
| 786 |
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(canvas.width - pad.right, y); ctx.stroke();
|
| 787 |
+
ctx.fillStyle = "#888"; ctx.font = "9px Arial"; ctx.textAlign = "right";
|
| 788 |
+
ctx.fillText((maxP - (maxP - minP) * i / 4).toFixed(2), pad.left - 3, y + 3);
|
|
|
|
| 789 |
}
|
| 790 |
|
| 791 |
+
// Volume / price divider
|
| 792 |
+
ctx.strokeStyle = "#e0e0e0";
|
| 793 |
ctx.beginPath(); ctx.moveTo(pad.left, volY); ctx.lineTo(canvas.width - pad.right, volY); ctx.stroke();
|
| 794 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 795 |
// Volume bars
|
| 796 |
points.forEach((p, i) => {
|
| 797 |
+
const x = pad.left + i * slotW + (slotW - bodyW) / 2;
|
| 798 |
const bh = (p.volume / maxV) * volH;
|
| 799 |
+
ctx.fillStyle = p.close >= p.open ? "rgba(38,166,154,0.4)" : "rgba(239,83,80,0.4)";
|
| 800 |
+
ctx.fillRect(x, volY + volH - bh, bodyW, bh);
|
| 801 |
});
|
| 802 |
|
| 803 |
+
// Candlestick wicks + bodies
|
| 804 |
+
points.forEach((p, i) => {
|
| 805 |
+
const midX = pad.left + i * slotW + slotW / 2;
|
| 806 |
+
const x = pad.left + i * slotW + (slotW - bodyW) / 2;
|
| 807 |
+
const isUp = p.close >= p.open;
|
| 808 |
+
const color = isUp ? "#26a69a" : "#ef5350";
|
| 809 |
+
|
| 810 |
+
// Wick
|
| 811 |
+
ctx.strokeStyle = color; ctx.lineWidth = 1;
|
| 812 |
+
ctx.beginPath(); ctx.moveTo(midX, toY(p.high)); ctx.lineTo(midX, toY(p.low)); ctx.stroke();
|
| 813 |
+
|
| 814 |
+
// Body (min 1px so single-price ticks still show)
|
| 815 |
+
const bodyTop = Math.min(toY(p.open), toY(p.close));
|
| 816 |
+
const bodyH = Math.max(1, Math.abs(toY(p.close) - toY(p.open)));
|
| 817 |
+
ctx.fillStyle = color;
|
| 818 |
+
ctx.fillRect(x, bodyTop, bodyW, bodyH);
|
| 819 |
+
});
|
| 820 |
+
|
| 821 |
+
// Close-price line overlay (orange)
|
| 822 |
+
ctx.strokeStyle = "rgba(255,152,0,0.85)"; ctx.lineWidth = 1.5;
|
| 823 |
ctx.beginPath();
|
| 824 |
points.forEach((p, i) => {
|
| 825 |
const x = pad.left + i * slotW + slotW / 2;
|
| 826 |
+
i === 0 ? ctx.moveTo(x, toY(p.close)) : ctx.lineTo(x, toY(p.close));
|
|
|
|
| 827 |
});
|
| 828 |
ctx.stroke();
|
| 829 |
|
| 830 |
+
// Time labels (up to 5 evenly spaced)
|
| 831 |
+
ctx.fillStyle = "#888"; ctx.font = "9px Arial"; ctx.textAlign = "center";
|
| 832 |
+
const step = Math.max(1, Math.floor(n / 5));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 833 |
for (let i = 0; i < n; i += step) {
|
| 834 |
const dt = new Date(points[i].ts * 1000);
|
| 835 |
const lbl = (dt.getMonth()+1) + "/" + dt.getDate() + " "
|
| 836 |
+ dt.getHours().toString().padStart(2,"0") + ":"
|
| 837 |
+ dt.getMinutes().toString().padStart(2,"0");
|
| 838 |
+
ctx.fillText(lbl, pad.left + i * slotW + slotW / 2, canvas.height - pad.bottom + 12);
|
| 839 |
}
|
| 840 |
|
| 841 |
// Legend
|
| 842 |
+
ctx.font = "9px Arial"; ctx.textAlign = "left";
|
| 843 |
+
ctx.fillStyle = "#26a69a"; ctx.fillText("β²", pad.left, 12);
|
| 844 |
+
ctx.fillStyle = "#ef5350"; ctx.fillText("βΌ", pad.left + 14, 12);
|
| 845 |
+
ctx.fillStyle = "#888"; ctx.fillText("candle", pad.left + 24, 12);
|
| 846 |
+
ctx.strokeStyle = "rgba(255,152,0,0.85)"; ctx.lineWidth = 1.5;
|
| 847 |
+
ctx.beginPath(); ctx.moveTo(pad.left + 66, 9); ctx.lineTo(pad.left + 78, 9); ctx.stroke();
|
| 848 |
+
ctx.fillStyle = "#ff9800"; ctx.fillText("close", pad.left + 80, 12);
|
| 849 |
+
if (labelSuffix) { ctx.fillStyle = "#bbb"; ctx.fillText(labelSuffix, pad.left + 114, 12); }
|
| 850 |
}
|
| 851 |
|
| 852 |
function renderOrderBook(book) {
|
|
|
|
| 1123 |
if (period === "live") {
|
| 1124 |
let trades = state.trades.filter(t => t.symbol === sym);
|
| 1125 |
trades = trades.slice(0, 100).reverse();
|
| 1126 |
+
// Individual trades have no spread: treat each tick as o=h=l=c
|
| 1127 |
+
const points = trades.map(t => {
|
| 1128 |
+
const p = t.price || 0;
|
| 1129 |
+
return { open: p, high: p, low: p, close: p,
|
| 1130 |
+
volume: t.quantity || t.qty || 0,
|
| 1131 |
+
ts: t.timestamp || Date.now() / 1000 };
|
| 1132 |
+
});
|
| 1133 |
+
drawChart(points, `${trades.length} ticks`);
|
| 1134 |
+
statusEl.textContent = trades.length ? `${trades.length} ticks` : "No trades yet";
|
| 1135 |
return;
|
| 1136 |
}
|
| 1137 |
|
|
|
|
| 1140 |
const r = await fetch(`/history/${sym}?period=${period}`);
|
| 1141 |
const data = await r.json();
|
| 1142 |
const candles = data.candles || [];
|
| 1143 |
+
const points = candles.map(c => ({
|
| 1144 |
+
open: c.o, high: c.h, low: c.l, close: c.c, volume: c.v, ts: c.t
|
| 1145 |
+
}));
|
| 1146 |
drawChart(points, `${candles.length} candles`);
|
| 1147 |
statusEl.textContent = candles.length ? `${candles.length} candles` : "No data yet";
|
| 1148 |
} catch (e) {
|