@amcharts/amcharts5
Version:
amCharts 5
516 lines • 22.9 kB
JavaScript
import { AxisRendererX } from "../../xy/axes/AxisRendererX";
import { ValueAxis } from "../../xy/axes/ValueAxis";
import { Indicator } from "./Indicator";
import { ColumnSeries } from "../../xy/series/ColumnSeries";
import { percent } from "../../../core/util/Percent";
import * as $type from "../../../core/util/Type";
import * as $array from "../../../core/util/Array";
/**
* An implementation of a Volume Profile indicator for a [[StockChart]].
*
* @see {@link https://www.amcharts.com/docs/v5/charts/stock/indicators/} for more info
* @since 5.7.0
*/
export class VolumeProfile extends Indicator {
constructor() {
super(...arguments);
this._editableSettings = [{
key: "countType",
name: this.root.language.translateAny("Count"),
type: "dropdown",
options: [
{ value: "rows", text: this.root.language.translateAny("number of rows"), extTarget: "count", extTargetValue: 24, extTargetMinValue: 10, extTargetMaxValue: 500, extTargetStep: 1 },
{ value: "ticks", text: this.root.language.translateAny("ticks per row"), extTarget: "count", extTargetValue: 1000, extTargetMinValue: 200 }
]
}, {
key: "count",
name: this.root.language.translateAny("Count"),
type: "number",
minValue: 10,
maxValue: 500,
step: 1
}, {
key: "valueArea",
name: this.root.language.translateAny("Value Area"),
type: "number",
minValue: 50,
maxValue: 95,
step: 1
}, {
key: "upColor",
name: this.root.language.translateAny("Up volume"),
type: "color"
}, {
key: "downColor",
name: this.root.language.translateAny("Down volume"),
type: "color"
}, {
key: "axisWidth",
name: this.root.language.translateAny("Width %"),
type: "number",
minValue: 10,
maxValue: 100,
step: 1
}];
}
/**
* Keeps the `count` setting's limits in sync with the current `countType`:
* rows mode is a fixed 10..500 (step 1); ticks mode uses an axis-dependent
* range derived from the y-axis step (so it scales with the instrument), with
* an upper cap that keeps the row count sane. Also mirrors those bounds onto
* the `countType` dropdown's "ticks" option. Looks entries up by key rather
* than by array index.
*/
_updateCountBounds() {
let ticksOption;
let countSetting;
$array.each(this._editableSettings, (setting) => {
if (setting.key == "countType" && setting.options) {
$array.each(setting.options, (option) => {
if (option && option.value == "ticks") {
ticksOption = option;
}
});
}
else if (setting.key == "count" && setting.type == "number") {
countSetting = setting;
}
});
// Dynamic ticks range from the y-axis step. Left undefined until the axis
// step is known, so we never clamp a ticks value against a fixed fallback
// (which would corrupt a legitimately small tick size on a low-priced
// instrument). `_updateCountBounds` is re-run with a re-clamp once the step
// arrives (see the onPrivate("step") handler).
let ticksDefault = 1000;
let ticksMin = undefined;
let ticksMax = undefined;
const stockSeries = this.get("stockSeries");
const yAxis = stockSeries ? stockSeries.get("yAxis") : undefined;
const step = yAxis ? yAxis.getPrivate("step") : undefined;
if (step !== undefined) {
const val = step * 50;
ticksDefault = val;
ticksMin = val * 0.2;
ticksMax = val * 100;
}
if (ticksOption) {
ticksOption.extTargetValue = ticksDefault;
ticksOption.extTargetMinValue = ticksMin;
ticksOption.extTargetMaxValue = ticksMax;
}
if (countSetting) {
if (this.get("countType") == "ticks") {
countSetting.minValue = ticksMin;
countSetting.maxValue = ticksMax;
countSetting.step = undefined;
}
else {
countSetting.minValue = 10;
countSetting.maxValue = 500;
countSetting.step = 1;
}
}
}
_clampSettings(force = false) {
this._updateCountBounds();
super._clampSettings(force);
}
_afterNew() {
super._afterNew();
const volumeSeries = this.get("volumeSeries");
const stockSeries = this.get("stockSeries");
if (volumeSeries) {
const chart = stockSeries.chart;
const root = this._root;
if (chart) {
const yAxis = stockSeries.get("yAxis");
this._updateCountBounds();
yAxis.onPrivate("step", () => {
// Bounds are now known: re-clamp count into range and, if that
// actually changed it, recompute so heavy build never runs stale.
const before = this.get("count");
this._clampSettings(true);
if (this.get("count") !== before) {
this.markDataDirty();
}
});
const panelXAxis = stockSeries.get("xAxis");
panelXAxis.on("start", () => {
this.markDataDirty();
});
panelXAxis.on("end", () => {
this.markDataDirty();
});
const xRenderer = AxisRendererX.new(root, {});
xRenderer.grid.template.set("forceHidden", true);
xRenderer.labels.template.set("forceHidden", true);
this.xAxis = chart.xAxes.push(ValueAxis.new(root, {
zoomable: false,
strictMinMax: true,
panX: false,
panY: false,
renderer: xRenderer
}));
if (yAxis.get("renderer").get("opposite")) {
xRenderer.set("inversed", true);
this.xAxis.setAll({
x: percent(100),
centerX: percent(100)
});
}
this.series = chart.series.unshift(ColumnSeries.new(root, {
xAxis: this.xAxis,
yAxis: yAxis,
snapTooltip: false,
valueXField: "down",
openValueXField: "xOpen",
openValueYField: "yOpen",
valueYField: "y",
calculateAggregates: true,
turboMode: false,
themeTags: ["indicator", "volumeprofile", "down"]
}));
this.upSeries = chart.series.unshift(ColumnSeries.new(root, {
xAxis: this.xAxis,
yAxis: yAxis,
snapTooltip: false,
valueXField: "total",
openValueXField: "down",
openValueYField: "yOpen",
valueYField: "y",
calculateAggregates: true,
themeTags: ["indicator", "volumeprofile", "up"]
}));
this.upSeries.setPrivate("doNotUpdateLegend", true);
this.series.setPrivate("doNotUpdateLegend", true);
this.upSeries.setPrivate("baseValueSeries", stockSeries);
this.series.setPrivate("baseValueSeries", stockSeries);
this._handleLegend(this.series);
this._addInteractivity(this.series);
this._addInteractivity(this.upSeries);
}
}
}
_addInteractivity(series) {
series.columns.template.events.on("pointerover", (e) => {
let dataItem = e.target.dataItem;
if (dataItem) {
if (dataItem.component == this.upSeries) {
dataItem = this.series.dataItems[this.upSeries.dataItems.indexOf(dataItem)];
if (dataItem) {
const column = dataItem.get("graphics");
if (column) {
column.hover();
this._previousColumn = column;
}
}
}
else {
dataItem = this.upSeries.dataItems[this.series.dataItems.indexOf(dataItem)];
if (dataItem) {
const column = dataItem.get("graphics");
if (column) {
column.hover();
this._previousColumn = column;
}
}
}
this.series.updateLegendValue(dataItem);
}
});
series.columns.template.events.on("pointerout", () => {
this.series.updateLegendValue(undefined);
if (this._previousColumn) {
this._previousColumn.unhover();
}
});
series.columns.template.adapters.add("fillOpacity", (fillOpacity, target) => {
const dataItem = target.dataItem;
if (dataItem) {
const dataContext = dataItem.dataContext;
if (dataContext) {
if (dataContext.area) {
return this.get("valueAreaOpacity", .7);
}
}
}
return fillOpacity;
});
}
_updateChildren() {
if (this.series) {
super._updateChildren();
if (this.isDirty("count") || this.isDirty("countType") || this.isDirty("valueArea")) {
this.markDataDirty();
}
if (this.isDirty("countType")) {
this._updateCountBounds();
}
if (this.isDirty("upColor")) {
const upColor = this.get("upColor");
this.upSeries.set("fill", upColor);
this.upSeries.set("stroke", upColor);
this.upSeries.columns.template.setAll({
fill: upColor,
stroke: upColor
});
this.setCustomData("upColor", upColor);
}
if (this.isDirty("downColor")) {
const downColor = this.get("downColor");
this.series.set("fill", downColor);
this.series.set("stroke", downColor);
this.series.columns.template.setAll({
fill: downColor,
stroke: downColor
});
this.setCustomData("downColor", downColor);
}
if (this.isDirty("axisWidth")) {
this.xAxis.set("width", percent(this.get("axisWidth", 40)));
}
}
}
/**
* @ignore
*/
prepareData() {
const volumeSeries = this.get("volumeSeries");
const stockSeries = this.get("stockSeries");
if (volumeSeries && this.series) {
let startIndex = volumeSeries.getPrivate("adjustedStartIndex", volumeSeries.startIndex());
let endIndex = volumeSeries.endIndex();
const count = this.get("count", 20);
const type = this.get("countType");
let step = 1;
let min = Infinity;
let max = -Infinity;
for (let i = startIndex; i < endIndex; i++) {
const dataItem = stockSeries.dataItems[i];
if (dataItem) {
const close = dataItem.get("valueY");
if ($type.isNumber(close)) {
const low = dataItem.get("lowValueY", close);
const high = dataItem.get("highValueY", close);
if (low < min) {
min = low;
}
if (high > max) {
max = high;
}
}
}
}
if (min != Infinity) {
let rows;
if (type == "ticks") {
step = count / 100;
min = Math.floor(min / step) * step;
max = Math.ceil(max / step) * step;
rows = (max - min) / step;
}
else {
step = (max - min) / count;
rows = count;
}
// Hard safety cap: a pathological count (e.g. a deserialized ticks
// config restored before the axis step is known) could otherwise
// build a runaway number of rows and freeze the thread. Rebase the
// step so binning still spans the range. Normal rows-mode (<=500)
// and sane ticks values stay well under this.
rows = Math.max(1, Math.ceil(rows));
if (rows > 2000) {
rows = 2000;
step = (max - min) / rows;
}
const rowDataDown = [];
const rowDataUp = [];
for (let i = 0; i < rows; i++) {
rowDataDown[i] = 0;
rowDataUp[i] = 0;
}
for (let i = startIndex; i < endIndex; i++) {
const dataItem = stockSeries.dataItems[i];
const volumeDataItem = volumeSeries.dataItems[i];
if (dataItem && volumeDataItem) {
const close = dataItem.get("valueY");
const volume = volumeDataItem.get("valueY");
if ($type.isNumber(close) && $type.isNumber(volume)) {
const barOpen = dataItem.get("openValueY", close);
const low = dataItem.get("lowValueY", close);
const high = dataItem.get("highValueY", close);
const bodyTop = Math.max(close, barOpen);
const bodyBot = Math.min(close, barOpen);
const green = close >= barOpen;
const topWick = high - bodyTop;
const bottomWick = bodyBot - low;
const body = bodyTop - bodyBot;
// Spread the bar volume over where it traded, split into body + two
// wicks with the wicks at double the body per-price density. The
// body is up volume on a green candle (close >= open), down
// otherwise; each wick is split evenly between up and down.
const denom = 2 * topWick + 2 * bottomWick + body;
if (denom <= 0) {
// Flat bar (high == low): drop it all in one row.
let index = Math.floor((close - min) / step);
if (!(index >= 0)) {
index = 0;
} // also catches NaN from a zero-width range
if (index >= rows) {
index = rows - 1;
}
if (green) {
rowDataUp[index] += volume;
}
else {
rowDataDown[index] += volume;
}
}
else {
const bodyVol = body * volume / denom;
const topVol = 2 * topWick * volume / denom;
const botVol = 2 * bottomWick * volume / denom;
let jLow = Math.floor((low - min) / step);
let jHigh = Math.floor((high - min) / step);
if (jLow < 0) {
jLow = 0;
}
if (jHigh >= rows) {
jHigh = rows - 1;
}
for (let j = jLow; j <= jHigh; j++) {
const rowBot = min + j * step;
const rowTop = rowBot + step;
const bodyOv = body > 0 ? bodyVol * (Math.max(0, Math.min(rowTop, bodyTop) - Math.max(rowBot, bodyBot)) / body) : 0;
const topOv = topWick > 0 ? topVol * (Math.max(0, Math.min(rowTop, high) - Math.max(rowBot, bodyTop)) / topWick) : 0;
const botOv = bottomWick > 0 ? botVol * (Math.max(0, Math.min(rowTop, bodyBot) - Math.max(rowBot, low)) / bottomWick) : 0;
rowDataUp[j] += (green ? bodyOv : 0) + topOv / 2 + botOv / 2;
rowDataDown[j] += (green ? 0 : bodyOv) + topOv / 2 + botOv / 2;
}
}
}
}
}
const dataDown = [];
let sum = 0;
for (let i = 0; i < rows; i++) {
let total = rowDataUp[i] + rowDataDown[i];
sum += total;
dataDown.push({
yOpen: min + i * step,
y: min + (i + 1) * step,
up: rowDataUp[i],
down: rowDataDown[i],
total: total,
xOpen: 0,
area: false
});
}
let len = this.series.data.length;
if (len && len == dataDown.length) {
for (let i = 0; i < len; i++) {
this.series.data.setIndex(i, dataDown[i]);
}
}
else {
this.series.data.setAll(dataDown);
}
const dataUp = [];
let highest = 0;
let hi = 0;
for (let i = 0; i < rows; i++) {
let total = rowDataUp[i] + rowDataDown[i];
dataUp.push({
yOpen: min + i * step,
y: min + (i + 1) * step,
up: rowDataUp[i],
down: rowDataDown[i],
total: total,
area: false
});
if (total > highest) {
highest = total;
hi = i;
}
}
let valueArea = sum * this.get("valueArea", 70) / 100;
let area = highest;
let cd = 1;
let cu = 1;
let dlen = dataUp.length;
dataUp[hi].area = true;
dataDown[hi].area = true;
// single row
while (area < valueArea) {
let rowAbove1 = hi + cu;
let sumAbove = 0;
if (rowAbove1 < dlen) {
sumAbove += dataUp[rowAbove1].total;
}
let rowBelow1 = hi - cd;
let sumBelow = 0;
if (rowBelow1 >= 0) {
sumBelow += dataUp[rowBelow1].total;
}
if (sumBelow <= sumAbove) {
area += sumAbove;
if (rowAbove1 < dlen) {
dataDown[rowAbove1].area = true;
dataUp[rowAbove1].area = true;
cu++;
}
}
else {
area += sumBelow;
if (rowBelow1 >= 0) {
dataDown[rowBelow1].area = true;
dataUp[rowBelow1].area = true;
cd++;
}
}
if (sumBelow == 0) {
cd++;
}
if (sumAbove == 0) {
cu++;
}
if ((cd > dlen && cu > dlen)) {
break;
}
}
area = Math.ceil(area);
len = this.upSeries.data.length;
if (len > 0 && len == dataUp.length) {
for (let i = 0; i < len; i++) {
this.upSeries.data.setIndex(i, dataUp[i]);
}
}
else {
this.upSeries.data.setAll(dataUp);
}
}
else {
this.upSeries.data.clear();
this.series.data.clear();
}
}
this.series.columns.each((column) => {
column._markDirtyKey("fillOpacity");
});
this.upSeries.columns.each((column) => {
column._markDirtyKey("fillOpacity");
});
}
_dispose() {
super._dispose();
if (this.upSeries) {
this.upSeries.dispose();
}
if (this.xAxis) {
this.xAxis.dispose();
}
}
}
VolumeProfile.className = "VolumeProfile";
VolumeProfile.classNames = Indicator.classNames.concat([VolumeProfile.className]);
//# sourceMappingURL=VolumeProfile.js.map