diff --git a/static/dashboard.js b/static/dashboard.js index ad71280..e4d6ff3 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -28,6 +28,10 @@ const IND_NAME = { adx: "ADX", roc: "Rate of change", keltner: "Keltner channel", 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 SIG_ICON = { STRONG_BUY: "fa-angles-up", BUY: "fa-arrow-trend-up", NEUTRAL: "fa-arrows-left-right", @@ -275,11 +279,41 @@ function indChart(tf) { 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: { indexAxis: "y", responsive: true, maintainAspectRatio: false, layout: { padding: { right: 4 } }, + // Hover the whole row: a zero-length bar cannot be intersected. + interaction: { mode: "index", axis: "y", intersect: false }, scales: { x: { min: -2, max: 2, @@ -291,8 +325,8 @@ function indChart(tf) { grid: { display: false }, border: { display: false }, ticks: { - // An indicator with no reading is not a neutral vote: both draw - // no bar, so the dimmed label is what tells them apart. + // An indicator with no reading is not a neutral vote: the label + // 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) ? "rgba(255,255,255,.32)" : C.axis, font: { family: '"IBM Plex Mono", monospace', size: 10 }, @@ -307,7 +341,10 @@ function indChart(tf) { title: items => IND_NAME[IND_ORDER[items[0].dataIndex]], label: item => { 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)}%`; } } @@ -687,9 +724,12 @@ function indTable(src) { const i = src && src[name]; const k = `${IND_SHORT[name]}`; if (!i) { - return `${k}no reading— + const why = PATTERN_IND.has(name) + ? "no pattern was found on this scan" + : "this indicator had nothing to say on this scan"; + return `${k}${absentWord(name)}— —— - this indicator had nothing to say on this scan`; + ${why}`; } const w = i.weight == null ? null : +i.weight; return `${k} diff --git a/test_all.py b/test_all.py index 99b2c53..da871a7 100644 --- a/test_all.py +++ b/test_all.py @@ -5537,6 +5537,19 @@ def _(): 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") def _(): with tempfile.TemporaryDirectory() as tmp: