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
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.

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 []
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"],

View file

@ -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.

View file

@ -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(

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