Label the time axis in local time while keeping the data in UTC

Lightweight Charts is timezone-agnostic: it reads epoch seconds as UTC and
labels them as UTC, so the axis disagreed with the wall clock by the viewer's
offset. tickMarkFormatter now formats axis ticks through the browser's own zone,
and localization.timeFormatter does the same for the crosshair readout, which
would otherwise contradict the axis.

Not done by shifting the bar timestamps, which is the other common recipe for
this. Every time in this codebase is epoch UTC by convention, and the chart's
times feed trendline anchors, indexAt, hit testing and the values posted back
for manual lines. An offset applied to the data would put every one of them out
by that offset — the same class of bug that once priced a trendline 147 points
from where it was drawn.

Tick placement is still computed on UTC days, so the day-change divider sits at
00:00 UTC rather than local midnight, carrying the local date. Verified under
America/Chicago: a bar at 13:03 UTC labels as 08:03 and the axis reads 05:30
through 08:00, with no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chris Amow 2026-08-10 08:04:34 -05:00
parent 374e255c95
commit d1056ed486
2 changed files with 45 additions and 1 deletions

View file

@ -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 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 — 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. 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.

View file

@ -53,7 +53,34 @@ class ConfluenceChart {
vertLines: { color: 'rgba(128,128,128,.10)' }, vertLines: { color: 'rgba(128,128,128,.10)' },
horzLines: { 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 }, rightPriceScale: { borderVisible: false },
}); });
this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, { this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, {