This task was to display stock data from Quandl. It downloads the available tickers for a certain day, displays them and lets the user choose one, then it downloads the pricing data and draws candles on a canvas.
The first problem I ran into was CORS. I’m not sure whether I’m misusing their API or if they don’t want to specifically allow it, that was solved by using a proxy. The other thing I’m still not sure about is the fact that it doesn’t work on Firefox, but works on Chromium. From what I’ve read, it seems Firefox doesn’t allow ajax calls from file://
pages. If someone could shed some light on that, it would be great.
Apart from the problems listed at the top of the page, I would like to receive reviews regarding the JavaScript, if you can clarify the issues I mentioned above too, even better.
script.js
/* Known problems: - non-trading dates will not display any tickers - dates in the future can be chosen - no date validation (Chrome takes care of it though) - Firefox doesn't appear to allow XMLHttpRequest from file://something.html (https://stackoverflow.com/q/5005960) */ var chartApp = function() { const apiKey = ''; const corsProxy = 'https://cors-anywhere.herokuapp.com/'; const candleSize = 15; const candleMargin = 3; const resizeTimeout = 100; var Candle = { line: { y0: 0, y1: 0 }, box: { y0: 0, y1: 0 }, color: 'gray' }; var CandleData = { open: 0, close: 0, low: 0, high: 0 }; var StockDay = { date: null, open: 0, close: 0, low: 0, high: 0 }; var state = { tickers: [], // A, AA ... chartData: [], // information for each candle baseDate: null, basePrice: 0, // bottom $ maxPrice: 0, slots: 0, // how many candles fit in the canvas m: 0, // basePrice + y * m = priceAtY timeout: null, // clear and set timeout }; // avoid calling document.getElementBy* all the time var pcs = { controls: null, date: null, tickers: null, priceInfo: null, dateAtCursor: null, priceAtCursor: null, openAtCursor: null, closeAtCursor: null, lowAtCursor: null, highAtCursor: null, chart: null, chartCtx: null, }; function init() { pcs.controls = byId('controls'); pcs.date = byId('startDate'); pcs.tickers = byId('tickers'); pcs.priceInfo = byId('priceInfo'); pcs.dateAtCursor = byId('dateAtCursor'); pcs.priceAtCursor = byId('priceAtCursor'); pcs.openAtCursor = byId('openAtCursor'); pcs.closeAtCursor = byId('closeAtCursor'); pcs.lowAtCursor = byId('lowAtCursor'); pcs.highAtCursor = byId('highAtCursor'); pcs.chart = byId('chart'); pcs.chartCtx = pcs.chart.getContext('2d'); pcs.date.addEventListener('change', dateChangeHandler); pcs.tickers.addEventListener('change', tickerChangeHandler); window.addEventListener('resize', windowResizeHandler); reset(); } function reset() { pcs.priceInfo.style.display = 'none'; pcs.tickers.disabled = true; pcs.tickers.innerHTML = '<option>choose one</option>'; setupCanvas(); } function clearCanvas() { pcs.chartCtx.fillStyle = 'black'; pcs.chartCtx.fillRect(0, 0, pcs.chart.width, pcs.chart.height); pcs.chartCtx.fill(); } function setupCanvas() { // make 1 unit match 1px pcs.chart.width = pcs.chart.clientWidth; pcs.chart.height = pcs.chart.clientHeight; // make y start at bottom pcs.chartCtx.translate(0, pcs.chart.height); pcs.chartCtx.scale(1, -1); clearCanvas(); state.slots = Math.floor(pcs.chart.width / (candleSize + candleMargin)); } function displayTickers() { var option; var i; pcs.tickers.innerHTML = '<option>choose one</option>'; for (i = 0; i < state.tickers.length; i++) { option = document.createElement('option'); option.innerText = state.tickers[i]; pcs.tickers.appendChild(option); } pcs.tickers.disabled = false; } function queryAvailableTickers() { var start = pcs.date.value; // TODO validate user input var url = corsProxy + 'https://www.quandl.com/api/v3/datatables/WIKI/PRICES.json?' + 'date=' + start + '&qopts.columns=ticker&api_key=' + apiKey; // parse JSON and store the list of tickers function getTickers(data) { var obj = JSON.parse(data); var tickers = obj.datatable.data; var i; // remove any previous tickers state.tickers = []; for (i = 0; i < tickers.length; i++) { state.tickers.push(tickers[i][0]); } } function queryTickersCb(data) { getTickers(data); displayTickers(); } sendAjaxRequest('GET', url, queryTickersCb); } function queryTickerData() { var ticker = pcs.tickers.value; if (ticker === 'choose one') { return; } // start date state.baseDate = new Date(pcs.date.value); var startStr = getDateStr(state.baseDate); // end date var endDate = new Date(pcs.date.value); endDate.setDate(endDate.getDate() + state.slots); var endStr = getDateStr(endDate); var url = corsProxy + 'https://www.quandl.com/api/v3/datatables/WIKI/PRICES?' + 'date.gte=' + startStr + '&date.lt=' + endStr + '&ticker=' + ticker + '&qopts.columns=date,open,close,low,high' + '&api_key=' + apiKey; // store daily data for tickers function getTickerData(data) { var obj = JSON.parse(data); var i; var day; var minPrice = Number.MAX_VALUE; var maxPrice = -1; var data = obj.datatable.data; state.chartData = []; for (i = 0; i < data.length; i++) { day = Object.create(StockDay); day.date = new Date(data[i][0]); day.open = data[i][1]; day.close = data[i][2]; day.low = data[i][3]; day.high = data[i][4]; if (day.low < minPrice) { minPrice = day.low; } if (day.high > maxPrice) { maxPrice = day.high; } state.chartData[getDateStr(day.date)] = day; } setLowHigh(minPrice, maxPrice); } function tickerDataCb(data) { getTickerData(data); drawTickers(); turnDisplayOn(); } sendAjaxRequest('GET', url, tickerDataCb); } function drawTickers() { var i, date, key, day, x; var candle = Object.create(Candle); clearCanvas(); for (i = 0; i < state.slots; i++) { date = new Date(pcs.date.value); date.setDate(date.getDate() + i); key = getDateStr(date); day = state.chartData[key]; if (typeof day !== 'undefined') { candle.line.y0 = priceToY(day.low); candle.line.y1 = priceToY(day.high); candle.box.y0 = priceToY(day.open); candle.box.y1 = priceToY(day.close); candle.color = (day.open > day.close) ? 'red' : 'green'; x = (day.date.getTime() - state.baseDate.getTime()) / 86400000; drawCandle(x, candle); } } } function drawCandle(x, candle) { var x0 = x * (candleSize + candleMargin); var y0 = candle.box.y0; var y1 = candle.box.y1; if (y0 > y1) { y1 = y0; y0 = candle.box.y1; } var height = y1 - y0; x = Math.floor(x0 + candleSize / 2); // line pcs.chartCtx.strokeStyle = 'white'; pcs.chartCtx.beginPath(); pcs.chartCtx.lineTo(x, candle.line.y0); pcs.chartCtx.lineTo(x, candle.line.y1); pcs.chartCtx.closePath(); pcs.chartCtx.stroke(); // box pcs.chartCtx.fillStyle = candle.color; pcs.chartCtx.fillRect(x0, y0, candleSize, height); pcs.chartCtx.fill(); } function priceToY(p) { return (p - state.basePrice) / state.m; } function getPriceAtY(y) { return state.basePrice + y * state.m; } function getDiscreteX(x) { return Math.floor(x / (candleSize + candleMargin)); } function turnDisplayOff() { pcs.chart.removeEventListener('mousemove', mouseMoveHandler); pcs.priceInfo.style.display = 'none'; } function turnDisplayOn() { mouseMoveHandler({clientX: 0, clientY: 0}); pcs.chart.addEventListener('mousemove', mouseMoveHandler); pcs.priceInfo.style.display = 'inline-block'; } function setLowHigh(low, high) { var usableHeight = pcs.chart.height - (pcs.controls.offsetHeight + 16); state.m = (high - low) / usableHeight; state.basePrice = low; state.maxPrice = high; } function mouseMoveHandler(ev) { // compute price at Y var y = pcs.chart.height - ev.clientY; var price = getPriceAtY(y).toFixed(2); // compute date at X var x = getDiscreteX(ev.clientX); var date = new Date(pcs.date.value); date.setDate(date.getDate() + x); var dateStr = getDateStr(date); // get chart at cursor var day = state.chartData[dateStr]; // update bar pcs.priceAtCursor.innerText = '$ ' + price; pcs.dateAtCursor.innerText = dateStr; // non-trading days aren't stored if (typeof day !== 'undefined') { pcs.openAtCursor.innerText = '$ ' + day.open; pcs.closeAtCursor.innerText = '$ ' + day.close; pcs.lowAtCursor.innerText = '$ ' + day.low; pcs.highAtCursor.innerText = '$ ' + day.high; } } function dateChangeHandler() { reset(); turnDisplayOff(); queryAvailableTickers(); } function tickerChangeHandler() { turnDisplayOff(); setupCanvas(); queryTickerData(); } function lastResize() { state.timeout = null; tickerChangeHandler(); } function windowResizeHandler() { if (state.timeout !== null) { clearTimeout(state.timeout); } state.timeout = setTimeout(lastResize, resizeTimeout); } // the functions below would ideally come from a library function byId(id) { return document.getElementById(id); } function sendAjaxRequest(method, url, cb, userErrCb) { var req = new XMLHttpRequest(); var errCb = (typeof userErrCb !== 'undefined') ? userErrCb : defaultErrCb; function defaultErrCb() { console.log('ajax error'); } function stateChangeHandler() { if (req.readyState === 4) { if (req.status === 200) { cb(req.responseText); } else { errCb(); } } } req.open(method, url); req.send(); req.onreadystatechange = stateChangeHandler; } function getDateStr(date) { var month = '' + (date.getMonth() + 1); if (month.length != 2) { month = '0' + month; } var day = '' + date.getDate(); if (day.length != 2) { day = '0' + day; } return date.getFullYear() + '-' + month + '-' + day; } function run() { init(); } return { run }; }(); chartApp.run();
index.html
<!doctype html> <html> <head> <meta charset="utf-8"> <title>Chart Task</title> <link rel="stylesheet" type="text/css" href="style.css"> </head> <body> <noscript>Please turn JavaScript on</noscript> <div id="controls"> <form> <p>Start date:</p> <input type="date" id="startDate" placeholder="yyyy-mm-dd"> <p>Ticker:</p> <select id="tickers"></select> </form> <div id="priceInfo"> <p>Date: <span id="dateAtCursor"></span></p> <p>Price: <span id="priceAtCursor"></span></p> <p>Open: <span id="openAtCursor"></span></p> <p>Close: <span id="closeAtCursor"></span></p> <p>Low: <span id="lowAtCursor"></span></p> <p>High: <span id="highAtCursor"></span></p> </div> </div> <canvas id="chart"></canvas> <script src="script.js"></script> </body> </html>
style.css
* { margin:0; padding:0; } html, body { height:100%; width:100%; background-color:#fff; color:#000; font-family:sans-serif; font-size:0; } #controls { position:absolute; background-color: #494949; color:#fff; width:100%; font-size:16px; padding:8px; box-sizing: border-box; z-index:2; } #controls * { display:inline-block; } #controls p { padding-left: 8px; border-left:1px solid #fff; } canvas { position:absolute; width:100%; height:100%; cursor:crosshair; z-index:0; }
screenshot