Skip to content

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:

ts
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).

ts
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 own BarStart.
  • 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:

ts
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.

MarkettimeZoneRegular sessionDaylight saving
KRX (Korea)Asia/Seoul09:00–15:30none
NYSE (US)America/New_York09:30–16:00yes — 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.