This commit is contained in:
Chris Amow 2026-08-15 03:40:34 -05:00
parent e1d2c60af7
commit 73570d2904
3 changed files with 82 additions and 3 deletions

View file

@ -1056,3 +1056,16 @@ or cutoff to the nearest canvas sample, never for the live-edge extension.
Browser coverage requires the canvas series to own an already-existing future
slot at the canonical source-space price, to extend its future tail on a new
5m bar, and to have no SVG future-projection layer.
The production-only bend that remained was not renderer disagreement. Diagnostic
mode showed #12 changing 0.139 points over the last historical display slot and
0.015 over the first future slot, with screen slopes differing by the same factor.
The final real 1m candles were nine minutes apart but Lightweight Charts placed
them in adjacent logical slots; the future axis then resumed one-minute slots.
The canonical source-space prices were right and the canvas point existed.
`futureSpace` now also owns whitespace timestamps inside short intraday data gaps,
so nine elapsed minutes occupy nine logical slots and the line remains straight.
Gaps over 30 minutes stay compressed, preserving the established treatment of the
65-minute settlement break and weekends. A browser regression pins both the
nine-slot spacing and equal historical/future screen slope.

View file

@ -96,6 +96,10 @@ class ConfluenceChart {
// convergence a few sessions out without dominating a zoomed-out view.
static FUTURE_SLOTS = 180;
// Preserve short intraday gaps as empty slots. Longer closures remain
// compressed, including the 65-minute CME settlement break and weekends.
static MAX_INTRADAY_GAP_SECONDS = 30 * 60;
/**
* The timeframe's spacing, measured rather than assumed.
*
@ -422,7 +426,7 @@ class ConfluenceChart {
this.candles.setData(candleData);
this.updateCurrentPrice(bars[bars.length - 1]?.c);
this.volume.setData(bars.map(ConfluenceChart.toVolume));
this.futureSpace.setData(this.futureTimes(bars));
this.futureSpace.setData(this.timeScaleSlots(bars));
// Anchored by time, not by logical index. A logical index addresses the
// chart's *shared* scale — the union of every series' time points — not
// this array. The daily MAs land straight after with hundreds of points
@ -532,6 +536,20 @@ class ConfluenceChart {
return slots;
}
timeScaleSlots(bars) {
const step = ConfluenceChart.barInterval(bars);
if (!(step > 0)) return [];
const slots = [];
for (let index = 1; index < bars.length; index += 1) {
const gap = bars[index].t - bars[index - 1].t;
if (gap <= step || gap > ConfluenceChart.MAX_INTRADAY_GAP_SECONDS) continue;
for (let time = bars[index - 1].t + step; time < bars[index].t; time += step) {
slots.push({ time });
}
}
return [...slots, ...this.futureTimes(bars)];
}
updateBar(bar) {
// update() throws on anything older than the series' last point, which
// takes the whole app down rather than dropping one stale bar. Ticks made
@ -558,8 +576,8 @@ class ConfluenceChart {
if (!(step > 0)) return;
// A session gap invalidates the old future horizon. Rebuild only the small
// manual-line series; ordinary bars extend their already-owned future tail.
if (previous && bar.t - previous.t > step * 2) {
this.futureSpace.setData(this.futureTimes(this.bars));
if (previous && bar.t - previous.t > step) {
this.futureSpace.setData(this.timeScaleSlots(this.bars));
for (const level of this.levels) {
if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level)) continue;
this.levelSeries.get(level.id)?.series.setData(this.seriesLineData(level));

View file

@ -164,6 +164,54 @@ test('future 5m projections keep their slope after the settlement break',
});
});
test('a short final data gap does not kink a future trendline',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const result = await page.evaluate(() => {
const c = window.__chart;
const minute = 60;
const bars = Array.from({ length: 20 }, (_, index) => ({
tf: '1m', t: 100000 + index * minute, o: 100, h: 101, l: 99, c: 100,
v: 1, closed: true,
}));
bars.push({ ...bars.at(-1), t: bars.at(-1).t + 9 * minute });
const line = {
id: 'sparse-tail', kind: 'manual', tf: '30m', side: 'support', label: 'probe',
anchor_t: bars[0].t, anchor_p: 100, last_t: bars[0].t + 30 * minute,
slope: 3 / (30 * minute), cutoff_t: null, hidden: false, color: '#65b7cf',
line_width: 2, provisional: false,
};
const sourceTimes = Array.from({ length: 4 }, (_, index) => bars[0].t + index * 30 * minute);
c.setBars(bars);
c.setTrendlineGeometry({
mode: 'source_tf', series: { '30m': { times: sourceTimes, duration: 30 * minute } },
});
c.syncLevels([line]);
const previousTime = bars.at(-2).t;
const currentTime = bars.at(-1).t;
const futureTime = currentTime + minute;
const point = time => ({
x: c.chart.timeScale().timeToCoordinate(time),
y: c.candles.priceToCoordinate(c.linePriceAtIndex(line, c.indexAt(time))),
});
const previous = point(previousTime);
const current = point(currentTime);
const future = point(futureTime);
return {
historicalSlope: (current.y - previous.y) / (current.x - previous.x),
futureSlope: (future.y - current.y) / (future.x - current.x),
historicalSlots: (current.x - previous.x) / (future.x - current.x),
};
});
assert.ok(Math.abs(result.historicalSlots - 9) < 0.01,
`a nine-minute data gap occupied ${result.historicalSlots} slots`);
assert.ok(Math.abs(result.historicalSlope - result.futureSlope) < 1e-9,
`line kinked from ${result.historicalSlope} to ${result.futureSlope}`);
assertNoPageErrors(page, assert);
});
});
test('a manual line series owns its future 5m slots without an SVG projection',
{ timeout: 180000 }, async () => {
await withChart(async page => {