diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 0544185..befaa06 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1363,3 +1363,20 @@ Worth recording for the next person who reads a gap as a bug: the remaining pauses are the market, not the pipe. In thin pre-open tape /ES genuinely goes seconds without a price-changing trade, and then moves several ticks at once — which is what a "gap up" after a quiet spell actually is. + +**The time axis reads local, the data stays UTC.** Lightweight Charts is +timezone-agnostic: it reads epoch seconds as UTC and labels them as UTC, which +is why the axis disagreed with the wall clock. Fixed with `tickMarkFormatter` +for the axis and `localization.timeFormatter` for the crosshair, both going +through the browser's own zone. + +Deliberately *not* fixed by shifting the bar timestamps, which is the other +common recipe. Every time in this codebase is epoch UTC by convention, and the +chart's own times feed trendline anchors, `indexAt`, hit testing and the values +posted back for manual lines — an offset applied to the data would put all of +them out by the offset, which is exactly the class of bug that once priced a +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. diff --git a/static/chart.js b/static/chart.js index 6a3ecb9..5f0cf6d 100644 --- a/static/chart.js +++ b/static/chart.js @@ -53,7 +53,34 @@ class ConfluenceChart { vertLines: { color: 'rgba(128,128,128,.10)' }, horzLines: { color: 'rgba(128,128,128,.10)' }, }, - timeScale: { timeVisible: true, secondsVisible: false }, + timeScale: { + timeVisible: true, + secondsVisible: false, + // The library is timezone-agnostic: it reads epoch seconds as UTC and + // labels them as UTC. Bars stay in UTC — that convention holds + // everywhere in this codebase, and shifting the values themselves would + // put every trendline anchor, hit test and alert an offset out. Only + // the label is localised, via the browser's own zone. + tickMarkFormatter: (time, tickMarkType) => { + const at = new Date(time * 1000); + if (tickMarkType === LightweightCharts.TickMarkType.Year) return String(at.getFullYear()); + if (tickMarkType === LightweightCharts.TickMarkType.Month) { + return at.toLocaleString(undefined, { month: 'short' }); + } + if (tickMarkType === LightweightCharts.TickMarkType.DayOfMonth) return String(at.getDate()); + return at.toLocaleTimeString(undefined, { + hour12: false, hour: '2-digit', minute: '2-digit', + ...(tickMarkType === LightweightCharts.TickMarkType.TimeWithSeconds + ? { second: '2-digit' } : {}), + }); + }, + }, + localization: { + // The crosshair readout, which would otherwise disagree with the axis. + timeFormatter: time => new Date(time * 1000).toLocaleString(undefined, { + hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', + }), + }, rightPriceScale: { borderVisible: false }, }); this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, {