Published
Edited
May 25, 2021
Importers
4 stars
Insert cell
Insert cell
Insert cell
Insert cell
domain = [
new Date(Date.UTC(2021, 0, 1)),
new Date(Date.UTC(2022, 0, 1))
]
Insert cell
Insert cell
vc = new CashFlow([
{date: new Date(Date.UTC(2021, 2, 1)), value: 2e6},
{date: new Date(Date.UTC(2021, 11, 15)), value: 2.5e6},
], {label: "VC", domain})
Insert cell
vc.plotDiff()
Insert cell
mrr = new CashFlow((_, i) => 3000 * Math.exp(i / 20), {label: "MRR", domain})
Insert cell
mrr.plotDiff()
Insert cell
CashFlow.sum([mrr, vc]).plot()
Insert cell
Insert cell
rent = new CashFlow(-15000, {label: "Rent", domain})
Insert cell
rent.plotDiff()
Insert cell
payroll = new CashFlow((_, i) => -9000 * (i * 2 + 10), {label: "Payroll", domain})
Insert cell
payroll.plotDiff()
Insert cell
Insert cell
total = CashFlow.sum([vc, mrr, rent, payroll])
Insert cell
total.plotCum()
Insert cell
Insert cell
// A CashFlow is a UTC month-aligned array of {date, value} pairs with some helper functions
// TODO fromCum: initialize from cumulative instead of differences
// TODO truncate: choose whether to ignore out-of-bounds values or roll them up
// TODO upper end of domain inclusive or exclusive?
// TODO settable date/value accessors

class CashFlow {
constructor(data, {label = "", domain = [], truncate = false /*todo*/, fromCum = false /*todo*/} = {}) {
this.label = label;
const getDate = d => d.date;
const getValue = d => d.value;
if(!domain.length && Array.isArray(data)) domain = [
d3.min(data, getDate),
d3.utcMonth.offset(d3.max(data, getDate), 1)
]
this.domain = domain.map(d3.utcMonth.floor);
this.index = d3.utcMonth.range(...this.domain);
// convert data to plain array
if (typeof data === "function") {
// f(date) -> monthly flow
this.diff = this.index
.map((date, i) => ({date, value: data(date, i)}));
} else if (Array.isArray(data)) {
// array of {date, value}
const rollup = d3.rollup(
data,
arr => d3.sum(arr, getValue),
({date}) => d3.utcMonth.floor(date)
);
this.diff = this.index
.map(date => ({date, value: rollup.get(date) || 0}));
} else if (typeof data === "number") {
// constant monthly flow
this.diff = this.index.map(date => ({date, value: data}));
} else {
throw new Error(`Invalid flow data. Please provide one of the following:
- a number (constant monthly flow)
- an array of {date, value} objects
- a function that takes a month and returns a {date, value} object`);
}
// constructor expects series of differences;
// this rolls them up into cumulative sum
this.cum = Array.from(d3.cumsum(this.diff, getValue),
(value, i) => ({date: this.index[i], value}));

}
static sum(flows) {
const data = Array.from(
d3.rollup(
flows.flatMap(d => [...d]),
values => ({
date: values[0].date,
value: d3.sum(values, d => d.value)
}),
d => +d.date
).values());
return new CashFlow(data, {
label: flows.map(flow => flow.label).join(" + ")
});
}
static plotFlow(data, label, options = {}) {
return Plot.plot({
height: 200,
x: {tickFormat: dateFormat, label: null},
y: {tickFormat: d3.format("$"), label, transform: d => d / 1000},
color: {
type: "diverging",
scheme: "RdBu",
domain: [-1e-6, 1e-6]
},
marks: [
Plot.barY(data, {x: "date", y: "value", fill: "value"}),
Plot.ruleY([0])
],
...options
})
}
plot(options) {
return this.plotDiff(options)
}
plotCum(options) {
return CashFlow.plotFlow(this.cum, `Cumulative ${this.label || "flow"} (k)`, options)
}
plotDiff(options) {
return CashFlow.plotFlow(this.diff, `${this.label || "Flow"} / mo. (k)`, options)
}
npv(rate) {
return xnpv(rate, this.diff.map(d => d.value), this.diff.map(d => d.date))
}
[Symbol.iterator]() {
return this.diff[Symbol.iterator]()
};
}
Insert cell
Insert cell
dateFormat = d => d.getUTCMonth() ? d3.utcFormat("%b")(d) : d3.utcFormat("%Y")(d)
Insert cell
longMonth = d3.utcFormat("%B %Y")
Insert cell
// If you open a CSV with dates formatted like 2020-03-04, Excel will reformat it as 3/4/20
parseExcelDate = d3.utcParse("%m/%d/%y")
Insert cell
// https://support.microsoft.com/en-us/office/xnpv-function-1b42bbf6-370f-4532-a0eb-d67c16b664b7
// https://gist.github.com/ghalimi/4670010
xnpv = (rate, values, dates) =>
d3.sum(
values.map(
(value, i) =>
value / (1 + rate) ** (d3.timeDay.count(dates[0], dates[i]) / 365)
)
)
Insert cell
autoTypeExcel = row => {
row = d3.autoType(row);
for (const key in row) {
if (typeof row[key] === "string") {
const date = parseExcelDate(row[key].trim());
if (date) row[key] = date;
}
}
return row;
}
Insert cell
rollupByMonth = arr => [...d3.rollup(
arr.flat(Infinity),
arr => ({date: d3.utcMonth.floor(arr[0].date), value: d3.sum(arr, d => d.value)}),
d => d3.utcMonth.floor(d.date)
).values()].sort((a, b) => d3.ascending(a.date, b.date))
Insert cell
zip = obj => [...d3.rollup(
Object.entries(obj)
.flatMap(([name, arr]) => arr.map(({date, value}) => ({date, [name]: value}))),
arr => (Object.assign({}, ...arr)),
d => d.date
).values()]
Insert cell
cumsum = arr => Array.from(
d3.cumsum(arr, d => d.value),
(value, i) => ({date: arr[i].date, value})
)
Insert cell
zero = arr => arr.find(({value}) => value <= 0)?.date
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more