Draw volume, which travelled the whole pipeline unseen
Volume is parsed from both Schwab services, aggregated into every timeframe, stored and broadcast in every bar payload — and nothing ever drew it. An overlay histogram on its own hidden scale, confined to the bottom fifth and tinted by each bar's direction. An overlay rather than a second pane, and deliberately not on the price scale: volumes are five figures against four-figure prices, so sharing a scale would flatten the candles into a line. Verified in a browser that the price scales are untouched — the same price still maps to the identical coordinate through both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
16932f85ec
commit
bec445b599
2 changed files with 61 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue