diff --git a/auto_tests/tests/candlestick_plotter.js b/auto_tests/tests/candlestick_plotter.js new file mode 100644 index 000000000..400284e3c --- /dev/null +++ b/auto_tests/tests/candlestick_plotter.js @@ -0,0 +1,54 @@ +/** + * @fileoverview Tests for the candlestick plotter. + * + * @author petr.shevtsov@gmail.com (Petr Shevtsov) + */ + +import Dygraph from '../../src/dygraph'; +import '../../src/extras/candlestick-plotter'; // defines Dygraph.candlestickPlotter +import '../../src/extras/compress'; // defines Dygraph.DataHandlers.CompressHandler + +describe("candlestick-plotter", function() { + var candlestickPlotter = Dygraph.candlestickPlotter; + var getPrices = candlestickPlotter._getPrices; + + beforeEach(function() { + }); + + afterEach(function() { + }); + + it('testPrices', function() { + var set = [ + [{yval: 200, y: 0.2}], // Open + [{yval: 400, y: 0.4}], // High + [{yval: 100, y: 0.1}], // Low + [{yval: 300, y: 0.3}] // Close + ]; + + assert.deepEqual([{ + open: 200, + openY: 0.2, + high: 400, + highY: 0.4, + low: 100, + lowY: 0.1, + close: 300, + closeY: 0.3 + }], getPrices(set)); + }); + + it('testCompressHandler', function() { + var series = []; + var x; + var y = 0; + for (var i = 1; i < 365; i++) { + x = i * 1000 * 60 * 60 * 24; + series.push([x, y]); + } + var comp = new Dygraph.DataHandlers.CompressHandler; + var pts = comp.seriesToPoints(series, "", 0); + + assert.deepEqual(51, pts.length); // 50 bars + 1 to compensate + }); +}); diff --git a/src/extras/candlestick-plotter.js b/src/extras/candlestick-plotter.js new file mode 100644 index 000000000..6288aec4e --- /dev/null +++ b/src/extras/candlestick-plotter.js @@ -0,0 +1,86 @@ +/** + * The Candle chart plotter is adapted from code written by + * Zhenlei Cai (jpenguin@gmail.com) + * https://github.com/danvk/dygraphs/pull/141/files + */ + +(function() { + "use strict"; + + var Dygraph; + if (window.Dygraph) { + Dygraph = window.Dygraph; + } else if (typeof(module) !== 'undefined') { + Dygraph = require('../dygraph'); + } + + function getPrices(sets) { + var prices = []; + var price; + for (var p = 0 ; p < sets[0].length; p++) { + price = { + open : sets[0][p].yval, + high : sets[1][p].yval, + low : sets[2][p].yval, + close : sets[3][p].yval, + openY : sets[0][p].y, + highY : sets[1][p].y, + lowY : sets[2][p].y, + closeY : sets[3][p].y + }; + prices.push(price); + } + return prices; + } + + function candlestickPlotter(e) { + if (e.seriesIndex > 3) { + Dygraph.Plotters.linePlotter(e); + return; + } + // This is the officially endorsed way to plot all the series at once. + if (e.seriesIndex !== 0) return; + + var sets = e.allSeriesPoints.slice(0, 4); // Slice first four sets for candlestick chart + var prices = getPrices(sets); + var area = e.plotArea; + var ctx = e.drawingContext; + ctx.strokeStyle = '#202020'; + ctx.lineWidth = 0.6; + + var minBarWidth = 2; + var numBars = prices.length + 1; // To compensate the probably removed first "incomplete" bar + var barWidth = Math.round((area.w / numBars) / 2); + if (barWidth % 2 !== 0) { + barWidth++; + } + barWidth = Math.max(barWidth, minBarWidth); + + var price; + for (var p = 0 ; p < prices.length; p++) { + ctx.beginPath(); + + price = prices[p]; + var topY = area.h * price.highY + area.y; + var bottomY = area.h * price.lowY + area.y; + var centerX = Math.floor(area.x + sets[0][p].x * area.w) + 0.5; // crisper rendering + ctx.moveTo(centerX, topY); + ctx.lineTo(centerX, bottomY); + ctx.closePath(); + ctx.stroke(); + var bodyY; + if (price.open > price.close) { + ctx.fillStyle ='rgba(244,44,44,1.0)'; + bodyY = area.h * price.openY + area.y; + } + else { + ctx.fillStyle ='rgba(44,244,44,1.0)'; + bodyY = area.h * price.closeY + area.y; + } + var bodyHeight = area.h * Math.abs(price.openY - price.closeY); + ctx.fillRect(centerX - barWidth / 2, bodyY, barWidth, bodyHeight); + } + }; + candlestickPlotter._getPrices = getPrices; // for testing + Dygraph.candlestickPlotter = candlestickPlotter; +})(); diff --git a/src/extras/compress.js b/src/extras/compress.js new file mode 100644 index 000000000..86f38c161 --- /dev/null +++ b/src/extras/compress.js @@ -0,0 +1,257 @@ +/** + * @license + * Copyright 2015 Petr Shevtsov (petr.shevtsov@gmail.com) + * MIT-licensed (http://opensource.org/licenses/MIT) + * + * Compress data handler "compresses" chart data annually, quarterly, monthly, + * weekly or daily. + * + * See "tests/candlestick.html" for demo. + */ + +(function() { + "use strict"; + + var Dygraph; + if (window.Dygraph) { + Dygraph = window.Dygraph; + } else if (typeof(module) !== 'undefined') { + Dygraph = require('../dygraph'); + } + + /** + * Get week number for date + */ + var getWeek = function(d) { + var d = new Date(+d); + d.setHours(0,0,0); + d.setDate(d.getDate()+4-(d.getDay()||7)); + return Math.ceil((((d-new Date(d.getFullYear(),0,1))/8.64e7)+1)/7); + }; + /** + * Get week endpoints (i.e. start of the week and end of the week dates) for date + */ + var getWeekEndPoints = function(d) { + var d = new Date(+d); + var today = new Date(d.setHours(0, 0, 0, 0)); + var day = today.getDay(); + var date = today.getDate() - day; + + var StartDate = new Date(today.setDate(date)); + var EndDate = new Date(today.setDate(date + 7)); + + return [StartDate, EndDate]; + }; + /** + * Get quarter for date + */ + var getQuarter = function(d) { + var d = new Date(+d); + var m = Math.floor(d.getMonth()/3) + 2; + return m > 4 ? m - 5 : m; + }; + Dygraph.DataHandlers.CompressHandler = function() {}; + var CompressHandler = Dygraph.DataHandlers.CompressHandler; + CompressHandler.prototype = new Dygraph.DataHandlers.DefaultHandler(); + CompressHandler.prototype.seriesToPoints = function(series, setName, boundaryIdStart) { + var compress = { + titles: [ + "annually", + "quarterly", + "monthly", + "weekly", + "daily" + ], + days: [ + 365, + 90, + 30, + 7, + 1 + ], + bars: [], + barsRange: [20, 50] + }; + var firstItem = series[0]; + var lastItem = series[series.length - 1]; + var dateDiff = lastItem[0] - firstItem[0]; + var dayMs = 1000 * 60 * 60 * 24; + var compressedSeries = []; + var ratio = 1; + var points = []; + var bounds = []; + var idx; + var compressTitle; + + for (var i = 0; i < compress.days.length; i++) { + ratio = compress.days[i] * dayMs; + var bars = Math.round(dateDiff / ratio); + compress.bars.push(bars); + } + + idx = compress.bars.reduce(function(previous, current, index) { + if (current < compress.barsRange[1]) { + return index; + } + return previous; + }, 0); + bounds.push(idx); + + idx = compress.bars.reduceRight(function(previous, current, index) { + if (current > compress.barsRange[0]) { + return index; + } + return previous; + }, compress.bars.length - 1); + bounds.push(idx); + + if (bounds[0] === bounds[1]) { + compressTitle = compress.titles[bounds[0]]; + } else { + var lower_idx = bounds[0]; + var lower = Math.abs(compress.bars[idx] - compress.barsRange[0]); + var higher_idx = bounds[1]; + var higher = Math.abs(compress.bars[idx] - compress.barsRange[1]); + var min = Math.min(lower, higher); + idx = (min === lower) ? lower_idx : higher_idx; + compressTitle = compress.titles[idx]; + } + + var doCompress = function(title) { + var period; + var currentPeriod; + var buffer = []; + var compressed = []; + var getPeriodEndPoints = function(period, date) { + var endpoints = []; + var currentYear = new Date(date).getFullYear(); + var currentPeriod; + switch (period) { + case "annually": + endpoints.push(new Date(currentYear, 0, 1)); + endpoints.push(new Date(currentYear, 11, 31)); + break; + case "quarterly": + currentPeriod = getQuarter(new Date(item[0])); + if (currentPeriod === 0) { + endpoints.push(new Date(currentYear, 0, 1)); + endpoints.push(new Date(currentYear, 2, 31)); + } else if (currentPeriod === 1) { + endpoints.push(new Date(currentYear, 3, 1)); + endpoints.push(new Date(currentYear, 5, 30)); + } else if (currentPeriod === 2) { + endpoints.push(new Date(currentYear, 6, 1)); + endpoints.push(new Date(currentYear, 8, 30)); + } else { + endpoints.push(new Date(currentYear, 9, 1)); + endpoints.push(new Date(currentYear, 11, 31)); + } + break; + case "monthly": + currentPeriod = new Date(item[0]).getMonth(); + endpoints.push(new Date(currentYear, currentPeriod, 1)); + endpoints.push(new Date(currentYear, currentPeriod + 1, 0)); + break; + case "weekly": + endpoints = getWeekEndPoints(new Date(item[0])); + break; + case "daily": + endpoints = [new Date(item[0]), new Date(item[0])]; + break; + } + return endpoints; + }; + for (var i = 0; i < series.length; i++) { + var item = series[i]; + switch (title) { + case "annually": + currentPeriod = new Date(item[0]).getFullYear(); + break; + case "quarterly": + currentPeriod = getQuarter(new Date(item[0])); + break; + case "monthly": + currentPeriod = new Date(item[0]).getMonth(); + break; + case "weekly": + currentPeriod = getWeek(new Date(item[0])); + break; + case "daily": + currentPeriod = new Date(item[0]).getDay(); + break; + } + if (period === undefined) { + period = currentPeriod; + } + if (period === currentPeriod) { + buffer.push(item); + } else { + compressed.push(buffer); + buffer = []; + buffer.push(item); + period = currentPeriod; + } + } + return compressed.reduce(function(prev, curr, index) { + var date = curr[curr.length - 1][0]; + var value; + + // Check if we have more or less full period for the first bar + if (index === 0) { + var endpoints = getPeriodEndPoints(title, curr[0][0]); + var range = []; + range.push(curr[0][0]); // first date + range.push(date); // last date + + if (endpoints[0] !== range[0] || endpoints[1] !== range[1]) { + return prev; + } + } + + switch (setName.toLowerCase()) { + case "open": + value = curr[0][1]; // Open of the first day + break; + case "high": + // Highest High of all the daily Highs + value = Math.max.apply(null, curr.reduce(function(p, c) { + p.push(c[1]); + return p; + }, [])); + break; + case "low": + // Lowest Low of all the daily Lows + value = Math.min.apply(null, curr.reduce(function(p, c) { + p.push(c[1]); + return p; + }, [])); + break; + case "close": + value = curr[curr.length - 1][1]; // Close of the last day + break; + } + prev.push([date, value]); + + return prev; + }, []); + }; + + compressedSeries = doCompress(compressTitle); + + for (i = 0; i < compressedSeries.length; ++i) { + var item = compressedSeries[i]; + var point = { + x : NaN, + y : NaN, + xval : item[0], + yval : item[1], + name : setName, + idx : i + boundaryIdStart + }; + points.push(point); + } + this.onPointsCreated_(compressedSeries, points); + return points; + }; + +})(); diff --git a/tests/candlestick.html b/tests/candlestick.html new file mode 100644 index 000000000..1af8ea017 --- /dev/null +++ b/tests/candlestick.html @@ -0,0 +1,142 @@ + + + +
++ Candlesticks are traditional way of displaying price ranges as well as trend in + which a financial stock traded during a day (or week, month, etc.), from the + stock exchange open till close during the period, marking the + highest and lowest price reached during that period and + color-coding the trend. +
+
+ The data argument is a table containing a Date column and columns
+ with names Open, Close, High and
+ Low (in the exact order). High value is always the
+ highest of the four, Low value is always the lowest. If
+ Close is higher than Open for that period, the
+ candlestick will be green, otherwise it will be red.
+
+ It is possible to compress data (annualy, quarterly, monthly, weekly or daily)
+ depending on the current chart zoom level to prevent chart bars overflow using
+ the CompressHandler data handler.
+