diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index befaa06..cceaff4 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1380,3 +1380,39 @@ trendline 147 points away. One limit worth knowing: tick *placement* is still computed on UTC days, so the day-change divider sits at 00:00 UTC rather than local midnight, labelled with the local date. The labels are right; the divider is in the UTC place. + +### 2026-08-10 (afternoon) — update rate, the left scale, and volume + +**Schwab conflates Level 1 to one update per second.** Chasing "still slow in +market hours" ended at a hard ceiling rather than a bug. In regular hours the +gaps between updates are whole multiples of 1.005s — 2.01, 3.02, 4.03 — which +only happens if the source emits on a one-second cadence and some seconds carry +no trade. `SCHWAB_TICK_SECONDS` was the limiter at 1.0 and is now 0.25, where it +no longer binds. **One update per second is the source's ceiling.** Anything +faster would mean inventing prices between trades, which a chart must not do. + +Two real losses were found on the way and fixed: + +- Higher timeframes only moved once a minute, because tick bars are 1m and the + socket filters by subscriber timeframe. `Runtime.provisional_higher` now + combines the aggregator's committed state with the live minute — without + mutating it, since the aggregator accumulates volume and would double count. +- Trades carrying only a trade stamp and a moved `TOTAL_VOLUME` — no + `LAST_PRICE`, no `LAST_SIZE` — were skipped. 66 → 74 updates per 90s. + +**Daily context moved to the left price scale.** The right had prior-day levels, +session VWAP and five daily MAs competing with the live price and hand-drawn +intraday levels. The trap: a price scale takes its range from the series on it, +so moving levels across draws them against a different range and puts them at +the wrong height. A transparent candlestick mirror on the left scale feeds it +exactly the right scale's input; verified as a zero-pixel delta between the two. +Hand-drawn levels stay right, which is the space being cleared. `priceScaleId` +is fixed at series creation, so it is passed at construction and kept out of the +options reapplied afterwards. + +**Volume is finally drawn.** It travelled the entire pipeline — parsed from both +Schwab services, aggregated, stored, broadcast in every bar — and nothing +rendered it. Now an overlay histogram on its own hidden scale in the bottom +fifth. An overlay rather than a pane, and emphatically not the price scale: +volumes are five figures against four-figure prices, and sharing a scale would +flatten the candles to a line. diff --git a/static/chart.js b/static/chart.js index 7869438..8f7d006 100644 --- a/static/chart.js +++ b/static/chart.js @@ -100,6 +100,20 @@ class ConfluenceChart { wickUpColor: 'transparent', wickDownColor: 'transparent', lastValueVisible: false, priceLineVisible: false, }); + // Volume as an overlay on its own hidden scale, confined to the bottom + // fifth. An overlay rather than a pane so it cannot alter the price scale: + // volumes are five figures and prices four, and sharing a scale would + // flatten the candles into a line. + this.volume = this.chart.addSeries(LightweightCharts.HistogramSeries, { + priceScaleId: 'volume', + priceFormat: { type: 'volume' }, + lastValueVisible: false, + priceLineVisible: false, + }); + this.chart.priceScale('volume').applyOptions({ + scaleMargins: { top: 0.82, bottom: 0 }, + visible: false, + }); this.resizeObserver = new ResizeObserver(() => { this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight }); requestAnimationFrame(() => this.renderAnchorHandles()); @@ -186,6 +200,7 @@ class ConfluenceChart { const candleData = bars.map(this.toCandle); this.candles.setData(candleData); this.leftMirror.setData(candleData); + this.volume.setData(bars.map(ConfluenceChart.toVolume)); // Anchored by time, not by logical index. A logical index addresses the // chart's *shared* scale — the union of every series' time points — not // this array. The daily MAs land straight after with hundreds of points @@ -214,6 +229,7 @@ class ConfluenceChart { if (last && bar.t < last.t) return; this.candles.update(this.toCandle(bar)); this.leftMirror.update(this.toCandle(bar)); + this.volume.update(ConfluenceChart.toVolume(bar)); if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar; else this.bars.push(bar); this.renderAnchorHandles(); @@ -724,6 +740,15 @@ class ConfluenceChart { if (this.onLineEnd) this.onLineEnd({ ...level }); } + // Tinted by the bar's own direction, muted so the candles stay the subject. + static toVolume(bar) { + return { + time: bar.t, + value: bar.v, + color: bar.c >= bar.o ? 'rgba(39,130,92,.38)' : 'rgba(189,69,69,.38)', + }; + } + toCandle(bar) { return { time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c }; }