Skip to main content

Bollinger bands

The seam between studies and charts. bollinger() is a pure (series, options) => series from @pond-ts/financial that appends three columns to a bar series and knows nothing about rendering. A <BandChart> then reads two of those columns as its edges and a <LineChart> reads the third. There is no adapter, no "indicator" type, and no chart-side plugin — the study's output is an ordinary TimeSeries with more columns on it.

The chart

Hover: the strip above the chart reads all four layers at that session — band edges, middle line, close — because the in-chart crosshair can't. cursor="crosshair" draws one pill per row, not one per series, so a row carrying a band, a line and a candle still gets a single value pinned to the axis. Several layers at one instant is onTrackerChanged's job; the mechanics are under Build it.

The band is a volatility measure, so it does what volatility does: at a 20-session window it narrows to 2.7% of price at its quietest (2026-05-08) and flares to 30.0% at its widest (2026-04-07), an eleven-fold range across one year.

The Gallery card for this chart sweeps the window length from 10 to 40 sessions and back, because the period is the parameter worth seeing rather than reading about.

The data

The prices are modelled, not measured — see the fixture header for the process and the candlestick page for why. The same 251-session year as the other three finance cards; this chart draws the last 120 sessions of it, which covers the −23.5% drawdown's trough.

The study appends three columns to those bars:

ColumnWhat it is
bbMiddleThe period-bar simple moving average
bbUpperbbMiddle + stdDev × σ (population σ)
bbLowerbbMiddle − stdDev × σ

Two properties of that output shape matter more than the arithmetic:

  • It is length-preserving. The 19 warm-up rows before the first full window emit undefined, they are not dropped. Row counts stay aligned with the bars, and the chart's gap handling draws the warm-up as a hole rather than a fabricated value.
  • A flat window emits nothing. Where σ is exactly 0 the bands are undefined rather than collapsed onto the middle, so an "outside the band" test doesn't fire on every bar of a flat stretch.

One honest note on this data: 18 of the 101 non-warm-up closes in this window (18%) sit outside the 2σ band — 40 of 232 (17%) over the full year. On a mean-reverting series you'd expect roughly 5–10%. This series trends hard through two regimes, and a lagging moving average against a trending price is exactly where that assumption breaks. Worth seeing, rather than tuning away.

Counting breaches: don't let the warm-up in

toFloat64Array() materializes the undefined warm-up rows as 0, so a naive close > bbUpper sweep scores all 19 of them as breaches and inflates the count by exactly the warm-up length. Read through the nullable accessor (column('bbUpper').read(i), which returns undefined) and skip the rows the study hasn't warmed into. The numbers above are counted that way.

Build it

The study first. It's a plain function call on a plain series — no chart in sight:

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

const study = bollinger(bars, { period: 20 });
// study now has bbMiddle / bbUpper / bbLower alongside open/high/low/close

Then draw those columns. Back-to-front: the band fills first so the line and candles sit on top of it.

<ChartRow height={250}>
<YAxis id="price" side="right" format="$,.2f" width={62} />
<Layers>
<BandChart
series={study}
lower="bbLower"
upper="bbUpper"
axis="price"
as="inner"
/>
<LineChart series={study} column="bbMiddle" axis="price" as="secondary" />
<Candlestick series={bars} as="ACME" gap={1} />
</Layers>
</ChartRow>

Declaration order is z-order. as picks the style from the theme — inner is the more opaque of the two band tones (0.2 against outer's 0.1), secondary the second line hue. There is no per-component colour prop, on purpose: the theme is the single styling channel.

One ordering choice worth calling out: the series is cropped to the window before the study runs (the reason is on the candlestick page — an auto-fit <YAxis> sees every point of the series it's handed). That means the warm-up hole belongs to the window: the band starts 19 candles in, exactly as it would on a chart that began there. Run the study first and crop after, and the warm-up would be somewhere off-screen and the band would start at the left edge — which is a different, and less honest, chart.

Reading four layers at one cursor

cursor="crosshair" is a per-row reticle: shared vertical line, a dot, and one value pill on the row's y-axis. It does not fan a pill out per series, so on this chart it reports a single number for four layers. The full set comes off the chart:

<ChartContainer cursor="crosshair" onTrackerChanged={setTracker}>

TrackerInfo.values then carries one sample per layer. One catch worth knowing before you print them:

// What this chart's layers actually report:
// 'inner lower' → bbLower 'inner upper' → bbUpper
// 'secondary' → bbMiddle 'ACME' → the candle's close

A sample's label is the layer's as — and as is a theme role, not a data name. Style by role and the readout says secondary $142.99, which tells the reader nothing. Either map the labels for display (what this page does) or set as to something that reads as an identity and carry the styling elsewhere.

Studies compose, because each one returns a series:

import { bollinger, ema } from '@pond-ts/financial';

const study = ema(bollinger(bars, { period: 20 }), { period: 10 });
// …or, with the opt-in fluent import:
import '@pond-ts/financial/fluent';
const study = bars.bollinger({ period: 20 }).ema({ period: 10 });

Options to try

OptionWhat it doesReach for it when
{ stdDev: 1 }Narrower band — roughly the interquartile look2σ is so wide it never gets touched on your data
{ column: 'typical' }Runs the study off another columnYou want HLC/3 or VWAP rather than the close
{ prefix: 'bb20' }Renames the appended family (bb20Upper, …)Two Bollingers at different periods on one series — otherwise they collide
A second <BandChart>Nested envelopes, wider one declared firstShowing 1σ inside 2σ, the percentile-fan look
as="outer"The lighter band toneThe band is context behind a busier foreground
curve="natural"Smooths both band edgesThe band is drawn from sparse aggregated bars and reads as jagged

See also