website: snapshot

This commit is contained in:
nym21
2026-02-02 13:51:50 +01:00
parent da923e409a
commit cf4bc470e4
10 changed files with 451 additions and 741 deletions
+229 -498
View File
@@ -1,11 +1,72 @@
/** Investing section - Investment strategy tools and analysis */
import { Unit } from "../utils/units.js";
import { priceLine } from "./constants.js";
import { line, baseline, price, dotted } from "./series.js";
import { satsBtcUsd } from "./shared.js";
import { periodIdToName } from "./utils.js";
const SHORT_PERIODS = /** @type {const} */ (["_1w", "_1m", "_3m", "_6m", "_1y"]);
const LONG_PERIODS = /** @type {const} */ (["_2y", "_3y", "_4y", "_5y", "_6y", "_8y", "_10y"]);
/** @typedef {typeof SHORT_PERIODS[number]} ShortPeriodKey */
/** @typedef {typeof LONG_PERIODS[number]} LongPeriodKey */
/** @typedef {ShortPeriodKey | LongPeriodKey} AllPeriodKey */
/**
* Add CAGR to a base entry item
* @param {BaseEntryItem} entry
* @param {AnyMetricPattern} cagr
* @returns {LongEntryItem}
*/
const withCagr = (entry, cagr) => ({ ...entry, cagr });
const YEARS_2020S = /** @type {const} */ ([2026, 2025, 2024, 2023, 2022, 2021, 2020]);
const YEARS_2010S = /** @type {const} */ ([2019, 2018, 2017, 2016, 2015]);
/** @param {AllPeriodKey} key */
const periodName = (key) => periodIdToName(key.slice(1), true);
/**
* Base entry item for compare and single-entry charts
* @typedef {Object} BaseEntryItem
* @property {string} name - Display name
* @property {Color} color - Item color
* @property {AnyPricePattern} costBasis - Cost basis metric
* @property {AnyMetricPattern} returns - Returns metric
* @property {AnyMetricPattern} minReturn - Min return metric
* @property {AnyMetricPattern} maxReturn - Max return metric
* @property {AnyMetricPattern} daysInProfit - Days in profit metric
* @property {AnyMetricPattern} daysInLoss - Days in loss metric
* @property {AnyValuePattern} stack - Stack pattern
*/
/**
* Long-term entry item with CAGR
* @typedef {BaseEntryItem & { cagr: AnyMetricPattern }} LongEntryItem
*/
/**
* Build DCA class entry from year
* @param {Colors} colors
* @param {MarketDca} dca
* @param {number} year
* @returns {BaseEntryItem}
*/
function buildYearEntry(colors, dca, year) {
const key = /** @type {keyof Colors["dcaYears"]} */ (`_${year}`);
return {
name: `${year}`,
color: colors.dcaYears[key],
costBasis: dca.classAveragePrice[key],
returns: dca.classReturns[key],
minReturn: dca.classMinReturn[key],
maxReturn: dca.classMaxReturn[key],
daysInProfit: dca.classDaysInProfit[key],
daysInLoss: dca.classDaysInLoss[key],
stack: dca.classStack[key],
};
}
/**
* Create Investing section
* @param {PartialContext} ctx
@@ -20,99 +81,17 @@ export function createInvestingSection(ctx) {
name: "Investing",
tree: [
createDcaVsLumpSumSection(ctx, { dca, lookback, returns }),
createDcaByPeriodSection(ctx, { dca }),
createLumpSumByPeriodSection(ctx, { dca, lookback }),
createDcaByPeriodSection(ctx, { dca, returns }),
createLumpSumByPeriodSection(ctx, { dca, lookback, returns }),
createDcaByStartYearSection(ctx, { dca }),
],
};
}
/** Period configuration by term group */
const PERIODS = {
short: [
{ id: "1w", key: /** @type {const} */ ("_1w") },
{ id: "1m", key: /** @type {const} */ ("_1m") },
{ id: "3m", key: /** @type {const} */ ("_3m") },
{ id: "6m", key: /** @type {const} */ ("_6m") },
],
medium: [
{ id: "1y", key: /** @type {const} */ ("_1y") },
{ id: "2y", key: /** @type {const} */ ("_2y") },
{ id: "3y", key: /** @type {const} */ ("_3y") },
],
long: [
{ id: "4y", key: /** @type {const} */ ("_4y") },
{ id: "5y", key: /** @type {const} */ ("_5y") },
{ id: "6y", key: /** @type {const} */ ("_6y") },
{ id: "8y", key: /** @type {const} */ ("_8y") },
{ id: "10y", key: /** @type {const} */ ("_10y") },
],
};
const ALL_PERIODS = [...PERIODS.short, ...PERIODS.medium, ...PERIODS.long];
/** DCA year classes by decade */
const YEAR_GROUPS = {
_2020s: /** @type {const} */ ([2026, 2025, 2024, 2023, 2022, 2021, 2020]),
_2010s: /** @type {const} */ ([2019, 2018, 2017, 2016, 2015]),
};
const ALL_YEARS = [...YEAR_GROUPS._2020s, ...YEAR_GROUPS._2010s];
/** @typedef {ReturnType<typeof buildYearClass>} YearClass */
/**
* Build DCA class data from year
* @param {Colors} colors
* @param {MarketDca} dca
* @param {number} year
*/
function buildYearClass(colors, dca, year) {
const key = /** @type {keyof Colors["dcaYears"]} */ (`_${year}`);
return {
year,
color: colors.dcaYears[key],
costBasis: dca.classAveragePrice[key],
returns: dca.classReturns[key],
stack: dca.classStack[key],
daysInProfit: dca.classDaysInProfit[key],
daysInLoss: dca.classDaysInLoss[key],
minReturn: dca.classMinReturn[key],
maxReturn: dca.classMaxReturn[key],
};
}
/**
* Pattern for creating a single entry (period or year)
* @typedef {Object} SingleEntryPattern
* @property {string} name - Display name
* @property {string} [titlePrefix] - Prefix for chart titles (defaults to name)
* @property {Color} color - Primary color
* @property {AnyPricePattern} costBasis - Cost basis metric
* @property {AnyMetricPattern} returns - Returns metric
* @property {AnyMetricPattern} minReturn - Min return metric
* @property {AnyMetricPattern} maxReturn - Max return metric
* @property {AnyMetricPattern} daysInProfit - Days in profit metric
* @property {AnyMetricPattern} daysInLoss - Days in loss metric
* @property {AnyValuePattern} stack - Stack pattern
*/
/**
* Item for compare charts
* @typedef {Object} CompareItem
* @property {string} name - Display name
* @property {Color} color - Item color
* @property {AnyPricePattern} costBasis - Cost basis metric
* @property {AnyMetricPattern} returns - Returns metric
* @property {AnyMetricPattern} daysInProfit - Days in profit metric
* @property {AnyMetricPattern} daysInLoss - Days in loss metric
* @property {AnyValuePattern} stack - Stack pattern
*/
/**
* Create profitability folder for compare charts
* @param {string} context
* @param {CompareItem[]} items
* @param {Pick<BaseEntryItem, 'name' | 'color' | 'costBasis' | 'daysInProfit' | 'daysInLoss'>[]} items
*/
function createProfitabilityFolder(context, items) {
const top = items.map(({ name, color, costBasis }) =>
@@ -144,7 +123,7 @@ function createProfitabilityFolder(context, items) {
/**
* Create compare folder from items
* @param {string} context
* @param {CompareItem[]} items
* @param {Pick<BaseEntryItem, 'name' | 'color' | 'costBasis' | 'returns' | 'daysInProfit' | 'daysInLoss' | 'stack'>[]} items
*/
function createCompareFolder(context, items) {
const topPane = items.map(({ name, color, costBasis }) =>
@@ -163,12 +142,7 @@ function createCompareFolder(context, items) {
title: `Returns: ${context}`,
top: topPane,
bottom: items.map(({ name, color, returns }) =>
baseline({
metric: returns,
name,
color: [color, color],
unit: Unit.percentage,
}),
baseline({ metric: returns, name, color: [color, color], unit: Unit.percentage }),
),
},
createProfitabilityFolder(context, items),
@@ -185,79 +159,62 @@ function createCompareFolder(context, items) {
}
/**
* Create a single entry from a pattern
* Create single entry tree structure
* @param {Colors} colors
* @param {SingleEntryPattern} pattern
* @param {BaseEntryItem & { titlePrefix?: string }} item
* @param {object[]} returnsBottom - Bottom pane items for returns chart
*/
function createSingleEntry(colors, pattern) {
const {
name,
titlePrefix = name,
color,
costBasis,
returns,
minReturn,
maxReturn,
daysInProfit,
daysInLoss,
stack,
} = pattern;
function createSingleEntryTree(colors, item, returnsBottom) {
const { name, titlePrefix = name, color, costBasis, daysInProfit, daysInLoss, stack } = item;
const top = [price({ metric: costBasis, name: "Cost Basis", color })];
return {
name,
tree: [
{ name: "Cost Basis", title: `Cost Basis: ${titlePrefix}`, top },
{
name: "Returns",
title: `Returns: ${titlePrefix}`,
top,
bottom: [
baseline({ metric: returns, name: "Current", unit: Unit.percentage }),
dotted({
metric: maxReturn,
name: "Max",
color: colors.green,
unit: Unit.percentage,
defaultActive: false,
}),
dotted({
metric: minReturn,
name: "Min",
color: colors.red,
unit: Unit.percentage,
defaultActive: false,
}),
],
},
{ name: "Returns", title: `Returns: ${titlePrefix}`, top, bottom: returnsBottom },
{
name: "Profitability",
title: `Profitability: ${titlePrefix}`,
top,
bottom: [
line({
metric: daysInProfit,
name: "Days in Profit",
color: colors.green,
unit: Unit.days,
}),
line({
metric: daysInLoss,
name: "Days in Loss",
color: colors.red,
unit: Unit.days,
}),
line({ metric: daysInProfit, name: "Days in Profit", color: colors.green, unit: Unit.days }),
line({ metric: daysInLoss, name: "Days in Loss", color: colors.red, unit: Unit.days }),
],
},
{
name: "Accumulated",
title: `Accumulated Value: ${titlePrefix}`,
top,
bottom: satsBtcUsd({ pattern: stack, name: "Value" }),
},
{ name: "Accumulated", title: `Accumulated Value: ${titlePrefix}`, top, bottom: satsBtcUsd({ pattern: stack, name: "Value" }) },
],
};
}
/**
* Create a single entry from a base item (no CAGR)
* @param {Colors} colors
* @param {BaseEntryItem & { titlePrefix?: string }} item
*/
function createShortSingleEntry(colors, item) {
const { returns, minReturn, maxReturn } = item;
return createSingleEntryTree(colors, item, [
baseline({ metric: returns, name: "Current", unit: Unit.percentage }),
dotted({ metric: maxReturn, name: "Max", color: colors.green, unit: Unit.percentage, defaultActive: false }),
dotted({ metric: minReturn, name: "Min", color: colors.red, unit: Unit.percentage, defaultActive: false }),
]);
}
/**
* Create a single entry from a long item (with CAGR)
* @param {Colors} colors
* @param {LongEntryItem & { titlePrefix?: string }} item
*/
function createLongSingleEntry(colors, item) {
const { returns, minReturn, maxReturn, cagr } = item;
return createSingleEntryTree(colors, item, [
baseline({ metric: returns, name: "Current", unit: Unit.percentage }),
baseline({ metric: cagr, name: "CAGR", unit: Unit.cagr }),
dotted({ metric: maxReturn, name: "Max", color: colors.green, unit: Unit.percentage, defaultActive: false }),
dotted({ metric: minReturn, name: "Min", color: colors.red, unit: Unit.percentage, defaultActive: false }),
]);
}
/**
* Create DCA vs Lump Sum section
* @param {PartialContext} ctx
@@ -269,14 +226,9 @@ function createSingleEntry(colors, pattern) {
export function createDcaVsLumpSumSection(ctx, { dca, lookback, returns }) {
const { colors } = ctx;
// Chart builders
/** @param {AllPeriodKey} key */
const topPane = (key) => [
price({
metric: dca.periodAveragePrice[key],
name: "DCA",
color: colors.green,
}),
price({ metric: dca.periodAveragePrice[key], name: "DCA", color: colors.green }),
price({ metric: lookback[key], name: "Lump Sum", color: colors.orange }),
];
@@ -288,7 +240,29 @@ export function createDcaVsLumpSumSection(ctx, { dca, lookback, returns }) {
});
/** @param {string} name @param {AllPeriodKey} key */
const returnsFolder = (name, key) => ({
const returnsMinMax = (name, key) => [
{
name: "Max",
title: `Max Return: ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
baseline({ metric: dca.periodMaxReturn[key], name: "DCA", unit: Unit.percentage }),
baseline({ metric: dca.periodLumpSumMaxReturn[key], name: "Lump Sum", color: [colors.cyan, colors.orange], unit: Unit.percentage }),
],
},
{
name: "Min",
title: `Min Return: ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
baseline({ metric: dca.periodMinReturn[key], name: "DCA", unit: Unit.percentage }),
baseline({ metric: dca.periodLumpSumMinReturn[key], name: "Lump Sum", color: [colors.cyan, colors.orange], unit: Unit.percentage }),
],
},
];
/** @param {string} name @param {ShortPeriodKey} key */
const shortReturnsFolder = (name, key) => ({
name: "Returns",
tree: [
{
@@ -296,60 +270,16 @@ export function createDcaVsLumpSumSection(ctx, { dca, lookback, returns }) {
title: `Returns: ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
baseline({
metric: dca.periodReturns[key],
name: "DCA",
unit: Unit.percentage,
}),
baseline({
metric: dca.periodLumpSumReturns[key],
name: "Lump Sum",
color: [colors.cyan, colors.orange],
unit: Unit.percentage,
}),
],
},
{
name: "Max",
title: `Max Return: ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
baseline({
metric: dca.periodMaxReturn[key],
name: "DCA",
unit: Unit.percentage,
}),
baseline({
metric: dca.periodLumpSumMaxReturn[key],
name: "Lump Sum",
color: [colors.cyan, colors.orange],
unit: Unit.percentage,
}),
],
},
{
name: "Min",
title: `Min Return: ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
baseline({
metric: dca.periodMinReturn[key],
name: "DCA",
unit: Unit.percentage,
}),
baseline({
metric: dca.periodLumpSumMinReturn[key],
name: "Lump Sum",
color: [colors.cyan, colors.orange],
unit: Unit.percentage,
}),
baseline({ metric: dca.periodReturns[key], name: "DCA", unit: Unit.percentage }),
baseline({ metric: dca.periodLumpSumReturns[key], name: "Lump Sum", color: [colors.cyan, colors.orange], unit: Unit.percentage }),
],
},
...returnsMinMax(name, key),
],
});
/** @param {string} name @param {LongPeriodKey} key */
const returnsFolderWithCagr = (name, key) => ({
const longReturnsFolder = (name, key) => ({
name: "Returns",
tree: [
{
@@ -357,72 +287,13 @@ export function createDcaVsLumpSumSection(ctx, { dca, lookback, returns }) {
title: `Returns: ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
baseline({
metric: dca.periodReturns[key],
name: "DCA",
unit: Unit.percentage,
}),
baseline({
metric: dca.periodLumpSumReturns[key],
name: "Lump Sum",
color: [colors.cyan, colors.orange],
unit: Unit.percentage,
}),
line({
metric: dca.periodCagr[key],
name: "DCA CAGR",
color: colors.purple,
unit: Unit.percentage,
defaultActive: false,
}),
line({
metric: returns.cagr[key],
name: "Lump Sum CAGR",
color: colors.indigo,
unit: Unit.percentage,
defaultActive: false,
}),
priceLine({ ctx, unit: Unit.percentage }),
],
},
{
name: "Max",
title: `Max Return: ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
line({
metric: dca.periodMaxReturn[key],
name: "DCA",
color: colors.green,
unit: Unit.percentage,
}),
line({
metric: dca.periodLumpSumMaxReturn[key],
name: "Lump Sum",
color: colors.orange,
unit: Unit.percentage,
}),
],
},
{
name: "Min",
title: `Min Return: ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
line({
metric: dca.periodMinReturn[key],
name: "DCA",
color: colors.green,
unit: Unit.percentage,
}),
line({
metric: dca.periodLumpSumMinReturn[key],
name: "Lump Sum",
color: colors.orange,
unit: Unit.percentage,
}),
baseline({ metric: dca.periodReturns[key], name: "DCA", unit: Unit.percentage }),
baseline({ metric: dca.periodLumpSumReturns[key], name: "Lump Sum", color: [colors.cyan, colors.orange], unit: Unit.percentage }),
baseline({ metric: dca.periodCagr[key], name: "DCA CAGR", unit: Unit.cagr }),
baseline({ metric: returns.cagr[key], name: "Lump Sum CAGR", color: [colors.cyan, colors.orange], unit: Unit.cagr }),
],
},
...returnsMinMax(name, key),
],
});
@@ -435,18 +306,8 @@ export function createDcaVsLumpSumSection(ctx, { dca, lookback, returns }) {
title: `Days in Profit: ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
line({
metric: dca.periodDaysInProfit[key],
name: "DCA",
color: colors.green,
unit: Unit.days,
}),
line({
metric: dca.periodLumpSumDaysInProfit[key],
name: "Lump Sum",
color: colors.orange,
unit: Unit.days,
}),
line({ metric: dca.periodDaysInProfit[key], name: "DCA", color: colors.green, unit: Unit.days }),
line({ metric: dca.periodLumpSumDaysInProfit[key], name: "Lump Sum", color: colors.orange, unit: Unit.days }),
],
},
{
@@ -454,18 +315,8 @@ export function createDcaVsLumpSumSection(ctx, { dca, lookback, returns }) {
title: `Days in Loss: ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
line({
metric: dca.periodDaysInLoss[key],
name: "DCA",
color: colors.green,
unit: Unit.days,
}),
line({
metric: dca.periodLumpSumDaysInLoss[key],
name: "Lump Sum",
color: colors.orange,
unit: Unit.days,
}),
line({ metric: dca.periodDaysInLoss[key], name: "DCA", color: colors.green, unit: Unit.days }),
line({ metric: dca.periodLumpSumDaysInLoss[key], name: "Lump Sum", color: colors.orange, unit: Unit.days }),
],
},
],
@@ -474,210 +325,125 @@ export function createDcaVsLumpSumSection(ctx, { dca, lookback, returns }) {
/** @param {string} name @param {AllPeriodKey} key */
const stackChart = (name, key) => ({
name: "Accumulated",
title: `Accumulated Value: ${name} DCA vs Lump Sum`,
title: `Accumulated Value ($100/day): ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
...satsBtcUsd({
pattern: dca.periodStack[key],
name: "DCA",
color: colors.green,
}),
...satsBtcUsd({
pattern: dca.periodLumpSumStack[key],
name: "Lump Sum",
color: colors.orange,
}),
...satsBtcUsd({ pattern: dca.periodStack[key], name: "DCA", color: colors.green }),
...satsBtcUsd({ pattern: dca.periodLumpSumStack[key], name: "Lump Sum", color: colors.orange }),
],
});
/**
* Check if a period key has CAGR data
* @param {AllPeriodKey} key
* @returns {key is LongPeriodKey}
*/
const hasCagr = (key) => key in dca.periodCagr;
/**
* Create individual period entry
* @param {{ id: string, key: AllPeriodKey }} period
*/
const createPeriodEntry = ({ id, key }) => {
const name = periodIdToName(id, true);
/** @param {ShortPeriodKey} key */
const createShortPeriodEntry = (key) => {
const name = periodName(key);
return {
name,
tree: [
costBasisChart(name, key),
hasCagr(key)
? returnsFolderWithCagr(name, key)
: returnsFolder(name, key),
profitabilityFolder(name, key),
stackChart(name, key),
],
tree: [costBasisChart(name, key), shortReturnsFolder(name, key), profitabilityFolder(name, key), stackChart(name, key)],
};
};
/**
* Create term group
* @param {string} name
* @param {string} title
* @param {{ id: string, key: AllPeriodKey }[]} periods
*/
const createTermGroup = (name, title, periods) => ({
name,
title,
tree: periods.map(createPeriodEntry),
});
/** @param {LongPeriodKey} key */
const createLongPeriodEntry = (key) => {
const name = periodName(key);
return {
name,
tree: [costBasisChart(name, key), longReturnsFolder(name, key), profitabilityFolder(name, key), stackChart(name, key)],
};
};
return {
name: "DCA vs Lump Sum",
title: "Compare Investment Strategies",
tree: [
createTermGroup("Short Term", "Under 1 Year", PERIODS.short),
createTermGroup("Medium Term", "1-3 Years", PERIODS.medium),
createTermGroup("Long Term", "4+ Years", PERIODS.long),
{ name: "Short Term", title: "Up to 1 Year", tree: SHORT_PERIODS.map(createShortPeriodEntry) },
{ name: "Long Term", title: "2+ Years", tree: LONG_PERIODS.map(createLongPeriodEntry) },
],
};
}
/**
* Create DCA by Period section (DCA only, no Lump Sum comparison)
* Create period-based section (DCA or Lump Sum)
* @param {PartialContext} ctx
* @param {Object} args
* @param {Market["dca"]} args.dca
* @param {Market["lookback"]} [args.lookback]
* @param {Market["returns"]} args.returns
*/
export function createDcaByPeriodSection(ctx, { dca }) {
function createPeriodSection(ctx, { dca, lookback, returns }) {
const { colors } = ctx;
const isLumpSum = !!lookback;
const suffix = isLumpSum ? "Lump Sum" : "DCA";
/**
* Create compare charts for a set of periods
* @param {string} context
* @param {{ id: string, key: AllPeriodKey }[]} periods
*/
const createCompare = (context, periods) =>
createCompareFolder(
context,
periods.map(({ id, key }) => ({
name: id,
color: colors.dcaPeriods[key],
costBasis: dca.periodAveragePrice[key],
returns: dca.periodReturns[key],
daysInProfit: dca.periodDaysInProfit[key],
daysInLoss: dca.periodDaysInLoss[key],
stack: dca.periodStack[key],
})),
);
/**
* Create individual period entry (DCA only)
* @param {{ id: string, key: AllPeriodKey }} period
*/
const createPeriodEntry = ({ id, key }) => {
const name = periodIdToName(id, true);
return createSingleEntry(colors, {
name,
titlePrefix: `${name} DCA`,
color: colors.dcaPeriods[key],
costBasis: dca.periodAveragePrice[key],
returns: dca.periodReturns[key],
maxReturn: dca.periodMaxReturn[key],
minReturn: dca.periodMinReturn[key],
daysInProfit: dca.periodDaysInProfit[key],
daysInLoss: dca.periodDaysInLoss[key],
stack: dca.periodStack[key],
});
};
/** @param {string} name @param {string} title @param {{ id: string, key: AllPeriodKey }[]} periods */
const createTermGroup = (name, title, periods) => ({
name,
title,
tree: [
createCompare(`${name} DCA`, periods),
...periods.map(createPeriodEntry),
],
/** @param {AllPeriodKey} key @returns {BaseEntryItem} */
const buildBaseEntry = (key) => ({
name: periodName(key),
color: colors.dcaPeriods[key],
costBasis: isLumpSum ? lookback[key] : dca.periodAveragePrice[key],
returns: isLumpSum ? dca.periodLumpSumReturns[key] : dca.periodReturns[key],
minReturn: isLumpSum ? dca.periodLumpSumMinReturn[key] : dca.periodMinReturn[key],
maxReturn: isLumpSum ? dca.periodLumpSumMaxReturn[key] : dca.periodMaxReturn[key],
daysInProfit: isLumpSum ? dca.periodLumpSumDaysInProfit[key] : dca.periodDaysInProfit[key],
daysInLoss: isLumpSum ? dca.periodLumpSumDaysInLoss[key] : dca.periodDaysInLoss[key],
stack: isLumpSum ? dca.periodLumpSumStack[key] : dca.periodStack[key],
});
/** @param {LongPeriodKey} key @returns {LongEntryItem} */
const buildLongEntry = (key) => withCagr(
buildBaseEntry(key),
isLumpSum ? returns.cagr[key] : dca.periodCagr[key],
);
/** @param {BaseEntryItem} entry */
const createShortEntry = (entry) =>
createShortSingleEntry(colors, { ...entry, titlePrefix: `${entry.name} ${suffix}` });
/** @param {LongEntryItem} entry */
const createLongEntry = (entry) =>
createLongSingleEntry(colors, { ...entry, titlePrefix: `${entry.name} ${suffix}` });
const shortEntries = SHORT_PERIODS.map(buildBaseEntry);
const longEntries = LONG_PERIODS.map(buildLongEntry);
return {
name: "DCA by Period",
title: "DCA Performance by Investment Period",
name: `${suffix} by Period`,
title: `${suffix} Performance by Investment Period`,
tree: [
createCompare("All Periods DCA", ALL_PERIODS),
createTermGroup("Short Term", "Under 1 Year", PERIODS.short),
createTermGroup("Medium Term", "1-3 Years", PERIODS.medium),
createTermGroup("Long Term", "4+ Years", PERIODS.long),
createCompareFolder(`All Periods ${suffix}`, [...shortEntries, ...longEntries]),
{
name: "Short Term",
title: "Up to 1 Year",
tree: [createCompareFolder(`Short Term ${suffix}`, shortEntries), ...shortEntries.map(createShortEntry)],
},
{
name: "Long Term",
title: "2+ Years",
tree: [createCompareFolder(`Long Term ${suffix}`, longEntries), ...longEntries.map(createLongEntry)],
},
],
};
}
/**
* Create DCA by Period section
* @param {PartialContext} ctx
* @param {Object} args
* @param {Market["dca"]} args.dca
* @param {Market["returns"]} args.returns
*/
export function createDcaByPeriodSection(ctx, { dca, returns }) {
return createPeriodSection(ctx, { dca, returns });
}
/**
* Create Lump Sum by Period section
* @param {PartialContext} ctx
* @param {Object} args
* @param {Market["dca"]} args.dca
* @param {Market["lookback"]} args.lookback
* @param {Market["returns"]} args.returns
*/
export function createLumpSumByPeriodSection(ctx, { dca, lookback }) {
const { colors } = ctx;
/**
* Create compare charts for a set of periods
* @param {string} context
* @param {{ id: string, key: AllPeriodKey }[]} periods
*/
const createCompare = (context, periods) =>
createCompareFolder(
context,
periods.map(({ id, key }) => ({
name: id,
color: colors.dcaPeriods[key],
costBasis: lookback[key],
returns: dca.periodLumpSumReturns[key],
daysInProfit: dca.periodLumpSumDaysInProfit[key],
daysInLoss: dca.periodLumpSumDaysInLoss[key],
stack: dca.periodLumpSumStack[key],
})),
);
/**
* Create individual period entry (Lump Sum only)
* @param {{ id: string, key: AllPeriodKey }} period
*/
const createPeriodEntry = ({ id, key }) => {
const name = periodIdToName(id, true);
return createSingleEntry(colors, {
name,
titlePrefix: `${name} Lump Sum`,
color: colors.dcaPeriods[key],
costBasis: lookback[key],
returns: dca.periodLumpSumReturns[key],
maxReturn: dca.periodLumpSumMaxReturn[key],
minReturn: dca.periodLumpSumMinReturn[key],
daysInProfit: dca.periodLumpSumDaysInProfit[key],
daysInLoss: dca.periodLumpSumDaysInLoss[key],
stack: dca.periodLumpSumStack[key],
});
};
/** @param {string} name @param {string} title @param {{ id: string, key: AllPeriodKey }[]} periods */
const createTermGroup = (name, title, periods) => ({
name,
title,
tree: [
createCompare(`${name} Lump Sum`, periods),
...periods.map(createPeriodEntry),
],
});
return {
name: "Lump Sum by Period",
title: "Lump Sum Performance by Investment Period",
tree: [
createCompare("All Periods Lump Sum", ALL_PERIODS),
createTermGroup("Short Term", "Under 1 Year", PERIODS.short),
createTermGroup("Medium Term", "1-3 Years", PERIODS.medium),
createTermGroup("Long Term", "4+ Years", PERIODS.long),
],
};
export function createLumpSumByPeriodSection(ctx, { dca, lookback, returns }) {
return createPeriodSection(ctx, { dca, lookback, returns });
}
/**
@@ -689,61 +455,26 @@ export function createLumpSumByPeriodSection(ctx, { dca, lookback }) {
export function createDcaByStartYearSection(ctx, { dca }) {
const { colors } = ctx;
/**
* Convert YearClass to CompareItem
* @param {YearClass} c
* @returns {CompareItem}
*/
const toCompareItem = (c) => ({
name: `${c.year}`,
color: c.color,
costBasis: c.costBasis,
returns: c.returns,
daysInProfit: c.daysInProfit,
daysInLoss: c.daysInLoss,
stack: c.stack,
});
/**
* Create individual year entry
* @param {YearClass} yearClass
*/
const createYearEntry = (yearClass) =>
createSingleEntry(colors, {
name: `${yearClass.year}`,
titlePrefix: `${yearClass.year} DCA`,
color: yearClass.color,
costBasis: yearClass.costBasis,
returns: yearClass.returns,
maxReturn: yearClass.maxReturn,
minReturn: yearClass.minReturn,
daysInProfit: yearClass.daysInProfit,
daysInLoss: yearClass.daysInLoss,
stack: yearClass.stack,
});
/** @param {string} name @param {string} title @param {YearClass[]} classes */
const createDecadeGroup = (name, title, classes) => ({
/** @param {string} name @param {string} title @param {BaseEntryItem[]} entries */
const createDecadeGroup = (name, title, entries) => ({
name,
title,
tree: [
createCompareFolder(`${name} DCA`, classes.map(toCompareItem)),
...classes.map(createYearEntry),
createCompareFolder(`${name} DCA`, entries),
...entries.map((entry) => createShortSingleEntry(colors, { ...entry, titlePrefix: `${entry.name} DCA` })),
],
});
// Build all classes once, then filter by decade
const allClasses = ALL_YEARS.map((year) => buildYearClass(colors, dca, year));
const classes2020s = allClasses.filter((c) => c.year >= 2020);
const classes2010s = allClasses.filter((c) => c.year < 2020);
const entries2020s = YEARS_2020S.map((year) => buildYearEntry(colors, dca, year));
const entries2010s = YEARS_2010S.map((year) => buildYearEntry(colors, dca, year));
return {
name: "DCA by Start Year",
title: "DCA Performance by When You Started",
tree: [
createCompareFolder("All Years DCA", allClasses.map(toCompareItem)),
createDecadeGroup("2020s", "2020-2026", classes2020s),
createDecadeGroup("2010s", "2015-2019", classes2010s),
createCompareFolder("All Years DCA", [...entries2020s, ...entries2010s]),
createDecadeGroup("2020s", "2020-2026", entries2020s),
createDecadeGroup("2010s", "2015-2019", entries2010s),
],
};
}