From 60ffaf4657d9c0b7201c9685afd25d059909696a Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Wed, 26 Aug 2026 03:12:00 -0500 Subject: [PATCH] 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. --- AGENTS.md | 8 +++++ docs/implementation.md | 58 ++++++++++++++++++++++++++++-------- static/app.js | 7 ++++- static/chart.js | 11 +++++-- tests/e2e/trendline.test.mjs | 30 +++++++++++++++++++ 5 files changed, 99 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 00cc49c..d3227bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/implementation.md b/docs/implementation.md index 4fda312..baf75dc 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -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`. diff --git a/static/app.js b/static/app.js index 1ea4dc9..bc66c3f 100644 --- a/static/app.js +++ b/static/app.js @@ -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(); } - syncVisibleLevels(); } else if (message.type === 'trendline_geometry') { chartApi.setTrendlineGeometry(message.geometry); syncVisibleLevels(); diff --git a/static/chart.js b/static/chart.js index 501ebe2..672aa4e 100644 --- a/static/chart.js +++ b/static/chart.js @@ -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; diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 5798eb6..61e0523 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -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 => {