/** * @license Highstock JS v11.4.1 (2024-04-04) * * Indicator series type for Highcharts Stock * * (c) 2010-2024 Paweł Dalek * * License: www.highcharts.com/license */ (function (factory) { if (typeof module === 'object' && module.exports) { factory['default'] = factory; module.exports = factory; } else if (typeof define === 'function' && define.amd) { define('highcharts/indicators/volume-by-price', ['highcharts', 'highcharts/modules/stock'], function (Highcharts) { factory(Highcharts); factory.Highcharts = Highcharts; return factory; }); } else { factory(typeof Highcharts !== 'undefined' ? Highcharts : undefined); } }(function (Highcharts) { 'use strict'; var _modules = Highcharts ? Highcharts._modules : {}; function _registerModule(obj, path, args, fn) { if (!obj.hasOwnProperty(path)) { obj[path] = fn.apply(null, args); if (typeof CustomEvent === 'function') { window.dispatchEvent(new CustomEvent( 'HighchartsModuleLoaded', { detail: { path: path, module: obj[path] } } )); } } } _registerModule(_modules, 'Stock/Indicators/VBP/VBPPoint.js', [_modules['Core/Series/SeriesRegistry.js']], function (SeriesRegistry) { /* * * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ /* * * * Imports * * */ const { sma: { prototype: { pointClass: SMAPoint } } } = SeriesRegistry.seriesTypes; /* * * * Class * * */ class VBPPoint extends SMAPoint { // Required for destroying negative part of volume destroy() { // @todo: this.negativeGraphic doesn't seem to be used anywhere if (this.negativeGraphic) { this.negativeGraphic = this.negativeGraphic.destroy(); } super.destroy.apply(this, arguments); } } /* * * * Default Export * * */ return VBPPoint; }); _registerModule(_modules, 'Stock/Indicators/VBP/VBPIndicator.js', [_modules['Stock/Indicators/VBP/VBPPoint.js'], _modules['Core/Animation/AnimationUtilities.js'], _modules['Core/Globals.js'], _modules['Core/Series/SeriesRegistry.js'], _modules['Core/Utilities.js']], function (VBPPoint, A, H, SeriesRegistry, U) { /* * * * (c) 2010-2024 Paweł Dalek * * Volume By Price (VBP) indicator for Highcharts Stock * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ const { animObject } = A; const { noop } = H; const { column: { prototype: columnProto }, sma: SMAIndicator } = SeriesRegistry.seriesTypes; const { addEvent, arrayMax, arrayMin, correctFloat, defined, error, extend, isArray, merge } = U; /* * * * Constants * * */ const abs = Math.abs; /* * * * Functions * * */ // Utils /** * @private */ function arrayExtremesOHLC(data) { const dataLength = data.length; let min = data[0][3], max = min, i = 1, currentPoint; for (; i < dataLength; i++) { currentPoint = data[i][3]; if (currentPoint < min) { min = currentPoint; } if (currentPoint > max) { max = currentPoint; } } return { min: min, max: max }; } /* * * * Class * * */ /** * The Volume By Price (VBP) series type. * * @private * @class * @name Highcharts.seriesTypes.vbp * * @augments Highcharts.Series */ class VBPIndicator extends SMAIndicator { /* * * * Functions * * */ init(chart, options) { const indicator = this; // Series.update() sends data that is not necessary as everything is // calculated in getValues(), #17007 delete options.data; super.init.apply(indicator, arguments); // Only after series are linked add some additional logic/properties. const unbinder = addEvent(this.chart.constructor, 'afterLinkSeries', function () { // Protection for a case where the indicator is being updated, // for a brief moment the indicator is deleted. if (indicator.options) { const params = indicator.options.params, baseSeries = indicator.linkedParent, volumeSeries = chart.get(params.volumeSeriesID); indicator.addCustomEvents(baseSeries, volumeSeries); } unbinder(); }, { order: 1 }); return indicator; } // Adds events related with removing series addCustomEvents(baseSeries, volumeSeries) { const indicator = this, toEmptyIndicator = () => { indicator.chart.redraw(); indicator.setData([]); indicator.zoneStarts = []; if (indicator.zoneLinesSVG) { indicator.zoneLinesSVG = indicator.zoneLinesSVG.destroy(); } }; // If base series is deleted, indicator series data is filled with // an empty array indicator.dataEventsToUnbind.push(addEvent(baseSeries, 'remove', function () { toEmptyIndicator(); })); // If volume series is deleted, indicator series data is filled with // an empty array if (volumeSeries) { indicator.dataEventsToUnbind.push(addEvent(volumeSeries, 'remove', function () { toEmptyIndicator(); })); } return indicator; } // Initial animation animate(init) { const series = this, inverted = series.chart.inverted, group = series.group, attr = {}; if (!init && group) { const position = inverted ? series.yAxis.top : series.xAxis.left; if (inverted) { group['forceAnimate:translateY'] = true; attr.translateY = position; } else { group['forceAnimate:translateX'] = true; attr.translateX = position; } group.animate(attr, extend(animObject(series.options.animation), { step: function (val, fx) { series.group.attr({ scaleX: Math.max(0.001, fx.pos) }); } })); } } drawPoints() { const indicator = this; if (indicator.options.volumeDivision.enabled) { indicator.posNegVolume(true, true); columnProto.drawPoints.apply(indicator, arguments); indicator.posNegVolume(false, false); } columnProto.drawPoints.apply(indicator, arguments); } // Function responsible for dividing volume into positive and negative posNegVolume(initVol, pos) { const indicator = this, signOrder = pos ? ['positive', 'negative'] : ['negative', 'positive'], volumeDivision = indicator.options.volumeDivision, pointLength = indicator.points.length; let posWidths = [], negWidths = [], i = 0, pointWidth, priceZone, wholeVol, point; if (initVol) { indicator.posWidths = posWidths; indicator.negWidths = negWidths; } else { posWidths = indicator.posWidths; negWidths = indicator.negWidths; } for (; i < pointLength; i++) { point = indicator.points[i]; point[signOrder[0] + 'Graphic'] = point.graphic; point.graphic = point[signOrder[1] + 'Graphic']; if (initVol) { pointWidth = point.shapeArgs.width; priceZone = indicator.priceZones[i]; wholeVol = priceZone.wholeVolumeData; if (wholeVol) { posWidths.push(pointWidth / wholeVol * priceZone.positiveVolumeData); negWidths.push(pointWidth / wholeVol * priceZone.negativeVolumeData); } else { posWidths.push(0); negWidths.push(0); } } point.color = pos ? volumeDivision.styles.positiveColor : volumeDivision.styles.negativeColor; point.shapeArgs.width = pos ? indicator.posWidths[i] : indicator.negWidths[i]; point.shapeArgs.x = pos ? point.shapeArgs.x : indicator.posWidths[i]; } } translate() { const indicator = this, options = indicator.options, chart = indicator.chart, yAxis = indicator.yAxis, yAxisMin = yAxis.min, zoneLinesOptions = indicator.options.zoneLines, priceZones = (indicator.priceZones); let yBarOffset = 0, volumeDataArray, maxVolume, primalBarWidth, barHeight, barHeightP, oldBarHeight, barWidth, pointPadding, chartPlotTop, barX, barY; columnProto.translate.apply(indicator); const indicatorPoints = indicator.points; // Do translate operation when points exist if (indicatorPoints.length) { pointPadding = options.pointPadding < 0.5 ? options.pointPadding : 0.1; volumeDataArray = indicator.volumeDataArray; maxVolume = arrayMax(volumeDataArray); primalBarWidth = chart.plotWidth / 2; chartPlotTop = chart.plotTop; barHeight = abs(yAxis.toPixels(yAxisMin) - yAxis.toPixels(yAxisMin + indicator.rangeStep)); oldBarHeight = abs(yAxis.toPixels(yAxisMin) - yAxis.toPixels(yAxisMin + indicator.rangeStep)); if (pointPadding) { barHeightP = abs(barHeight * (1 - 2 * pointPadding)); yBarOffset = abs((barHeight - barHeightP) / 2); barHeight = abs(barHeightP); } indicatorPoints.forEach(function (point, index) { barX = point.barX = point.plotX = 0; barY = point.plotY = (yAxis.toPixels(priceZones[index].start) - chartPlotTop - (yAxis.reversed ? (barHeight - oldBarHeight) : barHeight) - yBarOffset); barWidth = correctFloat(primalBarWidth * priceZones[index].wholeVolumeData / maxVolume); point.pointWidth = barWidth; point.shapeArgs = indicator.crispCol.apply(// eslint-disable-line no-useless-call indicator, [barX, barY, barWidth, barHeight]); point.volumeNeg = priceZones[index].negativeVolumeData; point.volumePos = priceZones[index].positiveVolumeData; point.volumeAll = priceZones[index].wholeVolumeData; }); if (zoneLinesOptions.enabled) { indicator.drawZones(chart, yAxis, indicator.zoneStarts, zoneLinesOptions.styles); } } } getExtremes() { const prevCompare = this.options.compare, prevCumulative = this.options.cumulative; let ret; // Temporarily disable cumulative and compare while getting the extremes if (this.options.compare) { this.options.compare = void 0; ret = super.getExtremes(); this.options.compare = prevCompare; } else if (this.options.cumulative) { this.options.cumulative = false; ret = super.getExtremes(); this.options.cumulative = prevCumulative; } else { ret = super.getExtremes(); } return ret; } getValues(series, params) { const indicator = this, xValues = series.processedXData, yValues = series.processedYData, chart = indicator.chart, ranges = params.ranges, VBP = [], xData = [], yData = [], volumeSeries = chart.get(params.volumeSeriesID); // Checks if base series exists if (!series.chart) { error('Base series not found! In case it has been removed, add ' + 'a new one.', true, chart); return; } // Checks if volume series exists and if it has data if (!volumeSeries || !volumeSeries.processedXData.length) { const errorMessage = volumeSeries && !volumeSeries.processedXData.length ? ' does not contain any data.' : ' not found! Check `volumeSeriesID`.'; error('Series ' + params.volumeSeriesID + errorMessage, true, chart); return; } // Checks if series data fits the OHLC format const isOHLC = isArray(yValues[0]); if (isOHLC && yValues[0].length !== 4) { error('Type of ' + series.name + ' series is different than line, OHLC or candlestick.', true, chart); return; } // Price zones contains all the information about the zones (index, // start, end, volumes, etc.) const priceZones = indicator.priceZones = indicator.specifyZones(isOHLC, xValues, yValues, ranges, volumeSeries); priceZones.forEach(function (zone, index) { VBP.push([zone.x, zone.end]); xData.push(VBP[index][0]); yData.push(VBP[index][1]); }); return { values: VBP, xData: xData, yData: yData }; } // Specifying where each zone should start ans end specifyZones(isOHLC, xValues, yValues, ranges, volumeSeries) { const indicator = this, rangeExtremes = (isOHLC ? arrayExtremesOHLC(yValues) : false), zoneStarts = indicator.zoneStarts = [], priceZones = []; let lowRange = rangeExtremes ? rangeExtremes.min : arrayMin(yValues), highRange = rangeExtremes ? rangeExtremes.max : arrayMax(yValues), i = 0, j = 1; // If the compare mode is set on the main series, change the VBP // zones to fit new extremes, #16277. const mainSeries = indicator.linkedParent; if (!indicator.options.compareToMain && mainSeries.dataModify) { lowRange = mainSeries.dataModify.modifyValue(lowRange); highRange = mainSeries.dataModify.modifyValue(highRange); } if (!defined(lowRange) || !defined(highRange)) { if (this.points.length) { this.setData([]); this.zoneStarts = []; if (this.zoneLinesSVG) { this.zoneLinesSVG = this.zoneLinesSVG.destroy(); } } return []; } const rangeStep = indicator.rangeStep = correctFloat(highRange - lowRange) / ranges; zoneStarts.push(lowRange); for (; i < ranges - 1; i++) { zoneStarts.push(correctFloat(zoneStarts[i] + rangeStep)); } zoneStarts.push(highRange); const zoneStartsLength = zoneStarts.length; // Creating zones for (; j < zoneStartsLength; j++) { priceZones.push({ index: j - 1, x: xValues[0], start: zoneStarts[j - 1], end: zoneStarts[j] }); } return indicator.volumePerZone(isOHLC, priceZones, volumeSeries, xValues, yValues); } // Calculating sum of volume values for a specific zone volumePerZone(isOHLC, priceZones, volumeSeries, xValues, yValues) { const indicator = this, volumeXData = volumeSeries.processedXData, volumeYData = volumeSeries.processedYData, lastZoneIndex = priceZones.length - 1, baseSeriesLength = yValues.length, volumeSeriesLength = volumeYData.length; let previousValue, startFlag, endFlag, value, i; // Checks if each point has a corresponding volume value if (abs(baseSeriesLength - volumeSeriesLength)) { // If the first point don't have volume, add 0 value at the // beginning of the volume array if (xValues[0] !== volumeXData[0]) { volumeYData.unshift(0); } // If the last point don't have volume, add 0 value at the end // of the volume array if (xValues[baseSeriesLength - 1] !== volumeXData[volumeSeriesLength - 1]) { volumeYData.push(0); } } indicator.volumeDataArray = []; priceZones.forEach(function (zone) { zone.wholeVolumeData = 0; zone.positiveVolumeData = 0; zone.negativeVolumeData = 0; for (i = 0; i < baseSeriesLength; i++) { startFlag = false; endFlag = false; value = isOHLC ? yValues[i][3] : yValues[i]; previousValue = i ? (isOHLC ? yValues[i - 1][3] : yValues[i - 1]) : value; // If the compare mode is set on the main series, // change the VBP zones to fit new extremes, #16277. const mainSeries = indicator.linkedParent; if (!indicator.options.compareToMain && mainSeries.dataModify) { value = mainSeries.dataModify.modifyValue(value); previousValue = mainSeries.dataModify .modifyValue(previousValue); } // Checks if this is the point with the // lowest close value and if so, adds it calculations if (value <= zone.start && zone.index === 0) { startFlag = true; } // Checks if this is the point with the highest // close value and if so, adds it calculations if (value >= zone.end && zone.index === lastZoneIndex) { endFlag = true; } if ((value > zone.start || startFlag) && (value < zone.end || endFlag)) { zone.wholeVolumeData += volumeYData[i]; if (previousValue > value) { zone.negativeVolumeData += volumeYData[i]; } else { zone.positiveVolumeData += volumeYData[i]; } } } indicator.volumeDataArray.push(zone.wholeVolumeData); }); return priceZones; } // Function responsible for drawing additional lines indicating zones drawZones(chart, yAxis, zonesValues, zonesStyles) { const indicator = this, renderer = chart.renderer, leftLinePos = 0, rightLinePos = chart.plotWidth, verticalOffset = chart.plotTop; let zoneLinesSVG = indicator.zoneLinesSVG, zoneLinesPath = [], verticalLinePos; zonesValues.forEach(function (value) { verticalLinePos = yAxis.toPixels(value) - verticalOffset; zoneLinesPath = zoneLinesPath.concat(chart.renderer.crispLine([[ 'M', leftLinePos, verticalLinePos ], [ 'L', rightLinePos, verticalLinePos ]], zonesStyles.lineWidth)); }); // Create zone lines one path or update it while animating if (zoneLinesSVG) { zoneLinesSVG.animate({ d: zoneLinesPath }); } else { zoneLinesSVG = indicator.zoneLinesSVG = renderer .path(zoneLinesPath) .attr({ 'stroke-width': zonesStyles.lineWidth, 'stroke': zonesStyles.color, 'dashstyle': zonesStyles.dashStyle, 'zIndex': indicator.group.zIndex + 0.1 }) .add(indicator.group); } } } /* * * * Static Properties * * */ /** * Volume By Price indicator. * * This series requires `linkedTo` option to be set. * * @sample stock/indicators/volume-by-price * Volume By Price indicator * * @extends plotOptions.sma * @since 6.0.0 * @product highstock * @requires stock/indicators/indicators * @requires stock/indicators/volume-by-price * @optionparent plotOptions.vbp */ VBPIndicator.defaultOptions = merge(SMAIndicator.defaultOptions, { /** * @excluding index, period */ params: { // Index and period are unchangeable, do not inherit (#15362) index: void 0, period: void 0, /** * The number of price zones. */ ranges: 12, /** * The id of volume series which is mandatory. For example using * OHLC data, volumeSeriesID='volume' means the indicator will be * calculated using OHLC and volume values. */ volumeSeriesID: 'volume' }, /** * The styles for lines which determine price zones. */ zoneLines: { /** * Enable/disable zone lines. */ enabled: true, /** * Specify the style of zone lines. * * @type {Highcharts.CSSObject} * @default {"color": "#0A9AC9", "dashStyle": "LongDash", "lineWidth": 1} */ styles: { /** @ignore-option */ color: '#0A9AC9', /** @ignore-option */ dashStyle: 'LongDash', /** @ignore-option */ lineWidth: 1 } }, /** * The styles for bars when volume is divided into positive/negative. */ volumeDivision: { /** * Option to control if volume is divided. */ enabled: true, styles: { /** * Color of positive volume bars. * * @type {Highcharts.ColorString} */ positiveColor: 'rgba(144, 237, 125, 0.8)', /** * Color of negative volume bars. * * @type {Highcharts.ColorString} */ negativeColor: 'rgba(244, 91, 91, 0.8)' } }, // To enable series animation; must be animationLimit > pointCount animationLimit: 1000, enableMouseTracking: false, pointPadding: 0, zIndex: -1, crisp: true, dataGrouping: { enabled: false }, dataLabels: { allowOverlap: true, enabled: true, format: 'P: {point.volumePos:.2f} | N: {point.volumeNeg:.2f}', padding: 0, style: { /** @internal */ fontSize: '0.5em' }, verticalAlign: 'top' } }); extend(VBPIndicator.prototype, { nameBase: 'Volume by Price', nameComponents: ['ranges'], calculateOn: { chart: 'render', xAxis: 'afterSetExtremes' }, pointClass: VBPPoint, markerAttribs: noop, drawGraph: noop, getColumnMetrics: columnProto.getColumnMetrics, crispCol: columnProto.crispCol }); SeriesRegistry.registerSeriesType('vbp', VBPIndicator); /* * * * Default Export * * */ /* * * * API Options * * */ /** * A `Volume By Price (VBP)` series. If the [type](#series.vbp.type) option is * not specified, it is inherited from [chart.type](#chart.type). * * @extends series,plotOptions.vbp * @since 6.0.0 * @product highstock * @excluding dataParser, dataURL, compare, compareBase, compareStart * @requires stock/indicators/indicators * @requires stock/indicators/volume-by-price * @apioption series.vbp */ ''; // To include the above in the js output return VBPIndicator; }); _registerModule(_modules, 'masters/indicators/volume-by-price.src.js', [_modules['Core/Globals.js']], function (Highcharts) { return Highcharts; }); }));