Coalesce forming 1m ticks to one candle update per frame.

Schwab can print many times a frame; only the last OHLC is visible.
This commit is contained in:
Chris Amow 2026-08-26 03:22:22 -05:00
parent fe4e6b1ae8
commit f7fbee1187
3 changed files with 57 additions and 0 deletions

View file

@ -1398,3 +1398,6 @@ HTF line, and the socket still attached 180 `future_times` (twice) plus a
A forming tick of an already-seen minute is now `{type, tf, bar}` only. HTF 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. 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. The header quote paints the DOM directly; Vue's `price` flushes at 1 Hz.
Schwab still prints many forming ticks per frame. Those now coalesce to one
`candles.update` on the next animation frame. A new minute flushes immediately.

View file

@ -211,6 +211,8 @@ class ConfluenceChart {
this.animateCurrentPrice = true; this.animateCurrentPrice = true;
this.autoScrollLivePrice = true; this.autoScrollLivePrice = true;
this.lastCurrentPrice = null; this.lastCurrentPrice = null;
this.pendingBar = null;
this.pendingBarFrame = null;
this.previewLine = null; this.previewLine = null;
this.lineBridgeLayer = null; this.lineBridgeLayer = null;
this.bars = []; this.bars = [];
@ -805,6 +807,30 @@ class ConfluenceChart {
} }
updateBar(bar) { updateBar(bar) {
const last = this.bars[this.bars.length - 1];
if (last && bar.t < last.t) return;
if (!last || bar.t > last.t) {
this.flushPendingBar();
this.applyBar(bar);
return;
}
this.pendingBar = bar;
if (this.pendingBarFrame == null) {
this.pendingBarFrame = requestAnimationFrame(() => this.flushPendingBar());
}
}
flushPendingBar() {
if (this.pendingBarFrame != null) {
cancelAnimationFrame(this.pendingBarFrame);
this.pendingBarFrame = null;
}
const bar = this.pendingBar;
this.pendingBar = null;
if (bar) this.applyBar(bar);
}
applyBar(bar) {
// update() throws on anything older than the series' last point, which // update() throws on anything older than the series' last point, which
// takes the whole app down rather than dropping one stale bar. Ticks made // takes the whole app down rather than dropping one stale bar. Ticks made
// this reachable often enough to matter, so refuse it here as well as at // this reachable often enough to matter, so refuse it here as well as at
@ -893,6 +919,10 @@ class ConfluenceChart {
updateCurrentPrice(price) { updateCurrentPrice(price) {
if (price == null) return; if (price == null) return;
if (this.currentPriceLine && price === this.lastCurrentPrice) {
this.syncCurrentPricePulse();
return;
}
const options = { const options = {
price, price,
color: '#b7771d', color: '#b7771d',

View file

@ -498,6 +498,30 @@ test('repeating the same future times does not rebuild the time scale',
}); });
}); });
test('forming ticks coalesce to one candle update per frame',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const result = await page.evaluate(async () => {
const c = window.__chart;
const last = c.bars.at(-1);
let updates = 0;
const original = c.candles.update.bind(c.candles);
c.candles.update = data => {
updates += 1;
return original(data);
};
for (let index = 0; index < 12; index += 1) {
c.updateBar({ ...last, c: last.c + index * 0.25, closed: false });
}
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return { updates };
});
assert.ok(result.updates <= 2,
`twelve forming ticks painted ${result.updates} candle updates`);
assertNoPageErrors(page, assert);
});
});
test('painted canvas pixels keep the same slope through the live edge', test('painted canvas pixels keep the same slope through the live edge',
{ timeout: 180000 }, async () => { { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {