From fe4e6b1ae8c21d31d72317dba2f266e8020a0821 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Wed, 26 Aug 2026 03:19:26 -0500 Subject: [PATCH] Send only the live candle on a forming 1m tick. Future calendars and HTF geometry wait until that timeframe advances. The quote paints the header directly so Vue does not rerender the page at 4 Hz. --- AGENTS.md | 23 ++++--- app/api/ws.py | 118 ++++++++++++++++++++++------------- docs/implementation.md | 12 ++++ static/app.js | 39 ++++++++++-- tests/test_ws_preferences.py | 37 +++++++++++ 5 files changed, 172 insertions(+), 57 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d3227bc..410da5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,6 +250,19 @@ Performance is a product feature, not a later cleanup. The app already pays for a live stream, 5k bars, and a canvas. New work must not add cost on the hot path unless the screen or an alert has to change. +**A forming tick may update the live candle and the price label. That +is all.** It must not rebuild, `setData`, walk bars, walk drawings, or +recompute geometry. If the work cannot be an `update()` of one point, +it does not belong on the tick. Naming a different function does not +make it cheap — we banned `scheduleOverlays(true)` on ticks and then +shipped `futureSpace.setData` plus `syncLevels` on the same path. + +Before you finish any change that touches bars, sockets, overlays, +drawings, or the time scale, answer: *what happens on a 1m forming +tick at 4 Hz with 5k bars?* If the answer is more than `candles.update` ++ price line, stop and make the rest a no-op unless a timestamp +actually advanced. + - Nothing extra on the event loop each closed bar or tick. Watch `loop_lag_ms`. CPU stays in the threadpool or off the loop — see `docs/async_refactor.md`. @@ -258,14 +271,8 @@ cost on the hot path unless the screen or an alert has to change. - Crosshair move is for the cursor (OHLC, drawing tooltip). Do not rebuild overlays that only depend on the viewport. - Overlay redraws go through `scheduleOverlays`. Force when bars are - replaced, a new bar opens, or size changed. **A forming-bar tick is - 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. + replaced, a new bar opens, or size changed. A forming-bar tick is + not that. - 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/app/api/ws.py b/app/api/ws.py index 6efd708..8f70030 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -88,6 +88,72 @@ def displayed_future_times(runtime, tf: Timeframe) -> list[int]: return future_bucket_starts(bars[-1].t, tf) if bars else [] +def bar_client_message(runtime, bar, displayed_tf: Timeframe, *, full: bool) -> dict | None: + """What one socket should hear about a bar. + + A forming tick of an already-seen minute is just the live candle. Future + calendars and HTF geometry go out only when that timeframe's timestamp + advances — otherwise 1m at 4 Hz resends 180 slots and walks the store. + """ + if bar.tf is displayed_tf: + payload = {"type": "bar", "tf": displayed_tf.value, "bar": bar.to_dict()} + if not full: + return payload + source_tfs = trendline_timeframes(runtime) + source_bars = runtime.store.get(bar.tf) + source_geometry = trendline_series(runtime, bar.tf) if source_bars else {} + first_source_t = source_bars[0].t if source_bars else None + source_index = next( + (index for index, value in enumerate(source_bars) if value.t == bar.t), None, + ) + previous_source_t = ( + source_bars[source_index - 1].t + if source_index is not None and source_index > 0 else None + ) + payload.update( + { + "duration": bucket_duration(bar.t, bar.tf), + "session_open": session_open(runtime), + "trendline_first_t": ( + first_source_t + if runtime.settings.trendline_source_geometry + and bar.tf in source_tfs else None + ), + "trendline_previous_t": previous_source_t, + "trendline_future_times": source_geometry.get("future_times", []), + "trendline_future_durations": source_geometry.get("future_durations", []), + "future_times": source_geometry.get("future_times", []), + } + ) + return payload + if ( + full + and runtime.settings.trendline_source_geometry + and bar.tf in trendline_timeframes(runtime) + ): + source_bars = runtime.store.get(bar.tf) + source_geometry = trendline_series(runtime, bar.tf) if source_bars else {} + first_source_t = source_bars[0].t if source_bars else None + source_index = next( + (index for index, value in enumerate(source_bars) if value.t == bar.t), None, + ) + previous_source_t = ( + source_bars[source_index - 1].t + if source_index is not None and source_index > 0 else None + ) + return { + "type": "trendline_bar", + "tf": bar.tf.value, + "t": bar.t, + "duration": bucket_duration(bar.t, bar.tf), + "first_t": first_source_t, + "previous_t": previous_source_t, + "future_times": source_geometry.get("future_times", []), + "future_durations": source_geometry.get("future_durations", []), + } + return None + + def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: events, events_more = runtime.events.recent() return { @@ -127,6 +193,7 @@ async def websocket_endpoint(websocket: WebSocket): prefs = None await websocket.send_json(snapshot(runtime, tf, prefs)) geometry_tfs = trendline_timeframes(runtime) + last_bar_t: dict[Timeframe, int] = {} async def receive(): nonlocal tf, prefs @@ -162,51 +229,12 @@ async def websocket_endpoint(websocket: WebSocket): break if event["type"] == "bar": bar = event["bar"] - source_tfs = trendline_timeframes(runtime) - source_bars = runtime.store.get(bar.tf) - first_source_t = source_bars[0].t if source_bars else None - source_index = next( - (index for index, value in enumerate(source_bars) if value.t == bar.t), None, - ) - previous_source_t = ( - source_bars[source_index - 1].t - if source_index is not None and source_index > 0 else None - ) - source_geometry = trendline_series(runtime, bar.tf) if source_bars else {} - if bar.tf is tf: - await websocket.send_json( - { - "type": "bar", "tf": tf.value, - "bar": bar.to_dict(), - "duration": bucket_duration(bar.t, bar.tf), - "session_open": session_open(runtime), - "trendline_first_t": ( - first_source_t - if runtime.settings.trendline_source_geometry - and bar.tf in source_tfs else None - ), - "trendline_previous_t": previous_source_t, - "trendline_future_times": source_geometry.get("future_times", []), - "trendline_future_durations": source_geometry.get("future_durations", []), - "future_times": source_geometry.get("future_times", []), - } - ) - elif ( - runtime.settings.trendline_source_geometry - and bar.tf in source_tfs - ): - await websocket.send_json( - { - "type": "trendline_bar", - "tf": bar.tf.value, - "t": bar.t, - "duration": bucket_duration(bar.t, bar.tf), - "first_t": first_source_t, - "previous_t": previous_source_t, - "future_times": source_geometry.get("future_times", []), - "future_durations": source_geometry.get("future_durations", []), - } - ) + full = last_bar_t.get(bar.tf) != bar.t + if full: + last_bar_t[bar.tf] = bar.t + payload = bar_client_message(runtime, bar, tf, full=full) + if payload is not None: + await websocket.send_json(payload) elif event["type"] == "levels": message = { "type": "levels", "changed": event["changed"], "removed": event["removed"], diff --git a/docs/implementation.md b/docs/implementation.md index baf75dc..fcede67 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -1386,3 +1386,15 @@ 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`. + +### 2026-08-26 — 1m tab CPU after the overlay ban + +Banning `scheduleOverlays(true)` on ticks was not enough. Forming 1m ticks +still `futureSpace.setData`'d 5k-gap + 180 future points, `syncLevels`'d every +HTF line, and the socket still attached 180 `future_times` (twice) plus a +`trendline_bar` per sloped HTF. Vue then re-rendered the whole page because +`price` and `dataReceivedAt` are refs on the one root component. + +A forming tick of an already-seen minute is now `{type, tf, bar}` only. HTF +geometry and future calendars go out when that timeframe's timestamp advances. +The header quote paints the DOM directly; Vue's `price` flushes at 1 Hz. diff --git a/static/app.js b/static/app.js index bc66c3f..b4b6d41 100644 --- a/static/app.js +++ b/static/app.js @@ -481,6 +481,34 @@ createApp({ }); watch([optionExpiryId, optionSide, optionMode, optionMin, optionMax], persistOptionPrefs); + let livePriceFlush = null; + let pendingLivePrice = null; + function paintQuote(next) { + if (next == null) return; + const priceEl = document.querySelector('.quote .price'); + if (priceEl) priceEl.textContent = next.toFixed(2); + const open = sessionOpen.value; + const changeEl = document.querySelector('.quote .quote-change'); + if (!changeEl || !(open > 0)) return; + const points = next - open; + const percent = points / open * 100; + changeEl.textContent = + `${points >= 0 ? '+' : ''}${points.toFixed(2)} (${percent >= 0 ? '+' : ''}${percent.toFixed(2)}%)`; + changeEl.classList.toggle('positive', points > 0); + changeEl.classList.toggle('negative', points < 0); + changeEl.classList.toggle('flat', points === 0); + } + function noteLivePrice(next) { + if (next == null) return; + paintQuote(next); + pendingLivePrice = next; + if (next === price.value || livePriceFlush) return; + livePriceFlush = setTimeout(() => { + livePriceFlush = null; + if (pendingLivePrice != null) price.value = pendingLivePrice; + }, 1000); + } + function connect() { const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; const query = authToken ? `?token=${encodeURIComponent(authToken)}` : ''; @@ -530,11 +558,14 @@ createApp({ syncVisibleLevels(); } } - chartApi.setDisplayFutureTimes(message.future_times); + if (message.future_times) chartApi.setDisplayFutureTimes(message.future_times); chartApi.updateBar(message.bar); - price.value = message.bar.c; - sessionOpen.value = message.session_open ?? sessionOpen.value; - status.value.last_bar_t = message.bar.t; + noteLivePrice(message.bar.c); + if (message.session_open != null) sessionOpen.value = message.session_open; + if (status.value.last_bar_t !== message.bar.t) { + status.value.last_bar_t = message.bar.t; + dataReceivedAt.value = Date.now(); + } } else if (message.type === 'trendline_bar') { const lastSource = chartApi.trendlineSourceSeries.get(message.tf)?.times.at(-1); const geometryCurrent = chartApi.updateTrendlineTime( diff --git a/tests/test_ws_preferences.py b/tests/test_ws_preferences.py index 5af2548..1a1e83b 100644 --- a/tests/test_ws_preferences.py +++ b/tests/test_ws_preferences.py @@ -130,6 +130,43 @@ def test_snapshot_rollback_gate_omits_source_geometry(tmp_path): } +def test_a_forming_tick_does_not_resend_future_times(tmp_path): + app = FastAPI() + app.include_router(ws_router) + runtime = Runtime(Settings( + manual_lines_path=tmp_path / "manual_lines.json", + trendline_source_geometry=True, + )) + runtime.store.put(Bar(Timeframe.M1, 1000, 1, 1, 1, 1, 1, True, "/ES", "test")) + runtime.store.put(Bar(Timeframe.M30, 0, 1, 1, 1, 1, 1, True, "/ES", "test")) + runtime.manual_lines.add(ManualLine( + "ml_30m", Timeframe.M30, Side.SUPPORT, 0, 100, 1 / 1800, 1800, 1800, + )) + runtime.rebuild_levels() + app.state.runtime = runtime + + with TestClient(app).websocket_connect("/ws") as websocket: + websocket.receive_json() + first = Bar(Timeframe.M1, 1060, 1, 2, 1, 1.5, 1, False, "/ES", "test") + runtime.broadcast({"type": "bar", "bar": first}) + opened = websocket.receive_json() + assert opened["type"] == "bar" + assert opened["future_times"] + + runtime.broadcast({"type": "bar", "bar": first}) + tick = websocket.receive_json() + assert tick["type"] == "bar" + assert "future_times" not in tick + assert "trendline_future_times" not in tick + + higher = Bar(Timeframe.M30, 1800, 1, 2, 1, 1.5, 1, False, "/ES", "test") + runtime.broadcast({"type": "bar", "bar": higher}) + assert websocket.receive_json()["type"] == "trendline_bar" + runtime.broadcast({"type": "bar", "bar": higher}) + runtime.broadcast({"type": "bar", "bar": first}) + assert websocket.receive_json()["type"] == "bar" + + def test_browser_can_request_full_geometry_after_a_source_delta_gap(tmp_path): app = FastAPI() app.include_router(ws_router)