Skip to content

Instantly share code, notes, and snippets.

@pingpoli
Created May 29, 2021 10:25
Show Gist options
  • Save pingpoli/ea558dd154fa9668b8e924c2a4d32faf to your computer and use it in GitHub Desktop.
Save pingpoli/ea558dd154fa9668b8e924c2a4d32faf to your computer and use it in GitHub Desktop.
/*
A single-file JavaScript class to draw candlestick charts.
Use at your own risk.
https://twitter.com/pingpoli
https://pingpoli.de
*/
function pingpoliCandlestick( timestamp , open , close , high , low )
{
this.timestamp = parseInt(timestamp);
this.open = parseFloat(open);
this.close = parseFloat(close);
this.high = parseFloat(high);
this.low = parseFloat(low);
}
function pingpoliCandlestickChart( canvasElementID )
{
this.canvas = document.getElementById( canvasElementID );
this.width = parseInt( this.canvas.width );
this.height = parseInt( this.canvas.height );
this.context = this.canvas.getContext( "2d" );
this.canvas.addEventListener( "mousemove" , ( e ) => {
this.mouseMove( e );
} );
this.canvas.addEventListener( "mouseout" , ( e ) => {
this.mouseOut( e );
} );
this.canvas.style.backgroundColor = "#252525";
this.context.font = '12px sans-serif';
this.gridColor = "#444444";
this.gridTextColor = "#aaaaaa";
this.mouseHoverBackgroundColor = "#eeeeee";
this.mouseHoverTextColor = "#000000";
this.greenColor = "#00cc00";
this.redColor = "#cc0000";
this.greenHoverColor = "#00ff00";
this.redHoverColor = "#ff0000";
this.context.lineWidth = 1;
this.candleWidth = 5;
this.marginLeft = 10;
this.marginRight = 100;
this.marginTop = 10;
this.marginBottom = 30;
this.yStart = 0;
this.yEnd = 0;
this.yRange = 0;
this.yPixelRange = this.height-this.marginTop-this.marginBottom;
this.xStart = 0;
this.xEnd = 0;
this.xRange = 0;
this.xPixelRange = this.width-this.marginLeft-this.marginRight;
// these are only approximations, the grid will be divided in a way so the numbers are nice
this.xGridCells = 16;
this.yGridCells = 16;
this.b_drawMouseOverlay = false;
this.mousePosition = { x: 0 , y: 0 };
this.xMouseHover = 0;
this.yMouseHover = 0;
this.hoveredCandlestickID = 0;
this.candlesticks = [];
}
pingpoliCandlestickChart.prototype.addCandlestick = function( candlestick )
{
this.candlesticks.push( candlestick );
}
pingpoliCandlestickChart.prototype.mouseMove = function( e )
{
var getMousePos = ( e ) =>
{
var rect = this.canvas.getBoundingClientRect();
return { x: e.clientX-rect.left , y: e.clientY-rect.top };
}
this.mousePosition = getMousePos( e );
this.mousePosition.x += this.candleWidth/2;
this.b_drawMouseOverlay = true;
if ( this.mousePosition.x < this.marginLeft ) this.b_drawMouseOverlay = false;
if ( this.mousePosition.x > this.width-this.marginRight+this.candleWidth ) this.b_drawMouseOverlay = false;
if ( this.mousePosition.y > this.height-this.marginBottom ) this.b_drawMouseOverlay = false;
if ( this.b_drawMouseOverlay )
{
this.yMouseHover = this.yToValueCoords( this.mousePosition.y );
this.xMouseHover = this.xToValueCoords( this.mousePosition.x );
// snap to candlesticks
var candlestickDelta = this.candlesticks[1].timestamp-this.candlesticks[0].timestamp;
this.hoveredCandlestickID = Math.floor((this.xMouseHover-this.candlesticks[0].timestamp)/candlestickDelta);
this.xMouseHover = Math.floor(this.xMouseHover/candlestickDelta)*candlestickDelta;
this.mousePosition.x = this.xToPixelCoords( this.xMouseHover );
this.draw();
}
else this.draw();
}
pingpoliCandlestickChart.prototype.mouseOut = function( e )
{
this.b_drawMouseOverlay = false;
this.draw();
}
pingpoliCandlestickChart.prototype.draw = function()
{
// clear background
this.context.clearRect( 0 , 0 , this.width , this.height );
this.calculateYRange();
this.calculateXRange();
this.drawGrid();
this.candleWidth = this.xPixelRange/this.candlesticks.length;
this.candleWidth--;
if ( this.candleWidth%2 == 0 ) this.candleWidth--;
for ( var i = 0 ; i < this.candlesticks.length ; ++i )
{
var color = ( this.candlesticks[i].close > this.candlesticks[i].open ) ? this.greenColor : this.redColor;
if ( i == this.hoveredCandlestickID )
{
if ( color == this.greenColor ) color = this.greenHoverColor;
else if ( color == this.redColor ) color = this.redHoverColor;
}
// draw the wick
this.drawLine( this.xToPixelCoords( this.candlesticks[i].timestamp ) , this.yToPixelCoords( this.candlesticks[i].low ) , this.xToPixelCoords( this.candlesticks[i].timestamp ) , this.yToPixelCoords( this.candlesticks[i].high ) , color );
// draw the candle
this.fillRect( this.xToPixelCoords( this.candlesticks[i].timestamp )-Math.floor( this.candleWidth/2 ) , this.yToPixelCoords( this.candlesticks[i].open ) , this.candleWidth , this.yToPixelCoords( this.candlesticks[i].close ) - this.yToPixelCoords( this.candlesticks[i].open ) , color );
}
// draw mouse hover
if ( this.b_drawMouseOverlay )
{
// price line
this.context.setLineDash( [5,5] );
this.drawLine( 0 , this.mousePosition.y , this.width , this.mousePosition.y , this.mouseHoverBackgroundColor );
this.context.setLineDash( [] );
var str = this.roundPriceValue( this.yMouseHover );
var textWidth = this.context.measureText( str ).width;
this.fillRect( this.width-70 , this.mousePosition.y-10 , 70 , 20 , this.mouseHoverBackgroundColor );
this.context.fillStyle = this.mouseHoverTextColor;
this.context.fillText( str , this.width-textWidth-5 , this.mousePosition.y+5 );
// time line
this.context.setLineDash( [5,5] );
this.drawLine( this.mousePosition.x , 0 , this.mousePosition.x , this.height , this.mouseHoverBackgroundColor );
this.context.setLineDash( [] );
str = this.formatDate( new Date( this.xMouseHover ) );
textWidth = this.context.measureText( str ).width;
this.fillRect( this.mousePosition.x-textWidth/2-5 , this.height-20 , textWidth+10 , 20 , this.mouseHoverBackgroundColor );
this.context.fillStyle = this.mouseHoverTextColor;
this.context.fillText( str , this.mousePosition.x-textWidth/2 , this.height-5 );
// data
var yPos = this.mousePosition.y-95;
if ( yPos < 0 ) yPos = this.mousePosition.y+15;
this.fillRect( this.mousePosition.x+15 , yPos , 100 , 80 , this.mouseHoverBackgroundColor );
var color = ( this.candlesticks[this.hoveredCandlestickID].close > this.candlesticks[this.hoveredCandlestickID].open ) ? this.greenColor : this.redColor;
this.fillRect( this.mousePosition.x+15 , yPos , 10 , 80 , color );
this.context.lineWidth = 2;
this.drawRect( this.mousePosition.x+15 , yPos , 100 , 80 , color );
this.context.lineWidth = 1;
this.context.fillStyle = this.mouseHoverTextColor;
this.context.fillText( "O: "+this.candlesticks[this.hoveredCandlestickID].open , this.mousePosition.x+30 , yPos+15 );
this.context.fillText( "C: "+this.candlesticks[this.hoveredCandlestickID].close , this.mousePosition.x+30 , yPos+35 );
this.context.fillText( "H: "+this.candlesticks[this.hoveredCandlestickID].high , this.mousePosition.x+30 , yPos+55 );
this.context.fillText( "L: "+this.candlesticks[this.hoveredCandlestickID].low , this.mousePosition.x+30 , yPos+75 );
}
}
pingpoliCandlestickChart.prototype.drawGrid = function()
{
// roughly divide the yRange into cells
var yGridSize = (this.yRange)/this.yGridCells;
// try to find a nice number to round to
var niceNumber = Math.pow( 10 , Math.ceil( Math.log10( yGridSize ) ) );
if ( yGridSize < 0.25 * niceNumber ) niceNumber = 0.25 * niceNumber;
else if ( yGridSize < 0.5 * niceNumber ) niceNumber = 0.5 * niceNumber;
// find next largest nice number above yStart
var yStartRoundNumber = Math.ceil( this.yStart/niceNumber ) * niceNumber;
// find next lowest nice number below yEnd
var yEndRoundNumber = Math.floor( this.yEnd/niceNumber ) * niceNumber;
for ( var y = yStartRoundNumber ; y <= yEndRoundNumber ; y += niceNumber )
{
this.drawLine( 0 , this.yToPixelCoords( y ) , this.width , this.yToPixelCoords( y ) , this.gridColor );
var textWidth = this.context.measureText( this.roundPriceValue( y ) ).width;
this.context.fillStyle = this.gridTextColor;
this.context.fillText( this.roundPriceValue( y ) , this.width-textWidth-5 , this.yToPixelCoords( y )-5 );
}
// roughly divide the xRange into cells
var xGridSize = (this.xRange)/this.xGridCells;
// try to find a nice number to round to
niceNumber = Math.pow( 10 , Math.ceil( Math.log10( xGridSize ) ) );
if ( xGridSize < 0.25 * niceNumber ) niceNumber = 0.25 * niceNumber;
else if ( xGridSize < 0.5 * niceNumber ) niceNumber = 0.5 * niceNumber;
// find next largest nice number above yStart
var xStartRoundNumber = Math.ceil( this.xStart/niceNumber ) * niceNumber;
// find next lowest nice number below yEnd
var xEndRoundNumber = Math.floor( this.xEnd/niceNumber ) * niceNumber;
// if the total x range is more than 5 days, format the timestamp as date instead of hours
var b_formatAsDate = false;
if ( this.xRange > 60*60*24*1000*5 ) b_formatAsDate = true;
for ( var x = xStartRoundNumber ; x <= xEndRoundNumber ; x += niceNumber )
{
this.drawLine( this.xToPixelCoords( x ) , 0 , this.xToPixelCoords( x ) , this.height , this.gridColor );
var date = new Date( x );
var dateStr = "";
if ( b_formatAsDate )
{
var day = date.getDate();
if ( day < 10 ) day = "0"+day;
var month = date.getMonth()+1;
if ( month < 10 ) month = "0"+month;
dateStr = day+"."+month;
}
else
{
var minutes = date.getMinutes();
if ( minutes < 10 ) minutes = "0"+minutes;
dateStr = date.getHours()+":"+minutes;
}
this.context.fillStyle = this.gridTextColor;
this.context.fillText( dateStr , this.xToPixelCoords( x )+5 , this.height-5 );
}
}
pingpoliCandlestickChart.prototype.calculateYRange = function()
{
for ( var i = 0 ; i < this.candlesticks.length ; ++i )
{
if ( i == 0 )
{
this.yStart = this.candlesticks[i].low;
this.yEnd = this.candlesticks[i].high;
}
else
{
if ( this.candlesticks[i].low < this.yStart )
{
this.yStart = this.candlesticks[i].low;
}
if ( this.candlesticks[i].high > this.yEnd )
{
this.yEnd = this.candlesticks[i].high;
}
}
}
this.yRange = this.yEnd - this.yStart;
}
pingpoliCandlestickChart.prototype.calculateXRange = function()
{
this.xStart = this.candlesticks[0].timestamp;
this.xEnd = this.candlesticks[this.candlesticks.length-1].timestamp;
this.xRange = this.xEnd - this.xStart;
}
pingpoliCandlestickChart.prototype.yToPixelCoords = function( y )
{
return this.height - this.marginBottom - (y-this.yStart) * this.yPixelRange/this.yRange;
}
pingpoliCandlestickChart.prototype.xToPixelCoords = function( x )
{
return this.marginLeft + (x-this.xStart) * this.xPixelRange/this.xRange;
}
pingpoliCandlestickChart.prototype.yToValueCoords = function( y )
{
return this.yStart + ( this.height - this.marginBottom - y ) * this.yRange/this.yPixelRange;
}
pingpoliCandlestickChart.prototype.xToValueCoords = function( x )
{
return this.xStart + ( x - this.marginLeft ) * this.xRange/this.xPixelRange;
}
pingpoliCandlestickChart.prototype.drawLine = function( xStart , yStart , xEnd , yEnd , color )
{
this.context.beginPath();
// to get a crisp 1 pixel wide line, we need to add 0.5 to the coords
this.context.moveTo( xStart+0.5 , yStart+0.5 );
this.context.lineTo( xEnd+0.5 , yEnd+0.5 );
this.context.strokeStyle = color;
this.context.stroke();
}
pingpoliCandlestickChart.prototype.fillRect = function( x , y , width , height , color )
{
this.context.beginPath();
this.context.fillStyle = color;
this.context.rect( x , y , width , height );
this.context.fill();
}
pingpoliCandlestickChart.prototype.drawRect = function( x , y , width , height , color )
{
this.context.beginPath();
this.context.strokeStyle = color;
this.context.rect( x , y , width , height );
this.context.stroke();
}
pingpoliCandlestickChart.prototype.formatDate = function( date )
{
var day = date.getDate();
if ( day < 10 ) day = "0"+day;
var month = date.getMonth()+1;
if ( month < 10 ) month = "0"+month;
var hours = date.getHours();
if ( hours < 10 ) hours = "0"+hours;
var minutes = date.getMinutes();
if ( minutes < 10 ) minutes = "0"+minutes;
return day+"."+month+"."+date.getFullYear()+" - "+hours+":"+minutes;
}
pingpoliCandlestickChart.prototype.roundPriceValue = function( value )
{
if ( value > 1.0 ) return Math.round( value*100 )/100;
if ( value > 0.001 ) return Math.round( value*1000 )/1000;
if ( value > 0.00001 ) return Math.round( value*100000 )/100000;
if ( value > 0.0000001 ) return Math.round( value*10000000 )/10000000;
else return Math.round( value*1000000000 )/1000000000;
}
@fatFire
Copy link

fatFire commented Aug 13, 2021

Hello,bro. I want to know the principle of calculating niceNumber. That is why you get niceNumber by such logic:

  var niceNumber = Math.pow(10, Math.ceil(Math.log10(yGridSize)))
  if (yGridSize < 0.25 * niceNumber) niceNumber = 0.25 * niceNumber
  else if (yGridSize < 0.5 * niceNumber) niceNumber = 0.5 * niceNumber

@pingpoli
Copy link
Author

yGridSize is most likely going to be something like 957 and you probably don't want a scale with multiples of such a weird number. We would much rather have a nice and round number like 1000 or 10,1,0.1, etc. All of these nice numbers are power of 10 numbers. And log base 10 and power of 10 are the inverse. So if we take the base 10 logarithm, then round to the next highest nice number with Math.ceil and then reverse the log 10 by power of 10 again, we will have the next highest power of 10 number for yGridSize.
The two lines after it are to include scales with multiples of 25% and 50% of the nice number, so for example, if we had a yGridSize of 476, the niceNumber would be 1000, which would be a little bit too big, so we just check whether it's smaller than 50% of the nice number and if it is just take 500 as our nice number.

@fatFire
Copy link

fatFire commented Aug 13, 2021

Awesome, thank you, bro! And I have another question that at line 91, this.mousePosition.x += this.candleWidth/2; why it need add this.candleWidth/2? If the mouse falls on the right half area of a candle, and we add this.candleWidth/2, this.mousePosition.x will point to the next candle. Am I right?

@pingpoli
Copy link
Author

In line 148 when drawing the rectangle for the candlestick body, it is drawn with a width of candleWidth from -candleWidth/2 to +candleWidth/2. The this.mousePosition.x += this.candleWidth/2; you mentioned is just to offset the mouse so it's consistent with the drawing.

@fatFire
Copy link

fatFire commented Aug 14, 2021

Thank you, bro! I got it.

Copy link

ghost commented Aug 21, 2021

Thanks, used it in this project 🙏! https://github.com/sigf0x/api

@jonathansilva
Copy link

Great!
I would like to know if it is possible to convert Candlestick to Renko
I need a Renko chart ( in vanilla Javascript ) to do Backtests

@pingpoli
Copy link
Author

@jonathansilva I don't think there is an easy way to use the candlestick chart for Renko data. Candlesticks are time-based, while Renko data isn't, which would require changing the x-axis logic. However, the general chart features could be reused so it probably wouldn't be too complicated to create a vanilla javascript Renko chart.

@jonathansilva
Copy link

@jonathansilva I don't think there is an easy way to use the candlestick chart for Renko data. Candlesticks are time-based, while Renko data isn't, which would require changing the x-axis logic. However, the general chart features could be reused so it probably wouldn't be too complicated to create a vanilla javascript Renko chart.

Thank you very much :)
I will try to adapt here

@jonathansilva
Copy link

jonathansilva commented Jul 24, 2022

It's harder than I imagined
I think it's better to start from the beginning hahaha

@ewaschenko
Copy link

Hi, do you know how you would increase performance for this when plotting large datasets? ie when plotting 50K+ candles, the graph grinds to a halt and performance really suffers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment