Skip to content

Commit

Permalink
bollinger mark & transform (#1772)
Browse files Browse the repository at this point in the history
* bollinger mark & transform

* strict & anchor for bollinger

* fancy candlesticks 🕯️

* more documentation
  • Loading branch information
mbostock authored and Fil committed Aug 21, 2023
1 parent d69960f commit 069a0a2
Show file tree
Hide file tree
Showing 8 changed files with 309 additions and 16 deletions.
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
132 changes: 132 additions & 0 deletions docs/marks/bollinger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<script setup>

import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
import {ref} from "vue";
import aapl from "../data/aapl.ts";

const n = ref(20);
const k = ref(2);

</script>

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

<p>
<label class="label-input">
<span>Window size (n):</span>
<input type="range" v-model.number="n" min="1" max="100" step="1" />
<span style="font-variant-numeric: tabular-nums;">{{n.toLocaleString("en-US")}}</span>
</label>
<label class="label-input">
<span>Radius (k):</span>
<input type="range" v-model.number="k" min="0" max="10" step="0.1" />
<span style="font-variant-numeric: tabular-nums;">{{k.toLocaleString("en-US")}}</span>
</label>
</p>

:::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.
4 changes: 2 additions & 2 deletions docs/marks/rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,15 @@ 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 <span style="border-bottom: solid 2px currentColor;">{{$dark ? "white" : "black"}}</span> rule spans the day’s low and high; and a <span style="border-bottom: solid 2px var(--vp-c-green);">green</span> or <span style="border-bottom: solid 2px var(--vp-c-red);">red</span> rule spans the day’s open and close.

:::plot defer https://observablehq.com/@observablehq/plot-candlestick-chart
```js
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})
Expand Down
1 change: 1 addition & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
93 changes: 93 additions & 0 deletions src/marks/bollinger.d.ts
Original file line number Diff line number Diff line change
@@ -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;
76 changes: 76 additions & 0 deletions src/marks/bollinger.js
Original file line number Diff line number Diff line change
@@ -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});
}
17 changes: 3 additions & 14 deletions test/plots/aapl-bollinger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})
]
});
Expand All @@ -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})
]
});
Expand All @@ -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"});
}

0 comments on commit 069a0a2

Please sign in to comment.