Skip to content

Instantly share code, notes, and snippets.

@robatwilliams
Last active January 18, 2018 21:13
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save robatwilliams/d95b506b767398151bfb0af49f9def6e to your computer and use it in GitHub Desktop.
Save robatwilliams/d95b506b767398151bfb0af49f9def6e to your computer and use it in GitHub Desktop.
Compound annual growth rates (dynamic scale)

Displays investment compound annual growth rates over all time frames. Rows represent start years from 1986, columns represent number of years the investment was held for.

Dynamic scale using ckmeans data clustering algorithm. This assigns the colours to groups with minimal differences in their values. A dynamic scale makes the visualisation usable on a wide variety of data, but makes it difficult to compare different sets of data (the scales would be different). In this implementation, it also results in a non-nice scale.

Inspired by the Portfolio Charts website's heat map - calculator, explanation.

FTSE All-Share Index yearly return data from http://www.swanlowpark.co.uk/ftseannual.jsp

References for CAGR calculation in code comments.

Year Total Return
2017 13.1
2016 16.75
2015 0.98
2014 1.18
2013 20.81
2012 12.3
2011 -3.46
2010 14.51
2009 30.12
2008 -29.93
2007 5.32
2006 16.75
2005 22.04
2004 12.84
2003 20.86
2002 -22.7
2001 -13.3
2000 -5.9
1999 24.2
1998 13.8
1997 23.4
1996 16.7
1995 23.9
1994 -5.8
1993 28.4
1992 20.5
1991 20.8
1990 -9.7
1989 36.1
1988 11.5
1987 8.4
1986 27.2
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Returns</title>
<link rel="stylesheet" href="style.css" />
<script defer src="https://unpkg.com/d3@4.12.2/build/d3.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/d3-legend/2.25.4/d3-legend.js"></script>
<script defer src="https://unpkg.com/simple-statistics@5.2.1"></script>
</head>
<body>
<svg id="chart" width="500" height="500">
<g id="legend" transform="translate(250,300)"></g>
</svg>
<script defer src="index.js"></script>
</body>
</html>
(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
};
}
})();
body {
margin: 0;
font-family: sans-serif;
}
.cell {
stroke: black;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment