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
server session calendar are different coordinate systems. Never substitute
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
looks valid before a settlement/weekend break must remain resolvable when real
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
tick — that walked every trendline across 5k bars at ~4 Hz and
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
live overlay path. `?diag=1` is not a license to sync the GPU every
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,
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
its empty `futureSpace` slots, including the Yahoo→Schwab join at 02:18–02:27
CDT. Settlement (15:59–17:00) and the weekend stay compressed on purpose.
This looked like a live-edge renderer bug, then like a Yahoo→Schwab join hole,
then like missing `futureSpace` slots. It was none of those. Write this down
before "fixing" the next kink.
The remaining bend is source index: a 1m/5m line still treated the bars on
either side of a slotted hole as adjacent, so nine screen slots carried one
minute of price. `fill_short_gaps` now inserts the missing bucket opens (same
30-minute bound as the display) in trendline geometry and in
`price_in_timeframe_space`. Weekends and settlement are unchanged.
**What it looked like.** 30m/1m manual lines on the production 1m chart bent
mid-session. Local Yahoo-only did not. The join after each Schwab deploy is a
~9-minute 1m hole (today 02:18→02:27 CDT). That was the obvious suspect.
`?diag=1` also posts one `kind=geometry` SNAPDBG after bars load — gap ownership
and off-median line segments — so the next check is `logs --since 20m`, not
another console paste.
**What we measured.** Last-80 1m times had that one hole. The full tape had 23
holes. For each hole, count missing minutes vs timestamps already owned by
`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;
dataReceivedAt.value = Date.now();
if (message.trendline_first_t != null) {
const lastSource = chartApi.trendlineSourceSeries.get(message.bar.tf)?.times.at(-1);
const geometryCurrent = chartApi.updateTrendlineTime(
message.bar.tf, message.bar.t, message.duration, message.trendline_first_t,
message.trendline_previous_t,
@ -525,6 +526,8 @@ createApp({
);
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
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);
@ -533,14 +536,16 @@ createApp({
sessionOpen.value = message.session_open ?? sessionOpen.value;
status.value.last_bar_t = message.bar.t;
} else if (message.type === 'trendline_bar') {
const lastSource = chartApi.trendlineSourceSeries.get(message.tf)?.times.at(-1);
const geometryCurrent = chartApi.updateTrendlineTime(
message.tf, message.t, message.duration, message.first_t, message.previous_t,
message.future_times, message.future_durations,
);
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'trendline_geometry' }));
}
} else if (chartApi.trendlineSourceSeries.get(message.tf)?.times.at(-1) !== lastSource) {
syncVisibleLevels();
}
} else if (message.type === 'trendline_geometry') {
chartApi.setTrendlineGeometry(message.geometry);
syncVisibleLevels();

View file

@ -705,7 +705,10 @@ class ConfluenceChart {
}
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) {
this.futureSpace.setData(this.timeScaleSlots(this.bars));
}
@ -748,7 +751,11 @@ class ConfluenceChart {
} else if (t === source.times[last] && source.duration == null && duration != null) {
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.allTimes = [...source.times, ...source.futureTimes];
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',
{ timeout: 180000 }, async () => {
await withChart(async page => {