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:
parent
60ffaf4657
commit
fe4e6b1ae8
5 changed files with 172 additions and 57 deletions
23
AGENTS.md
23
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
|
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.
|
||||||
|
|
|
||||||
118
app/api/ws.py
118
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 []
|
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"],
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
if (status.value.last_bar_t !== message.bar.t) {
|
||||||
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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue