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:
| Column | What it is |
|---|---|
bbMiddle | The period-bar simple moving average |
bbUpper | bbMiddle + stdDev × σ (population σ) |
bbLower | bbMiddle − 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
undefinedrather 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.
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
| Option | What it does | Reach for it when |
|---|---|---|
{ stdDev: 1 } | Narrower band — roughly the interquartile look | 2σ is so wide it never gets touched on your data |
{ column: 'typical' } | Runs the study off another column | You 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 first | Showing 1σ inside 2σ, the percentile-fan look |
as="outer" | The lighter band tone | The band is context behind a busier foreground |
curve="natural" | Smooths both band edges | The band is drawn from sparse aggregated bars and reads as jagged |
See also
<BandChart>— every prop, and nesting two bands- Financial charts — the studies-to-chart walkthrough
@pond-ts/financial— the study catalogue, the pandas/TA-Lib oracle they're verified against, and the fluent import- Candles on a trading calendar — the same bars, bare
- Storybook — the systematic knob walk