|
(function () { |
|
const cellSize = 15; |
|
const percentFormat = d3.format('+.1%'); |
|
|
|
const chart = d3.select('#chart'); |
|
|
|
const colorScale = clusterScale(); |
|
|
|
const legend = d3.legendColor() |
|
.scale(colorScale) |
|
.labels(legendThresholdLabels) |
|
.labelFormat(percentFormat); |
|
|
|
d3.csv('ftse-all-share-index.csv', row, (error, data) => { |
|
if (error) throw error; |
|
|
|
const yearlyReturns = { |
|
endYear: data[0].year, |
|
startYear: data[data.length - 1].year, |
|
data: data.reverse().map(d => d.rate) |
|
}; |
|
|
|
const cagrs = periodsCagrs(yearlyReturns); |
|
|
|
setClusterScaleDomain(cagrs); |
|
|
|
const rows = chart.selectAll('g.row') |
|
.data(cagrs) |
|
.enter() |
|
.append('g') |
|
.classed('row', true) |
|
.attr('transform', (d, i) => `translate(0,${i * cellSize})`); |
|
|
|
const cells = rows.selectAll('rect.cell') |
|
.data(d => d) |
|
.enter() |
|
.append('rect') |
|
.classed('cell', true) |
|
.attr('width', cellSize) |
|
.attr('height', cellSize) |
|
.attr('x', (d, i) => i * cellSize) |
|
.attr('fill', d => colorScale(d)); |
|
|
|
cells.selectAll('text') |
|
.data(d => [d]) |
|
.enter() |
|
.append('text') |
|
.text(percentFormat); |
|
|
|
cells.selectAll('title') |
|
.data(d => [d]) |
|
.enter() |
|
.append('title') |
|
.text(percentFormat); |
|
|
|
chart.select('#legend') |
|
.call(legend); |
|
}); |
|
|
|
function clusterScale() { |
|
// Chroma.js color scale helper, with the extreme and middle colors of portfoliocharts |
|
// https://gka.github.io/palettes/#colors=#C0504D,#F2F2F2,#31869B|steps=7|bez=0|coL=0 |
|
// Add different colour at each end for extremes. Lightness gradient is maintained. |
|
return d3.scaleThreshold() |
|
.range(['black', '#c0504d', '#d78780', '#e8bcb8', '#f2f2f2', '#b6cdd4', '#79a9b7', '#31869b', '#1d6232']); |
|
} |
|
|
|
function setClusterScaleDomain(cagrs) { |
|
const absNeutralChange = 0.03; |
|
const rangeLength = colorScale.range().length; |
|
|
|
const values = cagrs.reduce((memo, curr) => [...memo, ...curr], []); |
|
const downValues = values.filter(v => v < -absNeutralChange); |
|
const upValues = values.filter(v => v > absNeutralChange); |
|
|
|
const directionClusterCount = (rangeLength - 1) / 2; |
|
const downClusters = ss.ckmeans(downValues, directionClusterCount); |
|
const upClusters = ss.ckmeans(upValues, directionClusterCount); |
|
|
|
const domain = [ |
|
// omit first, because threshold scale uses uses first colour for values less than domain[0] |
|
...downClusters.slice(1).map(cluster => cluster[0]), |
|
|
|
// precise neutral band around the middle |
|
-absNeutralChange, |
|
absNeutralChange, |
|
|
|
// omit first, because we're forcing in a precise 0.03 for the end of the middle |
|
...upClusters.slice(1).map(cluster => cluster[0]) |
|
]; |
|
|
|
if (domain.length !== rangeLength - 1) throw new Error('wrong domain length for range'); |
|
|
|
console.log(domain); |
|
|
|
colorScale.domain(domain); |
|
} |
|
|
|
function legendThresholdLabels({ i, genLength, generatedLabels }) { |
|
const label = generatedLabels[i]; |
|
|
|
// workaround for https://github.com/susielu/d3-legend/issues/77 |
|
const formattedUndefined = legend.labelFormat()(undefined); |
|
|
|
if (i === 0) { |
|
return label.replace(formattedUndefined + ' to', 'Less than'); |
|
} else if (i === genLength - 1) { |
|
// workaround for https://github.com/susielu/d3-legend/issues/78 |
|
return label.replace('to ' + formattedUndefined, 'or more'); |
|
} else { |
|
return label; |
|
} |
|
} |
|
|
|
function periodsCagrs(yearlyReturns) { |
|
const cagrs = []; |
|
|
|
for (let startYear = yearlyReturns.startYear; startYear <= yearlyReturns.endYear; startYear++) { |
|
const periodCagrs = []; |
|
cagrs.push(periodCagrs); |
|
|
|
for (let endYear = startYear; endYear <= yearlyReturns.endYear; endYear++) { |
|
periodCagrs.push(periodCagr(yearlyReturns, startYear, endYear)); |
|
} |
|
} |
|
|
|
return cagrs; |
|
} |
|
|
|
function periodCagr(yearlyReturns, startYearInclusive, endYearInclusive) { |
|
if (endYearInclusive < startYearInclusive) throw new Error('end < start'); |
|
if (startYearInclusive < yearlyReturns.startYear) throw new Error('start too far back'); |
|
if (endYearInclusive > yearlyReturns.endYear) throw new Error('end too far forward'); |
|
|
|
const years = endYearInclusive - startYearInclusive + 1; |
|
const fromIndex = startYearInclusive - yearlyReturns.startYear; |
|
const yearsReturns = yearlyReturns.data.slice(fromIndex, fromIndex + years); |
|
|
|
return cagr(yearsReturns); |
|
} |
|
|
|
function cagr(simpleReturns) { |
|
/* |
|
* Calculate using log returns, which are better, and aggregate across time. |
|
* |
|
* Inspiration: http://www.moneychimp.com/features/market_cagr.htm |
|
* Reasoning: http://www.dcfnerds.com/94/arithmetic-vs-logarithmic-rates-of-return/ |
|
* Method: http://www.portfolioprobe.com/2010/10/04/a-tale-of-two-returns/ (see: Transmutation, Aggregation) |
|
*/ |
|
const logReturns = simpleReturns.map(rate => Math.log(rate + 1)); |
|
|
|
const avgLogReturn = sum(logReturns) / logReturns.length; |
|
const avgSimpleReturn = Math.exp(avgLogReturn) - 1; |
|
|
|
return avgSimpleReturn; |
|
} |
|
|
|
function sum(values) { |
|
return values.reduce((a, b) => a + b, 0); |
|
} |
|
|
|
function row(d) { |
|
return { |
|
year: Number(d.Year), |
|
rate: Number(d['Total Return']) / 100 |
|
}; |
|
} |
|
|
|
})(); |