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.
This commit is contained in:
Chris Amow 2026-08-26 03:19:26 -05:00
parent 60ffaf4657
commit fe4e6b1ae8
5 changed files with 172 additions and 57 deletions

View file

@ -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 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. 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 - 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 `loop_lag_ms`. CPU stays in the threadpool or off the loop — see
`docs/async_refactor.md`. `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 - Crosshair move is for the cursor (OHLC, drawing tooltip). Do not
rebuild overlays that only depend on the viewport. rebuild overlays that only depend on the viewport.
- Overlay redraws go through `scheduleOverlays`. Force when bars are - Overlay redraws go through `scheduleOverlays`. Force when bars are
replaced, a new bar opens, or size changed. **A forming-bar tick is replaced, a new bar opens, or size changed. A forming-bar tick is
not that.** `updateBar` must not `scheduleOverlays(true)` on every not that.
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 - 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

@ -88,6 +88,72 @@ def displayed_future_times(runtime, tf: Timeframe) -> list[int]:
return future_bucket_starts(bars[-1].t, tf) if bars else [] 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: def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
events, events_more = runtime.events.recent() events, events_more = runtime.events.recent()
return { return {
@ -127,6 +193,7 @@ async def websocket_endpoint(websocket: WebSocket):
prefs = None prefs = None
await websocket.send_json(snapshot(runtime, tf, prefs)) await websocket.send_json(snapshot(runtime, tf, prefs))
geometry_tfs = trendline_timeframes(runtime) geometry_tfs = trendline_timeframes(runtime)
last_bar_t: dict[Timeframe, int] = {}
async def receive(): async def receive():
nonlocal tf, prefs nonlocal tf, prefs
@ -162,51 +229,12 @@ async def websocket_endpoint(websocket: WebSocket):
break break
if event["type"] == "bar": if event["type"] == "bar":
bar = event["bar"] bar = event["bar"]
source_tfs = trendline_timeframes(runtime) full = last_bar_t.get(bar.tf) != bar.t
source_bars = runtime.store.get(bar.tf) if full:
first_source_t = source_bars[0].t if source_bars else None last_bar_t[bar.tf] = bar.t
source_index = next( payload = bar_client_message(runtime, bar, tf, full=full)
(index for index, value in enumerate(source_bars) if value.t == bar.t), None, if payload is not None:
) await websocket.send_json(payload)
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", []),
}
)
elif event["type"] == "levels": elif event["type"] == "levels":
message = { message = {
"type": "levels", "changed": event["changed"], "removed": event["removed"], "type": "levels", "changed": event["changed"], "removed": event["removed"],

View file

@ -1386,3 +1386,15 @@ Weekends and settlement stay one step.
`?diag=1` posts one `kind=geometry` SNAPDBG after bars load (gap ownership `?diag=1` posts one `kind=geometry` SNAPDBG after bars load (gap ownership
and off-median segments). Read it with `logs --since 20m`. 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.

View file

@ -481,6 +481,34 @@ createApp({
}); });
watch([optionExpiryId, optionSide, optionMode, optionMin, optionMax], persistOptionPrefs); 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() { function connect() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const query = authToken ? `?token=${encodeURIComponent(authToken)}` : ''; const query = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
@ -530,11 +558,14 @@ createApp({
syncVisibleLevels(); syncVisibleLevels();
} }
} }
chartApi.setDisplayFutureTimes(message.future_times); if (message.future_times) chartApi.setDisplayFutureTimes(message.future_times);
chartApi.updateBar(message.bar); chartApi.updateBar(message.bar);
price.value = message.bar.c; noteLivePrice(message.bar.c);
sessionOpen.value = message.session_open ?? sessionOpen.value; if (message.session_open != null) sessionOpen.value = message.session_open;
status.value.last_bar_t = message.bar.t; 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') { } else if (message.type === 'trendline_bar') {
const lastSource = chartApi.trendlineSourceSeries.get(message.tf)?.times.at(-1); const lastSource = chartApi.trendlineSourceSeries.get(message.tf)?.times.at(-1);
const geometryCurrent = chartApi.updateTrendlineTime( const geometryCurrent = chartApi.updateTrendlineTime(

View file

@ -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): def test_browser_can_request_full_geometry_after_a_source_delta_gap(tmp_path):
app = FastAPI() app = FastAPI()
app.include_router(ws_router) app.include_router(ws_router)