Time zones and sessions
This page assumes your x is a timestamp in milliseconds — an instant. If your x is in seconds or some other unit, timeTicks takes epochOf and xOfEpoch and the axis section still applies; the aggregation and anchor recipes below do not — barAggregator and periodAnchor hand the raw x to the BarStart, so they want milliseconds or a BarStart of your own written for your unit.
x is an instant; the axis decides the zone
The data never carries a zone. A bar's x is the instant it opened, the same number in Seoul and in New York, and every conversion happens once, at your boundary:
const candles = rows.map((row) => ({ ...row, x: row.time.getTime() })); // Date objects
const candles = rows.map(({ time, ...row }) => ({ ...row, x: time * 1000 })); // seconds
// A date string must say its zone. new Date("2026-08-16") is UTC midnight and
// "2026/08/16" is local midnight — and the second form is not even portable.
const candles = rows.map((row) => ({ ...row, x: Date.parse(`${row.day}T00:00:00Z`) }));Where a zone appears is the axis. timeTicks({ timeZone, locale }) places the ticks on that zone's calendar boundaries and words them in that language; and the same strategy formats the decorations — the crosshair badge and the tooltip header read the same clock, to the second. One option, one clock — the axis, the crosshair badge and the tooltip header. A format of your own on axis.x still wins for the decorations (the strategy keeps its own tick labels — see the plot contract).
import {
barAggregator,
candleSeries,
fixedBars,
priceFormat,
sessionStart,
timeTicks,
} from "@finchart/core";
import type { OHLC, Trade } from "@finchart/core";
import { PlotBuilder, browserDeps } from "@finchart/dom";
import { attachVwap, periodAnchor } from "@finchart/indicators";
declare const bars: OHLC[];
declare const trades: Trade[];
// The exchange's zone, said once — the axis, the crosshair badge and the
// tooltip header then read the same clock. Left out, both the zone and the
// locale would be the runtime's: a server and a browser in different places
// would print different labels for the same bar.
const TIME_ZONE = "Asia/Seoul";
const plot = PlotBuilder.create<OHLC>(browserDeps({ autoSize: true }))
.setSize(900, 480)
.setAxis({
x: { ticks: timeTicks({ timeZone: TIME_ZONE, locale: "ko-KR" }) },
y: { position: "right", format: priceFormat({ compact: true, locale: "ko-KR" }) },
})
.build(document.getElementById("chart")!);
const price = plot.mainPane.addSeries({ series: candleSeries(), data: bars, name: "Price" });
// Intraday bars of a fixed width are aligned to the epoch — the minute and
// hour bars of a market that never closes. A daily bar is a session, and a
// session is a calendar day in one zone: that is what sessionStart decides.
const minuteBars = barAggregator({ barStart: fixedBars({ interval: 60_000 }) });
const dailyBars = barAggregator({ barStart: sessionStart({ timeZone: TIME_ZONE }) });
let minute: OHLC | null = null;
let day: OHLC | null = null;
for (const trade of trades) {
minute = minuteBars.fold(minute, trade);
day = dailyBars.fold(day, trade);
}
// A VWAP that resets at the session open — the same rule about where a bar
// starts, handed to the indicator as its anchor.
plot.mainPane.use(
attachVwap({
source: price,
anchor: periodAnchor({ barStart: sessionStart({ timeZone: TIME_ZONE }) }),
name: "Session VWAP",
}),
);
export { plot, minute, day };The default is the runtime's — say the zone
Leave timeZone out and the strategy formats in the runtime's zone; leave locale out and it words things in the runtime's language. That is the JavaScript default, and it is a trap for a chart: a headless render on a server in UTC and a browser in Seoul print different labels for the same bar, and so do two colleagues' machines. This is not a hydration problem — the server does not render the labels — it is two clocks. Say the exchange's zone and an explicit locale, and the labels are the same everywhere.
Aggregation: epoch bars and calendar sessions
Two kinds of bar boundary, and they are different tools:
fixedBars({ interval })— bars of a fixed width, aligned to the epoch: the minute, hour and four-hour bars of a market that never closes. At most a day wide, because past a day the epoch is the wrong ruler (an epoch-aligned week starts on a Thursday). Intraday bars aligned to an exchange's open rather than to the epoch are a few lines of your ownBarStart.sessionStart({ timeZone })— where a session starts, for a market whose session is a calendar day in one zone. The zone is required here, unlike on the axis, because this decides an x that gets stored — into a daily bar, a drawing's coordinates, a history cursor — and a default would let a server in UTC and a browser in Seoul disagree about the same bar. It opens the day at its earliest real instant, so the days a clock moves are handled.
Only "a session is a calendar day" lives there. A holiday, a half day, or an open that crosses midnight is your knowledge, and BarStart is the tool to write it with. Both go into barAggregator({ barStart }); the live feed guide shows the aggregator inside a real feed.
Going to a date
A "go to date" box hands you a calendar date, and the chart wants the instant that date's session opens in the market's zone. Writing the offset in by hand (T00:00:00-05:00) is an hour off for most of New York's year, and a single guess at the instant (UTC noon of that date) lands on the wrong day in zones past UTC+12. The recipe finds an instant the zone reads as that date, then lets sessionStart open the day from it:
import { sessionStart } from "@finchart/core";
const HOUR = 60 * 60 * 1000;
/**
* The instant a calendar date's session opens in a market's zone — what a
* "go to date" box needs on a daily chart. `date` is `YYYY-MM-DD`, a year
* from 0001 to 9999 on the Gregorian calendar; a date that is not one, or
* that the zone skipped (Samoa went from 29 to 31 December 2011), throws.
*/
export function sessionOfDate(date: string, timeZone: string): number {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
if (!match) throw new RangeError(`${date} is not YYYY-MM-DD`);
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
// setUTCFullYear, not Date.UTC — Date.UTC reads the years 0–99 as 1900–1999.
const midnight = new Date(0);
midnight.setUTCFullYear(year, month - 1, day);
midnight.setUTCHours(0, 0, 0, 0);
// A date the calendar rolls over (February 30th) comes back as another.
const onCalendar =
midnight.getUTCFullYear() === year && midnight.getUTCMonth() === month - 1 && midnight.getUTCDate() === day;
if (year < 1 || !onCalendar) throw new RangeError(`${date} is not a date from 0001 to 9999`);
// The zone's own reading of an instant, as numbers — a formatted string
// differs between runtimes, the parts do not.
const reader = new Intl.DateTimeFormat("en-US", {
timeZone,
calendar: "gregory",
numberingSystem: "latn",
year: "numeric",
month: "numeric",
day: "numeric",
});
const readsAsDate = (at: number): boolean => {
const parts = reader.formatToParts(at);
const part = (type: string) => Number(parts.find((each) => each.type === type)?.value);
return part("year") === year && part("month") === month && part("day") === day;
};
// No zone has been more than 16 hours off UTC, so the date begins after
// UTC midnight −16h and ends before +40h. A step of an hour lands inside
// every date that lasts an hour or more — the shortest on record is 14.
// One instant inside the date is enough: sessionStart finds where it opened.
for (let hour = -16; hour <= 40; hour++) {
const at = midnight.getTime() + hour * HOUR;
if (readsAsDate(at)) return sessionStart({ timeZone })(at);
}
throw new RangeError(`${date} never happened in ${timeZone}`);
}It throws for a date the zone never had — Samoa skipped 30 December 2011 — so the caller decides what "go to" means there (the next day, or nothing).
Indicators that reset at the session
VWAP and pivot points restart at a boundary — a session, a week. They take an anchor predicate, and periodAnchor({ barStart }) builds one from any rule about where a bar starts, including sessionStart({ timeZone }). The snippet above anchors a VWAP to the Seoul session.
Two markets
Regular sessions; both exchanges publish calendar exceptions (holidays, half days) that are yours to apply.
| Market | timeZone | Regular session | Daylight saving |
|---|---|---|---|
| KRX (Korea) | Asia/Seoul | 09:00–15:30 | none |
| NYSE (US) | America/New_York | 09:30–16:00 | yes — the zone applies it; your x values never change |
The zone name carries the daylight-saving rule, so a New York chart in March and in November needs no change from you: the same timeTicks({ timeZone: "America/New_York" }) labels both correctly, and sessionStart opens each day at its real 00:00 there.
Shading the hours
The session shading example covers the after-hours stretch with a gradient band drawn through a custom command with a flat fallback. Read it for what it is: how a decoration recovers time from the axis ticks (x.fromDomain(tick.value) — a tick's value is a domain value, an index under bar-index coordinates) and how a fallback travels with a command. Its hours are read in UTC and each band runs one tick spacing from a tick, so it is an approximation, not a session calendar.