Skip to content

Instantly share code, notes, and snippets.

@robatwilliams
Last active January 17, 2018 22:17
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/88ee94f7dabeef2d3aabd6b511b49508 to your computer and use it in GitHub Desktop.
Save robatwilliams/88ee94f7dabeef2d3aabd6b511b49508 to your computer and use it in GitHub Desktop.
Compound annual growth rates

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.

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>
</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 = d3.scaleLinear().range(['red', 'white', 'green']);
//const colorScale = portfolioChartsThresholdScale();
const colorScale = myThresholdScale();
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);
// setScaleDomainFromData(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', colorScale);
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 myThresholdScale() {
// 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
// http://colorbrewer2.org
// 7 original colours + different one at each end. Lightness gradient is maintained.
// not symmetric because positives have a greater range
return d3.scaleThreshold()
.range(['black', '#c0504d', '#d78780', '#e8bcb8', '#f2f2f2', '#b6cdd4', '#79a9b7', '#31869b', '#1d6232'])
.domain([-0.12, -0.09, -0.06, -0.03, 0.03, 0.06, 0.09, 0.15]); // must have one less element than the range
}
function portfolioChartsThresholdScale() {
// Not symmetric, which may be misleading.
// Out-of-domain values don't look any different, so big gains/drops aren't noticeable.
// Returns between -3 and +3 show as zero, this is probably ok given long period.
// Values around thresholds which are meaningfully the same can be coloured differently.
// Values around thresholds can be in bucket that doesn't match the value when formatted to 1dp.
// Colorbrewer recommends 5-7 classes, or up to 9 when similar colours next to each other, making them
// easier to distinguish.
return d3.scaleThreshold()
.range(['#C0504D', '#E38B8B', '#F2F2F2', '#B7DEE8', '#6EBBD0', '#31869B'])
.domain([-0.06, -0.03, 0.03, 0.06, 0.09]); // must have one less element than the range
}
function setScaleDomainFromData(cagrs) {
// Not symmetric.
// Lots of light green
// Not easy to look at a point and think _how_ good a period it was.
const minCagr = d3.min(cagrs, d => d3.min(d));
const maxCagr = d3.max(cagrs, d => d3.max(d));
colorScale.domain([minCagr, 0, maxCagr]);
}
function legendThresholdLabels({ i, genLength, generatedLabels }) {
// workaround for https://github.com/susielu/d3-legend/issues/77
const formattedNaN = percentFormat(NaN);
if (i === 0) {
return generatedLabels[i].replace(formattedNaN + ' to', 'Less than');
} else if (i === genLength - 1) {
return `More than ${generatedLabels[genLength - 1].replace(' to ' + formattedNaN, '')}`;
}
return generatedLabels[i];
}
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