diff --git a/static/chart.js b/static/chart.js index f70f012..e95802c 100644 --- a/static/chart.js +++ b/static/chart.js @@ -2514,10 +2514,18 @@ class ConfluenceChart { if (bar.t >= level.anchor_t && bar.t <= cutoff) stamps.add(bar.t); } } else { + const displayed = this.bars.filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff); for (const time of ConfluenceChart.sourceTimes(source) || []) { - if (time >= first && time <= lastBar && time >= level.anchor_t && time <= cutoff) { - stamps.add(time); + if (time < first || time > lastBar || time < level.anchor_t || time > cutoff) continue; + let lo = 0; + let hi = displayed.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (displayed[mid].t < time) lo = mid + 1; + else hi = mid; } + const bar = displayed[lo] || displayed[displayed.length - 1]; + if (bar) stamps.add(bar.t); } if (lastBar >= level.anchor_t && lastBar <= cutoff) stamps.add(lastBar); } diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 7ddfa83..56da4ca 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -94,9 +94,10 @@ test('a 30m line keeps source geometry when the 1m window starts after its ancho 'the rollback path no longer restores displayed-grid geometry'); assert.equal(result.shifted, result.sourceSecondBar, 'a source-timeframe nudge did not move by one 30m bar'); - assert.deepEqual(result.sampledTimes.slice(0, result.displayedTimes.length), result.displayedTimes, - 'manual line samples no longer match the displayed candle grid'); - assert.ok(result.sampledTimes.slice(result.displayedTimes.length) + const historical = result.sampledTimes.filter(time => time <= result.displayedTimes.at(-1)); + assert.deepEqual(historical, [result.displayedTimes[0], result.displayedTimes.at(-1)], + 'a 30m line on 1m should sample source-bar times, not every 1m candle'); + assert.ok(result.sampledTimes.slice(historical.length) .every(time => time > result.displayedTimes.at(-1)), 'manual-line extension mixed future points into candle history'); assert.deepEqual(result.trimmedTimes, result.expectedTrimmedTimes, @@ -353,6 +354,62 @@ test('a short final data gap does not kink a future trendline', }); }); +test('a 30m line stays straight on 1m when the bucket is missing its first minutes', + { timeout: 180000 }, async () => { + await withChart(async page => { + const result = await page.evaluate(() => { + const c = window.__chart; + const minute = 60; + const half = 30 * minute; + const open = 400000; + const bars = []; + for (let t = open - 2 * half; t < open; t += minute) { + bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true }); + } + for (let t = open + 9 * minute; t < open + 2 * half; t += minute) { + bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true }); + } + const line = { + id: 'late-open', kind: 'manual', tf: '30m', side: 'support', label: 'late', + anchor_t: open - 2 * half, anchor_p: 100, last_t: open, + slope: 3 / half, cutoff_t: null, hidden: false, color: '#65b7cf', + line_width: 2, provisional: false, + }; + c.setDisplayFutureTimes(Array.from( + { length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * minute, + )); + c.setBars(bars); + c.setTrendlineGeometry({ + mode: 'source_tf', + series: { '30m': { times: [open - 2 * half, open - half, open, open + half], duration: half } }, + }); + c.syncLevels([line]); + const data = c.levelSeries.get(line.id).series.data(); + const point = time => { + const row = data.find(value => Number(value.time) === time); + return { + x: c.chart.timeScale().timeToCoordinate(time), + y: row ? c.candles.priceToCoordinate(row.value) : null, + }; + }; + const a = point(open - half); + const b = point(open + 9 * minute); + const d = point(open + half); + return { + snappedOpen: data.some(value => Number(value.time) === open + 9 * minute), + injectedOpen: data.some(value => Number(value.time) === open), + lateSlope: (b.y - a.y) / (b.x - a.x), + fullSlope: (d.y - b.y) / (d.x - b.x), + }; + }); + assert.equal(result.injectedOpen, false, '30m open that is not a 1m bar was placed on the scale'); + assert.equal(result.snappedOpen, true, 'missing 30m open was not snapped to the first 1m bar'); + assert.ok(Math.abs(result.lateSlope - result.fullSlope) < 1e-6, + `late-open 30m segment kinked from ${result.lateSlope} to ${result.fullSlope}`); + assertNoPageErrors(page, assert); + }); + }); + test('painted canvas pixels keep the same slope through the live edge', { timeout: 180000 }, async () => { await withChart(async page => {