Stop rebuilding the 1m time scale on every forming tick.

Identical future_times are now a no-op, and HTF ticks only resync lines
when a source bar is actually appended. Document the slotted-hole kink.
This commit is contained in:
Chris Amow 2026-08-26 03:12:00 -05:00
parent f5ba154c98
commit 60ffaf4657
5 changed files with 99 additions and 15 deletions

View file

@ -152,6 +152,10 @@ geometry change that needs explicit browser verification.
- The displayed time scale, a drawing's source-timeframe bar space, and the - The displayed time scale, a drawing's source-timeframe bar space, and the
server session calendar are different coordinate systems. Never substitute server session calendar are different coordinate systems. Never substitute
wall-clock seconds or the displayed grid for canonical source geometry. wall-clock seconds or the displayed grid for canonical source geometry.
- A short 1m hole that already owns empty `futureSpace` slots still kinks a
line if source index treats the surrounding bars as adjacent. Count both
before changing geometry — see the 2026-08-26 entry in
`docs/implementation.md`. Settlement and weekend compression are intended.
- Daily and intraday future slots must skip non-session time. An endpoint that - Daily and intraday future slots must skip non-session time. An endpoint that
looks valid before a settlement/weekend break must remain resolvable when real looks valid before a settlement/weekend break must remain resolvable when real
history arrives. history arrives.
@ -258,6 +262,10 @@ cost on the hot path unless the screen or an alert has to change.
not that.** `updateBar` must not `scheduleOverlays(true)` on every not that.** `updateBar` must not `scheduleOverlays(true)` on every
tick — that walked every trendline across 5k bars at ~4 Hz and tick — that walked every trendline across 5k bars at ~4 Hz and
melted the tab. We wrote this section and then shipped that anyway. melted the tab. We wrote this section and then shipped that anyway.
- 1m is the expensive chart. A forming tick must not
`futureSpace.setData`, `syncLevels`, or walk source times. Identical
`future_times` are a no-op; `trendline_bar` only rebuilds lines when
a source timestamp is actually appended.
- Diagnostic `getImageData` / painted-pixel sampling is never on the - Diagnostic `getImageData` / painted-pixel sampling is never on the
live overlay path. `?diag=1` is not a license to sync the GPU every live overlay path. `?diag=1` is not a license to sync the GPU every
frame. frame.

View file

@ -1337,18 +1337,52 @@ Finally, source-dependent shift operations no longer silently keep the old time
or fall back to displayed geometry beyond the supplied horizon. Drag, duplicate, or fall back to displayed geometry beyond the supplied horizon. Drag, duplicate,
and nudge abort cleanly when the canonical shift cannot be represented. and nudge abort cleanly when the canonical shift cannot be represented.
### 2026-08-26 — prod 1m holes were slotted, source index was not ### 2026-08-26 — bent 1m trendlines: slotted holes, uncounted source index
A production 1m dump showed 23 holes. Every 1–10 minute tape hole already owned This looked like a live-edge renderer bug, then like a Yahoo→Schwab join hole,
its empty `futureSpace` slots, including the Yahoo→Schwab join at 02:18–02:27 then like missing `futureSpace` slots. It was none of those. Write this down
CDT. Settlement (15:59–17:00) and the weekend stay compressed on purpose. before "fixing" the next kink.
The remaining bend is source index: a 1m/5m line still treated the bars on **What it looked like.** 30m/1m manual lines on the production 1m chart bent
either side of a slotted hole as adjacent, so nine screen slots carried one mid-session. Local Yahoo-only did not. The join after each Schwab deploy is a
minute of price. `fill_short_gaps` now inserts the missing bucket opens (same ~9-minute 1m hole (today 02:18→02:27 CDT). That was the obvious suspect.
30-minute bound as the display) in trendline geometry and in
`price_in_timeframe_space`. Weekends and settlement are unchanged.
`?diag=1` also posts one `kind=geometry` SNAPDBG after bars load — gap ownership **What we measured.** Last-80 1m times had that one hole. The full tape had 23
and off-median line segments — so the next check is `logs --since 20m`, not holes. For each hole, count missing minutes vs timestamps already owned by
another console paste. `futureSpace`:
- 1–10 minute Yahoo holes, including the 02:18 join and the nightly
22:58–23:09 gap: `owned === missing`. Slots were already there.
- Daily 15:59→17:00 (60 missing) and Fri 15:59→Sun 17:10: `owned === 0`.
Compressed on purpose (`MAX_INTRADAY_GAP_SECONDS = 30m`). Local Yahoo has
those too.
So the join-hole theory was dead. Do not insert more slots for a hole that
already owns them. Do not "fix" settlement or the weekend unless the product
decision changes.
**What was actually bent.** The time scale and the line's source index
disagreed. `futureSpace` gave the hole eight empty columns. Source times were
still the real bars only, so `timeframeIndexAt` treated 02:18 and 02:27 as
neighbours: one index step, nine slots of x, the segment went flat, then
resumed. Worst on 1m-native and 5m (aggregator does not synthesise empty
buckets). 30m often survived because Yahoo seeds 30m separately, so those
opens still existed.
**The check, in order, next time a line kinks:**
1. List 1m holes and `futureSpace` ownership (`kind=geometry` SNAPDBG).
2. If `owned === missing`, the scale is not the bug. Compare source index
across the hole to slot count. They must match.
3. If `owned === 0` and the hole is ≤30m, slots were dropped — that is a
scale bug. If the hole is settlement/weekend, compression is intended.
4. Do not patch live-edge geometry from a mid-session tape hole. Do not
ask for another console paste; `?diag=1` posts the report.
**The fix.** `fill_short_gaps` inserts missing bucket opens inside the same
30-minute bound, in `trendline_series`, in `price_in_timeframe_space`, and in
the browser's `sourceTimes`. A 9-minute hole becomes nine index steps.
Weekends and settlement stay one step.
`?diag=1` posts one `kind=geometry` SNAPDBG after bars load (gap ownership
and off-median segments). Read it with `logs --since 20m`.

View file

@ -518,6 +518,7 @@ createApp({
if (message.tf && message.tf !== timeframe.value) return; if (message.tf && message.tf !== timeframe.value) return;
dataReceivedAt.value = Date.now(); dataReceivedAt.value = Date.now();
if (message.trendline_first_t != null) { if (message.trendline_first_t != null) {
const lastSource = chartApi.trendlineSourceSeries.get(message.bar.tf)?.times.at(-1);
const geometryCurrent = chartApi.updateTrendlineTime( const geometryCurrent = chartApi.updateTrendlineTime(
message.bar.tf, message.bar.t, message.duration, message.trendline_first_t, message.bar.tf, message.bar.t, message.duration, message.trendline_first_t,
message.trendline_previous_t, message.trendline_previous_t,
@ -525,6 +526,8 @@ createApp({
); );
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) { if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'trendline_geometry' })); socket.send(JSON.stringify({ type: 'trendline_geometry' }));
} else if (chartApi.trendlineSourceSeries.get(message.bar.tf)?.times.at(-1) !== lastSource) {
syncVisibleLevels();
} }
} }
chartApi.setDisplayFutureTimes(message.future_times); chartApi.setDisplayFutureTimes(message.future_times);
@ -533,14 +536,16 @@ createApp({
sessionOpen.value = message.session_open ?? sessionOpen.value; sessionOpen.value = message.session_open ?? sessionOpen.value;
status.value.last_bar_t = message.bar.t; status.value.last_bar_t = message.bar.t;
} else if (message.type === 'trendline_bar') { } else if (message.type === 'trendline_bar') {
const lastSource = chartApi.trendlineSourceSeries.get(message.tf)?.times.at(-1);
const geometryCurrent = chartApi.updateTrendlineTime( const geometryCurrent = chartApi.updateTrendlineTime(
message.tf, message.t, message.duration, message.first_t, message.previous_t, message.tf, message.t, message.duration, message.first_t, message.previous_t,
message.future_times, message.future_durations, message.future_times, message.future_durations,
); );
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) { if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'trendline_geometry' })); socket.send(JSON.stringify({ type: 'trendline_geometry' }));
} else if (chartApi.trendlineSourceSeries.get(message.tf)?.times.at(-1) !== lastSource) {
syncVisibleLevels();
} }
syncVisibleLevels();
} else if (message.type === 'trendline_geometry') { } else if (message.type === 'trendline_geometry') {
chartApi.setTrendlineGeometry(message.geometry); chartApi.setTrendlineGeometry(message.geometry);
syncVisibleLevels(); syncVisibleLevels();

View file

@ -705,7 +705,10 @@ class ConfluenceChart {
} }
setDisplayFutureTimes(times) { setDisplayFutureTimes(times) {
this.displayFutureTimes = (times || []).map(Number); const next = (times || []).map(Number);
const prev = this.displayFutureTimes || [];
if (next.length === prev.length && next.every((time, index) => time === prev[index])) return;
this.displayFutureTimes = next;
if (this.bars.length && this.futureSpace) { if (this.bars.length && this.futureSpace) {
this.futureSpace.setData(this.timeScaleSlots(this.bars)); this.futureSpace.setData(this.timeScaleSlots(this.bars));
} }
@ -748,7 +751,11 @@ class ConfluenceChart {
} else if (t === source.times[last] && source.duration == null && duration != null) { } else if (t === source.times[last] && source.duration == null && duration != null) {
source.durations[last] = Number(duration); source.durations[last] = Number(duration);
} }
source.futureTimes = futureTimes.map(Number); const nextFuture = futureTimes.map(Number);
const sameFuture = nextFuture.length === source.futureTimes.length
&& nextFuture.every((time, index) => time === source.futureTimes[index]);
if (sameFuture && last >= 0 && t === source.times[last]) return true;
source.futureTimes = nextFuture;
source.futureDurations = futureDurations.map(Number); source.futureDurations = futureDurations.map(Number);
source.allTimes = [...source.times, ...source.futureTimes]; source.allTimes = [...source.times, ...source.futureTimes];
source._filled = null; source._filled = null;

View file

@ -468,6 +468,36 @@ test('a 1m line stays straight across a slotted mid-session hole',
}); });
}); });
test('repeating the same future times does not rebuild the time scale',
{ 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: 600000 + index * minute, o: 100, h: 101, l: 99, c: 100,
v: 1, closed: true,
}));
const future = Array.from(
{ length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * minute,
);
c.setDisplayFutureTimes(future);
c.setBars(bars);
let writes = 0;
const original = c.futureSpace.setData.bind(c.futureSpace);
c.futureSpace.setData = data => {
writes += 1;
return original(data);
};
c.setDisplayFutureTimes(future.slice());
c.setDisplayFutureTimes(future.map(Number));
return { writes };
});
assert.equal(result.writes, 0, 'identical future times rebuilt futureSpace');
assertNoPageErrors(page, assert);
});
});
test('painted canvas pixels keep the same slope through the live edge', test('painted canvas pixels keep the same slope through the live edge',
{ timeout: 180000 }, async () => { { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {