diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 8e57c3efd7..ed1c1d2921 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -79,6 +79,7 @@ export default defineConfig({ {text: "Auto", link: "/marks/auto"}, {text: "Axis", link: "/marks/axis"}, {text: "Bar", link: "/marks/bar"}, + {text: "Bollinger", link: "/marks/bollinger"}, {text: "Box", link: "/marks/box"}, {text: "Cell", link: "/marks/cell"}, {text: "Contour", link: "/marks/contour"}, diff --git a/docs/marks/bollinger.md b/docs/marks/bollinger.md new file mode 100644 index 0000000000..1075a863b9 --- /dev/null +++ b/docs/marks/bollinger.md @@ -0,0 +1,132 @@ + + +# Bollinger mark + +The **bollinger mark** is a [composite mark](../features/marks.md#marks) consisting of a [line](./line.md) representing a moving average and an [area](./area.md) representing volatility as a band; the band thickness is proportional to the deviation of nearby values. The bollinger mark is often used to analyze the price of financial instruments such as stocks. + +For example, the chart below shows the price of Apple stock from 2013 to 2018, with a window size *n* of {{n}} days and radius *k* of {{k}} standard deviations. + +
+ + +
+ +:::plot hidden +```js +Plot.bollingerY(aapl, {x: "Date", y: "Close", n, k}).plot() +``` +::: + +```js-vue +Plot.bollingerY(aapl, {x: "Date", y: "Close", n: {{n}}, k: {{k}}}).plot() +``` + +For more control, you can also use the [bollinger map method](#bollinger) directly with the [map transform](../transforms/map.md). + +:::plot +```js +Plot.plot({ + marks: [ + Plot.lineY(aapl, Plot.mapY(Plot.bollinger({n: 20, k: -2}), {x: "Date", y: "Close", stroke: "red"})), + Plot.lineY(aapl, Plot.mapY(Plot.bollinger({n: 20, k: 2}), {x: "Date", y: "Close", stroke: "green"})), + Plot.lineY(aapl, Plot.mapY(Plot.bollinger({n: 20}), {x: "Date", y: "Close"})) + ] +}) +``` +::: + +Below a candlestick chart is constructed from two [rule marks](./rule.md), with a bollinger mark underneath to emphasize the days when the stock was more volatile. + +:::plot +```js +Plot.plot({ + x: {domain: [new Date("2014-01-01"), new Date("2014-06-01")]}, + y: {domain: [68, 92], grid: true}, + color: {domain: [-1, 0, 1], range: ["red", "black", "green"]}, + marks: [ + Plot.bollingerY(aapl, {x: "Date", y: "Close", stroke: "none", clip: true}), + Plot.ruleX(aapl, {x: "Date", y1: "Low", y2: "High", strokeWidth: 1, clip: true}), + Plot.ruleX(aapl, {x: "Date", y1: "Open", y2: "Close", strokeWidth: 3, stroke: (d) => Math.sign(d.Close - d.Open), clip: true}) + ] +}) +``` +::: + +The bollinger mark has two constructors: the common [bollingerY](#bollingerY) for when time goes right→ (or ←left); and the rare [bollingerX](#bollingerX) for when time goes up↑ (or down↓). + +:::plot +```js +Plot.bollingerX(aapl, {y: "Date", x: "Close"}).plot() +``` +::: + +As [shorthand](../features/shorthand.md), you can pass an array of numbers as data. Below, the *x* axis represents the zero-based index into the data (*i.e.*, trading days since May 13, 2013). + +:::plot +```js +Plot.bollingerY(aapl.map((d) => d.Close)).plot() +``` +::: + +## Bollinger options + +The bollinger mark is a [composite mark](../features/marks.md#marks) consisting of two marks: + +* an [area](../marks/area.md) representing volatility as a band, and +* a [line](../marks/line.md) representing a moving average + +The bollinger mark supports the following special options: + +* **n** - the window size (the window transform’s **k** option), an integer; defaults to 20 +* **k** - the band radius, a number representing a multiple of standard deviations; defaults to 2 +* **color** - the fill color of the area, and the stroke color of the line; defaults to *currentColor* +* **opacity** - the fill opacity of the area; defaults to 0.2 +* **fill** - the fill color of the area; defaults to **color** +* **fillOpacity** - the fill opacity of the area; defaults to **opacity** +* **stroke** - the stroke color of the line; defaults to **color** +* **strokeOpacity** - the stroke opacity of the line; defaults to 1 +* **strokeWidth** - the stroke width of the line in pixels; defaults to 1.5 + +Any additional options are passed through to the underlying [line mark](./line.md), [area mark](./area.md), and [window transform](../transforms/window.md). Unlike the window transform, the **strict** option defaults to true, and the **anchor** option defaults to *end* (which assumes that the data is in chronological order). + +## bollingerX(*data*, *options*) {#bollingerX} + +```js +Plot.bollingerX(aapl, {y: "Date", x: "Close"}) +``` + +Returns a bollinger mark for when time goes up↑ (or down↓). If the **x** option is not specified, it defaults to the identity function, as when *data* is an array of numbers [*x₀*, *x₁*, *x₂*, …]. If the **y** option is not specified, it defaults to [0, 1, 2, …]. + +## bollingerY(*data*, *options*) {#bollingerY} + +```js +Plot.bollingerY(aapl, {x: "Date", y: "Close"}) +``` + +Returns a bollinger mark for when time goes right→ (or ←left). If the **y** option is not specified, it defaults to the identity function, as when *data* is an array of numbers [*y₀*, *y₁*, *y₂*, …]. If the **x** option is not specified, it defaults to [0, 1, 2, …]. + +## bollinger(*options*) {#bollinger} + +```js +Plot.lineY(data, Plot.map({y: Plot.bollinger({n: 20})}, {x: "Date", y: "Close"})) +``` + +Returns a bollinger map method for use with the [map transform](../transforms/map.md). The **k** option here defaults to zero instead of two. diff --git a/docs/marks/rule.md b/docs/marks/rule.md index dece412095..e81758ba0a 100644 --- a/docs/marks/rule.md +++ b/docs/marks/rule.md @@ -89,7 +89,7 @@ Plot.plot({ ``` ::: -In the dense [candlestick chart](https://observablehq.com/@observablehq/observable-plot-candlestick) below, three rules are drawn for each trading day: a gray rule spans the chart, showing gaps for weekends and holidays; a black rule spans the day’s low and high; and a green or red rule spans the day’s open and close. +In the dense [candlestick chart](https://observablehq.com/@observablehq/observable-plot-candlestick) below, three rules are drawn for each trading day: a gray rule spans the chart, showing gaps for weekends and holidays; a {{$dark ? "white" : "black"}} rule spans the day’s low and high; and a green or red rule spans the day’s open and close. :::plot defer https://observablehq.com/@observablehq/plot-candlestick-chart ```js @@ -97,7 +97,7 @@ Plot.plot({ inset: 6, label: null, y: {grid: true, label: "Stock price ($)"}, - color: {type: "threshold", range: ["#e41a1c", "#4daf4a"]}, + color: {type: "threshold", range: ["red", "green"]}, marks: [ Plot.ruleX(aapl, {x: "Date", y1: "Low", y2: "High"}), Plot.ruleX(aapl, {x: "Date", y1: "Open", y2: "Close", stroke: (d) => d.Close - d.Open, strokeWidth: 4}) diff --git a/src/index.d.ts b/src/index.d.ts index 19e9442a31..24e2344eef 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -14,6 +14,7 @@ export * from "./marks/arrow.js"; export * from "./marks/auto.js"; export * from "./marks/axis.js"; export * from "./marks/bar.js"; +export * from "./marks/bollinger.js"; export * from "./marks/box.js"; export * from "./marks/cell.js"; export * from "./marks/contour.js"; diff --git a/src/index.js b/src/index.js index fec7e243ec..98f5772b0d 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ export {Arrow, arrow} from "./marks/arrow.js"; export {auto, autoSpec} from "./marks/auto.js"; export {axisX, axisY, axisFx, axisFy, gridX, gridY, gridFx, gridFy} from "./marks/axis.js"; export {BarX, BarY, barX, barY} from "./marks/bar.js"; +export {bollinger, bollingerX, bollingerY} from "./marks/bollinger.js"; export {boxX, boxY} from "./marks/box.js"; export {Cell, cell, cellX, cellY} from "./marks/cell.js"; export {Contour, contour} from "./marks/contour.js"; diff --git a/src/marks/bollinger.d.ts b/src/marks/bollinger.d.ts new file mode 100644 index 0000000000..8a2087c140 --- /dev/null +++ b/src/marks/bollinger.d.ts @@ -0,0 +1,93 @@ +import type {CompoundMark, Data, MarkOptions} from "../mark.js"; +import type {Map} from "../transforms/map.js"; +import type {WindowOptions} from "../transforms/window.js"; +import type {AreaXOptions, AreaYOptions} from "./area.js"; +import type {LineXOptions, LineYOptions} from "./line.js"; + +/** Options for the bollinger window transform. */ +export interface BollingerWindowOptions { + /** The number of consecutive values in the window; defaults to 20. */ + n?: number; + + /** The number of standard deviations to offset the bands; defaults to 2. */ + k?: number; + + /** + * How to align the rolling window, placing the current value: + * + * - *start* - as the first element in the window + * - *middle* - in the middle of the window, rounding down if **n** is even + * - *end* (default) - as the last element in the window + * + * Note that *start* and *end* are relative to input order, not natural + * ascending order by value. For example, if the data is in reverse + * chronological order, then the meaning of *start* and *end* is effectively + * reversed because the first data point is the most recent. + */ + anchor?: WindowOptions["anchor"]; + + /** + * If true (the default), the output start values or end values or both + * (depending on the **anchor**) of each series may be undefined since there + * are not enough elements to create a window of size **n**; output values may + * also be undefined if some of the input values in the corresponding window + * are undefined. + * + * If false, the window will be automatically truncated as needed, and + * undefined input values are ignored. For example, if **n** is 24 and + * **anchor** is *middle*, then the initial 11 values have effective window + * sizes of 13, 14, 15, … 23, and likewise the last 12 values have effective + * window sizes of 23, 22, 21, … 12. Values computed with a truncated window + * may be noisy. + */ + strict?: WindowOptions["strict"]; +} + +/** Options for the bollinger mark. */ +export interface BollingerOptions extends BollingerWindowOptions { + /** + * Shorthand for setting both **fill** and **stroke**; affects the stroke of + * the line and the fill of the area; defaults to *currentColor*. + */ + color?: MarkOptions["stroke"]; +} + +/** Options for the bollingerX mark. */ +export type BollingerXOptions = BollingerOptions & AreaXOptions & LineXOptions; + +/** Options for the bollingerY mark. */ +export type BollingerYOptions = BollingerOptions & AreaYOptions & LineYOptions; + +/** + * Returns a new vertically-oriented bollinger mark for the given *data* and + * *options*, as in a time-series area chart where time goes up↑ (or down↓). + * + * If the *x* option is not specified, it defaults to the identity function, as + * when data is an array of numbers [*x*₀, *x*₁, *x*₂, …]. If the *y* option is + * not specified, it defaults to [0, 1, 2, …]. + */ +export function bollingerX(data?: Data, options?: BollingerXOptions): CompoundMark; + +/** + * Returns a new horizontally-oriented bollinger mark for the given *data* and + * *options*, as in a time-series area chart where time goes right→ (or ←left). + * + * If the *y* option is not specified, it defaults to the identity function, as + * when data is an array of numbers [*y*₀, *y*₁, *y*₂, …]. If the *x* option is + * not specified, it defaults to [0, 1, 2, …]. + */ +export function bollingerY(data?: Data, options?: BollingerYOptions): CompoundMark; + +/** + * Given the specified bollinger *options*, returns a corresponding map + * implementation for use with the map transform, allowing the bollinger + * transform to be applied to arbitrary channels instead of only *x* and *y*. + * For example, to compute the upper volatility band: + * + * ```js + * Plot.map({y: Plot.bollinger({n: 20, k: 2})}, {x: "Date", y: "Close"}) + * ``` + * + * Here the *k* option defaults to zero instead of two. + */ +export function bollinger(options?: BollingerWindowOptions): Map; diff --git a/src/marks/bollinger.js b/src/marks/bollinger.js new file mode 100644 index 0000000000..03b46189d2 --- /dev/null +++ b/src/marks/bollinger.js @@ -0,0 +1,76 @@ +import {deviation, mean} from "d3"; +import {marks} from "../mark.js"; +import {map} from "../transforms/map.js"; +import {window} from "../transforms/window.js"; +import {areaX, areaY} from "./area.js"; +import {lineX, lineY} from "./line.js"; +import {identity} from "../options.js"; + +const defaults = { + n: 20, + k: 2, + color: "currentColor", + opacity: 0.2, + strict: true, + anchor: "end" +}; + +export function bollingerX( + data, + { + x = identity, + y, + k = defaults.k, + color = defaults.color, + opacity = defaults.opacity, + fill = color, + fillOpacity = opacity, + stroke = color, + strokeOpacity, + strokeWidth, + ...options + } = {} +) { + return marks( + areaX( + data, + map( + {x1: bollinger({k: -k, ...options}), x2: bollinger({k, ...options})}, + {x1: x, x2: x, y, fill, fillOpacity, ...options} + ) + ), + lineX(data, map({x: bollinger(options)}, {x, y, stroke, strokeOpacity, strokeWidth, ...options})) + ); +} + +export function bollingerY( + data, + { + x, + y = identity, + k = defaults.k, + color = defaults.color, + opacity = defaults.opacity, + fill = color, + fillOpacity = opacity, + stroke = color, + strokeOpacity, + strokeWidth, + ...options + } = {} +) { + return marks( + areaY( + data, + map( + {y1: bollinger({k: -k, ...options}), y2: bollinger({k, ...options})}, + {x, y1: y, y2: y, fill, fillOpacity, ...options} + ) + ), + lineY(data, map({y: bollinger(options)}, {x, y, stroke, strokeOpacity, strokeWidth, ...options})) + ); +} + +export function bollinger({n = defaults.n, k = 0, strict = defaults.strict, anchor = defaults.anchor} = {}) { + return window({k: n, reduce: (Y) => mean(Y) + k * (deviation(Y) || 0), strict, anchor}); +} diff --git a/test/plots/aapl-bollinger.ts b/test/plots/aapl-bollinger.ts index 7ff5f6d2ce..7d554d301e 100644 --- a/test/plots/aapl-bollinger.ts +++ b/test/plots/aapl-bollinger.ts @@ -8,8 +8,7 @@ export async function aaplBollinger() { grid: true }, marks: [ - Plot.areaY(AAPL, bollingerBandY(20, 2, {x: "Date", y: "Close", fillOpacity: 0.2})), - Plot.line(AAPL, Plot.map({y: bollinger(20, 0)}, {x: "Date", y: "Close", stroke: "blue"})), + Plot.bollingerY(AAPL, {x: "Date", y: "Close", stroke: "blue"}), Plot.line(AAPL, {x: "Date", y: "Close", strokeWidth: 1}) ] }); @@ -26,8 +25,7 @@ export async function aaplBollingerGridInterval() { Plot.gridX({tickSpacing: 40, stroke: "#fff", strokeOpacity: 1, strokeWidth: 0.5}), Plot.gridX({tickSpacing: 80, stroke: "#fff", strokeOpacity: 1}), Plot.axisX({tickSpacing: 80}), - Plot.areaY(AAPL, bollingerBandY(20, 2, {x: "Date", y: "Close", fillOpacity: 0.2})), - Plot.line(AAPL, Plot.map({y: bollinger(20, 0)}, {x: "Date", y: "Close", stroke: "blue"})), + Plot.bollingerY(AAPL, {x: "Date", y: "Close", stroke: "blue"}), Plot.line(AAPL, {x: "Date", y: "Close", strokeWidth: 1}) ] }); @@ -44,17 +42,8 @@ export async function aaplBollingerGridSpacing() { Plot.gridX({interval: "3 months", stroke: "#fff", strokeOpacity: 1, strokeWidth: 0.5}), Plot.gridX({interval: "1 year", stroke: "#fff", strokeOpacity: 1}), Plot.axisX({interval: "1 year"}), - Plot.areaY(AAPL, bollingerBandY(20, 2, {x: "Date", y: "Close", fillOpacity: 0.2})), - Plot.line(AAPL, Plot.map({y: bollinger(20, 0)}, {x: "Date", y: "Close", stroke: "blue"})), + Plot.bollingerY(AAPL, {x: "Date", y: "Close", stroke: "blue"}), Plot.line(AAPL, {x: "Date", y: "Close", strokeWidth: 1}) ] }); } - -function bollingerBandY(N, K, options) { - return Plot.map({y1: bollinger(N, -K), y2: bollinger(N, K)}, options); -} - -function bollinger(N, K) { - return Plot.window({k: N, reduce: (Y) => d3.mean(Y) + K * d3.deviation(Y), strict: true, anchor: "end"}); -}