Charts: Interactive price chart view with lines or candles and volume, backed by Yahoo, Massive, or Databento bar data.
| 1 | // The lightweight-charts chart: series per mode, the volume pane, rebasing lines on the visible range, |
| 2 | // and New York time labels. |
| 3 | import { |
| 4 | CandlestickSeries, |
| 5 | ColorType, |
| 6 | CrosshairMode, |
| 7 | HistogramSeries, |
| 8 | LineSeries, |
| 9 | createChart, |
| 10 | type IChartApi, |
| 11 | type ISeriesApi, |
| 12 | type MouseEventParams, |
| 13 | type SeriesType, |
| 14 | type Time, |
| 15 | } from 'lightweight-charts' |
| 16 | import type { Bar } from './plan' |
| 17 | import { crosshairLabel, tickLabel } from './time' |
| 18 | |
| 19 | export interface SeriesInput { |
| 20 | symbol: string |
| 21 | color: string |
| 22 | bars: Bar[] |
| 23 | } |
| 24 | |
| 25 | export interface ChartModel { |
| 26 | mode: 'lines' | 'candles' |
| 27 | series: SeriesInput[] |
| 28 | active: string | null |
| 29 | volume: boolean |
| 30 | intraday: boolean |
| 31 | /** A caption at the top of the volume pane, or null for none. */ |
| 32 | volumeLabel?: string | null |
| 33 | /** Changes when the interval changes: the chart fits all data again. */ |
| 34 | dataKey: string |
| 35 | } |
| 36 | |
| 37 | export interface LegendEntry { |
| 38 | symbol: string |
| 39 | color: string |
| 40 | text: string |
| 41 | } |
| 42 | |
| 43 | /** What the price pane holds after an update, as the view reports it. pane -1 means its series are split across panes. */ |
| 44 | export interface PriceCheck { |
| 45 | series: number |
| 46 | points: number |
| 47 | finite: boolean |
| 48 | pane: number | null |
| 49 | volumePane: number | null |
| 50 | } |
| 51 | |
| 52 | const PRICE_PANE = 0 |
| 53 | const VOLUME_PANE = 1 |
| 54 | const UP = '#26a69a' |
| 55 | const DOWN = '#ef5350' |
| 56 | const pct = (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(2)}%` |
| 57 | const price = (v: number) => (Math.abs(v) >= 1000 ? v.toFixed(0) : Math.abs(v) >= 1 ? v.toFixed(2) : v.toFixed(4)) |
| 58 | const vol = (v: number) => |
| 59 | v >= 1e9 ? `${(v / 1e9).toFixed(2)}B` : v >= 1e6 ? `${(v / 1e6).toFixed(2)}M` : v >= 1e3 ? `${(v / 1e3).toFixed(1)}K` : String(v) |
| 60 | const t = (bar: Bar) => bar.time as Time |
| 61 | /** Whether time a comes before b: seconds against seconds, YYYY-MM-DD against YYYY-MM-DD. */ |
| 62 | const before = (a: number | string, b: number | string | { year: number; month: number; day: number }) => { |
| 63 | if (typeof b === 'object') b = `${b.year}-${String(b.month).padStart(2, '0')}-${String(b.day).padStart(2, '0')}` |
| 64 | return typeof a === 'number' && typeof b === 'number' ? a < b : String(a) < String(b) |
| 65 | } |
| 66 | |
| 67 | export class PriceChart { |
| 68 | private chart: IChartApi |
| 69 | private model: ChartModel | null = null |
| 70 | private lines = new Map<string, ISeriesApi<'Line'>>() |
| 71 | private candles: ISeriesApi<'Candlestick'> | null = null |
| 72 | private volumeSeries: ISeriesApi<'Histogram'> | null = null |
| 73 | private hover: MouseEventParams<Time> | null = null |
| 74 | private frame = 0 |
| 75 | private onDblClick = () => this.fit() |
| 76 | |
| 77 | private onLegend: (entries: LegendEntry[]) => void |
| 78 | |
| 79 | constructor(private el: HTMLElement, onLegend: (entries: LegendEntry[], check: PriceCheck) => void) { |
| 80 | this.onLegend = (entries) => onLegend(entries, this.check()) |
| 81 | this.chart = createChart(el, { |
| 82 | autoSize: true, |
| 83 | layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: '#a3a3a3', fontSize: 11, attributionLogo: true }, |
| 84 | grid: { vertLines: { color: 'rgba(255,255,255,0.05)' }, horzLines: { color: 'rgba(255,255,255,0.05)' } }, |
| 85 | rightPriceScale: { borderColor: 'rgba(255,255,255,0.12)' }, |
| 86 | timeScale: { |
| 87 | borderColor: 'rgba(255,255,255,0.12)', |
| 88 | timeVisible: true, |
| 89 | secondsVisible: false, |
| 90 | rightOffset: 3, |
| 91 | tickMarkFormatter: (time: Time, markType: number) => tickLabel(time, markType), |
| 92 | }, |
| 93 | crosshair: { mode: CrosshairMode.Normal }, |
| 94 | localization: { timeFormatter: (time: Time) => crosshairLabel(time) }, |
| 95 | handleScroll: true, |
| 96 | handleScale: true, |
| 97 | }) |
| 98 | // Pane 0 is the price pane for the chart's whole life. lightweight-charts v5 deletes a pane when its |
| 99 | // last series is removed, which happens whenever the price series are swapped (an interval change in |
| 100 | // lines mode removes every line until the new bars arrive; a mode switch removes the old series |
| 101 | // first). The volume pane then slid up to index 0 and the next price series landed beside the |
| 102 | // volume, on its scale, flattened to nothing. |
| 103 | ;(this.chart.panes()[0] as unknown as { setPreserveEmptyPane?: (p: boolean) => void } | undefined)?.setPreserveEmptyPane?.(true) |
| 104 | this.chart.timeScale().subscribeVisibleTimeRangeChange(() => this.schedule()) |
| 105 | this.chart.subscribeCrosshairMove((param) => { |
| 106 | this.hover = param.time === undefined ? null : param |
| 107 | this.legend() |
| 108 | }) |
| 109 | el.addEventListener('dblclick', this.onDblClick) |
| 110 | this.label.className = 'yc-vol-label' |
| 111 | this.label.style.display = 'none' |
| 112 | el.appendChild(this.label) |
| 113 | this.resize = new ResizeObserver(() => requestAnimationFrame(() => this.placeLabel())) |
| 114 | this.resize.observe(el) |
| 115 | } |
| 116 | |
| 117 | private label = document.createElement('div') |
| 118 | private resize: ResizeObserver |
| 119 | |
| 120 | /** The volume caption, at the top of the volume pane. */ |
| 121 | private placeLabel(): void { |
| 122 | const text = this.model?.volume && this.volumeSeries ? this.model.volumeLabel : null |
| 123 | if (!text) { |
| 124 | this.label.style.display = 'none' |
| 125 | return |
| 126 | } |
| 127 | const pane = this.chart.panes()[0] as unknown as { getHeight?: () => number } | undefined |
| 128 | this.label.textContent = text |
| 129 | this.label.style.top = `${(pane?.getHeight?.() ?? 0) + 4}px` |
| 130 | this.label.style.display = 'block' |
| 131 | } |
| 132 | |
| 133 | update(model: ChartModel): void { |
| 134 | const before = this.model |
| 135 | const refit = !before || before.dataKey !== model.dataKey || before.mode !== model.mode |
| 136 | const hadData = !!before && before.series.some((s) => s.bars.length > 0) |
| 137 | const range = refit ? null : this.chart.timeScale().getVisibleRange() |
| 138 | this.model = model |
| 139 | this.chart.applyOptions({ timeScale: { timeVisible: model.intraday } }) |
| 140 | |
| 141 | if (model.mode === 'lines') { |
| 142 | this.remove(this.candles) |
| 143 | this.candles = null |
| 144 | const wanted = new Set(model.series.filter((s) => s.bars.length > 0).map((s) => s.symbol)) |
| 145 | for (const [symbol, series] of this.lines) |
| 146 | if (!wanted.has(symbol)) { |
| 147 | this.chart.removeSeries(series) |
| 148 | this.lines.delete(symbol) |
| 149 | } |
| 150 | for (const s of model.series) { |
| 151 | if (!wanted.has(s.symbol)) continue |
| 152 | const opts = { color: s.color, lineWidth: s.symbol === model.active ? 2 : 1, priceLineVisible: false, lastValueVisible: true } |
| 153 | const line = this.lines.get(s.symbol) |
| 154 | if (line) line.applyOptions(opts) |
| 155 | else { |
| 156 | this.bases.delete(s.symbol) |
| 157 | this.lines.set( |
| 158 | s.symbol, |
| 159 | this.chart.addSeries(LineSeries, { |
| 160 | ...opts, |
| 161 | title: '', |
| 162 | priceFormat: { type: 'custom', formatter: pct, minMove: 0.01 }, |
| 163 | }, PRICE_PANE), |
| 164 | ) |
| 165 | } |
| 166 | } |
| 167 | } else { |
| 168 | for (const series of this.lines.values()) this.chart.removeSeries(series) |
| 169 | this.lines.clear() |
| 170 | this.bases.clear() |
| 171 | const bars = model.series.find((s) => s.symbol === model.active)?.bars ?? [] |
| 172 | if (!this.candles) |
| 173 | this.candles = this.chart.addSeries(CandlestickSeries, { |
| 174 | upColor: UP, |
| 175 | downColor: DOWN, |
| 176 | borderVisible: false, |
| 177 | wickUpColor: UP, |
| 178 | wickDownColor: DOWN, |
| 179 | priceFormat: { type: 'custom', formatter: price, minMove: 0.0001 }, |
| 180 | }, PRICE_PANE) |
| 181 | this.candles.setData(bars.map((b) => ({ time: t(b), open: b.open, high: b.high, low: b.low, close: b.close }))) |
| 182 | } |
| 183 | |
| 184 | this.updateVolume(model) |
| 185 | this.checkPanes(model) |
| 186 | this.rebase() |
| 187 | |
| 188 | if (refit || !hadData) this.fit() |
| 189 | else if (range) { |
| 190 | try { |
| 191 | this.chart.timeScale().setVisibleRange(range) |
| 192 | } catch { |
| 193 | // The range is gone with the data: leave the chart where it is. |
| 194 | } |
| 195 | } |
| 196 | this.legend() |
| 197 | this.placeLabel() |
| 198 | requestAnimationFrame(() => this.placeLabel()) |
| 199 | } |
| 200 | |
| 201 | /** The active symbol's volume in a pane of its own at the bottom, colored by the bar's direction. */ |
| 202 | private updateVolume(model: ChartModel): void { |
| 203 | const bars = model.series.find((s) => s.symbol === model.active)?.bars ?? [] |
| 204 | if (!model.volume) { |
| 205 | this.remove(this.volumeSeries) |
| 206 | this.volumeSeries = null |
| 207 | if (this.chart.panes().length > 1) |
| 208 | try { |
| 209 | this.chart.removePane(1) |
| 210 | } catch { |
| 211 | // Already gone with its last series. |
| 212 | } |
| 213 | return |
| 214 | } |
| 215 | if (!this.volumeSeries) { |
| 216 | this.volumeSeries = this.chart.addSeries( |
| 217 | HistogramSeries, |
| 218 | { priceFormat: { type: 'custom', formatter: vol, minMove: 1 }, priceLineVisible: false, lastValueVisible: false }, |
| 219 | VOLUME_PANE, |
| 220 | ) |
| 221 | const panes = this.chart.panes() as unknown as { setStretchFactor?: (n: number) => void; setHeight?: (n: number) => void }[] |
| 222 | if (panes[0]?.setStretchFactor && panes[1]?.setStretchFactor) { |
| 223 | panes[0].setStretchFactor(4) |
| 224 | panes[1].setStretchFactor(1) |
| 225 | } else panes[1]?.setHeight?.(Math.max(40, Math.round(this.el.clientHeight * 0.2))) |
| 226 | } |
| 227 | this.volumeSeries.setData( |
| 228 | bars.map((b) => ({ time: t(b), value: b.volume, color: b.close >= b.open ? 'rgba(38,166,154,0.55)' : 'rgba(239,83,80,0.55)' })), |
| 229 | ) |
| 230 | } |
| 231 | |
| 232 | /** |
| 233 | * The price series in pane 0 and the volume alone in pane 1. Should the price pane have been dropped |
| 234 | * all the same (a lightweight-charts without setPreserveEmptyPane), the volume series is put back in |
| 235 | * a pane of its own below the price. |
| 236 | */ |
| 237 | private checkPanes(model: ChartModel): void { |
| 238 | const v = this.volumeSeries |
| 239 | if (!v) return |
| 240 | const pane = v.getPane() |
| 241 | if (pane.paneIndex() === VOLUME_PANE && pane.getSeries().length === 1) return |
| 242 | this.chart.removeSeries(v) |
| 243 | this.volumeSeries = null |
| 244 | this.updateVolume(model) |
| 245 | } |
| 246 | |
| 247 | /** What the price pane holds, for the view to report: points, whether all are finite, and its pane. */ |
| 248 | private check(): PriceCheck { |
| 249 | const series: ISeriesApi<SeriesType>[] = this.model?.mode === 'candles' ? (this.candles ? [this.candles] : []) : [...this.lines.values()] |
| 250 | let points = 0 |
| 251 | let finite = true |
| 252 | let pane: number | null = null |
| 253 | for (const s of series) { |
| 254 | const data = s.data() as { value?: number; open?: number; high?: number; low?: number; close?: number }[] |
| 255 | points += data.length |
| 256 | for (const d of data) { |
| 257 | const values = 'value' in d ? [d.value] : [d.open, d.high, d.low, d.close] |
| 258 | if (!values.every((x) => typeof x === 'number' && Number.isFinite(x))) finite = false |
| 259 | } |
| 260 | const index = s.getPane().paneIndex() |
| 261 | pane = pane === null || pane === index ? index : -1 |
| 262 | } |
| 263 | const volumePane = this.volumeSeries ? this.volumeSeries.getPane().paneIndex() : null |
| 264 | return { series: series.length, points, finite: points > 0 && finite, pane, volumePane } |
| 265 | } |
| 266 | |
| 267 | /** Every bar on screen. */ |
| 268 | fit(): void { |
| 269 | this.chart.timeScale().fitContent() |
| 270 | } |
| 271 | |
| 272 | destroy(): void { |
| 273 | cancelAnimationFrame(this.frame) |
| 274 | this.resize.disconnect() |
| 275 | this.label.remove() |
| 276 | this.el.removeEventListener('dblclick', this.onDblClick) |
| 277 | this.chart.remove() |
| 278 | } |
| 279 | |
| 280 | /** Rebases the lines once a frame while the visible range moves. */ |
| 281 | private schedule(): void { |
| 282 | if (this.frame) return |
| 283 | this.frame = requestAnimationFrame(() => { |
| 284 | this.frame = 0 |
| 285 | this.rebase() |
| 286 | this.placeLabel() |
| 287 | }) |
| 288 | } |
| 289 | |
| 290 | /** Each line as % change from its first bar on screen. */ |
| 291 | private rebase(): void { |
| 292 | const model = this.model |
| 293 | if (!model || model.mode !== 'lines') return |
| 294 | const from = this.chart.timeScale().getVisibleRange()?.from as number | string | undefined |
| 295 | for (const s of model.series) { |
| 296 | const line = this.lines.get(s.symbol) |
| 297 | if (!line || s.bars.length === 0) continue |
| 298 | const first = from === undefined ? s.bars[0] : (s.bars.find((b) => !before(b.time, from)) ?? s.bars[s.bars.length - 1]) |
| 299 | const base = first.close |
| 300 | if (!base) continue |
| 301 | const key = `${String(first.time)}:${base}:${s.bars.length}:${String(s.bars[s.bars.length - 1].time)}:${s.bars[s.bars.length - 1].close}` |
| 302 | if (this.bases.get(s.symbol) === key) continue |
| 303 | this.bases.set(s.symbol, key) |
| 304 | line.setData(s.bars.map((b) => ({ time: t(b), value: (b.close / base - 1) * 100 }))) |
| 305 | } |
| 306 | this.legend() |
| 307 | } |
| 308 | |
| 309 | private bases = new Map<string, string>() |
| 310 | |
| 311 | /** The legend's lines: each symbol's hovered or latest value. */ |
| 312 | private legend(): void { |
| 313 | const model = this.model |
| 314 | if (!model) return this.onLegend([]) |
| 315 | const hovered = this.hover |
| 316 | if (model.mode === 'lines') { |
| 317 | this.onLegend( |
| 318 | model.series.map((s) => { |
| 319 | const line = this.lines.get(s.symbol) |
| 320 | if (!line) return { symbol: s.symbol, color: s.color, text: '–' } |
| 321 | const at = hovered?.seriesData.get(line) as { value?: number } | undefined |
| 322 | const data = line.data() |
| 323 | const last = data[data.length - 1] as { value?: number } | undefined |
| 324 | const value = at?.value ?? last?.value |
| 325 | return { symbol: s.symbol, color: s.color, text: value === undefined ? '–' : pct(value) } |
| 326 | }), |
| 327 | ) |
| 328 | return |
| 329 | } |
| 330 | const s = model.series.find((x) => x.symbol === model.active) |
| 331 | if (!s || !this.candles || s.bars.length === 0) return this.onLegend(s ? [{ symbol: s.symbol, color: s.color, text: '–' }] : []) |
| 332 | const at = hovered?.seriesData.get(this.candles) as { time?: Time } | undefined |
| 333 | const index = at?.time === undefined ? s.bars.length - 1 : s.bars.findIndex((b) => String(b.time) === String(at.time)) |
| 334 | const bar = s.bars[index < 0 ? s.bars.length - 1 : index] |
| 335 | const prev = s.bars[(index < 0 ? s.bars.length - 1 : index) - 1] |
| 336 | const change = prev ? ` ${pct((bar.close / prev.close - 1) * 100)}` : '' |
| 337 | this.onLegend([ |
| 338 | { |
| 339 | symbol: s.symbol, |
| 340 | color: s.color, |
| 341 | text: `O ${price(bar.open)} H ${price(bar.high)} L ${price(bar.low)} C ${price(bar.close)}${change} V ${vol(bar.volume)}`, |
| 342 | }, |
| 343 | ]) |
| 344 | } |
| 345 | |
| 346 | private remove(series: ISeriesApi<SeriesType> | null): void { |
| 347 | if (series) this.chart.removeSeries(series) |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | export { UP, DOWN, pct, price, vol, t } |