Skip to main content

Candles on a trading calendar

Daily OHLC bars, drawn on an axis that only contains open market time. A year of daily bars spans 364 calendar days but only 251 sessions, and the market is actually open for 18.6% of the wall clock between the first bar and the last. A plain time axis spends the other 81.4% of the pixels drawing nothing.

The chart

Hover a candle: showOHLC fans the full quote to the readout instead of the single close. Drag to pan, wheel to zoom — there is no drag-to-zoom gesture, and as the span narrows the axis relabels itself from months to days to sessions. The last 60 sessions are in view to start, which here covers Memorial Day, Juneteenth and the observed Independence Day — none of which take up any room.

The data

The prices are modelled, not measured. Market price data is the sharp licensing case for a public docs site: essentially every feed forbids redistribution, so rather than quietly ship someone's bars, the Gallery's finance cards run on a process model (src/examples/lib/financial-fixtures.ts), and the fixture header says exactly what it does.

What is not modelled is the calendar. NYSE hours (09:30–16:00 America/New_York), the ten 2025-26 US market holidays and the two 13:00 half-days are calendar facts, and they're what this chart is about.

// one row per session, point-keyed at the session's open
[time, open, high, low, close, volume];

251 rows, 2025-08-01 → 2026-07-31. The quirks that matter here:

QuirkNumber
Sessions in the year251, over 364 calendar days — 114 days have no bar
Wall time the market is open18.6%
Bars that gap off the prior close249 of 250 — open is drawn separately from close
Median absolute gap0.45%, largest 3.20%, 40 gaps wider than 1%
Session lengthstwo: 6.5 h, and 3.5 h on the two half-days

The gaps are in the data, not painted on: weekends and holidays simply have no row. That's what makes the two axis modes interesting — the same series drawn on a plain time axis shows the weekends as dead space, and on a calendar axis shows nothing at all.

Build it

A <Candlestick> reads four price columns off a point-keyed series and derives each candle's slot from neighbour spacing. No aggregation pass, no precomputed body extents:

import {
Candlestick,
ChartContainer,
ChartRow,
Layers,
YAxis,
} from '@pond-ts/charts';

<ChartContainer range={[from, to]} width={680} theme={theme}>
<ChartRow height={240}>
<YAxis id="price" side="right" format="$,.2f" width={62} />
<Layers>
<Candlestick series={bars} />
</Layers>
</ChartRow>
</ChartContainer>;

That draws correctly — and leaves a two-day hole every five candles. Hand the container a calendar and the closed time collapses:

import { TradingCalendar } from '@pond-ts/financial';

const calendar = TradingCalendar.fromRules(
{
timeZone: 'America/New_York',
open: '09:30',
close: '16:00',
holidays: ['2025-09-01', '2025-11-27' /* … */],
earlyCloses: [{ date: '2025-11-28', close: '13:00' }],
},
{ from: '2025-08-01', to: '2026-07-31' },
);

<ChartContainer calendar={calendar} range={[from, to]} width={680} theme={theme}>

@pond-ts/charts never imports @pond-ts/financial — the calendar prop is typed against a structural shape, and a TradingCalendar satisfies it. Build it once and pass a stable reference; the x scale rebuilds whenever its identity changes, which on an animating chart is every frame.

Two more things get you the finished chart. showOHLC turns the readout from one close pill into the full quote, and as names the series so the readout labels it:

<Candlestick series={bars} as="ACME" showOHLC gap={2} />

Last, the view range. A point-keyed candle's slot reaches halfway to a notional neighbour, so a range that stops exactly on the first and last bar slices both down the middle. Pad by half a session each side:

const sessions = calendar.sessions();
const half = (sessions[0].close - sessions[0].open) / 2;
const range = [sessions[i].open - half, sessions[j].open + half];
Crop the series to the window you're showing

A <YAxis> with no explicit min/max auto-fits the union of its layers' extents — and a layer's extent covers every point in the series it was handed, not the points inside the container's range. Hand it a year and show 60 sessions and the price axis still spans the year, so the candles crowd into a third of the row. Crop first:

const bars = allBars.slice(i, j + 1); // column-native row range, not a filter

The trade is that a fixed crop and a pannable chart don't mix: pan, and the view leaves the crop behind — empty canvas, and a price axis still fitted to the window you started in. Two ways out. Either pin the axis with an explicit <YAxis min max> and hand the layer the whole series, or — what this chart does — control the range and re-crop from it:

const [range, setRange] = useState(initialRange);
const bars = useMemo(() => rangeWindow(allBars, range), [allBars, range]);

<ChartContainer range={range} onTimeRangeChange={setRange} panZoom="panZoom">

panZoom="panZoom" is drag to pan, wheel to zoom. There is no drag-to-zoom gesture: drag-to-select-a-span is cursor="region" with onRegionSelect, and cursor takes a single value, so it can't be combined with the crosshair.

Options to try

OptionWhat it doesReach for it when
spacing="uniform"Every session gets equal width regardless of its lengthHalf-days shouldn't render as narrower candles than full ones
variant="hollow"Rising candles draw hollow, falling filledThe classic Japanese two-tone reads as too heavy at density
variant="bar"OHLC tick bars instead of bodiesYou want the open/close ticks without the body's visual weight
colorBy="series"One colour off the as role instead of green/redTwo instruments on one chart, where direction colour would make them indistinguishable
sessionDividers="all"A rule at every collapse seamReaders need to see where the axis skipped, not just that it's continuous
decimate={false}Draw every candle rather than per-pixel aggregate candlesYou're zoomed out far enough that the aggregation is doing real work and you'd rather not

See also