Як побудувати історичні графіки цін за допомогою D3.js

Покроковий підхід до візуалізації наборів фінансових даних

Це складне завдання передавати дані та відображати ці візуалізації на декількох пристроях та платформах.

“Дані просто як сирі. Це цінно, але якщо воно не перероблене, то насправді не може бути використано ". - Майкл Палмер

D3 (документи, керовані даними) вирішує цю давню дилему. Він надає розробникам та аналітикам можливість створювати власні візуалізації для Інтернету з повною свободою. D3.js дозволяє нам прив'язувати дані до DOM (об'єктна модель документа). Потім застосуйте керовані даними перетворення для створення вдосконаленої візуалізації даних.

У цьому посібнику ми зрозуміємо, як ми можемо змусити бібліотеку D3.js працювати для нас.

Починаємо

Ми створимо діаграму, яка ілюструє рух фінансового інструменту протягом певного періоду. Ця візуалізація нагадує графіки цін, надані Yahoo Finance. Ми розберемо різні компоненти, необхідні для відображення інтерактивного графіку цін, який відстежує певні акції.

Необхідні компоненти:

  1. Завантаження та аналіз даних
  2. Елемент SVG
  3. Осі X і Y.
  4. Закрити графік ліній ціни
  5. Проста діаграма кривої ковзного середнього з деякими розрахунками
  6. Стовпчаста діаграма обсягів серії
  7. Наведення курсора миші і легенда

Завантаження та аналіз даних

const loadData = d3.json('sample-data.json').then(data => { const chartResultsData = data['chart']['result'][0]; const quoteData = chartResultsData['indicators']['quote'][0]; return chartResultsData['timestamp'].map((time, index) => ({ date: new Date(time * 1000), high: quoteData['high'][index], low: quoteData['low'][index], open: quoteData['open'][index], close: quoteData['close'][index], volume: quoteData['volume'][index] })); });

По-перше, ми будемо використовувати модуль fetch для завантаження наших зразкових даних. D3-fetch також підтримує інші формати, такі як файли TSV та CSV. Далі дані будуть оброблені для повернення масиву об’єктів. Кожен об'єкт містить позначку часу торгів, високу ціну, низьку ціну, ціну відкриття, ціну закриття та обсяг торгів.

body { background: #00151c; } #chart { background: #0e3040; color: #67809f; }

Додайте вищевказані базові властивості CSS, щоб персоналізувати стиль вашої діаграми для максимальної візуальної привабливості.

Додавання елемента SVG

const initialiseChart = data => { const margin = { top: 50, right: 50, bottom: 50, left: 50 }; const width = window.innerWidth - margin.left - margin.right; const height = window.innerHeight - margin.top - margin.bottom; // add SVG to the page const svg = d3 .select('#chart') .append('svg') .attr('width', width + margin['left'] + margin['right']) .attr('height', height + margin['top'] + margin['bottom']) .call(responsivefy) .append('g') .attr('transform', `translate(${margin['left']}, ${margin['top']})`);

Згодом ми можемо використовувати append()метод для додавання елемента SVG доiv> element with the id, chart. Next, we use the attr() method to assign the width and height of the SVG element. We then call the responsivefy() method (originally written by Brendan Sudol). This allows the SVG element to have responsive capabilities by listening to window resize events.

Remember to append the SVG group element to the above SVG element before translating it using the values from the margin constant.

Rendering the X and Y Axes

Before rendering the axes component, we will need to define our domain and range, which will then be used to create our scales for the axes

// find data range const xMin = d3.min(data, d => { return d['date']; }); const xMax = d3.max(data, d => { return d['date']; }); const yMin = d3.min(data, d => { return d['close']; }); const yMax = d3.max(data, d => { return d['close']; }); // scales for the charts const xScale = d3 .scaleTime() .domain([xMin, xMax]) .range([0, width]); const yScale = d3 .scaleLinear() .domain([yMin - 5, yMax]) .range([height, 0]);

The x and y axes for the close price line chart consist of the trade date and close price respectively. Therefore, we have to define the minimum and maximum x and y values, using d3.max() and d3.min(). We can then make use of D3-scale’s scaleTime() and scaleLinear() to create the time scale on the x-axis and the linear scale on the y-axis respectively. The range of the scales is defined by the width and height of our SVG element.

// create the axes component svg .append('g') .attr('id', 'xAxis') .attr('transform', `translate(0, ${height})`) .call(d3.axisBottom(xScale)); svg .append('g') .attr('id', 'yAxis') .attr('transform', `translate(${width}, 0)`) .call(d3.axisRight(yScale));

After this step, we need to append the first g element to the SVG element, which calls the d3.axisBottom() method, taking in xScale as the parameter to generate the x-axis. The x-axis is then translated to the bottom of the chart area. Similarly, the y-axis is generated by appending the g element, calling d3.axisRight() with yScale as the parameter, before translating the y-axis to the right of the chart area.

Rendering the Close Price Line Chart

// generates close price line chart when called const line = d3 .line() .x(d => { return xScale(d['date']); }) .y(d => { return yScale(d['close']); }); // Append the path and bind data svg .append('path') .data([data]) .style('fill', 'none') .attr('id', 'priceChart') .attr('stroke', 'steelblue') .attr('stroke-width', '1.5') .attr('d', line);

Now, we can append the path element inside our main SVG element, followed by passing our parsed dataset,data. We set the attribute d with our helper function, line. which calls the d3.line() method. The x and y attributes of the line accept the anonymous functions and return the date and close price respectively.

By now, this is how your chart should look like:

Original text


Rendering the Simple Moving Average Curve

Instead of relying purely on the close price as our only form of technical indicator, we use the Simple Moving Average. This average identifies uptrends and downtrends for the particular security.

const movingAverage = (data, numberOfPricePoints) => { return data.map((row, index, total) => { const start = Math.max(0, index - numberOfPricePoints); const end = index; const subset = total.slice(start, end + 1); const sum = subset.reduce((a, b) => { return a + b['close']; }, 0); return { date: row['date'], average: sum / subset.length }; }); };

We define our helper function, movingAverage to calculate the simple moving average. This function accepts two parameters, namely the dataset, and the number of price points, or periods. It then returns an array of objects, with each object containing the date and average for each data point.

// calculates simple moving average over 50 days const movingAverageData = movingAverage(data, 49); // generates moving average curve when called const movingAverageLine = d3 .line() .x(d => { return xScale(d['date']); }) .y(d => { return yScale(d['average']); }) .curve(d3.curveBasis); svg .append('path') .data([movingAverageData]) .style('fill', 'none') .attr('id', 'movingAverageLine') .attr('stroke', '#FF8900') .attr('d', movingAverageLine);

For our current context, movingAverage() calculates the simple moving average over a period of 50 days. Similar to the close price line chart, we append the path element within our main SVG element, followed by passing our moving average dataset, and setting the attribute d with our helper function, movingAverageLine. The only difference from the above is that we passed d3.curveBasis to d3.line().curve() in order to achieve a curve.

This results in the simple moving average curve overlaid on top of our current chart:

Rendering the Volume Series Bar Chart

For this component, we will be rendering the trade volume in the form of a color-coded bar chart occupying the same SVG element. The bars are green when the stock closes higher than the previous day’s close price. They are red when the stock closes lower than the previous day’s close price. This illustrates the volume traded for each trade date. This can then be used alongside the above chart to analyze price movements.

/* Volume series bars */ const volData = data.filter(d => d['volume'] !== null && d['volume'] !== 0); const yMinVolume = d3.min(volData, d => { return Math.min(d['volume']); }); const yMaxVolume = d3.max(volData, d => { return Math.max(d['volume']); }); const yVolumeScale = d3 .scaleLinear() .domain([yMinVolume, yMaxVolume]) .range([height, 0]);

The x and y axes for the volume series bar chart consist of the trade date and volume respectively. Thus, we will need to redefine the minimum and maximum y values and make use of scaleLinear()on the y-axis. The range of these scales are defined by the width and height of our SVG element. We will be reusing xScale since the x-axis of the bar chart corresponds similarly to the trade date.

svg .selectAll() .data(volData) .enter() .append('rect') .attr('x', d => { return xScale(d['date']); }) .attr('y', d => { return yVolumeScale(d['volume']); }) .attr('fill', (d, i) => { if (i === 0) { return '#03a678'; } else { return volData[i - 1].close > d.close ? '#c0392b' : '#03a678'; } }) .attr('width', 1) .attr('height', d => { return height - yVolumeScale(d['volume']); });

This section relies on your understanding of how theselectAll() method works with the enter() and append() methods. You may wish to read this (written by Mike Bostock himself) if you are unfamiliar with those methods. This may be important as those methods are used as part of the enter-update-exit pattern, which I may cover in a subsequent tutorial.

To render the bars, we will first use .selectAll() to return an empty selection, or an empty array. Next, we pass volData to define the height of each bar. The enter() method compares the volData dataset with the selection from selectAll(), which is currently empty. Currently, the DOM does not contain any ct> element. Thus, the append() method accepts an argument ‘rect’, which creates a new element in the DOM for every single object in volData.

Here is a breakdown of the attributes of the bars. We will be using the following attributes: x, y, fill, width, and height.

.attr('x', d => { return xScale(d['date']); }) .attr('y', d => { return yVolumeScale(d['volume']); })

The first attr() method defines the x-coordinate. It accepts an anonymous function which returns the date. Similarly, the second attr() method defines the y-coordinate. It accepts an anonymous function which returns the volume. These will define the position of each bar.

.attr('width', 1) .attr('height', d => { return height - yVolumeScale(d['volume']); });

We assign a width of 1 pixel to each bar. To make the bar stretch from the top (defined by y)to the x-axis, simply deduct the height with the y value.

.attr('fill', (d, i) => { if (i === 0) { return '#03a678'; } else { return volData[i - 1].close > d.close ? '#c0392b' : '#03a678'; } })

Remember the way that the bars will be color coded? We will be using the fill attribute to define the colors of each bar. For stocks that closed higher than the previous day’s close price, the bar will be green in color. Otherwise, the bar will be red.

This is how your current chart should look like:

Rendering Crosshair and Legend for interactivity

We have reached the final step of this tutorial, whereby we will generate a mouseover crosshair that displays drop lines. Mousing over the various points in the chart will cause the legends to be updated. This provides us the full information (open price, close price, high price, low price, and volume) for each trade date.

The following section is referenced from Micah Stubb’s excellent example.

// renders x and y crosshair const focus = svg .append('g') .attr('class', 'focus') .style('display', 'none'); focus.append('circle').attr('r', 4.5); focus.append('line').classed('x', true); focus.append('line').classed('y', true); svg .append('rect') .attr('class', 'overlay') .attr('width', width) .attr('height', height) .on('mouseover', () => focus.style('display', null)) .on('mouseout', () => focus.style('display', 'none')) .on('mousemove', generateCrosshair); d3.select('.overlay').style('fill', 'none'); d3.select('.overlay').style('pointer-events', 'all'); d3.selectAll('.focus line').style('fill', 'none'); d3.selectAll('.focus line').style('stroke', '#67809f'); d3.selectAll('.focus line').style('stroke-width', '1.5px'); d3.selectAll('.focus line').style('stroke-dasharray', '3 3');

The crosshair consists of a translucent circle with drop lines consisting of dashes. The above code block provides the styling of the individual elements. Upon mouseover, it will generate the crosshair based on the function below.

const bisectDate = d3.bisector(d => d.date).left; function generateCrosshair() { //returns corresponding value from the domain const correspondingDate = xScale.invert(d3.mouse(this)[0]); //gets insertion point const i = bisectDate(data, correspondingDate, 1); const d0 = data[i - 1]; const d1 = data[i]; const currentPoint = correspondingDate - d0['date'] > d1['date'] - correspondingDate ? d1 : d0; focus.attr('transform',`translate(${xScale(currentPoint['date'])}, ${yScale(currentPoint['close'])})`); focus .select('line.x') .attr('x1', 0) .attr('x2', width - xScale(currentPoint['date'])) .attr('y1', 0) .attr('y2', 0); focus .select('line.y') .attr('x1', 0) .attr('x2', 0) .attr('y1', 0) .attr('y2', height - yScale(currentPoint['close'])); updateLegends(currentPoint); }

We can then make use of the d3.bisector() method to locate the insertion point, which will highlight the closest data point on the close price line graph. After determining the currentPoint, the drop lines will be updated. The updateLegends() method uses the currentPoint as the parameter.

const updateLegends = currentData => { d3.selectAll('.lineLegend').remove();
const updateLegends = currentData => { d3.selectAll('.lineLegend').remove(); const legendKeys = Object.keys(data[0]); const lineLegend = svg .selectAll('.lineLegend') .data(legendKeys) .enter() .append('g') .attr('class', 'lineLegend') .attr('transform', (d, i) => { return `translate(0, ${i * 20})`; }); lineLegend .append('text') .text(d => { if (d === 'date') { return `${d}: ${currentData[d].toLocaleDateString()}`; } else if ( d === 'high' || d === 'low' || d === 'open' || d === 'close') { return `${d}: ${currentData[d].toFixed(2)}`; } else { return `${d}: ${currentData[d]}`; } }) .style('fill', 'white') .attr('transform', 'translate(15,9)'); };

The updateLegends() method updates the legend by displaying the date, open price, close price, high price, low price, and volume of the selected mouseover point on the close line graph. Similar to the Volume bar charts, we will make use of the selectAll() method with the enter() and append() methods.

To render the legends, we will use.selectAll('.lineLegend') to select the legends, followed by calling the remove() method to remove them. Next, we pass the keys of the legends, legendKeys, which will be used to define the height of each bar. The enter() method is called, which compares the volData dataset and at the selection from selectAll(), which is currently empty. Currently, the DOM does not contain any ct> element. Thus, the append() method accepts an argument ‘rect’, which creates a new element in the DOM for every single object in volData.

Next, append the legends with their respective properties. We further process the values by converting the prices to 2 decimal places. We also set the date object to the default locale for readability.

This will be the end result:

Closing Thoughts

Congratulations! You have reached the end of this tutorial. As demonstrated above, D3.js is simple yet dynamic. It allows you to create custom visualizations for all your data sets. In the coming weeks, I will release the second part of this series which will deep dive into D3.js’s enter-update-exit pattern. Meanwhile, you may wish to check out the API documentation, more tutorials, and other interesting visualizations built with D3.js.

Feel free to check out the source code as well as the full demonstration of this tutorial. Thank you, and I hope you have learned something new today!

Special thanks to Debbie Leong for reviewing this article.