Price Chart: show all securities as normalised % chart when no symbol selected
Browse files- Default selection is "All Symbols" (empty value) instead of auto-picking first symbol
- drawMultiChart(): plots each security as a % change line from its first data point,
so differently-priced securities (e.g. EXAE ~42 vs AAAK ~4) are comparable on one chart
- Right-side vertical legend with colour-coded symbol names
- Zero-line dashed when chart spans positive and negative % territory
- loadAllHistory(): live mode groups state.trades by symbol; historical mode fetches
all symbols in parallel via Promise.all and draws the multi-line chart
- loadHistory() routes to loadAllHistory() when no symbol is selected
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- dashboard/templates/index.html +146 -6
dashboard/templates/index.html
CHANGED
|
@@ -329,7 +329,7 @@
|
|
| 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="">
|
| 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>
|
|
@@ -677,10 +677,9 @@
|
|
| 677 |
histSel.appendChild(opt);
|
| 678 |
}
|
| 679 |
});
|
| 680 |
-
if (!histCurrent
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
} else if (histCurrent) {
|
| 684 |
histSel.value = histCurrent;
|
| 685 |
}
|
| 686 |
}
|
|
@@ -1114,11 +1113,152 @@
|
|
| 1114 |
|
| 1115 |
// ββ Price History chart ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1117 |
async function loadHistory() {
|
| 1118 |
const sym = document.getElementById("history-symbol").value;
|
| 1119 |
const period = document.getElementById("history-period").value;
|
| 1120 |
const statusEl = document.getElementById("history-status");
|
| 1121 |
-
if (!sym) return;
|
| 1122 |
|
| 1123 |
if (period === "live") {
|
| 1124 |
let trades = state.trades.filter(t => t.symbol === sym);
|
|
|
|
| 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="">All Symbols</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>
|
|
|
|
| 677 |
histSel.appendChild(opt);
|
| 678 |
}
|
| 679 |
});
|
| 680 |
+
if (!histCurrent) {
|
| 681 |
+
loadHistory(); // draws all-symbols multi-chart
|
| 682 |
+
} else {
|
|
|
|
| 683 |
histSel.value = histCurrent;
|
| 684 |
}
|
| 685 |
}
|
|
|
|
| 1113 |
|
| 1114 |
// ββ Price History chart ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1115 |
|
| 1116 |
+
const CHART_COLORS = [
|
| 1117 |
+
"#2196F3","#4CAF50","#FF9800","#E91E63","#9C27B0",
|
| 1118 |
+
"#00BCD4","#FF5722","#8BC34A","#607D8B","#F44336"
|
| 1119 |
+
];
|
| 1120 |
+
|
| 1121 |
+
// ββ Multi-symbol normalised % chart ββββββββββββββββββββββββββββββββββββββββ
|
| 1122 |
+
function drawMultiChart(series, labelSuffix) {
|
| 1123 |
+
const canvas = document.getElementById("history-chart");
|
| 1124 |
+
const ctx = canvas.getContext("2d");
|
| 1125 |
+
canvas.width = canvas.offsetWidth || 500;
|
| 1126 |
+
canvas.height = canvas.offsetHeight || 300;
|
| 1127 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 1128 |
+
|
| 1129 |
+
const active = series.filter(s => s.points.length > 0);
|
| 1130 |
+
if (!active.length) {
|
| 1131 |
+
ctx.fillStyle = "#999"; ctx.font = "13px Arial"; ctx.textAlign = "center";
|
| 1132 |
+
ctx.fillText("No data for this period", canvas.width / 2, canvas.height / 2);
|
| 1133 |
+
return;
|
| 1134 |
+
}
|
| 1135 |
+
|
| 1136 |
+
const pad = { top: 18, right: 48, bottom: 32, left: 46 };
|
| 1137 |
+
const W = canvas.width - pad.left - pad.right;
|
| 1138 |
+
const H = canvas.height - pad.top - pad.bottom;
|
| 1139 |
+
|
| 1140 |
+
// Normalise each series to % change from its first point
|
| 1141 |
+
const norm = active.map(s => {
|
| 1142 |
+
const base = s.points[0].price || 1;
|
| 1143 |
+
return { ...s, pcts: s.points.map(p => (p.price - base) / base * 100) };
|
| 1144 |
+
});
|
| 1145 |
+
|
| 1146 |
+
const allPcts = norm.flatMap(s => s.pcts);
|
| 1147 |
+
let minPct = Math.min(...allPcts);
|
| 1148 |
+
let maxPct = Math.max(...allPcts);
|
| 1149 |
+
const rng = maxPct - minPct || 1;
|
| 1150 |
+
minPct -= rng * 0.06; maxPct += rng * 0.06;
|
| 1151 |
+
|
| 1152 |
+
// Global ts range
|
| 1153 |
+
const allTs = active.flatMap(s => s.points.map(p => p.ts));
|
| 1154 |
+
const minTs = Math.min(...allTs);
|
| 1155 |
+
const maxTs = Math.max(...allTs);
|
| 1156 |
+
const tsRng = maxTs - minTs || 1;
|
| 1157 |
+
|
| 1158 |
+
const toX = ts => pad.left + ((ts - minTs) / tsRng) * W;
|
| 1159 |
+
const toY = pct => pad.top + H - ((pct - minPct) / (maxPct - minPct)) * H;
|
| 1160 |
+
|
| 1161 |
+
// Grid + Y labels
|
| 1162 |
+
ctx.strokeStyle = "#eee"; ctx.lineWidth = 1;
|
| 1163 |
+
for (let i = 0; i <= 4; i++) {
|
| 1164 |
+
const pct = minPct + (maxPct - minPct) * i / 4;
|
| 1165 |
+
const y = toY(pct);
|
| 1166 |
+
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + W, y); ctx.stroke();
|
| 1167 |
+
ctx.fillStyle = "#888"; ctx.font = "9px Arial"; ctx.textAlign = "right";
|
| 1168 |
+
ctx.fillText(pct.toFixed(1) + "%", pad.left - 3, y + 3);
|
| 1169 |
+
}
|
| 1170 |
+
|
| 1171 |
+
// Zero line
|
| 1172 |
+
if (minPct < 0 && maxPct > 0) {
|
| 1173 |
+
const y0 = toY(0);
|
| 1174 |
+
ctx.strokeStyle = "#ccc"; ctx.lineWidth = 1; ctx.setLineDash([3, 3]);
|
| 1175 |
+
ctx.beginPath(); ctx.moveTo(pad.left, y0); ctx.lineTo(pad.left + W, y0); ctx.stroke();
|
| 1176 |
+
ctx.setLineDash([]);
|
| 1177 |
+
}
|
| 1178 |
+
|
| 1179 |
+
// Series lines
|
| 1180 |
+
norm.forEach((s, idx) => {
|
| 1181 |
+
const color = CHART_COLORS[idx % CHART_COLORS.length];
|
| 1182 |
+
ctx.strokeStyle = color; ctx.lineWidth = 1.5;
|
| 1183 |
+
ctx.beginPath();
|
| 1184 |
+
s.points.forEach((p, i) => {
|
| 1185 |
+
const x = toX(p.ts);
|
| 1186 |
+
const y = toY(s.pcts[i]);
|
| 1187 |
+
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
| 1188 |
+
});
|
| 1189 |
+
ctx.stroke();
|
| 1190 |
+
});
|
| 1191 |
+
|
| 1192 |
+
// Time labels (up to 4)
|
| 1193 |
+
ctx.fillStyle = "#888"; ctx.font = "9px Arial"; ctx.textAlign = "center";
|
| 1194 |
+
for (let i = 0; i <= 3; i++) {
|
| 1195 |
+
const ts = minTs + tsRng * i / 3;
|
| 1196 |
+
const dt = new Date(ts * 1000);
|
| 1197 |
+
const lbl = (dt.getMonth()+1)+"/"+dt.getDate()+" "
|
| 1198 |
+
+ dt.getHours().toString().padStart(2,"0")+":"
|
| 1199 |
+
+ dt.getMinutes().toString().padStart(2,"0");
|
| 1200 |
+
ctx.fillText(lbl, toX(ts), canvas.height - pad.bottom + 12);
|
| 1201 |
+
}
|
| 1202 |
+
|
| 1203 |
+
// Right-side vertical legend
|
| 1204 |
+
ctx.font = "9px Arial"; ctx.textAlign = "left";
|
| 1205 |
+
norm.forEach((s, idx) => {
|
| 1206 |
+
const color = CHART_COLORS[idx % CHART_COLORS.length];
|
| 1207 |
+
const ly = pad.top + idx * 13;
|
| 1208 |
+
ctx.fillStyle = color;
|
| 1209 |
+
ctx.fillRect(canvas.width - pad.right + 2, ly - 1, 10, 3);
|
| 1210 |
+
ctx.fillText(s.symbol, canvas.width - pad.right + 14, ly + 3);
|
| 1211 |
+
});
|
| 1212 |
+
|
| 1213 |
+
if (labelSuffix) {
|
| 1214 |
+
ctx.fillStyle = "#bbb"; ctx.textAlign = "right";
|
| 1215 |
+
ctx.fillText(labelSuffix, canvas.width - pad.right - 2, 12);
|
| 1216 |
+
}
|
| 1217 |
+
}
|
| 1218 |
+
|
| 1219 |
+
async function loadAllHistory() {
|
| 1220 |
+
const period = document.getElementById("history-period").value;
|
| 1221 |
+
const statusEl = document.getElementById("history-status");
|
| 1222 |
+
const symbols = Object.keys(state.bbos).sort();
|
| 1223 |
+
|
| 1224 |
+
if (!symbols.length) {
|
| 1225 |
+
drawMultiChart([], "");
|
| 1226 |
+
statusEl.textContent = "No symbols yet";
|
| 1227 |
+
return;
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
if (period === "live") {
|
| 1231 |
+
const series = symbols.map(sym => {
|
| 1232 |
+
let trades = state.trades.filter(t => t.symbol === sym).slice(0, 100).reverse();
|
| 1233 |
+
return { symbol: sym, points: trades.map(t => ({ price: t.price || 0, ts: t.timestamp || Date.now()/1000 })) };
|
| 1234 |
+
});
|
| 1235 |
+
drawMultiChart(series, `${symbols.length} symbols`);
|
| 1236 |
+
statusEl.textContent = `${symbols.length} symbols`;
|
| 1237 |
+
return;
|
| 1238 |
+
}
|
| 1239 |
+
|
| 1240 |
+
statusEl.textContent = "Loading...";
|
| 1241 |
+
try {
|
| 1242 |
+
const results = await Promise.all(
|
| 1243 |
+
symbols.map(sym => fetch(`/history/${sym}?period=${period}`).then(r => r.json()))
|
| 1244 |
+
);
|
| 1245 |
+
const series = symbols.map((sym, i) => ({
|
| 1246 |
+
symbol: sym,
|
| 1247 |
+
points: (results[i].candles || []).map(c => ({ price: c.c, ts: c.t }))
|
| 1248 |
+
}));
|
| 1249 |
+
const filled = series.filter(s => s.points.length > 0);
|
| 1250 |
+
drawMultiChart(series, `${filled.length} symbols`);
|
| 1251 |
+
statusEl.textContent = `${filled.length} symbols`;
|
| 1252 |
+
} catch (e) {
|
| 1253 |
+
statusEl.textContent = "Error: " + e.message;
|
| 1254 |
+
}
|
| 1255 |
+
}
|
| 1256 |
+
|
| 1257 |
async function loadHistory() {
|
| 1258 |
const sym = document.getElementById("history-symbol").value;
|
| 1259 |
const period = document.getElementById("history-period").value;
|
| 1260 |
const statusEl = document.getElementById("history-status");
|
| 1261 |
+
if (!sym) { await loadAllHistory(); return; }
|
| 1262 |
|
| 1263 |
if (period === "live") {
|
| 1264 |
let trades = state.trades.filter(t => t.symbol === sym);
|