dashboard: mark neutral votes and no-pattern rows on the indicator chart

A NEUTRAL vote (value 0) and an absent divergence/candlestick entry both
drew no bar, so RSI, Div and Cndl looked missing on the live page. The
engine computes them on every scan; divergence and candlestick only exist
when a pattern is found (by design, "no opinion"). The chart now draws a
grey dot on the zero line for a neutral vote and a faint "no pattern" tag
for an absent pattern indicator; rows hover as a whole. Display-only,
signal code and books untouched. Suite 554/554.
This commit is contained in:
Harwinder Sekhon
2026-09-22 14:29:21 -04:00
parent 3005e57367
commit 266f204d35
2 changed files with 58 additions and 5 deletions
+45 -5
View File
@@ -28,6 +28,10 @@ const IND_NAME = {
adx: "ADX", roc: "Rate of change", keltner: "Keltner channel", adx: "ADX", roc: "Rate of change", keltner: "Keltner channel",
divergence: "Divergence", candlestick: "Candlestick pattern" divergence: "Divergence", candlestick: "Candlestick pattern"
}; };
// Divergence and candlestick only exist on a scan that found a pattern
// (engine.py); for them an absent entry means "no pattern", not a failure.
const PATTERN_IND = new Set(["divergence", "candlestick"]);
const absentWord = name => PATTERN_IND.has(name) ? "no pattern" : "no reading";
const VOTE = { STRONG_BUY: 2, BUY: 1, NEUTRAL: 0, SELL: -1, STRONG_SELL: -2 }; const VOTE = { STRONG_BUY: 2, BUY: 1, NEUTRAL: 0, SELL: -1, STRONG_SELL: -2 };
const SIG_ICON = { const SIG_ICON = {
STRONG_BUY: "fa-angles-up", BUY: "fa-arrow-trend-up", NEUTRAL: "fa-arrows-left-right", STRONG_BUY: "fa-angles-up", BUY: "fa-arrow-trend-up", NEUTRAL: "fa-arrows-left-right",
@@ -275,11 +279,41 @@ function indChart(tf) {
barThickness: 7 barThickness: 7
}] }]
}, },
// A neutral vote and a missing reading both have no bar; this marks
// them on the zero line (grey dot = neutral, faint tag = nothing found).
plugins: [{
id: "indMarkers",
afterDatasetsDraw(chart) {
const cells = lastCells[tf];
if (!cells) return;
const { ctx, scales } = chart;
const x0 = scales.x.getPixelForValue(0);
ctx.save();
cells.forEach((cell, i) => {
const y = scales.y.getPixelForValue(i);
if (cell.present && cell.v === 0) {
ctx.fillStyle = C.flat;
ctx.beginPath();
ctx.arc(x0, y, 3, 0, Math.PI * 2);
ctx.fill();
} else if (!cell.present) {
ctx.fillStyle = "rgba(255,255,255,.32)";
ctx.font = '9px "IBM Plex Mono", monospace';
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(absentWord(IND_ORDER[i]), x0 + 6, y);
}
});
ctx.restore();
}
}],
options: { options: {
indexAxis: "y", indexAxis: "y",
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
layout: { padding: { right: 4 } }, layout: { padding: { right: 4 } },
// Hover the whole row: a zero-length bar cannot be intersected.
interaction: { mode: "index", axis: "y", intersect: false },
scales: { scales: {
x: { x: {
min: -2, max: 2, min: -2, max: 2,
@@ -291,8 +325,8 @@ function indChart(tf) {
grid: { display: false }, grid: { display: false },
border: { display: false }, border: { display: false },
ticks: { ticks: {
// An indicator with no reading is not a neutral vote: both draw // An indicator with no reading is not a neutral vote: the label
// no bar, so the dimmed label is what tells them apart. // dims and the indMarkers plugin tags the row instead of a dot.
color: ctx => (lastCells[tf] && lastCells[tf][ctx.index] && !lastCells[tf][ctx.index].present) color: ctx => (lastCells[tf] && lastCells[tf][ctx.index] && !lastCells[tf][ctx.index].present)
? "rgba(255,255,255,.32)" : C.axis, ? "rgba(255,255,255,.32)" : C.axis,
font: { family: '"IBM Plex Mono", monospace', size: 10 }, font: { family: '"IBM Plex Mono", monospace', size: 10 },
@@ -307,7 +341,10 @@ function indChart(tf) {
title: items => IND_NAME[IND_ORDER[items[0].dataIndex]], title: items => IND_NAME[IND_ORDER[items[0].dataIndex]],
label: item => { label: item => {
const cell = (lastCells[tf] || indCells(currentReport(tf)))[item.dataIndex]; const cell = (lastCells[tf] || indCells(currentReport(tf)))[item.dataIndex];
if (!cell || !cell.present) return "No reading on this scan"; if (!cell || !cell.present) {
const w = absentWord(IND_ORDER[item.dataIndex]);
return `${w[0].toUpperCase()}${w.slice(1)} on this scan`;
}
return `${sigWord(cell.sig)}, confidence ${Math.round(cell.conf)}%`; return `${sigWord(cell.sig)}, confidence ${Math.round(cell.conf)}%`;
} }
} }
@@ -687,9 +724,12 @@ function indTable(src) {
const i = src && src[name]; const i = src && src[name];
const k = `<td class="ind-k">${IND_SHORT[name]}</td>`; const k = `<td class="ind-k">${IND_SHORT[name]}</td>`;
if (!i) { if (!i) {
return `<tr>${k}<td class="flat">no reading</td><td class="num text-end">—</td> const why = PATTERN_IND.has(name)
? "no pattern was found on this scan"
: "this indicator had nothing to say on this scan";
return `<tr>${k}<td class="flat">${absentWord(name)}</td><td class="num text-end">—</td>
<td class="num text-end">—</td><td class="num text-end">—</td> <td class="num text-end">—</td><td class="num text-end">—</td>
<td class="flat">this indicator had nothing to say on this scan</td></tr>`; <td class="flat">${why}</td></tr>`;
} }
const w = i.weight == null ? null : +i.weight; const w = i.weight == null ? null : +i.weight;
return `<tr>${k} return `<tr>${k}
+13
View File
@@ -5537,6 +5537,19 @@ def _():
assert "scheduleBoundaryRefresh" in js and "localStorage" in js assert "scheduleBoundaryRefresh" in js and "localStorage" in js
@test("dashboard: indicator chart marks neutral votes and no-pattern rows")
def _():
# A NEUTRAL vote (value 0) and an absent divergence/candlestick entry
# both draw no bar; the chart must mark them so they don't read as
# missing data.
with open(os.path.join(os.path.dirname(__file__), "static", "dashboard.js"),
encoding="utf-8") as fh:
js = fh.read()
assert 'id: "indMarkers"' in js and "afterDatasetsDraw" in js
assert 'PATTERN_IND = new Set(["divergence", "candlestick"])' in js
assert '"no pattern"' in js and 'intersect: false' in js
@test("dashboard: publish is atomic and leaves no temp file") @test("dashboard: publish is atomic and leaves no temp file")
def _(): def _():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp: