From 333ca4c1118d80c3aa8600b99ddd06512100fcb6 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Fri, 14 Aug 2026 23:15:00 -0500 Subject: [PATCH] trendline extension slope fix --- docs/implementation.md | 6 +++++ static/chart.js | 8 ++++-- tests/e2e/trendline.test.mjs | 52 ++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/docs/implementation.md b/docs/implementation.md index df30c90..642c2e8 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -925,6 +925,12 @@ Worth generalising: a measurement that looks absurd is worth chasing rather than clamping. The absurd number here was "67 bars of padding", and the bug behind it had nothing to do with padding. +The same median must govern every conversion past the final bar, not only the +future slots. Leaving `timeAtIndex()` and `indexAt()` on their final raw gap +made the first future 5m slot after the 17:00-18:00 settlement break advance a +trendline by 65 minutes. Source-timeframe lines therefore became thirteen times +steeper immediately beyond the live edge. All three paths now use `barInterval`. + ### 2026-08-14 — trendlineproblem: high-TF lines used the wrong bar grid **Confirmed and fixed behind `TRENDLINE_SOURCE_GEOMETRY`.** No drawing records diff --git a/static/chart.js b/static/chart.js index f72be88..64a59c6 100644 --- a/static/chart.js +++ b/static/chart.js @@ -1661,7 +1661,10 @@ class ConfluenceChart { } const last = bars.length - 1; if (time >= bars[last].t) { - const step = bars[last].t - bars[last - 1].t; + // Future slots use the median interval, not the tail gap. After the CME + // settlement break the final gap is 65 minutes, and using it here makes a + // 5m future projection advance thirteen bars per visible slot. + const step = ConfluenceChart.barInterval(bars); return last + (step ? (time - bars[last].t) / step : 0); } let lo = 0; @@ -1759,7 +1762,8 @@ class ConfluenceChart { if (index <= 0) return Math.round(bars[0].t + index * (bars[1].t - bars[0].t)); const last = bars.length - 1; if (index >= last) { - return Math.round(bars[last].t + (index - last) * (bars[last].t - bars[last - 1].t)); + const step = ConfluenceChart.barInterval(bars); + return Math.round(bars[last].t + (index - last) * step); } const lower = Math.floor(index); const fraction = index - lower; diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 9fc90a6..a38b866 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -106,6 +106,58 @@ test('a 30m line keeps source geometry when the 1m window starts after its ancho assert.equal(await page.locator('.chart-line-hit').evaluate(node => getComputedStyle(node).fill), 'none', 'the invisible polyline hit region filled the chart as a black polygon'); assertNoPageErrors(page, assert); + }); +}); + +test('future 5m projections keep their slope after the settlement break', + { timeout: 180000 }, async () => { + await withChart(async page => { + const result = await page.evaluate(() => { + const c = window.__chart; + const fiveMinutes = 5 * 60; + const thirtyMinutes = 30 * 60; + // The final 65-minute gap is the 17:00-18:00 CME settlement break. + const displayed = Array.from({ length: 30 }, (_, index) => ({ + tf: '5m', t: index * fiveMinutes, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true, + })); + displayed.push({ + tf: '5m', t: displayed.at(-1).t + 13 * fiveMinutes, + o: 100, h: 101, l: 99, c: 100, v: 1, closed: true, + }); + const line = { + id: 'settlement-gap', kind: 'manual', tf: '30m', + anchor_t: 0, anchor_p: 100, last_t: thirtyMinutes, + slope: 6 / thirtyMinutes, cutoff_t: null, + }; + c.bars = displayed; + c.setTrendlineGeometry({ + mode: 'source_tf', + series: { + '30m': { + times: [0, thirtyMinutes, 2 * thirtyMinutes, 3 * thirtyMinutes, + 4 * thirtyMinutes, 7 * thirtyMinutes], + duration: thirtyMinutes, + future_times: [8 * thirtyMinutes, 9 * thirtyMinutes], + }, + }, + }); + const last = displayed.length - 1; + const before = c.linePriceAtIndex(line, last - 1); + const current = c.linePriceAtIndex(line, last); + const future = c.linePriceAtIndex(line, last + 1); + return { + historicalChange: current - before, + futureChange: future - current, + futureTime: c.timeAtIndex(last + 1), + expectedFutureTime: displayed[last].t + fiveMinutes, + }; + }); + + assert.equal(result.futureTime, result.expectedFutureTime, + 'one future 5m slot inherited the 65-minute settlement gap'); + assert.ok(Math.abs(result.futureChange - result.historicalChange) < 1e-9, + `future slope changed from ${result.historicalChange} to ${result.futureChange}`); + assertNoPageErrors(page, assert); }); });