Skip to content

Commit c73fa05

Browse files
authored
bollinger mark & transform (#1772)
* bollinger mark & transform * strict & anchor for bollinger * fancy candlesticks 🕯️ * more documentation
1 parent 1d4e82c commit c73fa05

File tree

8 files changed

+309
-16
lines changed

8 files changed

+309
-16
lines changed

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export default defineConfig({
7979
{text: "Auto", link: "/marks/auto"},
8080
{text: "Axis", link: "/marks/axis"},
8181
{text: "Bar", link: "/marks/bar"},
82+
{text: "Bollinger", link: "/marks/bollinger"},
8283
{text: "Box", link: "/marks/box"},
8384
{text: "Cell", link: "/marks/cell"},
8485
{text: "Contour", link: "/marks/contour"},

docs/marks/bollinger.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<script setup>
2+
3+
import * as Plot from "@observablehq/plot";
4+
import * as d3 from "d3";
5+
import {ref} from "vue";
6+
import aapl from "../data/aapl.ts";
7+
8+
const n = ref(20);
9+
const k = ref(2);
10+
11+
</script>
12+
13+
# Bollinger mark
14+
15+
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.
16+
17+
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.
18+
19+
<p>
20+
<label class="label-input">
21+
<span>Window size (n):</span>
22+
<input type="range" v-model.number="n" min="1" max="100" step="1" />
23+
<span style="font-variant-numeric: tabular-nums;">{{n.toLocaleString("en-US")}}</span>
24+
</label>
25+
<label class="label-input">
26+
<span>Radius (k):</span>
27+
<input type="range" v-model.number="k" min="0" max="10" step="0.1" />
28+
<span style="font-variant-numeric: tabular-nums;">{{k.toLocaleString("en-US")}}</span>
29+
</label>
30+
</p>
31+
32+
:::plot hidden
33+
```js
34+
Plot.bollingerY(aapl, {x: "Date", y: "Close", n, k}).plot()
35+
```
36+
:::
37+
38+
```js-vue
39+
Plot.bollingerY(aapl, {x: "Date", y: "Close", n: {{n}}, k: {{k}}}).plot()
40+
```
41+
42+
For more control, you can also use the [bollinger map method](#bollinger) directly with the [map transform](../transforms/map.md).
43+
44+
:::plot
45+
```js
46+
Plot.plot({
47+
marks: [
48+
Plot.lineY(aapl, Plot.mapY(Plot.bollinger({n: 20, k: -2}), {x: "Date", y: "Close", stroke: "red"})),
49+
Plot.lineY(aapl, Plot.mapY(Plot.bollinger({n: 20, k: 2}), {x: "Date", y: "Close", stroke: "green"})),
50+
Plot.lineY(aapl, Plot.mapY(Plot.bollinger({n: 20}), {x: "Date", y: "Close"}))
51+
]
52+
})
53+
```
54+
:::
55+
56+
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.
57+
58+
:::plot
59+
```js
60+
Plot.plot({
61+
x: {domain: [new Date("2014-01-01"), new Date("2014-06-01")]},
62+
y: {domain: [68, 92], grid: true},
63+
color: {domain: [-1, 0, 1], range: ["red", "black", "green"]},
64+
marks: [
65+
Plot.bollingerY(aapl, {x: "Date", y: "Close", stroke: "none", clip: true}),
66+
Plot.ruleX(aapl, {x: "Date", y1: "Low", y2: "High", strokeWidth: 1, clip: true}),
67+
Plot.ruleX(aapl, {x: "Date", y1: "Open", y2: "Close", strokeWidth: 3, stroke: (d) => Math.sign(d.Close - d.Open), clip: true})
68+
]
69+
})
70+
```
71+
:::
72+
73+
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↓).
74+
75+
:::plot
76+
```js
77+
Plot.bollingerX(aapl, {y: "Date", x: "Close"}).plot()
78+
```
79+
:::
80+
81+
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).
82+
83+
:::plot
84+
```js
85+
Plot.bollingerY(aapl.map((d) => d.Close)).plot()
86+
```
87+
:::
88+
89+
## Bollinger options
90+
91+
The bollinger mark is a [composite mark](../features/marks.md#marks) consisting of two marks:
92+
93+
* an [area](../marks/area.md) representing volatility as a band, and
94+
* a [line](../marks/line.md) representing a moving average
95+
96+
The bollinger mark supports the following special options:
97+
98+
* **n** - the window size (the window transform’s **k** option), an integer; defaults to 20
99+
* **k** - the band radius, a number representing a multiple of standard deviations; defaults to 2
100+
* **color** - the fill color of the area, and the stroke color of the line; defaults to *currentColor*
101+
* **opacity** - the fill opacity of the area; defaults to 0.2
102+
* **fill** - the fill color of the area; defaults to **color**
103+
* **fillOpacity** - the fill opacity of the area; defaults to **opacity**
104+
* **stroke** - the stroke color of the line; defaults to **color**
105+
* **strokeOpacity** - the stroke opacity of the line; defaults to 1
106+
* **strokeWidth** - the stroke width of the line in pixels; defaults to 1.5
107+
108+
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).
109+
110+
## bollingerX(*data*, *options*) {#bollingerX}
111+
112+
```js
113+
Plot.bollingerX(aapl, {y: "Date", x: "Close"})
114+
```
115+
116+
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, …].
117+
118+
## bollingerY(*data*, *options*) {#bollingerY}
119+
120+
```js
121+
Plot.bollingerY(aapl, {x: "Date", y: "Close"})
122+
```
123+
124+
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, …].
125+
126+
## bollinger(*options*) {#bollinger}
127+
128+
```js
129+
Plot.lineY(data, Plot.map({y: Plot.bollinger({n: 20})}, {x: "Date", y: "Close"}))
130+
```
131+
132+
Returns a bollinger map method for use with the [map transform](../transforms/map.md). The **k** option here defaults to zero instead of two.

docs/marks/rule.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,15 @@ Plot.plot({
8989
```
9090
:::
9191

92-
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.
92+
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.
9393

9494
:::plot defer https://observablehq.com/@observablehq/plot-candlestick-chart
9595
```js
9696
Plot.plot({
9797
inset: 6,
9898
label: null,
9999
y: {grid: true, label: "Stock price ($)"},
100-
color: {type: "threshold", range: ["#e41a1c", "#4daf4a"]},
100+
color: {type: "threshold", range: ["red", "green"]},
101101
marks: [
102102
Plot.ruleX(aapl, {x: "Date", y1: "Low", y2: "High"}),
103103
Plot.ruleX(aapl, {x: "Date", y1: "Open", y2: "Close", stroke: (d) => d.Close - d.Open, strokeWidth: 4})

src/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export * from "./marks/arrow.js";
1414
export * from "./marks/auto.js";
1515
export * from "./marks/axis.js";
1616
export * from "./marks/bar.js";
17+
export * from "./marks/bollinger.js";
1718
export * from "./marks/box.js";
1819
export * from "./marks/cell.js";
1920
export * from "./marks/contour.js";

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {Arrow, arrow} from "./marks/arrow.js";
55
export {auto, autoSpec} from "./marks/auto.js";
66
export {axisX, axisY, axisFx, axisFy, gridX, gridY, gridFx, gridFy} from "./marks/axis.js";
77
export {BarX, BarY, barX, barY} from "./marks/bar.js";
8+
export {bollinger, bollingerX, bollingerY} from "./marks/bollinger.js";
89
export {boxX, boxY} from "./marks/box.js";
910
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
1011
export {Contour, contour} from "./marks/contour.js";

src/marks/bollinger.d.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type {CompoundMark, Data, MarkOptions} from "../mark.js";
2+
import type {Map} from "../transforms/map.js";
3+
import type {WindowOptions} from "../transforms/window.js";
4+
import type {AreaXOptions, AreaYOptions} from "./area.js";
5+
import type {LineXOptions, LineYOptions} from "./line.js";
6+
7+
/** Options for the bollinger window transform. */
8+
export interface BollingerWindowOptions {
9+
/** The number of consecutive values in the window; defaults to 20. */
10+
n?: number;
11+
12+
/** The number of standard deviations to offset the bands; defaults to 2. */
13+
k?: number;
14+
15+
/**
16+
* How to align the rolling window, placing the current value:
17+
*
18+
* - *start* - as the first element in the window
19+
* - *middle* - in the middle of the window, rounding down if **n** is even
20+
* - *end* (default) - as the last element in the window
21+
*
22+
* Note that *start* and *end* are relative to input order, not natural
23+
* ascending order by value. For example, if the data is in reverse
24+
* chronological order, then the meaning of *start* and *end* is effectively
25+
* reversed because the first data point is the most recent.
26+
*/
27+
anchor?: WindowOptions["anchor"];
28+
29+
/**
30+
* If true (the default), the output start values or end values or both
31+
* (depending on the **anchor**) of each series may be undefined since there
32+
* are not enough elements to create a window of size **n**; output values may
33+
* also be undefined if some of the input values in the corresponding window
34+
* are undefined.
35+
*
36+
* If false, the window will be automatically truncated as needed, and
37+
* undefined input values are ignored. For example, if **n** is 24 and
38+
* **anchor** is *middle*, then the initial 11 values have effective window
39+
* sizes of 13, 14, 15, … 23, and likewise the last 12 values have effective
40+
* window sizes of 23, 22, 21, … 12. Values computed with a truncated window
41+
* may be noisy.
42+
*/
43+
strict?: WindowOptions["strict"];
44+
}
45+
46+
/** Options for the bollinger mark. */
47+
export interface BollingerOptions extends BollingerWindowOptions {
48+
/**
49+
* Shorthand for setting both **fill** and **stroke**; affects the stroke of
50+
* the line and the fill of the area; defaults to *currentColor*.
51+
*/
52+
color?: MarkOptions["stroke"];
53+
}
54+
55+
/** Options for the bollingerX mark. */
56+
export type BollingerXOptions = BollingerOptions & AreaXOptions & LineXOptions;
57+
58+
/** Options for the bollingerY mark. */
59+
export type BollingerYOptions = BollingerOptions & AreaYOptions & LineYOptions;
60+
61+
/**
62+
* Returns a new vertically-oriented bollinger mark for the given *data* and
63+
* *options*, as in a time-series area chart where time goes up↑ (or down↓).
64+
*
65+
* If the *x* option is not specified, it defaults to the identity function, as
66+
* when data is an array of numbers [*x*₀, *x*₁, *x*₂, …]. If the *y* option is
67+
* not specified, it defaults to [0, 1, 2, …].
68+
*/
69+
export function bollingerX(data?: Data, options?: BollingerXOptions): CompoundMark;
70+
71+
/**
72+
* Returns a new horizontally-oriented bollinger mark for the given *data* and
73+
* *options*, as in a time-series area chart where time goes right→ (or ←left).
74+
*
75+
* If the *y* option is not specified, it defaults to the identity function, as
76+
* when data is an array of numbers [*y*₀, *y*₁, *y*₂, …]. If the *x* option is
77+
* not specified, it defaults to [0, 1, 2, …].
78+
*/
79+
export function bollingerY(data?: Data, options?: BollingerYOptions): CompoundMark;
80+
81+
/**
82+
* Given the specified bollinger *options*, returns a corresponding map
83+
* implementation for use with the map transform, allowing the bollinger
84+
* transform to be applied to arbitrary channels instead of only *x* and *y*.
85+
* For example, to compute the upper volatility band:
86+
*
87+
* ```js
88+
* Plot.map({y: Plot.bollinger({n: 20, k: 2})}, {x: "Date", y: "Close"})
89+
* ```
90+
*
91+
* Here the *k* option defaults to zero instead of two.
92+
*/
93+
export function bollinger(options?: BollingerWindowOptions): Map;

src/marks/bollinger.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {deviation, mean} from "d3";
2+
import {marks} from "../mark.js";
3+
import {map} from "../transforms/map.js";
4+
import {window} from "../transforms/window.js";
5+
import {areaX, areaY} from "./area.js";
6+
import {lineX, lineY} from "./line.js";
7+
import {identity} from "../options.js";
8+
9+
const defaults = {
10+
n: 20,
11+
k: 2,
12+
color: "currentColor",
13+
opacity: 0.2,
14+
strict: true,
15+
anchor: "end"
16+
};
17+
18+
export function bollingerX(
19+
data,
20+
{
21+
x = identity,
22+
y,
23+
k = defaults.k,
24+
color = defaults.color,
25+
opacity = defaults.opacity,
26+
fill = color,
27+
fillOpacity = opacity,
28+
stroke = color,
29+
strokeOpacity,
30+
strokeWidth,
31+
...options
32+
} = {}
33+
) {
34+
return marks(
35+
areaX(
36+
data,
37+
map(
38+
{x1: bollinger({k: -k, ...options}), x2: bollinger({k, ...options})},
39+
{x1: x, x2: x, y, fill, fillOpacity, ...options}
40+
)
41+
),
42+
lineX(data, map({x: bollinger(options)}, {x, y, stroke, strokeOpacity, strokeWidth, ...options}))
43+
);
44+
}
45+
46+
export function bollingerY(
47+
data,
48+
{
49+
x,
50+
y = identity,
51+
k = defaults.k,
52+
color = defaults.color,
53+
opacity = defaults.opacity,
54+
fill = color,
55+
fillOpacity = opacity,
56+
stroke = color,
57+
strokeOpacity,
58+
strokeWidth,
59+
...options
60+
} = {}
61+
) {
62+
return marks(
63+
areaY(
64+
data,
65+
map(
66+
{y1: bollinger({k: -k, ...options}), y2: bollinger({k, ...options})},
67+
{x, y1: y, y2: y, fill, fillOpacity, ...options}
68+
)
69+
),
70+
lineY(data, map({y: bollinger(options)}, {x, y, stroke, strokeOpacity, strokeWidth, ...options}))
71+
);
72+
}
73+
74+
export function bollinger({n = defaults.n, k = 0, strict = defaults.strict, anchor = defaults.anchor} = {}) {
75+
return window({k: n, reduce: (Y) => mean(Y) + k * (deviation(Y) || 0), strict, anchor});
76+
}

test/plots/aapl-bollinger.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ export async function aaplBollinger() {
88
grid: true
99
},
1010
marks: [
11-
Plot.areaY(AAPL, bollingerBandY(20, 2, {x: "Date", y: "Close", fillOpacity: 0.2})),
12-
Plot.line(AAPL, Plot.map({y: bollinger(20, 0)}, {x: "Date", y: "Close", stroke: "blue"})),
11+
Plot.bollingerY(AAPL, {x: "Date", y: "Close", stroke: "blue"}),
1312
Plot.line(AAPL, {x: "Date", y: "Close", strokeWidth: 1})
1413
]
1514
});
@@ -26,8 +25,7 @@ export async function aaplBollingerGridInterval() {
2625
Plot.gridX({tickSpacing: 40, stroke: "#fff", strokeOpacity: 1, strokeWidth: 0.5}),
2726
Plot.gridX({tickSpacing: 80, stroke: "#fff", strokeOpacity: 1}),
2827
Plot.axisX({tickSpacing: 80}),
29-
Plot.areaY(AAPL, bollingerBandY(20, 2, {x: "Date", y: "Close", fillOpacity: 0.2})),
30-
Plot.line(AAPL, Plot.map({y: bollinger(20, 0)}, {x: "Date", y: "Close", stroke: "blue"})),
28+
Plot.bollingerY(AAPL, {x: "Date", y: "Close", stroke: "blue"}),
3129
Plot.line(AAPL, {x: "Date", y: "Close", strokeWidth: 1})
3230
]
3331
});
@@ -44,17 +42,8 @@ export async function aaplBollingerGridSpacing() {
4442
Plot.gridX({interval: "3 months", stroke: "#fff", strokeOpacity: 1, strokeWidth: 0.5}),
4543
Plot.gridX({interval: "1 year", stroke: "#fff", strokeOpacity: 1}),
4644
Plot.axisX({interval: "1 year"}),
47-
Plot.areaY(AAPL, bollingerBandY(20, 2, {x: "Date", y: "Close", fillOpacity: 0.2})),
48-
Plot.line(AAPL, Plot.map({y: bollinger(20, 0)}, {x: "Date", y: "Close", stroke: "blue"})),
45+
Plot.bollingerY(AAPL, {x: "Date", y: "Close", stroke: "blue"}),
4946
Plot.line(AAPL, {x: "Date", y: "Close", strokeWidth: 1})
5047
]
5148
});
5249
}
53-
54-
function bollingerBandY(N, K, options) {
55-
return Plot.map({y1: bollinger(N, -K), y2: bollinger(N, K)}, options);
56-
}
57-
58-
function bollinger(N, K) {
59-
return Plot.window({k: N, reduce: (Y) => d3.mean(Y) + K * d3.deviation(Y), strict: true, anchor: "end"});
60-
}

0 commit comments

Comments
 (0)