From 01acdce045eb5af75c9e397c9dbe14378c17b634 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Mon, 24 Aug 2026 01:02:51 -0500 Subject: [PATCH] slope calc fix --- docs/implementation.md | 9 +++++++-- static/chart.js | 21 ++++++++++++++++++--- tests/e2e/trendline.test.mjs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/docs/implementation.md b/docs/implementation.md index 5c420e0..e3cab6e 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -1224,8 +1224,13 @@ Layer toggle **SPY open/close**, on by default. ### 2026-08-18 — Trendline slope in the tooltip Hovering a sloped line now shows signed points per hour (`+1.25 /h`, -`-0.40 /h`). Degrees follow the zoom; points per bar follow the timeframe. -The stored slope is still price per second. Flat levels omit the line. +`-0.40 /h`). The first implementation multiplied persisted wall-clock slope by +3,600. That disagreed between parallel duplicates when one endpoint span crossed +a settlement/weekend gap: duplication preserves source-bar geometry, not elapsed +wall time. The tooltip now derives price change per canonical source bar and +converts by that source bucket's active duration. Parallel duplicates therefore +show the same rate. The stored slope remains price per second so persisted +endpoints are unchanged. Flat levels omit the line. ### 2026-08-18 — Events is the sent-alert log diff --git a/static/chart.js b/static/chart.js index eca3400..2b25c97 100644 --- a/static/chart.js +++ b/static/chart.js @@ -2235,7 +2235,7 @@ class ConfluenceChart { const color = this.describeDrawingColor?.(line.color); this.tooltip.textContent = [ `#${line.number} ${line.label}`, - ConfluenceChart.slopeLabel(line), + this.slopeLabel(line), color?.name, color?.annotation, ].filter(Boolean).join('\n'); @@ -3016,9 +3016,24 @@ class ConfluenceChart { return marks.filter(mark => mark.t >= fromT - 3600 && mark.t <= toT + 3600); } - static slopeLabel(line) { + slopePerHour(line) { + const source = this.sourceSeries(line); + if (source) { + const start = ConfluenceChart.timeframeIndexAt(source, line.anchor_t); + const end = ConfluenceChart.timeframeIndexAt(source, line.last_t); + const duration = start == null || end == null + ? null : ConfluenceChart.sourceDuration(source, Math.floor((start + end) / 2)); + if (duration > 0 && end !== start) { + const endPrice = line.anchor_p + line.slope * (line.last_t - line.anchor_t); + return (endPrice - line.anchor_p) / (end - start) * 3600 / duration; + } + } + return line.slope * 3600; + } + + slopeLabel(line) { if (ConfluenceChart.isFlat(line) || !line.slope) return ''; - const perHour = line.slope * 3600; + const perHour = this.slopePerHour(line); const decimals = Math.abs(perHour) >= 10 ? 1 : 2; return `${perHour > 0 ? '+' : ''}${perHour.toFixed(decimals)} /h`; } diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index ae052a7..e01bbc8 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -112,6 +112,38 @@ test('a 30m line keeps source geometry when the 1m window starts after its ancho }); }); +test('parallel duplicates keep the same source-space hourly slope across a gap', + { timeout: 180000 }, async () => { + await withChart(async page => { + const result = await page.evaluate(() => { + const c = window.__chart; + const halfHour = 30 * 60; + const weekend = 49 * 3600; + const times = [ + 0, halfHour, halfHour + weekend, 2 * halfHour + weekend, + 3 * halfHour + weekend, 4 * halfHour + weekend, + ]; + c.setTrendlineGeometry({ mode: 'source_tf', series: { '30m': { times, duration: halfHour } } }); + const line = (anchor_t, last_t) => ({ + kind: 'manual', tf: '30m', anchor_t, anchor_p: 100, last_t, + slope: 2 / (last_t - anchor_t), + }); + const acrossGap = line(times[0], times[2]); + const contiguous = line(times[2], times[4]); + return { + raw: [acrossGap.slope * 3600, contiguous.slope * 3600], + canonical: [c.slopePerHour(acrossGap), c.slopePerHour(contiguous)], + labels: [c.slopeLabel(acrossGap), c.slopeLabel(contiguous)], + }; + }); + + assert.notEqual(result.raw[0], result.raw[1], 'fixture did not cross a wall-clock gap'); + assert.deepEqual(result.canonical, [2, 2]); + assert.deepEqual(result.labels, ['+2.00 /h', '+2.00 /h']); + assertNoPageErrors(page, assert); + }); + }); + test('future 5m projections keep their slope after the settlement break', { timeout: 180000 }, async () => { await withChart(async page => {