In financial stock charts, a candlestick chart is used to visualise open, high, low, close prices of an asset over a specific timeframe. The volume (amount traded) in each specific candle is significant for traders. Sometimes areas of high or low volume can act as support/resistance (difficult areas for the price to pass). Visualising the price levels associated with high volume can be done with a Volume Profile chart.
This post covers how to create the following elements on a JavaScript Chart:
- Candles showing price data in open, high, low, close format from an exchange
- Volume bars coloured red/green according to buy or sell
- A rotated** volume profile histogram** with calculated volume distribution docked to the right of the chart
For the purpose of the blog, we will be using SciChart.js - a commercial JavaScript Chart Library with a free community edition that can be used in non-commercial, educational or personal projects.
At the end of the blog post you will be able to create this in JavaScript. Full source-code is shared at the end of the post.
Initializing the Chart Layout
First of all we need a layout which meets the specification.
In SciChart.js this can be achieved by creating a chart with an X,Y axis, but then adding a second X,Y axis with xAxis.axisAlignment = EAxisAlignment.Left
and yAxis.axisAlignment = EAxisAlignment.Top
respectively. An axis may then be hidden by setting axis.isVisible = false
.
When the alignments of axis are switched in SciChart.js, you create a vertical chart, and with this library it's possible to have two sets of axis on the same chart which are transposed.
Here’s how to setup a transposed axis on the same chart as a normal X,Y axis in JavaScript using SciChart.js. This technique allows you to rotate a specific series on the chart.
const {
SciChartSurface,
NumericAxis,
SciChartJSLightTheme,
EAxisAlignment,
Thickness,
FastLineRenderableSeries,
XyDataSeries,
NumberRange,
ZoomPanModifier,
MouseWheelZoomModifier,
ZoomExtentsModifier
} = SciChart;
async function initSciChart(divElementId) {
const { sciChartSurface, wasmContext } = await SciChartSurface.create(divElementId, {
title: "Custom layout JS Chart with Transposed Axis",
titleStyle: { fontSize: 28, fontWeight: "bold" },
theme: new SciChartJSLightTheme()
});
// Add the primary x,y axis
sciChartSurface.xAxes.add(new NumericAxis(wasmContext, {
axisTitle: "X Axis",
backgroundColor: "#0066ff33"
}));
sciChartSurface.yAxes.add(new NumericAxis(wasmContext, {
axisTitle: "Y Axis",
backgroundColor: "#0066ff33"
}));
// Add the secondary y,x axis transposed
sciChartSurface.xAxes.add(new NumericAxis(wasmContext, {
id: "SecondaryXAxis",
axisAlignment: EAxisAlignment.Right,
axisTitle: "X Axis Transposed",
backgroundColor: "#ff660033"
}));
sciChartSurface.yAxes.add(new NumericAxis(wasmContext, {
id: "SecondaryYAxis",
axisAlignment: EAxisAlignment.Bottom,
axisTitle: "Y Axis Transposed",
backgroundColor: "#ff660033",
// growBy sets padding and yMax at 66% (2:1 ratio)
growBy: new NumberRange(0, 2)
}));
// Add some series to make it clear which axis is which
const xValues = Array.from(Array(100).keys());
const yValues = xValues.map(x => Math.sin(x*0.3) * (150-x));
// Blue series on primary X,Y axis
sciChartSurface.renderableSeries.add(new FastLineRenderableSeries(wasmContext, {
dataSeries: new XyDataSeries(wasmContext, { xValues, yValues }),
stroke: "0066ff77",
strokeThickness: 5
}));
// Orange series on secondary, transposed, X,Y axis
sciChartSurface.renderableSeries.add(new FastLineRenderableSeries(wasmContext, {
yAxisId: "SecondaryYAxis",
xAxisId: "SecondaryXAxis",
dataSeries: new XyDataSeries(wasmContext, { xValues, yValues }),
stroke: "ff660099",
strokeThickness: 5
}));
// Add panning, zooming interaction
sciChartSurface.chartModifiers.add(
new ZoomPanModifier(),
new MouseWheelZoomModifier(),
new ZoomExtentsModifier()
);
}
initSciChart("scichart-root");
This results in the following output:
Building the Volume Profile Chart
Now that we’ve covered the basics of how to achieve the layout of a docked, rotated JavaScript chart inside the main chart, we can proceed to build a volume profile.
Setting up a JavaScript Candlestick Chart with Market Data
With a chart setup as above with transposed X,Y axis, it's now possible to extend it as follows to draw real market data, volume bars, and a rotated volume profile chart.
To fetch the market data we're using a simple REST client for Binance which can be found on Github here. To fetch candle data, call this code. The return values are arrays of dates, open, high, low, close and volume values.
// Data format is { dateValues[], openValues[], highValues[], lowValues[], closeValues[] }
const { dateValues, openValues, highValues, lowValues, closeValues, volumeValues }
= await getCandles("BTCUSDT", "1h", 100);
Open, High, Low, Close values can be plotted on the chart as a candlestick series, while volume values can be plotted as a Column series. There are some extra tricks in SciChart.js to ensure the columns are docked bottom, and colored according to red/green bars in the main candle series.
// Plotting the market data as candles
//
const candleDataSeries = new OhlcDataSeries(wasmContext, {
xValues: dateValues,
openValues,
highValues,
lowValues,
closeValues,
});
// Create and add the Candlestick series
const candlestickSeries = new FastCandlestickRenderableSeries(wasmContext, {
strokeThickness: 1,
dataSeries: candleDataSeries,
dataPointWidth: 0.7,
brushUp: "#33ff3377",
brushDown: "#ff333377",
strokeUp: "#77ff77",
strokeDown: "#ff7777",
});
sciChartSurface.renderableSeries.add(candlestickSeries);
// Plotting the volume data as columns
//
// Helper class to colour column series according to price up or down
class VolumePaletteProvider extends DefaultPaletteProvider {
constructor(masterData, upColor, downColor) {
super();
this.upColorArgb = parseColorToUIntArgb(upColor);
this.downColorArgb = parseColorToUIntArgb(downColor);
this.ohlcDataSeries = masterData;
}
// Return up or down color for the volume bars depending on Ohlc data
overrideFillArgb(xValue, yValue, index, opacity, metadata) {
const isUpCandle =
this.ohlcDataSeries.getNativeOpenValues().get(index) >=
this.ohlcDataSeries.getNativeCloseValues().get(index);
return isUpCandle ? this.upColorArgb : this.downColorArgb;
}
}
// Ensure the main chart has a hidden yAxis for volume bars
const volumeYAxis = new NumericAxis(wasmContext, {
id: "volumeAxisId",
isVisible: false,
growBy: new NumberRange(0, 4)
});
sciChartSurface.yAxes.add(volumeYAxis);
// Add a volume series docked to bottom of the chart
const volumeSeries = new FastColumnRenderableSeries(wasmContext, {
dataPointWidth: 0.7,
strokeThickness: 0,
dataSeries: new XyDataSeries(wasmContext, { xValues: dateValues, yValues: volumeValues }),
yAxisId: "volumeAxisId",
paletteProvider: new VolumePaletteProvider(
candleDataSeries,
"#33ff3377",
"#ff333377"
),
});
sciChartSurface.renderableSeries.add(volumeSeries);
Calculating the Volume Profile distribution
For this bit I used GPT-4 to save myself some time doing maths :D
Here’s my prompt:
Prompt: I’d like to calculate an approximation of a volume profile from candlestick data in JavaScript Given arrays of data in JavaScript containing open, high, low, close, volume data respectively.
const dateValues = []; const openValues = []; const highValues = []; const lowValues = []; const closeValues = []; const volumeValues = [];
Assume that the volume profile price is in bins. You can specify the bin size as a function parameter. The output should be two arrays: xValues[] should be prices and yValues[] should be computed volume profile values in bins.
The answer it provided was relatively decent, with a code sample on how to compute a volume distribution given arrays of Open, High, Low, Close and Volume values. Here’s the code to calculate the volume distribution based on all candle data. Adjusting this and recalculating for a specific range of candle data should be quite easy:
const binSize = 25.0; // Define your bin size
// Function to calculate the bin for a given price
function getBin(price, binSize) {
return Math.floor(price / binSize) * binSize;
}
// Initialize volume profile
const volumeProfile = {};
// Function to distribute volume across bins
function distributeVolume(high, low, volume, binSize) {
const startBin = getBin(low, binSize);
const endBin = getBin(high, binSize);
let totalBins = (endBin - startBin) / binSize + 1;
const volumePerBin = volume / totalBins;
for (let bin = startBin; bin <= endBin; bin += binSize) {
if (volumeProfile[bin]) {
volumeProfile[bin] += volumePerBin;
} else {
volumeProfile[bin] = volumePerBin;
}
}
}
// Process each candlestick
for (let i = 0; i < highValues.length; i++) {
distributeVolume(highValues[i], lowValues[i], volumeValues[i], binSize);
}
const xVolValues = [];
const yVolValues = [];
// Extract bins (prices) and corresponding volumes from volumeProfile
for (const [price, volume] of Object.entries(volumeProfile)) {
xVolValues.push(parseFloat(price)); // Convert string key back to number
yVolValues.push(volume);
}
The computed Volume Profile data can be plotted in SciChart on a transposed X,Y axis docked to the right of the chart as follows:
// Create the transposed volume X-axis
const volXAxis = new NumericAxis(wasmContext, {
id: "VolX",
axisAlignment: EAxisAlignment.Right,
flippedCoordinates: true,
isVisible: false,
});
sciChartSurface.xAxes.add(volXAxis);
// Create the transposed volume Y-axis
sciChartSurface.yAxes.add(new NumericAxis(wasmContext, {
id: "VolY",
axisAlignment: EAxisAlignment.Bottom,
isVisible: false,
growBy: new NumberRange(0, 3)
}));
// When the main chart price yaxis changes, we want to update the range of the volume xAxis
priceYAxis.visibleRangeChanged.subscribe(args => {
volXAxis.visibleRange = new NumberRange(args.visibleRange.min, args.visibleRange.max)
});
// ...
// Render the volume profile series on transposed Y, X axis. This could also be a
// mountain series, stacked mountain series, and adding Point of Control (POC) is possible
// via Line Annotations
const volumeProfileSeries = new FastColumnRenderableSeries(wasmContext, {
dataSeries: new XyDataSeries(wasmContext, { xValues: xVolValues, yValues: yVolValues }),
dataPointWidth: 0.5,
opacity: 0.33,
fill: "White",
strokeThickness: 0,
xAxisId: "VolX",
yAxisId: "VolY"
});
sciChartSurface.renderableSeries.add(volumeProfileSeries);
Here’s the final result, also with all the code present: the fetching of candlestick data from Binance US, plotting a JavaScript Candlestick chart, plotting coloured volume bars docked to the bottom of the chart, plotting a Volume Profile docked to the right of the chart, with computed volume distribution from live market data, and with zooming, panning and mousewheel zoom behaviour.
Enjoy!
Future work & further enhancements
This sample could be expanded a lot further for use in real JS financial & trading applications.
- The Volume Profile could be rendered as a Stacked Mountain, Stacked Column or Mountain (Area) series
- The Bin size was hard coded to $25 in this example. In reality you'd probably want to vary this dynamically as the chart zooms.
- The volume profile distribution was calculated from all OHLC data, in practice you'd want to calculate this from visible data only, or a day-range
- You could add a Point of Control (POC) to the volume profile, using a HorizontalLineAnnotation at the price of maximum volume
- Using the SubCharts API in SciChart.js, you could place multiple charts inside the main chart to show the Volume Profile for specific ranges.
- Also using the SubCharts API, it could be placed on the axis rather than docked inside the main chart.
Top comments (1)
Great post, thanks!