website: redesign part 1

This commit is contained in:
nym21
2026-06-03 12:34:05 +02:00
parent 5f5563fece
commit 90e8741fb7
209 changed files with 23945 additions and 176 deletions
-447
View File
@@ -1,447 +0,0 @@
import { colors } from "../utils/colors.js";
import { brk } from "../utils/client.js";
import { Unit } from "../utils/units.js";
import {
dots,
line,
price,
multiSeriesTree,
percentRatioDots,
sumsAndAveragesCumulative,
} from "./series.js";
import { satsBtcUsd, priceRatioPercentilesTree } from "./shared.js";
/**
* Create Cointime section
* @returns {PartialOptionsGroup}
*/
export function createCointimeSection() {
const { cointime, cohorts, supply } = brk.series;
const {
prices: cointimePrices,
cap,
activity,
supply: cointimeSupply,
adjusted,
reserveRisk,
value,
} = cointime;
const { all } = cohorts.utxo;
// Reference lines for cap comparisons
const capReferenceLines = /** @type {const} */ ([
{ series: supply.marketCap.usd, name: "Market", color: colors.default },
{
series: all.realized.cap.usd,
name: "Realized",
color: colors.realized,
},
]);
/** @type {readonly { pattern: PriceRatioPercentilesPattern, name: string, title: (name: string) => string, color: Color, defaultActive: boolean }[]} */
const prices = [
{
pattern: cointimePrices.trueMarketMean,
name: "True Market Mean",
title: (name) => name,
color: colors.trueMarketMean,
defaultActive: true,
},
{
pattern: cointimePrices.vaulted,
name: "Vaulted",
title: (name) => `${name} Price`,
color: colors.vaulted,
defaultActive: true,
},
{
pattern: cointimePrices.active,
name: "Active",
title: (name) => `${name} Price`,
color: colors.active,
defaultActive: true,
},
{
pattern: cointimePrices.cointime,
name: "Cointime",
title: (name) => `${name} Price`,
color: colors.cointime,
defaultActive: true,
},
];
const caps = /** @type {const} */ ([
{
series: cap.vaulted.usd,
name: "Vaulted",
color: colors.vaulted,
defaultActive: true,
},
{
series: cap.active.usd,
name: "Active",
color: colors.active,
defaultActive: true,
},
{
series: cap.cointime.usd,
name: "Cointime",
color: colors.cointime,
defaultActive: true,
},
{
series: cap.investor.usd,
name: "Investor",
color: colors.investor,
defaultActive: false,
},
{
series: cap.thermo.usd,
name: "Thermo",
color: colors.thermo,
defaultActive: false,
},
]);
const supplyBreakdown = /** @type {const} */ ([
{ pattern: all.supply.total, name: "Total", color: colors.bitcoin },
{
pattern: cointimeSupply.vaulted,
name: "Vaulted",
color: colors.vaulted,
},
{
pattern: cointimeSupply.active,
name: "Active",
color: colors.active,
},
]);
const coinblocks = /** @type {const} */ ([
{
pattern: activity.coinblocksDestroyed,
name: "Destroyed",
title: "Coinblocks Destroyed",
color: colors.destroyed,
},
{
pattern: activity.coinblocksCreated,
name: "Created",
title: "Coinblocks Created",
color: colors.created,
},
{
pattern: activity.coinblocksStored,
name: "Stored",
title: "Coinblocks Stored",
color: colors.stored,
},
]);
// Colors aligned with coinblocks: Destroyed=red, Created=orange, Stored=green
const cointimeValues = /** @type {const} */ ([
{
pattern: value.created,
name: "Created",
title: "Cointime Value Created",
color: colors.created,
},
{
pattern: value.destroyed,
name: "Destroyed",
title: "Cointime Value Destroyed",
color: colors.destroyed,
},
{
pattern: value.stored,
name: "Stored",
title: "Cointime Value Stored",
color: colors.stored,
},
]);
const vocdd = /** @type {const} */ ({
pattern: value.vocdd,
name: "VOCDD",
title: "Value of Coin Days Destroyed",
color: colors.vocdd,
});
return {
name: "Cointime",
tree: [
{
name: "Prices",
tree: [
{
name: "Compare",
title: "Cointime Prices",
top: [
price({
series: all.realized.price,
name: "Realized",
color: colors.realized,
}),
price({
series: all.realized.capitalized.price,
name: "Capitalized",
color: colors.capitalized,
}),
...prices.map(({ pattern, name, color, defaultActive }) =>
price({ series: pattern, name, color, defaultActive }),
),
],
},
...prices.map(({ pattern, name, title, color }) => ({
name,
tree: priceRatioPercentilesTree({
pattern,
title: title(name),
legend: name,
color,
priceReferences: [
price({
series: all.realized.price,
name: "Realized",
color: colors.realized,
defaultActive: false,
}),
],
}),
})),
],
},
{
name: "Caps",
tree: [
{
name: "Compare",
title: "Cointime Caps",
bottom: [
...capReferenceLines.map(({ series, name, color }) =>
line({ series, name, color, unit: Unit.usd }),
),
...caps.map(({ series, name, color, defaultActive }) =>
line({ series, name, color, defaultActive, unit: Unit.usd }),
),
],
},
...caps.map(({ series, name, color }) => ({
name,
title: `${name} Cap`,
bottom: [
line({ series, name, color, unit: Unit.usd }),
...capReferenceLines.map((ref) =>
line({
series: ref.series,
name: ref.name,
color: ref.color,
unit: Unit.usd,
}),
),
],
})),
],
},
{
name: "Supply",
title: "Active vs Vaulted Supply",
bottom: supplyBreakdown.flatMap(({ pattern, name, color }) =>
satsBtcUsd({ pattern, name, color }),
),
},
{
name: "Activity",
title: "Liveliness & Vaultedness",
bottom: [
line({
series: activity.liveliness,
name: "Liveliness",
color: colors.liveliness,
unit: Unit.ratio,
}),
line({
series: activity.vaultedness,
name: "Vaultedness",
color: colors.vaulted,
unit: Unit.ratio,
}),
line({
series: activity.ratio,
name: "Liveliness / Vaultedness",
color: colors.activity,
unit: Unit.ratio,
defaultActive: false,
}),
],
},
{
name: "Coinblocks",
tree: [
...multiSeriesTree({
entries: coinblocks.map(({ pattern, name, color }) => ({
name,
color,
average: pattern.average,
sum: pattern.sum,
cumulative: pattern.cumulative,
})),
metric: "Coinblocks",
unit: Unit.coinblocks,
}),
...coinblocks.map(({ pattern, name, title: metric, color }) => ({
name,
tree: sumsAndAveragesCumulative({
sum: pattern.sum,
average: pattern.average,
cumulative: pattern.cumulative,
metric,
unit: Unit.coinblocks,
color,
}),
})),
],
},
{
name: "Value",
tree: [
...multiSeriesTree({
entries: [
...cointimeValues.map(({ pattern, name, color }) => ({
name,
color,
average: pattern.average,
sum: pattern.sum,
cumulative: pattern.cumulative,
})),
{
name: vocdd.name,
color: vocdd.color,
average: vocdd.pattern.average,
sum: vocdd.pattern.sum,
cumulative: vocdd.pattern.cumulative,
},
],
metric: "Cointime Value",
unit: Unit.usd,
}),
...cointimeValues.map(({ pattern, name, title: metric, color }) => ({
name,
tree: sumsAndAveragesCumulative({
sum: pattern.sum,
average: pattern.average,
cumulative: pattern.cumulative,
metric,
unit: Unit.usd,
color,
}),
})),
{
name: vocdd.name,
tree: sumsAndAveragesCumulative({
sum: vocdd.pattern.sum,
average: vocdd.pattern.average,
cumulative: vocdd.pattern.cumulative,
metric: vocdd.title,
unit: Unit.usd,
color: vocdd.color,
}),
},
],
},
{
name: "Indicators",
tree: [
{
name: "AVIV",
title: "AVIV Ratio",
bottom: [
line({
series: cap.aviv.ratio,
name: "AVIV",
unit: Unit.ratio,
}),
],
},
{
name: "Reserve Risk",
title: "Reserve Risk",
bottom: [
line({
series: reserveRisk.value,
name: "Ratio",
color: colors.reserveRisk,
unit: Unit.ratio,
}),
],
},
],
},
{
name: "Adjusted",
tree: [
{
name: "Inflation",
title: "Cointime-Adjusted Inflation",
bottom: [
dots({
series: supply.inflationRate.percent,
name: "Base",
color: colors.base,
unit: Unit.percentage,
}),
...percentRatioDots({
pattern: adjusted.inflationRate,
name: "Cointime-Adjusted",
color: colors.adjusted,
}),
],
},
{
name: "BTC Velocity",
title: "Cointime-Adjusted BTC Velocity",
bottom: [
line({
series: supply.velocity.native,
name: "Base",
color: colors.base,
unit: Unit.ratio,
}),
line({
series: adjusted.txVelocityNative,
name: "Cointime-Adjusted",
color: colors.adjusted,
unit: Unit.ratio,
}),
],
},
{
name: "USD Velocity",
title: "Cointime-Adjusted USD Velocity",
bottom: [
line({
series: supply.velocity.fiat,
name: "Base",
color: colors.thermo,
unit: Unit.ratio,
}),
line({
series: adjusted.txVelocityFiat,
name: "Cointime-Adjusted",
color: colors.vaulted,
unit: Unit.ratio,
}),
],
},
],
},
],
};
}
-53
View File
@@ -1,53 +0,0 @@
/** Constant helpers for creating price lines and reference lines */
import { colors } from "../utils/colors.js";
import { brk } from "../utils/client.js";
import { line } from "./series.js";
/**
* Get constant pattern by number dynamically from tree
* Examples: 0 → _0, 38.2 → _382, -1 → minus1
* @param {BrkClient["series"]["constants"]} constants
* @param {number} num
* @returns {AnySeriesPattern}
*/
export function getConstant(constants, num) {
const key =
num >= 0 ? `_${String(num).replace(".", "")}` : `minus${Math.abs(num)}`;
const constant = /** @type {AnySeriesPattern | undefined} */ (
/** @type {Record<string, AnySeriesPattern>} */ (constants)[key]
);
if (!constant) throw new Error(`Unknown constant: ${num} (key: ${key})`);
return constant;
}
/**
* Create a price line series (horizontal reference line)
* @param {{ number?: number, name?: string } & Omit<(Parameters<typeof line>)[0], 'name' | 'series'>} args
*/
export function priceLine(args) {
return line({
...args,
series: getConstant(brk.series.constants, args.number || 0),
name: args.name || `${args.number ?? 0}`,
color: args.color ?? colors.gray,
options: {
lineStyle: args.style ?? 4,
lastValueVisible: false,
crosshairMarkerVisible: false,
...args.options,
},
});
}
/**
* @param {{ numbers: number[] } & Omit<(Parameters<typeof priceLine>)[0], 'number'>} args
*/
export function priceLines(args) {
return args.numbers.map((number) =>
priceLine({
...args,
number,
}),
);
}
@@ -1,740 +0,0 @@
/**
* Activity section builders
*
* Capabilities by cohort type:
* - All/STH: activity (full), SOPR (rolling + adjusted), sell side risk, value (flows + breakdown), coins
* - LTH: activity (full), SOPR (rolling), sell side risk, value (flows + breakdown), coins
* - AgeRange/MaxAge: activity (basic), SOPR (24h only), value (no flows/breakdown), coins
* - Others (UtxoAmount, Empty, Address): no activity, value only
*/
import { Unit } from "../../utils/units.js";
import {
line,
baseline,
dotsBaseline,
percentRatio,
chartsFromCount,
averagesArray,
ROLLING_WINDOWS,
} from "../series.js";
import {
satsBtcUsd,
satsBtcUsdFullTree,
mapCohortsWithAll,
groupedWindowsCumulativeWithAll,
groupedWindowsCumulativeSatsBtcUsd,
} from "../shared.js";
import { colors } from "../../utils/colors.js";
// ============================================================================
// Shared Volume Helpers
// ============================================================================
/**
* @param {TransferVolumePattern} tv
* @param {Color} color
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function volumeTree(tv, color, title) {
return [
...satsBtcUsdFullTree({
pattern: tv,
title,
metric: "Transfer Volume",
color,
}),
{
name: "Profitability",
tree: [
...ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Transfer Volume Profitability`),
bottom: [
...satsBtcUsd({
pattern: tv.inProfit.sum[w.key],
name: "In Profit",
color: colors.profit,
}),
...satsBtcUsd({
pattern: tv.inLoss.sum[w.key],
name: "In Loss",
color: colors.loss,
}),
],
})),
{
name: "Cumulative",
title: title("Cumulative Transfer Volume Profitability"),
bottom: [
...satsBtcUsd({
pattern: tv.inProfit.cumulative,
name: "In Profit",
color: colors.profit,
}),
...satsBtcUsd({
pattern: tv.inLoss.cumulative,
name: "In Loss",
color: colors.loss,
}),
],
},
{
name: "In Profit",
tree: satsBtcUsdFullTree({
pattern: tv.inProfit,
title,
metric: "Transfer Volume In Profit",
color: colors.profit,
}),
},
{
name: "In Loss",
tree: satsBtcUsdFullTree({
pattern: tv.inLoss,
title,
metric: "Transfer Volume In Loss",
color: colors.loss,
}),
},
],
},
];
}
/**
* @param {{ transferVolume: TransferVolumePattern }} activity
* @param {Color} color
* @param {(name: string) => string} title
* @returns {PartialOptionsGroup}
*/
function volumeFolder(activity, color, title) {
return { name: "Volume", tree: volumeTree(activity.transferVolume, color, title) };
}
/**
* @param {{ transferVolume: TransferVolumePattern }} activity
* @param {CountPattern<number>} adjustedTransferVolume
* @param {Color} color
* @param {(name: string) => string} title
* @returns {PartialOptionsGroup}
*/
function volumeFolderWithAdjusted(activity, adjustedTransferVolume, color, title) {
return {
name: "Volume",
tree: [
...volumeTree(activity.transferVolume, color, title),
{ name: "Adjusted", tree: chartsFromCount({ pattern: adjustedTransferVolume, title, metric: "Adjusted Transfer Volume", unit: Unit.usd }) },
],
};
}
// ============================================================================
// Shared SOPR Helpers
// ============================================================================
/**
* @param {RollingWindowPattern<number>} ratio
* @param {(name: string) => string} title
* @param {string} [prefix]
* @returns {PartialOptionsTree}
*/
function singleRollingSoprTree(ratio, title, prefix = "") {
return [
{
name: "Compare",
title: title(`${prefix}SOPR`),
bottom: ROLLING_WINDOWS.map((w) =>
baseline({
series: ratio[w.key],
name: w.name,
color: w.color,
unit: Unit.ratio,
base: 1,
}),
),
},
...ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} ${prefix}SOPR`.trim()),
bottom: [
baseline({
series: ratio[w.key],
name: "SOPR",
unit: Unit.ratio,
base: 1,
}),
],
})),
];
}
/**
* @param {CountPattern<number>} valueDestroyed
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function valueDestroyedTree(valueDestroyed, title) {
return chartsFromCount({ pattern: valueDestroyed, title, metric: "Value Destroyed", unit: Unit.usd });
}
/**
* @param {CountPattern<number>} valueDestroyed
* @param {(name: string) => string} title
* @returns {PartialOptionsGroup}
*/
function valueDestroyedFolder(valueDestroyed, title) {
return { name: "Value Destroyed", tree: valueDestroyedTree(valueDestroyed, title) };
}
/**
* @param {CountPattern<number>} valueDestroyed
* @param {CountPattern<number>} adjusted
* @param {(name: string) => string} title
* @returns {PartialOptionsGroup}
*/
function valueDestroyedFolderWithAdjusted(valueDestroyed, adjusted, title) {
return {
name: "Value Destroyed",
tree: [
...valueDestroyedTree(valueDestroyed, title),
{ name: "Adjusted", tree: chartsFromCount({ pattern: adjusted, title, metric: "Adjusted Value Destroyed", unit: Unit.usd }) },
],
};
}
// ============================================================================
// Shared Sell Side Risk Helpers
// ============================================================================
/**
* @param {SellSideRiskPattern} sellSideRisk
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function singleSellSideRiskTree(sellSideRisk, title) {
return [
{
name: "Compare",
title: title("Sell Side Risk"),
bottom: ROLLING_WINDOWS.flatMap((w) =>
percentRatio({
pattern: sellSideRisk[w.key],
name: w.name,
color: w.color,
}),
),
},
...ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Sell Side Risk`),
bottom: percentRatio({
pattern: sellSideRisk[w.key],
name: "Sell Side Risk",
color: w.color,
}),
})),
];
}
// ============================================================================
// Single Cohort Activity Sections
// ============================================================================
/**
* Single activity tree items shared between WithAdjusted and basic
* @param {CohortAll | CohortFull | CohortLongTerm} cohort
* @param {(name: string) => string} title
* @param {PartialOptionsGroup} volumeItem
* @param {PartialOptionsGroup} soprFolder
* @param {PartialOptionsGroup} valueDestroyedItem
* @returns {PartialOptionsTree}
*/
function singleFullActivityTree(cohort, title, volumeItem, soprFolder, valueDestroyedItem) {
const { tree, color } = cohort;
return [
volumeItem,
soprFolder,
valueDestroyedItem,
{
name: "Coindays Destroyed",
tree: chartsFromCount({
pattern: tree.activity.coindaysDestroyed,
title,
metric: "Coindays Destroyed",
unit: Unit.coindays,
color,
}),
},
{
name: "Dormancy",
tree: averagesArray({
windows: tree.activity.dormancy,
title,
metric: "Dormancy",
unit: Unit.days,
}),
},
{
name: "Sell Side Risk",
tree: singleSellSideRiskTree(tree.realized.sellSideRiskRatio, title),
},
];
}
/** @param {{ cohort: CohortAll | CohortFull, title: (name: string) => string }} args */
export function createActivitySectionWithAdjusted({ cohort, title }) {
const { tree, color } = cohort;
const sopr = tree.realized.sopr;
return {
name: "Activity",
tree: singleFullActivityTree(cohort, title,
volumeFolderWithAdjusted(tree.activity, sopr.adjusted.transferVolume, color, title),
{
name: "SOPR",
tree: [
...singleRollingSoprTree(sopr.ratio, title),
{ name: "Adjusted", tree: singleRollingSoprTree(sopr.adjusted.ratio, title, "Adjusted ") },
],
},
valueDestroyedFolderWithAdjusted(sopr.valueDestroyed, sopr.adjusted.valueDestroyed, title),
),
};
}
/** @param {{ cohort: CohortFull | CohortLongTerm, title: (name: string) => string }} args */
export function createActivitySection({ cohort, title }) {
const { tree, color } = cohort;
return {
name: "Activity",
tree: singleFullActivityTree(cohort, title,
volumeFolder(tree.activity, color, title),
{ name: "SOPR", tree: singleRollingSoprTree(tree.realized.sopr.ratio, title) },
valueDestroyedFolder(tree.realized.sopr.valueDestroyed, title),
),
};
}
/**
* Activity section for cohorts with activity but basic realized (AgeRange/MaxAge — 24h SOPR only)
* @param {{ cohort: CohortAgeRange | CohortWithAdjusted, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createActivitySectionWithActivity({ cohort, title }) {
const { tree, color } = cohort;
const sopr = tree.realized.sopr;
return {
name: "Activity",
tree: [
volumeFolder(tree.activity, color, title),
{
name: "SOPR",
title: title("SOPR (24h)"),
bottom: [
dotsBaseline({
series: sopr.ratio._24h,
name: "SOPR",
unit: Unit.ratio,
base: 1,
}),
],
},
valueDestroyedFolder(sopr.valueDestroyed, title),
{
name: "Coindays Destroyed",
tree: chartsFromCount({
pattern: tree.activity.coindaysDestroyed,
title,
metric: "Coindays Destroyed",
unit: Unit.coindays,
color,
}),
},
],
};
}
/**
* Minimal activity section: volume only
* @param {{ cohort: CohortBasicWithMarketCap | CohortBasicWithoutMarketCap | CohortWithoutRelative | CohortAddr | AddrCohortObject, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createActivitySectionMinimal({ cohort, title }) {
return {
name: "Activity",
tree: satsBtcUsdFullTree({
pattern: cohort.tree.activity.transferVolume,
title,
metric: "Transfer Volume",
}),
};
}
/**
* Grouped minimal activity: volume
* @param {{ list: readonly (UtxoCohortObject | CohortWithoutRelative | CohortAddr | AddrCohortObject)[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedActivitySectionMinimal({ list, all, title }) {
return {
name: "Activity",
tree: groupedWindowsCumulativeSatsBtcUsd({
list, all, title, metricTitle: "Transfer Volume",
getMetric: (c) => c.tree.activity.transferVolume,
}),
};
}
/**
* Grouped profitability folder (compare + in profit + in loss)
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(name: string) => string} title
* @param {(c: T | A) => { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern }} getInProfit
* @param {(c: T | A) => { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern }} getInLoss
* @returns {PartialOptionsTree}
*/
function groupedProfitabilityArray(list, all, title, getInProfit, getInLoss) {
return [
{
name: "In Profit",
tree: groupedWindowsCumulativeSatsBtcUsd({
list,
all,
title,
metricTitle: "Transfer Volume In Profit",
getMetric: (c) => getInProfit(c),
}),
},
{
name: "In Loss",
tree: groupedWindowsCumulativeSatsBtcUsd({
list,
all,
title,
metricTitle: "Transfer Volume In Loss",
getMetric: (c) => getInLoss(c),
}),
},
];
}
/**
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(name: string) => string} title
* @param {(c: T | A) => { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern, inProfit: { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern }, inLoss: { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern } }} getTransferVolume
* @returns {PartialOptionsTree}
*/
function groupedVolumeTree(list, all, title, getTransferVolume) {
return [
...groupedWindowsCumulativeSatsBtcUsd({
list,
all,
title,
metricTitle: "Transfer Volume",
getMetric: (c) => getTransferVolume(c),
}),
...groupedProfitabilityArray(
list,
all,
title,
(c) => getTransferVolume(c).inProfit,
(c) => getTransferVolume(c).inLoss,
),
];
}
/**
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(name: string) => string} title
* @param {(c: T | A) => { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern, inProfit: { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern }, inLoss: { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern } }} getTransferVolume
* @returns {PartialOptionsGroup}
*/
function groupedVolumeFolder(list, all, title, getTransferVolume) {
return { name: "Volume", tree: groupedVolumeTree(list, all, title, getTransferVolume) };
}
/**
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(name: string) => string} title
* @param {(c: T | A) => { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern, inProfit: { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern }, inLoss: { sum: Record<string, AnyValuePattern>, cumulative: AnyValuePattern } }} getTransferVolume
* @param {(c: T | A) => CountPattern<number>} getAdjustedTransferVolume
* @returns {PartialOptionsGroup}
*/
function groupedVolumeFolderWithAdjusted(list, all, title, getTransferVolume, getAdjustedTransferVolume) {
return {
name: "Volume",
tree: [
...groupedVolumeTree(list, all, title, getTransferVolume),
{
name: "Adjusted",
tree: groupedWindowsCumulativeWithAll({
list, all, title, metricTitle: "Adjusted Transfer Volume",
getWindowSeries: (c, key) => getAdjustedTransferVolume(c).sum[key],
getCumulativeSeries: (c) => getAdjustedTransferVolume(c).cumulative,
seriesFn: line, unit: Unit.usd,
}),
},
],
};
}
// ============================================================================
// Grouped SOPR Helpers
// ============================================================================
/**
* @template {{ color: Color, name: string }} T
* @template {{ color: Color, name: string }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(item: T | A) => { _24h: AnySeriesPattern, _1w: AnySeriesPattern, _1m: AnySeriesPattern, _1y: AnySeriesPattern }} getRatio
* @param {(name: string) => string} title
* @param {string} [prefix]
* @returns {PartialOptionsTree}
*/
function groupedSoprCharts(list, all, getRatio, title, prefix = "") {
return ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} ${prefix}SOPR`.trim()),
bottom: mapCohortsWithAll(list, all, (c) =>
baseline({
series: getRatio(c)[w.key],
name: c.name,
color: c.color,
unit: Unit.ratio,
base: 1,
}),
),
}));
}
/**
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(name: string) => string} title
* @param {(c: T | A) => CountPattern<number>} getValueDestroyed
* @returns {PartialOptionsTree}
*/
function groupedValueDestroyedTree(list, all, title, getValueDestroyed) {
return groupedWindowsCumulativeWithAll({
list, all, title, metricTitle: "Value Destroyed",
getWindowSeries: (c, key) => getValueDestroyed(c).sum[key],
getCumulativeSeries: (c) => getValueDestroyed(c).cumulative,
seriesFn: line, unit: Unit.usd,
});
}
/**
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(name: string) => string} title
* @param {(c: T | A) => CountPattern<number>} getValueDestroyed
* @returns {PartialOptionsGroup}
*/
function groupedValueDestroyedFolder(list, all, title, getValueDestroyed) {
return { name: "Value Destroyed", tree: groupedValueDestroyedTree(list, all, title, getValueDestroyed) };
}
/**
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(name: string) => string} title
* @param {(c: T | A) => CountPattern<number>} getValueDestroyed
* @param {(c: T | A) => CountPattern<number>} getAdjustedValueDestroyed
* @returns {PartialOptionsGroup}
*/
function groupedValueDestroyedFolderWithAdjusted(list, all, title, getValueDestroyed, getAdjustedValueDestroyed) {
return {
name: "Value Destroyed",
tree: [
...groupedValueDestroyedTree(list, all, title, getValueDestroyed),
{ name: "Adjusted", tree: groupedValueDestroyedTree(list, all, title, getAdjustedValueDestroyed) },
],
};
}
// ============================================================================
// Grouped Activity Sections
// ============================================================================
/**
* Grouped activity tree items shared between WithAdjusted and basic
* @param {readonly (CohortFull | CohortLongTerm)[]} list
* @param {CohortAll} all
* @param {(name: string) => string} title
* @param {PartialOptionsGroup} volumeItem
* @param {PartialOptionsGroup} soprFolder
* @param {PartialOptionsGroup} valueDestroyedItem
* @returns {PartialOptionsTree}
*/
function groupedFullActivityTree(list, all, title, volumeItem, soprFolder, valueDestroyedItem) {
return [
volumeItem,
soprFolder,
valueDestroyedItem,
...groupedActivitySharedItems(list, all, title),
];
}
/** @param {{ list: readonly CohortFull[], all: CohortAll, title: (name: string) => string }} args */
export function createGroupedActivitySectionWithAdjusted({ list, all, title }) {
return {
name: "Activity",
tree: groupedFullActivityTree(list, all, title,
groupedVolumeFolderWithAdjusted(list, all, title, (c) => c.tree.activity.transferVolume, (c) => c.tree.realized.sopr.adjusted.transferVolume),
{
name: "SOPR",
tree: [
...groupedSoprCharts(list, all, (c) => c.tree.realized.sopr.ratio, title),
{ name: "Adjusted", tree: groupedSoprCharts(list, all, (c) => c.tree.realized.sopr.adjusted.ratio, title, "Adjusted ") },
],
},
groupedValueDestroyedFolderWithAdjusted(list, all, title, (c) => c.tree.realized.sopr.valueDestroyed, (c) => c.tree.realized.sopr.adjusted.valueDestroyed),
),
};
}
/** @param {{ list: readonly (CohortFull | CohortLongTerm)[], all: CohortAll, title: (name: string) => string }} args */
export function createGroupedActivitySection({ list, all, title }) {
return {
name: "Activity",
tree: groupedFullActivityTree(list, all, title,
groupedVolumeFolder(list, all, title, (c) => c.tree.activity.transferVolume),
{ name: "SOPR", tree: groupedSoprCharts(list, all, (c) => c.tree.realized.sopr.ratio, title) },
groupedValueDestroyedFolder(list, all, title, (c) => c.tree.realized.sopr.valueDestroyed),
),
};
}
/**
* Shared grouped activity items: coindays, dormancy, sell side risk
* @param {readonly (CohortFull | CohortLongTerm)[]} list
* @param {CohortAll} all
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function groupedActivitySharedItems(list, all, title) {
return [
{
name: "Coindays Destroyed",
tree: groupedWindowsCumulativeWithAll({
list,
all,
title,
metricTitle: "Coindays Destroyed",
getWindowSeries: (c, key) => c.tree.activity.coindaysDestroyed.sum[key],
getCumulativeSeries: (c) =>
c.tree.activity.coindaysDestroyed.cumulative,
seriesFn: line,
unit: Unit.coindays,
}),
},
{
name: "Dormancy",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Dormancy`),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({
series: tree.activity.dormancy[w.key],
name,
color,
unit: Unit.days,
}),
),
})),
},
{
name: "Sell Side Risk",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Sell Side Risk`),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({
series: tree.realized.sellSideRiskRatio[w.key].ratio,
name,
color,
unit: Unit.ratio,
}),
),
})),
},
];
}
/**
* Grouped activity for cohorts with activity but basic realized (AgeRange/MaxAge)
* @param {{ list: readonly (CohortAgeRange | CohortWithAdjusted)[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedActivitySectionWithActivity({ list, all, title }) {
return {
name: "Activity",
tree: [
groupedVolumeFolder(list, all, title, (c) => c.tree.activity.transferVolume),
{
name: "SOPR",
title: title("SOPR (24h)"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
baseline({
series: tree.realized.sopr.ratio._24h,
name,
color,
unit: Unit.ratio,
base: 1,
}),
),
},
groupedValueDestroyedFolder(list, all, title, (c) => c.tree.realized.sopr.valueDestroyed),
{
name: "Coindays Destroyed",
tree: [
...ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Coindays Destroyed`),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({
series: tree.activity.coindaysDestroyed.sum[w.key],
name,
color,
unit: Unit.coindays,
}),
),
})),
{
name: "Cumulative",
title: title("Cumulative Coindays Destroyed"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({
series: tree.activity.coindaysDestroyed.cumulative,
name,
color,
unit: Unit.coindays,
}),
),
},
],
},
],
};
}
@@ -1,211 +0,0 @@
/**
* Cost Basis section builders
*
* Structure:
* - Per Coin: sats-weighted (profitability + distribution)
* - Per Dollar: value-weighted (profitability + distribution)
* - Profitability: cross-cutting (per coin + per dollar on same chart)
* - Supply Density: cost basis supply density percentage
*
* Only for cohorts WITH costBasis (All, STH, LTH)
*/
import { colors } from "../../utils/colors.js";
import { entries } from "../../utils/array.js";
import { price, percentRatio } from "../series.js";
import { mapCohortsWithAll, flatMapCohortsWithAll } from "../shared.js";
const ACTIVE_PCTS = new Set(["pct75", "pct50", "pct25"]);
/**
* @param {PercentilesPattern} p
* @param {(name: string) => string} [n]
* @returns {FetchedPriceSeriesBlueprint[]}
*/
function percentileSeries(p, n = (x) => x) {
return entries(p)
.reverse()
.map(([key, s], i, arr) =>
price({
series: s,
name: n(key.replace("pct", "P")),
color: colors.at(i, arr.length),
...(ACTIVE_PCTS.has(key) ? {} : { defaultActive: false }),
}),
);
}
// ============================================================================
// Single cohort helpers
// ============================================================================
/**
* Per Coin or Per Dollar folder for a single cohort
* @param {Object} args
* @param {AnyPricePattern} args.avgPrice - realized price (per coin) or capitalized price (per dollar)
* @param {string} args.avgName
* @param {AnyPricePattern} args.inProfit
* @param {AnyPricePattern} args.inLoss
* @param {PercentilesPattern} args.percentiles
* @param {Color} args.color
* @param {string} args.weightLabel
* @param {(name: string) => string} args.title
* @param {AnyPricePattern} [args.min]
* @param {AnyPricePattern} [args.max]
* @returns {PartialOptionsTree}
*/
function singleWeightFolder({ avgPrice, avgName, inProfit, inLoss, percentiles, color, weightLabel, title, min, max }) {
return [
{
name: "Average",
title: title(`Cost Basis Average (${weightLabel})`),
top: [
price({ series: inProfit, name: "In Profit", color: colors.profit }),
price({ series: avgPrice, name: avgName, color }),
price({ series: inLoss, name: "In Loss", color: colors.loss }),
],
},
{
name: "Distribution",
title: title(`Cost Basis Distribution (${weightLabel})`),
top: [
price({ series: avgPrice, name: avgName, color }),
...(max ? [price({ series: max, name: "P100", color: colors.stat.max, defaultActive: false })] : []),
...percentileSeries(percentiles),
...(min ? [price({ series: min, name: "P0", color: colors.stat.min, defaultActive: false })] : []),
],
},
];
}
/**
* @param {{ cohort: CohortAll | CohortFull | CohortLongTerm, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createCostBasisSectionWithPercentiles({ cohort, title }) {
const { tree, color } = cohort;
const cb = tree.costBasis;
return {
name: "Cost Basis",
tree: [
{
name: "Per Coin",
tree: singleWeightFolder({
avgPrice: tree.realized.price, avgName: "All",
inProfit: cb.inProfit.perCoin, inLoss: cb.inLoss.perCoin,
percentiles: cb.perCoin, color, weightLabel: "BTC-weighted", title,
min: cb.min, max: cb.max,
}),
},
{
name: "Per Dollar",
tree: singleWeightFolder({
avgPrice: tree.realized.capitalized.price, avgName: "All",
inProfit: cb.inProfit.perDollar, inLoss: cb.inLoss.perDollar,
percentiles: cb.perDollar, color, weightLabel: "USD-weighted", title,
}),
},
{
name: "Supply Density",
title: title("Cost Basis Supply Density"),
bottom: percentRatio({ pattern: cb.supplyDensity, name: "Supply Density", color: colors.bitcoin }),
},
],
};
}
// ============================================================================
// Grouped cohort helpers
// ============================================================================
/**
* Per Coin or Per Dollar folder for grouped cohorts
* @param {Object} args
* @param {readonly (CohortAll | CohortFull | CohortLongTerm)[]} args.list
* @param {CohortAll} args.all
* @param {(c: CohortAll | CohortFull | CohortLongTerm) => AnyPricePattern} args.getAvgPrice
* @param {(c: CohortAll | CohortFull | CohortLongTerm) => AnyPricePattern} args.getInProfit
* @param {(c: CohortAll | CohortFull | CohortLongTerm) => AnyPricePattern} args.getInLoss
* @param {(c: CohortAll | CohortFull | CohortLongTerm) => PercentilesPattern} args.getPercentiles
* @param {string} args.avgTitle
* @param {string} args.weightLabel
* @param {(name: string) => string} args.title
* @returns {PartialOptionsTree}
*/
function groupedWeightFolder({ list, all, getAvgPrice, getInProfit, getInLoss, getPercentiles, avgTitle, weightLabel, title }) {
return [
{
name: "Average",
title: title(`Cost Basis ${avgTitle} (${weightLabel})`),
top: mapCohortsWithAll(list, all, (c) =>
price({ series: getAvgPrice(c), name: c.name, color: c.color }),
),
},
{
name: "In Profit",
title: title(`Cost Basis In Profit (${weightLabel})`),
top: mapCohortsWithAll(list, all, (c) =>
price({ series: getInProfit(c), name: c.name, color: c.color }),
),
},
{
name: "In Loss",
title: title(`Cost Basis In Loss (${weightLabel})`),
top: mapCohortsWithAll(list, all, (c) =>
price({ series: getInLoss(c), name: c.name, color: c.color }),
),
},
...(/** @type {const} */ ([
["pct50", "Median"],
["pct75", "Q3"],
["pct25", "Q1"],
])).map(([pct, label]) => ({
name: label,
title: title(`Cost Basis ${label} (${weightLabel})`),
top: mapCohortsWithAll(list, all, (c) =>
price({ series: getPercentiles(c)[pct], name: c.name, color: c.color }),
),
})),
];
}
/**
* @param {{ list: readonly (CohortAll | CohortFull | CohortLongTerm)[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedCostBasisSectionWithPercentiles({ list, all, title }) {
return {
name: "Cost Basis",
tree: [
{
name: "Per Coin",
tree: groupedWeightFolder({
list, all, title,
getAvgPrice: (c) => c.tree.realized.price,
getInProfit: (c) => c.tree.costBasis.inProfit.perCoin,
getInLoss: (c) => c.tree.costBasis.inLoss.perCoin,
getPercentiles: (c) => c.tree.costBasis.perCoin,
avgTitle: "Average", weightLabel: "BTC-weighted",
}),
},
{
name: "Per Dollar",
tree: groupedWeightFolder({
list, all, title,
getAvgPrice: (c) => c.tree.realized.capitalized.price,
getInProfit: (c) => c.tree.costBasis.inProfit.perDollar,
getInLoss: (c) => c.tree.costBasis.inLoss.perDollar,
getPercentiles: (c) => c.tree.costBasis.perDollar,
avgTitle: "Average", weightLabel: "USD-weighted",
}),
},
{
name: "Supply Density",
title: title("Cost Basis Supply Density"),
bottom: flatMapCohortsWithAll(list, all, ({ name, color, tree }) =>
percentRatio({ pattern: tree.costBasis.supplyDensity, name, color }),
),
},
],
};
}
@@ -1,249 +0,0 @@
import { colors } from "../../utils/colors.js";
import { entries } from "../../utils/array.js";
import { brk } from "../../utils/client.js";
/** @type {readonly AddressableType[]} */
const ADDRESSABLE_TYPES = [
"p2a",
"p2tr",
"p2wsh",
"p2wpkh",
"p2sh",
"p2pkh",
"p2pk33",
"p2pk65",
];
/** @type {(key: SpendableType) => key is AddressableType} */
const isAddressable = (key) =>
/** @type {readonly string[]} */ (ADDRESSABLE_TYPES).includes(key);
export function buildCohortData() {
const utxoCohorts = brk.series.cohorts.utxo;
const addressCohorts = brk.series.cohorts.addr;
const { addrs } = brk.series;
const {
TERM_NAMES,
EPOCH_NAMES,
UNDER_AGE_NAMES,
OVER_AGE_NAMES,
AGE_RANGE_NAMES,
OVER_AMOUNT_NAMES,
UNDER_AMOUNT_NAMES,
AMOUNT_RANGE_NAMES,
SPENDABLE_TYPE_NAMES,
CLASS_NAMES,
PROFITABILITY_RANGE_NAMES,
PROFIT_NAMES,
LOSS_NAMES,
} = brk;
const cohortAll = {
name: "",
title: "",
color: colors.bitcoin,
tree: utxoCohorts.all,
addressCount: {
base: addrs.funded.all,
delta: addrs.delta.all,
},
avgAmount: addrs.avgAmount.all,
};
const shortNames = TERM_NAMES.short;
const termShort = {
name: shortNames.short,
title: shortNames.long,
color: colors.term.short,
tree: utxoCohorts.sth,
};
const longNames = TERM_NAMES.long;
const termLong = {
name: longNames.short,
title: longNames.long,
color: colors.term.long,
tree: utxoCohorts.lth,
};
// Under age cohorts
const underAge = entries(UNDER_AGE_NAMES).map(([key, names], i, arr) => ({
name: names.short,
title: `UTXOs ${names.long}`,
color: colors.at(i, arr.length),
tree: utxoCohorts.underAge[key],
}));
// Over age cohorts
const overAge = entries(OVER_AGE_NAMES).map(([key, names], i, arr) => ({
name: names.short,
title: `UTXOs ${names.long}`,
color: colors.at(i, arr.length),
tree: utxoCohorts.overAge[key],
}));
const ageRange = entries(AGE_RANGE_NAMES).map(([key, names], i, arr) => ({
name: names.short,
title: `UTXOs ${names.long}`,
color: colors.at(i, arr.length),
tree: utxoCohorts.ageRange[key],
matured: utxoCohorts.matured[key],
}));
const epoch = entries(EPOCH_NAMES).map(([key, names], i, arr) => ({
name: names.short,
title: names.long,
color: colors.at(i, arr.length),
tree: utxoCohorts.epoch[key],
}));
const utxosOverAmount = entries(OVER_AMOUNT_NAMES).map(
([key, names], i, arr) => ({
name: names.short,
title: `UTXOs ${names.long}`,
color: colors.at(i, arr.length),
tree: utxoCohorts.overAmount[key],
}),
);
const addressesOverAmount = entries(OVER_AMOUNT_NAMES).map(
([key, names], i, arr) => {
const cohort = addressCohorts.overAmount[key];
return {
name: names.short,
title: `Addresses ${names.long}`,
color: colors.at(i, arr.length),
tree: cohort,
addressCount: cohort.addrCount,
};
},
);
const utxosUnderAmount = entries(UNDER_AMOUNT_NAMES).map(
([key, names], i, arr) => ({
name: names.short,
title: `UTXOs ${names.long}`,
color: colors.at(i, arr.length),
tree: utxoCohorts.underAmount[key],
}),
);
const addressesUnderAmount = entries(UNDER_AMOUNT_NAMES).map(
([key, names], i, arr) => {
const cohort = addressCohorts.underAmount[key];
return {
name: names.short,
title: `Addresses ${names.long}`,
color: colors.at(i, arr.length),
tree: cohort,
addressCount: cohort.addrCount,
};
},
);
const utxosAmountRange = entries(AMOUNT_RANGE_NAMES).map(
([key, names], i, arr) => ({
name: names.short,
title: `UTXOs ${names.long}`,
color: colors.at(i, arr.length),
tree: utxoCohorts.amountRange[key],
}),
);
const addressesAmountRange = entries(AMOUNT_RANGE_NAMES).map(
([key, names], i, arr) => {
const cohort = addressCohorts.amountRange[key];
return {
name: names.short,
title: `Addresses ${names.long}`,
color: colors.at(i, arr.length),
tree: cohort,
addressCount: cohort.addrCount,
};
},
);
const typeAddressable = ADDRESSABLE_TYPES.map((key) => {
const names = SPENDABLE_TYPE_NAMES[key];
return {
key,
name: names.short,
title: names.short,
color: colors.scriptType[key],
tree: utxoCohorts.type[key],
addressCount: {
base: addrs.funded[key],
delta: addrs.delta[key],
},
avgAmount: addrs.avgAmount[key],
exposed: addrs.exposed,
reused: addrs.reused,
respent: addrs.respent,
};
});
const typeOther = entries(SPENDABLE_TYPE_NAMES)
.filter(([key]) => !isAddressable(key))
.map(([key, names]) => ({
key,
name: names.short,
title: names.short,
color: colors.scriptType[key],
tree: utxoCohorts.type[key],
}));
const class_ = entries(CLASS_NAMES)
.reverse()
.map(([key, names], i, arr) => ({
name: names.short,
title: names.long,
color: colors.at(i, arr.length),
tree: utxoCohorts.class[key],
}));
const { range, profit, loss } = utxoCohorts.profitability;
const profitabilityRange = entries(PROFITABILITY_RANGE_NAMES).map(
([key, names], i, arr) => ({
name: names.short,
color: colors.at(i, arr.length),
pattern: range[key],
}),
);
const profitabilityProfit = entries(PROFIT_NAMES).map(
([key, names], i, arr) => ({
name: names.short,
color: colors.at(i, arr.length),
pattern: profit[key],
}),
);
const profitabilityLoss = entries(LOSS_NAMES).map(([key, names], i, arr) => ({
name: names.short,
color: colors.at(i, arr.length),
pattern: loss[key],
}));
return {
cohortAll,
termShort,
termLong,
underAge,
overAge,
ageRange,
epoch,
utxosOverAmount,
addressesOverAmount,
utxosUnderAmount,
addressesUnderAmount,
utxosAmountRange,
addressesAmountRange,
typeAddressable,
typeOther,
class: class_,
profitabilityRange,
profitabilityProfit,
profitabilityLoss,
};
}
@@ -1,762 +0,0 @@
/**
* Holdings section builders
*
* Supply pattern capabilities by cohort type:
* - DeltaHalfInRelTotalPattern2 (STH/LTH): inProfit + inLoss + dominance + share
* - SeriesTree_Cohorts_Utxo_All_Supply (All): inProfit + inLoss + share (no dominance)
* - DeltaHalfInRelTotalPattern (AgeRange/MaxAge/Epoch): inProfit + inLoss + dominance (no share)
* - DeltaHalfInTotalPattern2 (Type.*): inProfit + inLoss (no rel)
* - DeltaHalfTotalPattern (Empty/UtxoAmount/AddrAmount): total + half only
*/
import { Unit } from "../../utils/units.js";
import {
ROLLING_WINDOWS,
line,
baseline,
sumsTreeBaseline,
amountSumsTreeBaseline,
rollingPercentRatioTree,
percentRatio,
percentRatioBaseline,
chartsFromCount,
} from "../series.js";
import {
amountBaseline,
satsBtcUsd,
flatMapCohorts,
mapCohortsWithAll,
flatMapCohortsWithAll,
groupedWindowsCumulativeWithAll,
} from "../shared.js";
import { colors } from "../../utils/colors.js";
import { priceLine } from "../constants.js";
/**
* Simple supply series (total + half only, no profit/loss)
* @param {{ total: AnyValuePattern }} supply
* @returns {AnyFetchedSeriesBlueprint[]}
*/
function simpleSupplySeries(supply) {
return satsBtcUsd({
pattern: supply.total,
name: "Total",
});
}
/**
* @param {readonly (UtxoCohortObject | CohortWithoutRelative)[]} list
* @param {CohortAll} all
* @param {(name: string) => string} title
*/
function groupedOutputsFolder(list, all, title) {
return {
name: "Outputs",
tree: [
{
name: "Unspent",
tree: [
{
name: "Count",
title: title("UTXO Count"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({ series: tree.outputs.unspentCount.base, name, color, unit: Unit.count }),
),
},
...groupedDeltaItems(list, all, (c) => c.tree.outputs.unspentCount.delta, Unit.count, title, "UTXO Count"),
],
},
{
name: "Spent",
tree: groupedWindowsCumulativeWithAll({
list, all, title, metricTitle: "Spent UTXO Count",
getWindowSeries: (c, key) => c.tree.outputs.spentCount.sum[key],
getCumulativeSeries: (c) => c.tree.outputs.spentCount.cumulative,
seriesFn: line, unit: Unit.count,
}),
},
{
name: "Spending Rate",
title: title("Spending Rate"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({ series: tree.outputs.spendingRate, name, color, unit: Unit.ratio }),
),
},
],
};
}
/**
* @param {{ absolute: { _24h: AnySeriesPattern, _1w: AnySeriesPattern, _1m: AnySeriesPattern, _1y: AnySeriesPattern }, rate: { _24h: { percent: AnySeriesPattern, ratio: AnySeriesPattern }, _1w: { percent: AnySeriesPattern, ratio: AnySeriesPattern }, _1m: { percent: AnySeriesPattern, ratio: AnySeriesPattern }, _1y: { percent: AnySeriesPattern, ratio: AnySeriesPattern } } }} delta
* @param {Unit} unit
* @param {(name: string) => string} title
* @param {string} name
* @returns {PartialOptionsTree}
*/
function singleDeltaItems(delta, unit, title, name) {
return [
{
...sumsTreeBaseline({
windows: delta.absolute,
title,
metric: `${name} Change`,
unit,
legend: "Change",
}),
name: "Change",
},
{
...rollingPercentRatioTree({
windows: delta.rate,
title,
metric: `${name} Growth Rate`,
}),
name: "Growth Rate",
},
];
}
/**
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(c: T | A) => DeltaPattern} getDelta
* @param {Unit} unit
* @param {(name: string) => string} title
* @param {string} name
* @returns {PartialOptionsTree}
*/
function groupedDeltaItems(list, all, getDelta, unit, title, name) {
return [
{
name: "Change",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} ${name} Change`),
bottom: mapCohortsWithAll(list, all, (c) =>
baseline({
series: getDelta(c).absolute[w.key],
name: c.name,
color: c.color,
unit,
}),
),
})),
},
{
name: "Growth Rate",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} ${name} Growth Rate`),
bottom: flatMapCohortsWithAll(list, all, (c) =>
percentRatioBaseline({
pattern: getDelta(c).rate[w.key],
name: c.name,
color: c.color,
}),
),
})),
},
];
}
/**
* Amount-valued single-cohort delta: Change exposes sats + lazy btc per window.
* @param {AmountDeltaPattern} delta
* @param {(name: string) => string} title
* @param {string} name
* @returns {PartialOptionsTree}
*/
function singleAmountDeltaItems(delta, title, name) {
return [
{
...amountSumsTreeBaseline({
windows: delta.absolute,
title,
metric: `${name} Change`,
legend: "Change",
}),
name: "Change",
},
{
...rollingPercentRatioTree({
windows: delta.rate,
title,
metric: `${name} Growth Rate`,
}),
name: "Growth Rate",
},
];
}
/**
* Amount-valued grouped-cohort delta: Change exposes sats + lazy btc per window.
* @template {{ name: string, color: Color }} T
* @template {{ name: string, color: Color }} A
* @param {readonly T[]} list
* @param {A} all
* @param {(c: T | A) => AmountDeltaPattern} getDelta
* @param {(name: string) => string} title
* @param {string} name
* @returns {PartialOptionsTree}
*/
function groupedAmountDeltaItems(list, all, getDelta, title, name) {
return [
{
name: "Change",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} ${name} Change`),
bottom: flatMapCohortsWithAll(list, all, (c) =>
amountBaseline({
pattern: getDelta(c).absolute[w.key],
name: c.name,
color: c.color,
}),
),
})),
},
{
name: "Growth Rate",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} ${name} Growth Rate`),
bottom: flatMapCohortsWithAll(list, all, (c) =>
percentRatioBaseline({
pattern: getDelta(c).rate[w.key],
name: c.name,
color: c.color,
}),
),
})),
},
];
}
// ============================================================================
// Single Cohort Composable Builders
// ============================================================================
/**
* Amount chart: total + halved + in profit + in loss in sats/btc/usd.
* @param {{ total: AnyValuePattern, half: AnyValuePattern, inProfit: AnyValuePattern, inLoss: AnyValuePattern }} supply
* @param {(name: string) => string} title
* @returns {PartialChartOption}
*/
function profitabilityAmountChart(supply, title) {
return {
name: "Amount",
title: title("Supply Profitability"),
bottom: [
...satsBtcUsd({ pattern: supply.total, name: "Total", color: colors.default }),
...satsBtcUsd({ pattern: supply.inProfit, name: "In Profit", color: colors.profit }),
...satsBtcUsd({ pattern: supply.inLoss, name: "In Loss", color: colors.loss }),
...satsBtcUsd({ pattern: supply.half, name: "Halved", color: colors.gray, style: 4 }),
],
};
}
/**
* Composition chart: in profit / in loss as % of own supply.
* @param {{ inProfit: { share: { percent: AnySeriesPattern, ratio: AnySeriesPattern } }, inLoss: { share: { percent: AnySeriesPattern, ratio: AnySeriesPattern } } }} supply
* @param {(name: string) => string} title
* @returns {PartialChartOption}
*/
function profitabilityCompositionChart(supply, title) {
return {
name: "Composition",
title: title("Supply Profitability Composition"),
bottom: [
...percentRatio({ pattern: supply.inProfit.share, name: "In Profit", color: colors.profit }),
...percentRatio({ pattern: supply.inLoss.share, name: "In Loss", color: colors.loss }),
priceLine({ number: 100, color: colors.default, style: 0, unit: Unit.percentage }),
priceLine({ number: 50, unit: Unit.percentage }),
],
};
}
/**
* @param {{ dominance: PercentRatioPattern }} supply
* @param {Color} color
* @param {(name: string) => string} title
* @returns {PartialChartOption}
*/
function dominanceChart(supply, color, title) {
return {
name: "Dominance",
title: title("Supply Dominance"),
bottom: percentRatio({ pattern: supply.dominance, name: "Dominance", color }),
};
}
/**
* @param {OutputsPattern} outputs
* @param {Color} color
* @param {(name: string) => string} title
* @returns {PartialOptionsGroup}
*/
function outputsFolder(outputs, color, title) {
return {
name: "Outputs",
tree: [
countFolder(outputs.unspentCount, "Unspent", "UTXO Count", color, title),
{
name: "Spent",
tree: chartsFromCount({ pattern: outputs.spentCount, title, metric: "Spent UTXO Count", unit: Unit.count, color }),
},
{
name: "Spending Rate",
title: title("Spending Rate"),
bottom: [
line({ series: outputs.spendingRate, name: "Rate", color, unit: Unit.ratio }),
],
},
],
};
}
/**
* @param {{ base: AnySeriesPattern, delta: DeltaPattern }} pattern
* @param {string} name
* @param {string} chartTitle
* @param {Color} color
* @param {(name: string) => string} title
* @returns {PartialOptionsGroup}
*/
function countFolder(pattern, name, chartTitle, color, title) {
return {
name,
tree: [
{
name: "Count",
title: title(chartTitle),
bottom: [
line({
series: pattern.base,
name: "Count",
color,
unit: Unit.count,
}),
],
},
...singleDeltaItems(pattern.delta, Unit.count, title, chartTitle),
],
};
}
// ============================================================================
// Single Cohort Holdings Sections
// ============================================================================
/**
* @param {{ cohort: UtxoCohortObject | CohortWithoutRelative, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createHoldingsSection({ cohort, title }) {
const { supply } = cohort.tree;
return [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
...singleAmountDeltaItems(supply.delta, title, "Supply"),
],
},
outputsFolder(cohort.tree.outputs, cohort.color, title),
];
}
/**
* @param {{ cohort: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createHoldingsSectionAll({ cohort, title }) {
const { supply } = cohort.tree;
return [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
{
name: "Profitability",
tree: [
profitabilityAmountChart(supply, title),
profitabilityCompositionChart(supply, title),
],
},
...singleAmountDeltaItems(supply.delta, title, "Supply"),
],
},
outputsFolder(cohort.tree.outputs, cohort.color, title),
countFolder(cohort.addressCount, "Addresses", "Address Count", cohort.color, title),
];
}
/**
* @param {{ cohort: CohortFull | CohortLongTerm, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createHoldingsSectionWithRelative({ cohort, title }) {
const { supply } = cohort.tree;
return [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
{
name: "Profitability",
tree: [
profitabilityAmountChart(supply, title),
profitabilityCompositionChart(supply, title),
],
},
...singleAmountDeltaItems(supply.delta, title, "Supply"),
],
},
outputsFolder(cohort.tree.outputs, cohort.color, title),
];
}
/**
* @param {{ cohort: CohortWithAdjusted | CohortAgeRange, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createHoldingsSectionWithOwnSupply({ cohort, title }) {
const { supply } = cohort.tree;
return [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
{
name: "Profitability",
tree: [profitabilityAmountChart(supply, title)],
},
...singleAmountDeltaItems(supply.delta, title, "Supply"),
],
},
outputsFolder(cohort.tree.outputs, cohort.color, title),
];
}
/**
* @param {{ cohort: CohortWithoutRelative, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createHoldingsSectionWithProfitLoss({ cohort, title }) {
const { supply } = cohort.tree;
return [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
{
name: "Profitability",
tree: [profitabilityAmountChart(supply, title)],
},
...singleAmountDeltaItems(supply.delta, title, "Supply"),
],
},
outputsFolder(cohort.tree.outputs, cohort.color, title),
];
}
/**
* @param {{ cohort: CohortAddr, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createHoldingsSectionAddress({ cohort, title }) {
const { supply } = cohort.tree;
return [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
{
name: "Profitability",
tree: [profitabilityAmountChart(supply, title)],
},
...singleAmountDeltaItems(supply.delta, title, "Supply"),
],
},
outputsFolder(cohort.tree.outputs, cohort.color, title),
countFolder(cohort.addressCount, "Addresses", "Address Count", cohort.color, title),
];
}
/**
* @param {{ cohort: AddrCohortObject, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createHoldingsSectionAddressAmount({ cohort, title }) {
const { supply } = cohort.tree;
return [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: simpleSupplySeries(supply),
},
dominanceChart(supply, cohort.color, title),
...singleAmountDeltaItems(supply.delta, title, "Supply"),
],
},
outputsFolder(cohort.tree.outputs, cohort.color, title),
countFolder(cohort.addressCount, "Addresses", "Address Count", cohort.color, title),
];
}
// ============================================================================
// Grouped Cohort Supply Helpers
// ============================================================================
/**
* @template {{ name: string, color: Color, tree: { supply: { total: AnyValuePattern } } }} T
* @param {readonly T[]} list
* @param {CohortAll} all
* @param {(name: string) => string} title
* @returns {PartialChartOption}
*/
function groupedSupplyTotal(list, all, title) {
return { name: "Total", title: title("Supply"), bottom: flatMapCohortsWithAll(list, all, ({ name, color, tree }) => satsBtcUsd({ pattern: tree.supply.total, name, color })) };
}
/**
* @template {{ name: string, color: Color, tree: { supply: { inProfit: AnyValuePattern, inLoss: AnyValuePattern } } }} T
* @param {readonly T[]} list
* @param {CohortAll} all
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function groupedSupplyProfitLoss(list, all, title) {
return [
{ name: "In Profit", title: title("Supply In Profit"), bottom: flatMapCohortsWithAll(list, all, ({ name, color, tree }) => satsBtcUsd({ pattern: tree.supply.inProfit, name, color })) },
{ name: "In Loss", title: title("Supply In Loss"), bottom: flatMapCohortsWithAll(list, all, ({ name, color, tree }) => satsBtcUsd({ pattern: tree.supply.inLoss, name, color })) },
];
}
/**
* @template {{ name: string, color: Color, tree: { supply: { dominance: PercentRatioPattern } } }} T
* @param {readonly T[]} list
* @param {(name: string) => string} title
* @returns {PartialChartOption}
*/
function groupedDominanceChart(list, title) {
return {
name: "Dominance",
title: title("Supply Dominance"),
bottom: flatMapCohorts(list, ({ name, color, tree }) =>
percentRatio({ pattern: tree.supply.dominance, name, color }),
),
};
}
// ============================================================================
// Grouped Cohort Holdings Sections
// ============================================================================
/**
* @param {{ list: readonly CohortAddr[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createGroupedHoldingsSectionAddress({ list, all, title }) {
return [
{
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
groupedDominanceChart(list, title),
{
name: "Profitability",
tree: groupedSupplyProfitLoss(list, all, title),
},
...groupedAmountDeltaItems(list, all, (c) => c.tree.supply.delta, title, "Supply"),
],
},
groupedOutputsFolder(list, all, title),
{
name: "Addresses",
tree: [
{
name: "Count",
title: title("Address Count"),
bottom: mapCohortsWithAll(list, all, ({ name, color, addressCount }) =>
line({ series: addressCount.base, name, color, unit: Unit.count }),
),
},
...groupedDeltaItems(list, all, (c) => c.addressCount.delta, Unit.count, title, "Address Count"),
],
},
{
name: "Average Holdings",
tree: [
{
name: "Per UTXO",
title: title("Average Holdings per UTXO"),
bottom: flatMapCohortsWithAll(list, all, ({ name, color, avgAmount }) =>
satsBtcUsd({ pattern: avgAmount.utxo, name, color }),
),
},
{
name: "Per Address",
title: title("Average Holdings per Funded Address"),
bottom: flatMapCohortsWithAll(list, all, ({ name, color, avgAmount }) =>
satsBtcUsd({ pattern: avgAmount.addr, name, color }),
),
},
],
},
];
}
/**
* Grouped holdings for address amount cohorts (no inProfit/inLoss, has address count)
* @param {{ list: readonly AddrCohortObject[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createGroupedHoldingsSectionAddressAmount({ list, all, title }) {
return [
{
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
groupedDominanceChart(list, title),
...groupedAmountDeltaItems(list, all, (c) => c.tree.supply.delta, title, "Supply"),
],
},
groupedOutputsFolder(list, all, title),
{
name: "Addresses",
tree: [
{
name: "Count",
title: title("Address Count"),
bottom: mapCohortsWithAll(list, all, ({ name, color, addressCount }) =>
line({ series: addressCount.base, name, color, unit: Unit.count }),
),
},
...groupedDeltaItems(list, all, (c) => c.addressCount.delta, Unit.count, title, "Address Count"),
],
},
];
}
/** @param {{ list: readonly (UtxoCohortObject | CohortWithoutRelative)[], all: CohortAll, title: (name: string) => string }} args */
export function createGroupedHoldingsSection({ list, all, title }) {
return [
{
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
groupedDominanceChart(list, title),
...groupedAmountDeltaItems(list, all, (c) => c.tree.supply.delta, title, "Supply"),
],
},
groupedOutputsFolder(list, all, title),
];
}
/** @param {{ list: readonly CohortWithoutRelative[], all: CohortAll, title: (name: string) => string }} args */
export function createGroupedHoldingsSectionWithProfitLoss({ list, all, title }) {
return [
{
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
groupedDominanceChart(list, title),
{
name: "Profitability",
tree: groupedSupplyProfitLoss(list, all, title),
},
...groupedAmountDeltaItems(list, all, (c) => c.tree.supply.delta, title, "Supply"),
],
},
groupedOutputsFolder(list, all, title),
];
}
/** @param {{ list: readonly (CohortWithAdjusted | CohortAgeRange)[], all: CohortAll, title: (name: string) => string }} args */
export function createGroupedHoldingsSectionWithOwnSupply({ list, all, title }) {
return [
{
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
groupedDominanceChart(list, title),
{
name: "Profitability",
tree: groupedSupplyProfitLoss(list, all, title),
},
...groupedAmountDeltaItems(list, all, (c) => c.tree.supply.delta, title, "Supply"),
],
},
groupedOutputsFolder(list, all, title),
];
}
/**
* Grouped holdings with full relative series (dominance + share)
* For: CohortFull, CohortLongTerm
* @param {{ list: readonly (CohortFull | CohortLongTerm)[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsTree}
*/
export function createGroupedHoldingsSectionWithRelative({ list, all, title }) {
return [
{
name: "Supply",
tree: [
groupedSupplyTotal(list, all, title),
groupedDominanceChart(list, title),
{
name: "Profitability",
tree: [
...groupedSupplyProfitLoss(list, all, title),
{
name: "Composition",
tree: [
{ name: "In Profit", title: title("Supply In Profit Composition"), bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) => line({ series: tree.supply.inProfit.share.percent, name, color, unit: Unit.percentage })) },
{ name: "In Loss", title: title("Supply In Loss Composition"), bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) => line({ series: tree.supply.inLoss.share.percent, name, color, unit: Unit.percentage })) },
],
},
],
},
...groupedAmountDeltaItems(list, all, (c) => c.tree.supply.delta, title, "Supply"),
],
},
groupedOutputsFolder(list, all, title),
];
}
@@ -1,767 +0,0 @@
/**
* Cohort module - exports all cohort-related functionality
*
* Folder builders compose sections from building blocks:
* - holdings.js: Supply, UTXO Count, Address Count
* - valuation.js: Realized Cap, Market Cap, MVRV
* - prices.js: Realized Price, ratios
* - cost-basis.js: Cost basis percentiles
* - profitability.js: Unrealized/Realized P&L, Invested Capital
* - activity.js: SOPR, Volume, Lifespan
*/
import {
formatCohortTitle,
amountBaseline,
satsBtcUsd,
satsBtcUsdFullTree,
avgHoldingsSubtree,
exposedSubtree,
reusedSubtree,
} from "../shared.js";
import {
ROLLING_WINDOWS,
line,
percentRatio,
amountSumsTreeBaseline,
rollingPercentRatioTree,
} from "../series.js";
import { Unit } from "../../utils/units.js";
import { colors } from "../../utils/colors.js";
import { brk } from "../../utils/client.js";
// Section builders
import {
createHoldingsSection,
createHoldingsSectionAll,
createHoldingsSectionAddress,
createHoldingsSectionAddressAmount,
createHoldingsSectionWithProfitLoss,
createHoldingsSectionWithRelative,
createHoldingsSectionWithOwnSupply,
createGroupedHoldingsSection,
createGroupedHoldingsSectionAddress,
createGroupedHoldingsSectionAddressAmount,
createGroupedHoldingsSectionWithRelative,
createGroupedHoldingsSectionWithOwnSupply,
} from "./holdings.js";
import {
createValuationSection,
createValuationSectionFull,
createGroupedValuationSection,
createGroupedValuationSectionWithOwnMarketCap,
} from "./valuation.js";
import {
createPricesSectionFull,
createPricesSectionBasic,
createGroupedPricesSection,
createGroupedPricesSectionFull,
} from "./prices.js";
import {
createCostBasisSectionWithPercentiles,
createGroupedCostBasisSectionWithPercentiles,
} from "./cost-basis.js";
import {
createProfitabilitySection,
createProfitabilitySectionAll,
createProfitabilitySectionFull,
createProfitabilitySectionWithProfitLoss,
createProfitabilitySectionWithInvestedCapitalPct,
createProfitabilitySectionLongTerm,
createGroupedProfitabilitySection,
createGroupedProfitabilitySectionWithProfitLoss,
createGroupedProfitabilitySectionWithNupl,
createGroupedProfitabilitySectionWithInvestedCapitalPct,
} from "./profitability.js";
import {
createActivitySection,
createActivitySectionWithAdjusted,
createActivitySectionWithActivity,
createGroupedActivitySection,
createGroupedActivitySectionWithActivity,
createActivitySectionMinimal,
createGroupedActivitySectionMinimal,
} from "./activity.js";
// Re-export data builder
export { buildCohortData } from "./data.js";
// ============================================================================
// Single Cohort Folder Builders
// ============================================================================
/**
* All folder: for the special "All" cohort
* @param {CohortAll} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderAll(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionAll({ cohort, title }),
createValuationSectionFull({ cohort, title }),
createPricesSectionFull({ cohort, title }),
createCostBasisSectionWithPercentiles({ cohort, title }),
createProfitabilitySectionAll({ cohort, title }),
createActivitySectionWithAdjusted({ cohort, title }),
avgHoldingsSubtree(cohort.avgAmount, title),
],
};
}
/**
* Full folder: adjustedSopr + percentiles + RelToMarketCap
* @param {CohortFull} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderFull(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionWithRelative({ cohort, title }),
createValuationSectionFull({ cohort, title }),
createPricesSectionFull({ cohort, title }),
createCostBasisSectionWithPercentiles({ cohort, title }),
createProfitabilitySectionFull({ cohort, title }),
createActivitySectionWithAdjusted({ cohort, title }),
],
};
}
/**
* Adjusted folder: adjustedSopr only, no percentiles
* @param {CohortWithAdjusted} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderWithAdjusted(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionWithOwnSupply({ cohort, title }),
createValuationSection({ cohort, title }),
createPricesSectionBasic({ cohort, title }),
createProfitabilitySectionWithInvestedCapitalPct({ cohort, title }),
createActivitySectionWithActivity({ cohort, title }),
],
};
}
/**
* Folder for cohorts with nupl + percentiles
* @param {CohortWithNuplPercentiles} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderWithNupl(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionWithRelative({ cohort, title }),
createValuationSectionFull({ cohort, title }),
createPricesSectionFull({ cohort, title }),
createCostBasisSectionWithPercentiles({ cohort, title }),
createProfitabilitySection({ cohort, title }),
createActivitySection({ cohort, title }),
],
};
}
/**
* LongTerm folder: has own market cap + NUPL + peak regret + P/L ratio
* @param {CohortLongTerm} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderLongTerm(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionWithRelative({ cohort, title }),
createValuationSectionFull({ cohort, title }),
createPricesSectionFull({ cohort, title }),
createCostBasisSectionWithPercentiles({ cohort, title }),
createProfitabilitySectionLongTerm({ cohort, title }),
createActivitySection({ cohort, title }),
],
};
}
/**
* Age range folder: no nupl
* @param {CohortAgeRange} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderAgeRange(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionWithOwnSupply({ cohort, title }),
createValuationSection({ cohort, title }),
createPricesSectionBasic({ cohort, title }),
createProfitabilitySectionWithInvestedCapitalPct({ cohort, title }),
createActivitySectionWithActivity({ cohort, title }),
],
};
}
/**
* Age range folder with matured supply
* @param {CohortAgeRangeWithMatured} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderAgeRangeWithMatured(cohort) {
const folder = createCohortFolderAgeRange(cohort);
const title = formatCohortTitle(cohort.title);
folder.tree.push({
name: "Matured",
tree: satsBtcUsdFullTree({
pattern: cohort.matured,
title,
metric: "Matured Supply",
}),
});
return folder;
}
/**
* Basic folder WITH RelToMarketCap
* @param {CohortBasicWithMarketCap} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderBasicWithMarketCap(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSection({ cohort, title }),
createValuationSection({ cohort, title }),
createPricesSectionBasic({ cohort, title }),
createProfitabilitySection({ cohort, title }),
createActivitySectionMinimal({ cohort, title }),
],
};
}
/**
* Address folder: like basic but with address count
* @param {CohortAddr} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderAddress(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionAddress({ cohort, title }),
createValuationSection({ cohort, title }),
createPricesSectionBasic({ cohort, title }),
createProfitabilitySectionWithProfitLoss({ cohort, title }),
createActivitySectionMinimal({ cohort, title }),
avgHoldingsSubtree(cohort.avgAmount, title),
reusedSubtree(cohort.reused, cohort.respent, cohort.key, title),
exposedSubtree(cohort.exposed, cohort.key, title),
],
};
}
/**
* Folder for cohorts WITHOUT relative section
* @param {CohortWithoutRelative} cohort
* @returns {PartialOptionsGroup}
*/
export function createCohortFolderWithoutRelative(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionWithProfitLoss({ cohort, title }),
createValuationSection({ cohort, title }),
createPricesSectionBasic({ cohort, title }),
createProfitabilitySectionWithProfitLoss({ cohort, title }),
createActivitySectionMinimal({ cohort, title }),
],
};
}
/**
* Address amount cohort folder: has NUPL + addrCount
* @param {AddrCohortObject} cohort
* @returns {PartialOptionsGroup}
*/
export function createAddressCohortFolder(cohort) {
const title = formatCohortTitle(cohort.title);
return {
name: cohort.name || "all",
tree: [
...createHoldingsSectionAddressAmount({ cohort, title }),
createValuationSection({ cohort, title }),
createPricesSectionBasic({ cohort, title }),
createProfitabilitySection({ cohort, title }),
createActivitySectionMinimal({ cohort, title }),
],
};
}
// ============================================================================
// Grouped Cohort Folder Builders
// ============================================================================
/**
* @param {CohortGroupWithAdjusted} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedCohortFolderWithAdjusted({
name,
title: groupTitle,
list,
all,
}) {
const title = formatCohortTitle(groupTitle);
return {
name: name || "all",
tree: [
...createGroupedHoldingsSectionWithOwnSupply({ list, all, title }),
createGroupedValuationSection({ list, all, title }),
createGroupedPricesSection({ list, all, title }),
createGroupedProfitabilitySectionWithInvestedCapitalPct({
list,
all,
title,
}),
createGroupedActivitySectionWithActivity({ list, all, title }),
],
};
}
/**
* @param {CohortGroupWithNuplPercentiles} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedCohortFolderWithNupl({
name,
title: groupTitle,
list,
all,
}) {
const title = formatCohortTitle(groupTitle);
return {
name: name || "all",
tree: [
...createGroupedHoldingsSectionWithRelative({ list, all, title }),
createGroupedValuationSectionWithOwnMarketCap({ list, all, title }),
createGroupedPricesSectionFull({ list, all, title }),
createGroupedCostBasisSectionWithPercentiles({ list, all, title }),
createGroupedProfitabilitySectionWithNupl({ list, all, title }),
createGroupedActivitySection({ list, all, title }),
],
};
}
/**
* @param {CohortGroupAgeRange} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedCohortFolderAgeRange({
name,
title: groupTitle,
list,
all,
}) {
const title = formatCohortTitle(groupTitle);
return {
name: name || "all",
tree: [
...createGroupedHoldingsSectionWithOwnSupply({ list, all, title }),
createGroupedValuationSection({ list, all, title }),
createGroupedPricesSection({ list, all, title }),
createGroupedProfitabilitySectionWithInvestedCapitalPct({
list,
all,
title,
}),
createGroupedActivitySectionWithActivity({ list, all, title }),
],
};
}
/**
* @param {{ name: string, title: string, list: readonly CohortAgeRangeWithMatured[], all: CohortAll }} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedCohortFolderAgeRangeWithMatured({
name,
title: groupTitle,
list,
all,
}) {
const folder = createGroupedCohortFolderAgeRange({
name,
title: groupTitle,
list,
all,
});
const title = formatCohortTitle(groupTitle);
folder.tree.push({
name: "Matured",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Matured Supply`),
bottom: list.flatMap((cohort) =>
satsBtcUsd({
pattern: cohort.matured.sum[w.key],
name: cohort.name,
color: cohort.color,
}),
),
})),
});
return folder;
}
/**
* @param {CohortGroupBasicWithMarketCap} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedCohortFolderBasicWithMarketCap({
name,
title: groupTitle,
list,
all,
}) {
const title = formatCohortTitle(groupTitle);
return {
name: name || "all",
tree: [
...createGroupedHoldingsSection({ list, all, title }),
createGroupedValuationSection({ list, all, title }),
createGroupedPricesSection({ list, all, title }),
createGroupedProfitabilitySection({ list, all, title }),
createGroupedActivitySectionMinimal({ list, all, title }),
],
};
}
/**
* @param {CohortGroupAddr} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedCohortFolderAddress({
name,
title: groupTitle,
list,
all,
}) {
const title = formatCohortTitle(groupTitle);
return {
name: name || "all",
tree: [
...createGroupedHoldingsSectionAddress({ list, all, title }),
createGroupedValuationSection({ list, all, title }),
createGroupedPricesSection({ list, all, title }),
createGroupedProfitabilitySectionWithProfitLoss({
list,
all,
title,
}),
createGroupedActivitySectionMinimal({ list, all, title }),
],
};
}
/**
* @param {AddrCohortGroupObject} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedAddressCohortFolder({
name,
title: groupTitle,
list,
all,
}) {
const title = formatCohortTitle(groupTitle);
return {
name: name || "all",
tree: [
...createGroupedHoldingsSectionAddressAmount({ list, all, title }),
createGroupedValuationSection({ list, all, title }),
createGroupedPricesSection({ list, all, title }),
createGroupedProfitabilitySection({ list, all, title }),
createGroupedActivitySectionMinimal({ list, all, title }),
],
};
}
// ============================================================================
// UTXO Profitability Folder Builders
// ============================================================================
/**
* @param {{ name: string, color: Color, pattern: RealizedSupplyPattern }} bucket
* @param {string} [parentName]
* @returns {PartialOptionsGroup}
*/
function singleBucketFolder({ name, color, pattern }, parentName) {
const title = formatCohortTitle(parentName ? `${parentName} ${name}` : name);
return {
name,
tree: [
{
name: "Supply",
tree: [
{
name: "Total",
title: title("Supply"),
bottom: [
...satsBtcUsd({ pattern: pattern.supply.all, name: "Total" }),
...satsBtcUsd({
pattern: pattern.supply.sth,
name: "STH",
color: colors.term.short,
}),
],
},
{
...amountSumsTreeBaseline({
windows: pattern.supply.all.delta.absolute,
title,
metric: "Supply Change",
legend: "Change",
}),
name: "Change",
},
{
...rollingPercentRatioTree({
windows: pattern.supply.all.delta.rate,
title,
metric: "Supply Growth Rate",
}),
name: "Growth Rate",
},
],
},
{
name: "Realized Cap",
title: title("Realized Cap"),
bottom: [
line({
series: pattern.realizedCap.all,
name: "Total",
unit: Unit.usd,
}),
line({
series: pattern.realizedCap.sth,
name: "STH",
color: colors.term.short,
unit: Unit.usd,
}),
],
},
{
name: "Unrealized PnL",
title: title("Unrealized PnL"),
bottom: [
line({
series: pattern.unrealizedPnl.all,
name: "Total",
unit: Unit.usd,
}),
line({
series: pattern.unrealizedPnl.sth,
name: "STH",
color: colors.term.short,
unit: Unit.usd,
}),
],
},
{
name: "NUPL",
title: title("NUPL"),
bottom: [
line({ series: pattern.nupl.ratio, name, color, unit: Unit.ratio }),
],
},
],
};
}
/**
* @param {{ name: string, color: Color, pattern: RealizedSupplyPattern }[]} list
* @param {string} groupTitle
* @returns {PartialOptionsTree}
*/
function groupedBucketCharts(list, groupTitle) {
const title = formatCohortTitle(groupTitle);
return [
{
name: "Supply",
tree: [
{
name: "All",
title: title("Supply"),
bottom: list.flatMap(({ name, color, pattern }) =>
satsBtcUsd({ pattern: pattern.supply.all, name, color }),
),
},
{
name: "STH",
title: title("STH Supply"),
bottom: list.flatMap(({ name, color, pattern }) =>
satsBtcUsd({ pattern: pattern.supply.sth, name, color }),
),
},
{
name: "Change",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Supply Change`),
bottom: list.flatMap(({ name, color, pattern }) =>
amountBaseline({
pattern: pattern.supply.all.delta.absolute[w.key],
name,
color,
}),
),
})),
},
{
name: "Growth Rate",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Supply Growth Rate`),
bottom: list.flatMap(({ name, color, pattern }) =>
percentRatio({
pattern: pattern.supply.all.delta.rate[w.key],
name,
color,
}),
),
})),
},
],
},
{
name: "Realized Cap",
tree: [
{
name: "All",
title: title("Realized Cap"),
bottom: list.map(({ name, color, pattern }) =>
line({
series: pattern.realizedCap.all,
name,
color,
unit: Unit.usd,
}),
),
},
{
name: "STH",
title: title("STH Realized Cap"),
bottom: list.map(({ name, color, pattern }) =>
line({
series: pattern.realizedCap.sth,
name,
color,
unit: Unit.usd,
}),
),
},
],
},
{
name: "Unrealized PnL",
tree: [
{
name: "All",
title: title("Unrealized PnL"),
bottom: list.map(({ name, color, pattern }) =>
line({
series: pattern.unrealizedPnl.all,
name,
color,
unit: Unit.usd,
}),
),
},
{
name: "STH",
title: title("STH Unrealized PnL"),
bottom: list.map(({ name, color, pattern }) =>
line({
series: pattern.unrealizedPnl.sth,
name,
color,
unit: Unit.usd,
}),
),
},
],
},
{
name: "NUPL",
title: title("NUPL"),
bottom: list.map(({ name, color, pattern }) =>
line({ series: pattern.nupl.ratio, name, color, unit: Unit.ratio }),
),
},
];
}
/**
* @param {{ range: { name: string, color: Color, pattern: RealizedSupplyPattern }[], profit: { name: string, color: Color, pattern: RealizedSupplyPattern }[], loss: { name: string, color: Color, pattern: RealizedSupplyPattern }[] }} args
* @returns {PartialOptionsGroup}
*/
export function createUtxoProfitabilitySection({ range, profit, loss }) {
return {
name: "UTXO Profitability",
tree: [
{
name: "Range",
tree: [
{
name: "Compare",
tree: groupedBucketCharts(range, "Profitability Range"),
},
...range.map((bucket) => singleBucketFolder(bucket)),
],
},
{
name: "In Profit",
tree: [
{ name: "Compare", tree: groupedBucketCharts(profit, "In Profit") },
...profit.map((bucket) => singleBucketFolder(bucket, "In Profit")),
],
},
{
name: "In Loss",
tree: [
{ name: "Compare", tree: groupedBucketCharts(loss, "In Loss") },
...loss.map((bucket) => singleBucketFolder(bucket, "In Loss")),
],
},
],
};
}
/**
* Gini leaf for Distribution > Address Balance
* @returns {AnyPartialOption}
*/
export function createAddressBalanceGiniLeaf() {
return {
name: "Gini",
title: "Address Balance Gini Coefficient",
bottom: percentRatio({
pattern: brk.series.indicators.gini,
name: "Gini",
color: colors.loss,
}),
};
}
@@ -1,173 +0,0 @@
/**
* Prices section builders
*
* Structure (single cohort):
* - Compare: Both prices on one chart
* - Realized: Price + Ratio (MVRV) + Z-Scores (for full cohorts)
* - Capitalized: Price + Ratio + Z-Scores (for full cohorts)
*
* Structure (grouped cohorts):
* - Realized: Price + Ratio comparison across cohorts
* - Capitalized: Price + Ratio comparison across cohorts
*
* For cohorts WITHOUT full ratio patterns: basic Price/Ratio charts only (no Z-Scores)
*/
import { colors } from "../../utils/colors.js";
import { createPriceRatioCharts, mapCohortsWithAll, priceRatioPercentilesTree } from "../shared.js";
import { baseline, price } from "../series.js";
import { Unit } from "../../utils/units.js";
/**
* Create prices section for cohorts with full ratio patterns
* (CohortAll, CohortFull, CohortLongTerm)
* @param {{ cohort: CohortAll | CohortFull | CohortLongTerm, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createPricesSectionFull({ cohort, title }) {
const { tree, color } = cohort;
return {
name: "Prices",
tree: [
{
name: "Compare",
title: title("Realized Prices"),
top: [
price({ series: tree.realized.price, name: "Realized", color: colors.realized }),
price({ series: tree.realized.capitalized.price, name: "Capitalized", color: colors.capitalized }),
],
},
{
name: "Realized",
tree: createPriceRatioCharts({
context: cohort.title,
legend: "Realized",
pricePattern: tree.realized.price,
ratio: tree.realized.price,
color,
priceTitle: title("Realized Price"),
titlePrefix: "Realized Price",
}),
},
{
name: "Capitalized",
tree: priceRatioPercentilesTree({
pattern: tree.realized.capitalized.price,
title: title("Capitalized Price"),
ratioTitle: title("Capitalized Price Ratio"),
legend: "Capitalized",
color,
}),
},
],
};
}
/**
* Create prices section for cohorts with basic ratio patterns only
* (CohortWithAdjusted, CohortBasic, CohortAddr, CohortWithoutRelative)
* @param {{ cohort: CohortWithAdjusted | CohortBasic | CohortAddr | CohortWithoutRelative | CohortAgeRange, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createPricesSectionBasic({ cohort, title }) {
const { tree, color } = cohort;
return {
name: "Prices",
tree: [
{
name: "Realized",
tree: [
{
name: "Price",
title: title("Realized Price"),
top: [price({ series: tree.realized.price, name: "Realized", color })],
},
{
name: "Ratio",
title: title("Realized Price Ratio"),
bottom: [
baseline({
series: tree.realized.price.ratio,
name: "Ratio",
unit: Unit.ratio,
base: 1,
}),
],
},
],
},
],
};
}
/**
* Create prices section for grouped cohorts
* @param {{ list: readonly CohortObject[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
/**
* @param {readonly CohortObject[]} list
* @param {CohortAll} all
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function groupedRealizedPriceItems(list, all, title) {
return [
{
name: "Realized",
tree: [
{
name: "Price",
title: title("Realized Price"),
top: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
price({ series: tree.realized.price, name, color }),
),
},
{
name: "Ratio",
title: title("Realized Price Ratio"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
baseline({ series: tree.realized.mvrv, name, color, unit: Unit.ratio, base: 1 }),
),
},
],
},
];
}
/** @param {{ list: readonly CohortObject[], all: CohortAll, title: (name: string) => string }} args */
export function createGroupedPricesSection({ list, all, title }) {
return {
name: "Prices",
tree: groupedRealizedPriceItems(list, all, title),
};
}
/** @param {{ list: readonly (CohortAll | CohortFull | CohortLongTerm)[], all: CohortAll, title: (name: string) => string }} args */
export function createGroupedPricesSectionFull({ list, all, title }) {
return {
name: "Prices",
tree: [
...groupedRealizedPriceItems(list, all, title),
{
name: "Capitalized",
tree: [
{
name: "Price",
title: title("Capitalized Price"),
top: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
price({ series: tree.realized.capitalized.price, name, color }),
),
},
{
name: "Ratio",
title: title("Capitalized Price Ratio"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
baseline({ series: tree.realized.capitalized.price.ratio, name, color, unit: Unit.ratio, base: 1 }),
),
},
],
},
],
};
}
File diff suppressed because it is too large Load Diff
@@ -1,193 +0,0 @@
/**
* Capitalization section builders
*/
import { Unit } from "../../utils/units.js";
import { colors } from "../../utils/colors.js";
import { ROLLING_WINDOWS, line, baseline, mapWindows, sumsTreeBaseline, rollingPercentRatioTree, percentRatio, percentRatioBaseline } from "../series.js";
import { ratioBottomSeries, mapCohortsWithAll, flatMapCohortsWithAll } from "../shared.js";
import { priceLine } from "../constants.js";
// ============================================================================
// Shared building blocks
// ============================================================================
/**
* Single cohort: Change + Growth Rate items (flat)
* @param {UtxoCohortObject["tree"]} tree
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function singleDeltaItems(tree, title) {
return [
{ ...sumsTreeBaseline({ windows: mapWindows(tree.realized.cap.delta.absolute, (c) => c.usd), title, metric: "Realized Cap Change", unit: Unit.usd, legend: "Change" }), name: "Change" },
{ ...rollingPercentRatioTree({ windows: tree.realized.cap.delta.rate, title, metric: "Realized Cap Growth Rate" }), name: "Growth Rate" },
];
}
/**
* Grouped: Change + Growth Rate + MVRV items (flat)
* @param {readonly (UtxoCohortObject | CohortWithoutRelative)[]} list
* @param {CohortAll} all
* @param {(name: string) => string} title
* @returns {PartialOptionsTree}
*/
function groupedDeltaAndMvrv(list, all, title) {
return [
{
name: "MVRV",
title: title("MVRV"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
baseline({ series: tree.realized.mvrv, name, color, unit: Unit.ratio, base: 1 }),
),
},
{
name: "Change",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Realized Cap Change`),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
baseline({ series: tree.realized.cap.delta.absolute[w.key].usd, name, color, unit: Unit.usd }),
),
})),
},
{
name: "Growth Rate",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} Realized Cap Growth Rate`),
bottom: flatMapCohortsWithAll(list, all, ({ name, color, tree }) =>
percentRatioBaseline({ pattern: tree.realized.cap.delta.rate[w.key], name, color }),
),
})),
},
];
}
// ============================================================================
// Single Cohort Sections
// ============================================================================
/**
* Full capitalization (has invested capital, own market cap ratio, full MVRV)
* @param {{ cohort: CohortAll | CohortFull | CohortLongTerm, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createValuationSectionFull({ cohort, title }) {
const { tree, color } = cohort;
return {
name: "Capitalization",
tree: [
{ name: "Total", title: title("Realized Cap"), bottom: [line({ series: tree.realized.cap.usd, name: "Realized Cap", color, unit: Unit.usd })] },
{
name: "Profitability",
tree: [
{
name: "Amount",
title: title("Invested Capital"),
bottom: [
line({ series: tree.realized.cap.usd, name: "Total", color: colors.default, unit: Unit.usd }),
line({ series: tree.unrealized.investedCapital.inProfit.usd, name: "In Profit", color: colors.profit, unit: Unit.usd }),
line({ series: tree.unrealized.investedCapital.inLoss.usd, name: "In Loss", color: colors.loss, unit: Unit.usd }),
],
},
{
name: "Composition",
title: title("Invested Capital Composition"),
bottom: [
...percentRatio({ pattern: tree.investedCapital.inProfit.share, name: "In Profit", color: colors.profit }),
...percentRatio({ pattern: tree.investedCapital.inLoss.share, name: "In Loss", color: colors.loss }),
priceLine({ number: 100, color: colors.default, style: 0, unit: Unit.percentage }),
priceLine({ number: 50, unit: Unit.percentage }),
],
},
],
},
{ name: "MVRV", title: title("MVRV"), bottom: ratioBottomSeries(tree.realized.price) },
...singleDeltaItems(tree, title),
{ name: "% of Own Market Cap", title: title("Realized Cap (% of Own Market Cap)"), bottom: percentRatioBaseline({ pattern: tree.realized.cap.toOwnMcap, name: "% of Own Market Cap", color }) },
],
};
}
/**
* Basic capitalization (no invested capital, simple MVRV)
* @param {{ cohort: CohortWithAdjusted | CohortBasic | CohortAddr | CohortWithoutRelative, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createValuationSection({ cohort, title }) {
const { tree } = cohort;
return {
name: "Capitalization",
tree: [
{ name: "Total", title: title("Realized Cap"), bottom: [line({ series: tree.realized.cap.usd, name: "Realized Cap", color: cohort.color, unit: Unit.usd })] },
...singleDeltaItems(tree, title),
{ name: "MVRV", title: title("MVRV"), bottom: [baseline({ series: tree.realized.mvrv, name: "MVRV", unit: Unit.ratio, base: 1 })] },
],
};
}
// ============================================================================
// Grouped Cohort Sections
// ============================================================================
/**
* @param {{ list: readonly (UtxoCohortObject | CohortWithoutRelative)[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedValuationSection({ list, all, title }) {
return {
name: "Capitalization",
tree: [
{
name: "Total",
title: title("Realized Cap"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({ series: tree.realized.cap.usd, name, color, unit: Unit.usd }),
),
},
...groupedDeltaAndMvrv(list, all, title),
],
};
}
/**
* @param {{ list: readonly (CohortAll | CohortFull | CohortLongTerm)[], all: CohortAll, title: (name: string) => string }} args
* @returns {PartialOptionsGroup}
*/
export function createGroupedValuationSectionWithOwnMarketCap({ list, all, title }) {
return {
name: "Capitalization",
tree: [
{
name: "Total",
title: title("Realized Cap"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({ series: tree.realized.cap.usd, name, color, unit: Unit.usd }),
),
},
{
name: "In Profit",
title: title("Invested Capital In Profit"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({ series: tree.unrealized.investedCapital.inProfit.usd, name, color, unit: Unit.usd }),
),
},
{
name: "In Loss",
title: title("Invested Capital In Loss"),
bottom: mapCohortsWithAll(list, all, ({ name, color, tree }) =>
line({ series: tree.unrealized.investedCapital.inLoss.usd, name, color, unit: Unit.usd }),
),
},
...groupedDeltaAndMvrv(list, all, title),
{
name: "% of Own Market Cap",
title: title("Realized Cap (% of Own Market Cap)"),
bottom: flatMapCohortsWithAll(list, all, ({ name, color, tree }) =>
percentRatio({ pattern: tree.realized.cap.toOwnMcap, name, color }),
),
},
],
};
}
-481
View File
@@ -1,481 +0,0 @@
import { createPartialOptions } from "./partial.js";
import {
createAnchorElement,
createButtonElement,
createSmall,
} from "../utils/dom.js";
import { pushHistory, resetParams } from "../utils/url.js";
import { readStored, writeToStorage } from "../utils/storage.js";
import { stringToId } from "../utils/format.js";
import { logUnused } from "./unused.js";
import { setQr } from "../panes/share.js";
import { getConstant } from "./constants.js";
import { colors } from "../utils/colors.js";
import { Unit } from "../utils/units.js";
import { brk } from "../utils/client.js";
export function initOptions() {
const LS_SELECTED_KEY = `selected_path`;
const savedPath = /** @type {string[]} */ (
JSON.parse(readStored(LS_SELECTED_KEY) || "[]") || []
).filter((v) => v);
const partialOptions = createPartialOptions();
/** @type {Option[]} */
const list = [];
/** @type {Map<string, HTMLLIElement>} */
const liByPath = new Map();
/** @type {Set<(option: Option) => void>} */
const selectedListeners = new Set();
/** @type {HTMLLIElement[]} */
let highlightedLis = [];
/**
* @param {Option | undefined} sel
*/
function updateHighlight(sel) {
if (!sel) return;
for (const li of highlightedLis) {
delete li.dataset.highlight;
}
highlightedLis = [];
let pathKey = "";
for (const segment of sel.path) {
pathKey = pathKey ? `${pathKey}/${segment}` : segment;
const li = liByPath.get(pathKey);
if (li) {
li.dataset.highlight = "";
highlightedLis.push(li);
}
}
if (!highlightedLis.length) {
const li = liByPath.get(stringToId(sel.name));
if (li) {
li.dataset.highlight = "";
highlightedLis.push(li);
}
}
}
const selected = {
/** @type {Option | undefined} */
value: undefined,
/** @param {Option} v */
set(v) {
this.value = v;
updateHighlight(v);
selectedListeners.forEach((cb) => cb(v));
},
/** @param {(option: Option) => void} cb */
onChange(cb) {
selectedListeners.add(cb);
if (this.value) cb(this.value);
return () => selectedListeners.delete(cb);
},
};
/**
* @param {string[]} nodePath
*/
function isOnSelectedPath(nodePath) {
const selectedPath = selected.value?.path;
return (
selectedPath &&
nodePath.length <= selectedPath.length &&
nodePath.every((v, i) => v === selectedPath[i])
);
}
/**
* @template T
* @param {() => T} fn
* @returns {() => T}
*/
function lazy(fn) {
/** @type {T | undefined} */
let cached;
let computed = false;
return () => {
if (!computed) {
computed = true;
cached = fn();
}
return /** @type {T} */ (cached);
};
}
/**
* @param {(AnyFetchedSeriesBlueprint | FetchedPriceSeriesBlueprint)[]} [arr]
*/
function arrayToMap(arr) {
/** @type {Map<Unit, AnyFetchedSeriesBlueprint[]>} */
const map = new Map();
/** @type {Map<Unit, Set<number>>} */
const priceLines = new Map();
if (!arr) return map;
// Cache arrays for common units outside loop
/** @type {AnyFetchedSeriesBlueprint[] | undefined} */
let usdArr;
/** @type {AnyFetchedSeriesBlueprint[] | undefined} */
let satsArr;
for (let i = 0; i < arr.length; i++) {
const blueprint = arr[i];
// Check for undefined series
if (!blueprint.series) {
throw new Error(`Blueprint has undefined series: ${blueprint.title}`);
}
// Check for price pattern blueprint (has usd/sats sub-series)
// Use unknown cast for safe property access check
const maybePriceSeries =
/** @type {{ usd?: AnySeriesPattern, sats?: AnySeriesPattern }} */ (
/** @type {unknown} */ (blueprint.series)
);
if (maybePriceSeries.usd?.by && maybePriceSeries.sats?.by) {
const { usd, sats } = maybePriceSeries;
if (!usdArr) map.set(Unit.usd, (usdArr = []));
usdArr.push({ ...blueprint, series: usd, unit: Unit.usd });
if (!satsArr) map.set(Unit.sats, (satsArr = []));
satsArr.push({ ...blueprint, series: sats, unit: Unit.sats });
continue;
}
// After continue, we know this is a regular series blueprint
const regularBlueprint = /** @type {AnyFetchedSeriesBlueprint} */ (
blueprint
);
const s = regularBlueprint.series;
const unit = regularBlueprint.unit;
if (!unit) continue;
let unitArr = map.get(unit);
if (!unitArr) map.set(unit, (unitArr = []));
unitArr.push(regularBlueprint);
// Track baseline base values for auto price lines
const type = regularBlueprint.type;
if (type === "Baseline") {
let priceSet = priceLines.get(unit);
if (!priceSet) priceLines.set(unit, (priceSet = new Set()));
priceSet.add(regularBlueprint.options?.baseValue?.price ?? 0);
} else if (!type || type === "Line") {
// Check if manual price line - avoid Object.values() array allocation
const by = s.by;
for (const k in by) {
if (by[/** @type {Index} */ (k)]?.path?.includes("constant_")) {
priceLines.get(unit)?.delete(parseFloat(regularBlueprint.title));
}
break;
}
}
}
// Add price lines at end for remaining values
for (const [unit, values] of priceLines) {
const arr = map.get(unit);
if (!arr) continue;
for (const baseValue of values) {
const s = getConstant(brk.series.constants, baseValue);
arr.push({
series: s,
title: `${baseValue}`,
color: colors.gray,
unit,
options: {
lineStyle: 4,
lastValueVisible: false,
crosshairMarkerVisible: false,
},
});
}
}
return map;
}
/**
* @param {Option} option
*/
function selectOption(option) {
if (selected.value === option) return;
pushHistory(option.path);
resetParams(option);
writeToStorage(LS_SELECTED_KEY, JSON.stringify(option.path));
selected.set(option);
}
/**
* @param {Object} args
* @param {Option} args.option
* @param {string} [args.name]
*/
function createOptionElement({ option, name }) {
const title = option.title;
if (option.kind === "link") {
const href = option.url();
if (option.qrcode) {
return createButtonElement({
inside: option.name,
title,
onClick: () => {
setQr(option.url());
},
});
} else {
return createAnchorElement({
href,
blank: true,
text: option.name,
title,
});
}
} else {
return createAnchorElement({
href: `/${option.path.join("/")}`,
title,
text: name || option.name,
onClick: () => {
selectOption(option);
},
});
}
}
/** @type {Option | undefined} */
let savedOption;
/**
* @typedef {{ type: "group"; name: string; serName: string; path: string[]; pathKey: string; count: number; children: ProcessedNode[] }} ProcessedGroup
* @typedef {{ type: "option"; option: Option; path: string[]; pathKey: string }} ProcessedOption
* @typedef {ProcessedGroup | ProcessedOption} ProcessedNode
*/
const savedPathStr = savedPath?.join("/");
/**
* @param {PartialOptionsTree} partialTree
* @param {string[]} parentPath
* @param {string} parentPathStr
* @returns {{ nodes: ProcessedNode[], count: number }}
*/
function processPartialTree(
partialTree,
parentPath = [],
parentPathStr = "",
) {
/** @type {ProcessedNode[]} */
const nodes = [];
let totalCount = 0;
for (let i = 0; i < partialTree.length; i++) {
const anyPartial = partialTree[i];
if ("tree" in anyPartial) {
const serName = stringToId(anyPartial.name);
const pathStr = parentPathStr ? `${parentPathStr}/${serName}` : serName;
const path = parentPath.concat(serName);
const { nodes: children, count } = processPartialTree(
anyPartial.tree,
path,
pathStr,
);
// Skip groups with no children
if (count === 0) continue;
totalCount += count;
nodes.push({
type: "group",
name: anyPartial.name,
serName,
path,
pathKey: pathStr,
count,
children,
});
} else {
const option = /** @type {Option} */ (anyPartial);
const name = option.name;
const serName = stringToId(name);
const pathStr = parentPathStr ? `${parentPathStr}/${serName}` : serName;
const path = parentPath.concat(serName);
// Transform partial to full option
if ("kind" in anyPartial && anyPartial.kind === "explorer") {
option.kind = anyPartial.kind;
option.path = [];
option.name = name;
} else if ("kind" in anyPartial && anyPartial.kind === "heatmap") {
Object.assign(
option,
/** @satisfies {HeatmapOption} */ ({
...anyPartial,
path,
}),
);
} else if ("url" in anyPartial) {
Object.assign(
option,
/** @satisfies {UrlOption} */ ({
kind: "link",
path,
name,
title: name,
qrcode: !!anyPartial.qrcode,
url: anyPartial.url,
}),
);
} else {
const title = option.title || name;
const topArr = anyPartial.top;
const bottomArr = anyPartial.bottom;
const topFn = lazy(() => arrayToMap(topArr));
const bottomFn = lazy(() => arrayToMap(bottomArr));
Object.assign(
option,
/** @satisfies {ChartOption} */ ({
kind: "chart",
name,
title,
path,
top: topFn,
bottom: bottomFn,
}),
);
}
list.push(option);
totalCount++;
if (savedPathStr && pathStr === savedPathStr) {
savedOption = option;
}
nodes.push({
type: "option",
option,
path,
pathKey: pathStr,
});
}
}
return { nodes, count: totalCount };
}
logUnused(brk.series, partialOptions);
const { nodes: processedTree } = processPartialTree(partialOptions);
/**
* @param {ProcessedNode[]} nodes
* @param {HTMLElement} parentEl
* @param {boolean} autoOpen
*/
function buildTreeDOM(nodes, parentEl, autoOpen) {
const ul = window.document.createElement("ul");
for (const node of nodes) {
const li = window.document.createElement("li");
ul.append(li);
liByPath.set(node.pathKey, li);
if (node.type === "group") {
const details = window.document.createElement("details");
details.dataset.name = node.serName;
li.appendChild(details);
const summary = window.document.createElement("summary");
details.append(summary);
summary.append(node.name);
summary.append(createSmall(`[${node.count.toLocaleString("en-us")}]`));
let built = false;
if (autoOpen && isOnSelectedPath(node.path)) {
built = true;
details.open = true;
buildTreeDOM(node.children, details, true);
}
details.addEventListener("toggle", () => {
if (details.open && !built) {
built = true;
buildTreeDOM(node.children, details, false);
}
updateHighlight(selected.value);
});
} else {
const element = createOptionElement({
option: node.option,
});
li.append(element);
}
}
parentEl.append(ul);
}
/** @type {HTMLElement | null} */
let parentEl = null;
/**
* @param {HTMLElement} el
*/
function setParent(el) {
if (parentEl) return;
parentEl = el;
buildTreeDOM(processedTree, el, true);
updateHighlight(selected.value);
}
const tree = /** @type {OptionsTree} */ (partialOptions);
function resolveUrl() {
const segments = window.location.pathname.split("/").filter((v) => v);
let folder = tree;
for (let i = 0; i < segments.length; i++) {
const match = folder.find((v) => segments[i] === stringToId(v.name));
if (!match) break;
if (i < segments.length - 1) {
if (!("tree" in match)) break;
folder = match.tree;
} else if (!("tree" in match)) {
selected.set(match);
return;
} else {
break;
}
}
selected.set(!segments.length && savedOption ? savedOption : list[0]);
}
resolveUrl();
if (!selected.value) {
const option = savedOption || list[0];
if (option) {
selected.set(option);
}
}
return {
selected,
list,
tree,
setParent,
createOptionElement,
selectOption,
resolveUrl,
};
}
/** @typedef {ReturnType<typeof initOptions>} Options */
-540
View File
@@ -1,540 +0,0 @@
/** Investing section - Investment strategy tools and analysis */
import { colors } from "../utils/colors.js";
import { brk } from "../utils/client.js";
import { percentRatioBaseline, price } from "./series.js";
import { satsBtcUsd } from "./shared.js";
import { periodIdToName } from "../utils/time.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 {PercentRatioPattern} 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]);
/** @typedef {typeof YEARS_2020S[number] | typeof YEARS_2010S[number]} DcaYear */
/** @typedef {`from${DcaYear}`} DcaYearKey */
/** @param {AllPeriodKey} key */
const periodName = (key) => periodIdToName(key.slice(1), true);
/**
* @typedef {{ percent: AnySeriesPattern, ratio: AnySeriesPattern }} PercentRatioPattern
*/
/**
* 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 series
* @property {PercentRatioPattern} returns - Returns series
* @property {AnyValuePattern} stack - Stack pattern
*/
/**
* Long-term entry item with CAGR
* @typedef {BaseEntryItem & { cagr: PercentRatioPattern }} LongEntryItem
*/
const ALL_YEARS = /** @type {const} */ ([...YEARS_2020S, ...YEARS_2010S]);
/**
* Build DCA class entry from year
* @param {Investing} investing
* @param {DcaYear} year
* @param {number} i
* @returns {BaseEntryItem}
*/
function buildYearEntry(investing, year, i) {
const key = /** @type {DcaYearKey} */ (`from${year}`);
return {
name: `${year}`,
color: colors.at(i, ALL_YEARS.length),
costBasis: investing.class.dcaCostBasis[key],
returns: investing.class.dcaReturn[key],
stack: investing.class.dcaStack[key],
};
}
/**
* Create Investing section
* @returns {PartialOptionsGroup}
*/
export function createInvestingSection() {
const { market, investing } = brk.series;
const { lookback, returns } = market;
return {
name: "Investing",
tree: [
createDcaVsLumpSumSection({ investing, lookback, returns }),
createDcaByPeriodSection({ investing, returns }),
createLumpSumByPeriodSection({ investing, lookback, returns }),
createDcaByStartYearSection({ investing }),
],
};
}
/**
* Create compare folder from items
* @param {string} context
* @param {Pick<BaseEntryItem, 'name' | 'color' | 'costBasis' | 'returns' | 'stack'>[]} items
*/
function createCompareFolder(context, items) {
const topPane = items.map(({ name, color, costBasis }) =>
price({ series: costBasis, name, color }),
);
return {
name: "Compare",
tree: [
{
name: "Cost Basis",
title: `Cost Basis: ${context}`,
top: topPane,
},
{
name: "Returns",
title: `Returns: ${context}`,
top: topPane,
bottom: items.flatMap(({ name, color, returns }) =>
percentRatioBaseline({
pattern: returns,
name,
color: [color, color],
}),
),
},
{
name: "Accumulated",
title: `Accumulated Value ($100/day): ${context}`,
top: topPane,
bottom: items.flatMap(({ name, color, stack }) =>
satsBtcUsd({ pattern: stack, name, color }),
),
},
],
};
}
/**
* Create compare folder from long items (includes CAGR chart)
* @param {string} context
* @param {LongEntryItem[]} items
*/
function createLongCompareFolder(context, items) {
const topPane = items.map(({ name, color, costBasis }) =>
price({ series: costBasis, name, color }),
);
return {
name: "Compare",
tree: [
{
name: "Cost Basis",
title: `Cost Basis: ${context}`,
top: topPane,
},
{
name: "Returns",
title: `Returns: ${context}`,
top: topPane,
bottom: items.flatMap(({ name, color, returns }) =>
percentRatioBaseline({
pattern: returns,
name,
color: [color, color],
}),
),
},
{
name: "CAGR",
title: `CAGR: ${context}`,
top: topPane,
bottom: items.flatMap(({ name, color, cagr }) =>
percentRatioBaseline({
pattern: cagr,
name,
color: [color, color],
}),
),
},
{
name: "Accumulated",
title: `Accumulated Value ($100/day): ${context}`,
top: topPane,
bottom: items.flatMap(({ name, color, stack }) =>
satsBtcUsd({ pattern: stack, name, color }),
),
},
],
};
}
/**
* Create single entry tree structure
* @param {BaseEntryItem & { titlePrefix?: string }} item
* @param {object[]} returnsBottom - Bottom pane items for returns chart
*/
function createSingleEntryTree(item, returnsBottom) {
const { name, titlePrefix = name, color, costBasis, stack } = item;
const top = [price({ series: costBasis, name: "Cost Basis", color })];
return {
name,
tree: [
{ name: "Cost Basis", title: `Cost Basis: ${titlePrefix}`, top },
{
name: "Returns",
title: `Returns: ${titlePrefix}`,
top,
bottom: returnsBottom,
},
{
name: "Accumulated",
title: `Accumulated Value ($100/day): ${titlePrefix}`,
top,
bottom: satsBtcUsd({ pattern: stack, name: "Value" }),
},
],
};
}
/**
* Create a single entry from a base item (no CAGR)
* @param {BaseEntryItem & { titlePrefix?: string }} item
*/
function createShortSingleEntry(item) {
return createSingleEntryTree(
item,
percentRatioBaseline({ pattern: item.returns, name: "Return" }),
);
}
/**
* Create a single entry from a long item (with CAGR as its own chart)
* @param {LongEntryItem & { titlePrefix?: string }} item
*/
function createLongSingleEntry(item) {
const {
name,
titlePrefix = name,
color,
costBasis,
returns,
cagr,
stack,
} = item;
const top = [price({ series: costBasis, name: "Cost Basis", color })];
return {
name,
tree: [
{ name: "Cost Basis", title: `Cost Basis: ${titlePrefix}`, top },
{
name: "Returns",
title: `Returns: ${titlePrefix}`,
top,
bottom: percentRatioBaseline({ pattern: returns, name: "Return" }),
},
{
name: "CAGR",
title: `CAGR: ${titlePrefix}`,
top,
bottom: percentRatioBaseline({ pattern: cagr, name: "CAGR" }),
},
{
name: "Accumulated",
title: `Accumulated Value ($100/day): ${titlePrefix}`,
top,
bottom: satsBtcUsd({ pattern: stack, name: "Value" }),
},
],
};
}
/**
* Create DCA vs Lump Sum section
* @param {Object} args
* @param {Investing} args.investing
* @param {Market["lookback"]} args.lookback
* @param {Market["returns"]} args.returns
*/
export function createDcaVsLumpSumSection({ investing, lookback, returns }) {
/** @param {AllPeriodKey} key */
const topPane = (key) => [
price({
series: investing.period.dcaCostBasis[key],
name: "DCA",
color: colors.profit,
}),
price({ series: lookback[key], name: "Lump Sum", color: colors.bitcoin }),
];
/** @param {string} name @param {AllPeriodKey} key */
const costBasisChart = (name, key) => ({
name: "Cost Basis",
title: `Cost Basis: ${name} DCA vs Lump Sum`,
top: topPane(key),
});
/** @param {string} name @param {AllPeriodKey} key */
const returnsChart = (name, key) => ({
name: "Returns",
title: `Returns: ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
...percentRatioBaseline({
pattern: investing.period.dcaReturn[key],
name: "DCA",
}),
...percentRatioBaseline({
pattern: investing.period.lumpSumReturn[key],
name: "Lump Sum",
color: colors.bi.p2,
}),
],
});
/** @param {string} name @param {LongPeriodKey} key */
const longCagrChart = (name, key) => ({
name: "CAGR",
title: `CAGR: ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
...percentRatioBaseline({
pattern: investing.period.dcaCagr[key],
name: "DCA",
}),
...percentRatioBaseline({
pattern: returns.cagr[key],
name: "Lump Sum",
color: colors.bi.p2,
}),
],
});
/** @param {string} name @param {AllPeriodKey} key */
const stackChart = (name, key) => ({
name: "Accumulated",
title: `Accumulated Value ($100/day): ${name} DCA vs Lump Sum`,
top: topPane(key),
bottom: [
...satsBtcUsd({
pattern: investing.period.dcaStack[key],
name: "DCA",
color: colors.profit,
}),
...satsBtcUsd({
pattern: investing.period.lumpSumStack[key],
name: "Lump Sum",
color: colors.bitcoin,
}),
],
});
/** @param {ShortPeriodKey} key */
const createShortPeriodEntry = (key) => {
const name = periodName(key);
return {
name,
tree: [
costBasisChart(name, key),
returnsChart(name, key),
stackChart(name, key),
],
};
};
/** @param {LongPeriodKey} key */
const createLongPeriodEntry = (key) => {
const name = periodName(key);
return {
name,
tree: [
costBasisChart(name, key),
returnsChart(name, key),
longCagrChart(name, key),
stackChart(name, key),
],
};
};
return {
name: "DCA vs Lump Sum",
title: "Compare Investment Strategies",
tree: [
{
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 period-based section (DCA or Lump Sum)
* @param {Object} args
* @param {Investing} args.investing
* @param {Market["lookback"]} [args.lookback]
* @param {Market["returns"]} args.returns
*/
function createPeriodSection({ investing, lookback, returns }) {
const isLumpSum = !!lookback;
const suffix = isLumpSum ? "Lump Sum" : "DCA";
const allPeriods = /** @type {const} */ ([...SHORT_PERIODS, ...LONG_PERIODS]);
/** @param {AllPeriodKey} key @param {number} i @returns {BaseEntryItem} */
const buildBaseEntry = (key, i) => ({
name: periodName(key),
color: colors.at(i, allPeriods.length),
costBasis: isLumpSum ? lookback[key] : investing.period.dcaCostBasis[key],
returns: isLumpSum
? investing.period.lumpSumReturn[key]
: investing.period.dcaReturn[key],
stack: isLumpSum
? investing.period.lumpSumStack[key]
: investing.period.dcaStack[key],
});
/** @param {LongPeriodKey} key @param {number} i @returns {LongEntryItem} */
const buildLongEntry = (key, i) =>
withCagr(
buildBaseEntry(key, i),
isLumpSum ? returns.cagr[key] : investing.period.dcaCagr[key],
);
/** @param {BaseEntryItem} entry */
const createShortEntry = (entry) =>
createShortSingleEntry({
...entry,
titlePrefix: `${entry.name} ${suffix}`,
});
/** @param {LongEntryItem} entry */
const createLongEntry = (entry) =>
createLongSingleEntry({
...entry,
titlePrefix: `${entry.name} ${suffix}`,
});
const shortEntries = SHORT_PERIODS.map((key, i) => buildBaseEntry(key, i));
const longEntries = LONG_PERIODS.map((key, i) =>
buildLongEntry(key, SHORT_PERIODS.length + i),
);
return {
name: `${suffix} by Period`,
title: `${suffix} Performance by Investment Period`,
tree: [
{
name: "Short Term",
title: "Up to 1 Year",
tree: [
createCompareFolder(`Short Term ${suffix}`, shortEntries),
...shortEntries.map(createShortEntry),
],
},
{
name: "Long Term",
title: "2+ Years",
tree: [
createLongCompareFolder(`Long Term ${suffix}`, longEntries),
...longEntries.map(createLongEntry),
],
},
],
};
}
/**
* Create DCA by Period section
* @param {Object} args
* @param {Investing} args.investing
* @param {Market["returns"]} args.returns
*/
export function createDcaByPeriodSection({ investing, returns }) {
return createPeriodSection({ investing, returns });
}
/**
* Create Lump Sum by Period section
* @param {Object} args
* @param {Investing} args.investing
* @param {Market["lookback"]} args.lookback
* @param {Market["returns"]} args.returns
*/
export function createLumpSumByPeriodSection({ investing, lookback, returns }) {
return createPeriodSection({ investing, lookback, returns });
}
/**
* Create DCA by Start Year section
* @param {Object} args
* @param {Investing} args.investing
*/
export function createDcaByStartYearSection({ investing }) {
/** @param {string} name @param {string} title @param {BaseEntryItem[]} entries */
const createDecadeGroup = (name, title, entries) => ({
name,
title,
tree: [
createCompareFolder(`${name} DCA`, entries),
...entries.map((entry) =>
createShortSingleEntry({
...entry,
titlePrefix: `${entry.name} DCA`,
}),
),
],
});
const entries2020s = YEARS_2020S.map((year, i) =>
buildYearEntry(investing, year, i),
);
const entries2010s = YEARS_2010S.map((year, i) =>
buildYearEntry(investing, year, YEARS_2020S.length + i),
);
return {
name: "DCA by Start Year",
title: "DCA Performance by When You Started",
tree: [
createCompareFolder("All Years DCA", [...entries2020s, ...entries2010s]),
createDecadeGroup("2020s", "2020-2026", entries2020s),
createDecadeGroup("2010s", "2015-2019", entries2010s),
],
};
}
File diff suppressed because it is too large Load Diff
-626
View File
@@ -1,626 +0,0 @@
/** Mining section - Network security and miner economics */
import { Unit } from "../utils/units.js";
import { entries, includes } from "../utils/array.js";
import { colors } from "../utils/colors.js";
import {
line,
dots,
dotted,
distributionBtcSatsUsd,
statsAtWindow,
ROLLING_WINDOWS,
percentRatio,
percentRatioBaseline,
chartsFromCount,
} from "./series.js";
import {
satsBtcUsdFrom,
satsBtcUsdFullTree,
revenueBtcSatsUsd,
revenueRollingBtcSatsUsd,
formatCohortTitle,
} from "./shared.js";
import { brk } from "../utils/client.js";
/** Major pools to show in Compare section (by current hashrate dominance) */
const MAJOR_POOL_IDS = /** @type {const} */ ([
"foundryusa",
"antpool",
"viabtc",
"f2pool",
"marapool",
"braiinspool",
"spiderpool",
"ocean",
]);
/**
* AntPool & friends - pools sharing AntPool's block templates
* Based on b10c's research: https://b10c.me/blog/015-bitcoin-mining-centralization/
*/
const ANTPOOL_AND_FRIENDS_IDS = /** @type {const} */ ([
"antpool",
"poolin",
"btccom",
"braiinspool",
"ultimuspool",
"binancepool",
"secpool",
"sigmapoolcom",
"rawpool",
"luxor",
]);
/**
* Create Mining section
* @returns {PartialOptionsGroup}
*/
export function createMiningSection() {
const { blocks, pools, mining } = brk.series;
const majorPoolData = entries(pools.major).map(([id, pool]) => ({
id,
name: brk.POOL_ID_TO_POOL_NAME[id],
pool,
}));
const minorPoolData = entries(pools.minor).map(([id, pool]) => ({
id,
name: brk.POOL_ID_TO_POOL_NAME[id],
pool,
}));
const featuredPools = majorPoolData.filter((p) =>
includes(MAJOR_POOL_IDS, p.id),
);
const antpoolFriends = majorPoolData.filter((p) =>
includes(ANTPOOL_AND_FRIENDS_IDS, p.id),
);
/**
* @param {(metric: string) => string} title
* @param {string} metric
* @param {DominancePattern} dominance
*/
const dominanceTree = (title, metric, dominance) => ({
name: "Dominance",
tree: [
{
name: "Compare",
title: title(metric),
bottom: [
...ROLLING_WINDOWS.flatMap((w) =>
percentRatio({
pattern: dominance[w.key],
name: w.name,
color: w.color,
defaultActive: w.key !== "_24h",
}),
),
...percentRatio({
pattern: dominance,
name: "All Time",
color: colors.time.all,
}),
],
},
...ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: title(`${w.title} ${metric}`),
bottom: percentRatio({
pattern: dominance[w.key],
name: "Dominance",
color: w.color,
}),
})),
{
name: "All Time",
title: title(`All Time ${metric}`),
bottom: percentRatio({
pattern: dominance,
name: "Dominance",
color: colors.time.all,
}),
},
],
});
/**
* @param {typeof majorPoolData} poolList
*/
const createPoolTree = (poolList) =>
poolList.map(({ name, pool }) => {
const title = formatCohortTitle(name);
return {
name,
tree: [
dominanceTree(title, "Dominance", pool.dominance),
{
name: "Blocks Mined",
tree: chartsFromCount({
pattern: pool.blocksMined,
title,
metric: "Blocks Mined",
unit: Unit.count,
}),
},
{
name: "Rewards",
tree: satsBtcUsdFullTree({
pattern: pool.rewards,
title,
metric: "Rewards",
}),
},
],
};
});
/**
* @param {typeof minorPoolData} poolList
*/
const createMinorPoolTree = (poolList) =>
poolList.map(({ name, pool }) => {
const title = formatCohortTitle(name);
return {
name,
tree: [
{
name: "Dominance",
title: title("Dominance"),
bottom: percentRatio({
pattern: pool.dominance,
name: "All Time",
color: colors.time.all,
}),
},
{
name: "Blocks Mined",
tree: chartsFromCount({
pattern: pool.blocksMined,
title,
metric: "Blocks Mined",
unit: Unit.count,
}),
},
],
};
});
/**
* @param {string} groupTitle
* @param {typeof majorPoolData} poolList
* @param {string} [name]
*/
const createPoolCompare = (groupTitle, poolList, name = "Compare") => ({
name,
tree: [
{
name: "Dominance",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: formatCohortTitle(groupTitle)(`${w.title} Dominance`),
bottom: poolList.flatMap((p, i) =>
percentRatio({
pattern: p.pool.dominance[w.key],
name: p.name,
color: colors.at(i, poolList.length),
}),
),
})),
},
{
name: "Blocks Mined",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: formatCohortTitle(groupTitle)(`${w.title} Blocks Mined`),
bottom: poolList.map((p, i) =>
line({
series: p.pool.blocksMined.sum[w.key],
name: p.name,
color: colors.at(i, poolList.length),
unit: Unit.count,
}),
),
})),
},
],
});
return {
name: "Mining",
tree: [
{
name: "Hashrate",
tree: [
{
name: "Current",
title: "Network Hashrate",
bottom: [
dots({
series: mining.hashrate.rate.base,
name: "Hashrate",
unit: Unit.hashRate,
}),
line({
series: mining.hashrate.rate.sma._1w,
name: "1w SMA",
color: colors.time._1w,
unit: Unit.hashRate,
defaultActive: false,
}),
line({
series: mining.hashrate.rate.sma._1m,
name: "1m SMA",
color: colors.time._1m,
unit: Unit.hashRate,
defaultActive: false,
}),
line({
series: mining.hashrate.rate.sma._2m,
name: "2m SMA",
color: colors.indicator.main,
unit: Unit.hashRate,
defaultActive: false,
}),
line({
series: mining.hashrate.rate.sma._1y,
name: "1y SMA",
color: colors.time._1y,
unit: Unit.hashRate,
defaultActive: false,
}),
dotted({
series: blocks.difficulty.hashrate,
name: "From Difficulty",
color: colors.default,
unit: Unit.hashRate,
}),
line({
series: mining.hashrate.rate.ath,
name: "ATH",
color: colors.loss,
unit: Unit.hashRate,
defaultActive: false,
}),
],
},
{
name: "ATH",
title: "Network Hashrate ATH",
bottom: [
line({
series: mining.hashrate.rate.ath,
name: "ATH",
color: colors.loss,
unit: Unit.hashRate,
}),
dots({
series: mining.hashrate.rate.base,
name: "Hashrate",
color: colors.bitcoin,
unit: Unit.hashRate,
}),
],
},
{
name: "Drawdown",
title: "Network Hashrate Drawdown",
bottom: percentRatio({
pattern: mining.hashrate.rate.drawdown,
name: "Drawdown",
color: colors.loss,
}),
},
],
},
{
name: "Revenue",
tree: [
...ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: `${w.title} Mining Revenue`,
bottom: revenueRollingBtcSatsUsd({
coinbase: mining.rewards.coinbase.average[w.key],
subsidy: mining.rewards.subsidy.average[w.key],
fee: mining.rewards.fees.average[w.key],
}),
})),
{
name: "Cumulative",
title: "Cumulative Mining Revenue",
bottom: revenueBtcSatsUsd({
coinbase: mining.rewards.coinbase,
subsidy: mining.rewards.subsidy,
fee: mining.rewards.fees,
key: "cumulative",
}),
},
{
name: "Coinbase",
tree: satsBtcUsdFullTree({
pattern: mining.rewards.coinbase,
metric: "Coinbase Rewards",
}),
},
{
name: "Subsidy",
tree: satsBtcUsdFullTree({
pattern: mining.rewards.subsidy,
metric: "Block Subsidy",
}),
},
{
name: "Fees",
tree: [
...satsBtcUsdFullTree({
pattern: mining.rewards.fees,
metric: "Transaction Fee Revenue",
}),
{
name: "Distribution",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: `${w.title} Fee Revenue per Block Distribution`,
bottom: distributionBtcSatsUsd(
statsAtWindow(mining.rewards.fees, w.key),
),
})),
},
],
},
{
name: "Dominance",
tree: [
...ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: `${w.title} Mining Revenue Dominance`,
bottom: [
...percentRatio({
pattern: mining.rewards.subsidy.dominance[w.key],
name: "Subsidy",
color: colors.mining.subsidy,
}),
...percentRatio({
pattern: mining.rewards.fees.dominance[w.key],
name: "Fees",
color: colors.mining.fee,
}),
],
})),
{
name: "All Time",
title: "All Time Mining Revenue Dominance",
bottom: [
...percentRatio({
pattern: mining.rewards.subsidy.dominance,
name: "Subsidy",
color: colors.mining.subsidy,
}),
...percentRatio({
pattern: mining.rewards.fees.dominance,
name: "Fees",
color: colors.mining.fee,
}),
],
},
],
},
{
name: "Fee-to-Subsidy",
tree: ROLLING_WINDOWS.map((w) => ({
name: w.name,
title: `${w.title} Fee-to-Subsidy Ratio`,
bottom: [
line({
series: mining.rewards.fees.toSubsidyRatio[w.key].ratio,
name: "Ratio",
color: colors.mining.fee,
unit: Unit.ratio,
}),
],
})),
},
{
name: "Unclaimed",
title: "Unclaimed Rewards",
bottom: satsBtcUsdFrom({
source: mining.rewards.unclaimed,
key: "cumulative",
name: "All Time",
}),
},
],
},
{
name: "Economics",
tree: [
{
name: "Hash Price",
title: "Hash Price",
bottom: [
line({
series: mining.hashrate.price.ths,
name: "per TH/s",
color: colors.usd,
unit: Unit.usdPerThsPerDay,
}),
line({
series: mining.hashrate.price.phs,
name: "per PH/s",
color: colors.usd,
unit: Unit.usdPerPhsPerDay,
}),
dotted({
series: mining.hashrate.price.thsMin,
name: "per TH/s ATL",
color: colors.stat.min,
unit: Unit.usdPerThsPerDay,
}),
dotted({
series: mining.hashrate.price.phsMin,
name: "per PH/s ATL",
color: colors.stat.min,
unit: Unit.usdPerPhsPerDay,
}),
],
},
{
name: "Hash Value",
title: "Hash Value",
bottom: [
line({
series: mining.hashrate.value.ths,
name: "per TH/s",
color: colors.bitcoin,
unit: Unit.satsPerThsPerDay,
}),
line({
series: mining.hashrate.value.phs,
name: "per PH/s",
color: colors.bitcoin,
unit: Unit.satsPerPhsPerDay,
}),
dotted({
series: mining.hashrate.value.thsMin,
name: "per TH/s ATL",
color: colors.stat.min,
unit: Unit.satsPerThsPerDay,
}),
dotted({
series: mining.hashrate.value.phsMin,
name: "per PH/s ATL",
color: colors.stat.min,
unit: Unit.satsPerPhsPerDay,
}),
],
},
{
name: "Recovery",
title: "Hash Price & Value Recovery",
bottom: [
...percentRatio({
pattern: mining.hashrate.price.rebound,
name: "Hash Price",
color: colors.usd,
}),
...percentRatio({
pattern: mining.hashrate.value.rebound,
name: "Hash Value",
color: colors.bitcoin,
}),
],
},
],
},
{
name: "Halving",
tree: [
{
name: "Countdown",
title: "Next Halving",
bottom: [
line({
series: blocks.halving.blocksToHalving,
name: "Blocks",
unit: Unit.blocks,
}),
line({
series: blocks.halving.daysToHalving,
name: "Days",
unit: Unit.days,
}),
],
},
{
name: "Epoch",
title: "Halving Epoch",
bottom: [
line({
series: blocks.halving.epoch,
name: "Epoch",
unit: Unit.epoch,
}),
],
},
],
},
{
name: "Difficulty",
tree: [
{
name: "Current",
title: "Mining Difficulty",
bottom: [
line({
series: blocks.difficulty.value,
name: "Difficulty",
unit: Unit.difficulty,
}),
],
},
{
name: "Adjustment",
title: "Difficulty Adjustment",
bottom: percentRatioBaseline({
pattern: blocks.difficulty.adjustment,
name: "Change",
}),
},
{
name: "Countdown",
title: "Next Difficulty Adjustment",
bottom: [
line({
series: blocks.difficulty.blocksToRetarget,
name: "Blocks",
unit: Unit.blocks,
}),
line({
series: blocks.difficulty.daysToRetarget,
name: "Days",
unit: Unit.days,
}),
],
},
{
name: "Epoch",
title: "Difficulty Epoch",
bottom: [
line({
series: blocks.difficulty.epoch,
name: "Epoch",
unit: Unit.epoch,
}),
],
},
],
},
{
name: "Pools",
tree: [
createPoolCompare("Major Pools", featuredPools, "Featured"),
{
name: "AntPool & Friends",
tree: [
createPoolCompare("AntPool & Friends", antpoolFriends),
...createPoolTree(antpoolFriends),
],
},
{
name: "Major",
tree: createPoolTree(majorPoolData),
},
{
name: "Minor",
tree: createMinorPoolTree(minorPoolData),
},
],
},
],
};
}
File diff suppressed because it is too large Load Diff
-340
View File
@@ -1,340 +0,0 @@
/** Partial options - Main entry point */
import {
buildCohortData,
createCohortFolderAll,
createCohortFolderFull,
createCohortFolderWithAdjusted,
createCohortFolderLongTerm,
createCohortFolderAgeRangeWithMatured,
createCohortFolderBasicWithMarketCap,
createCohortFolderWithoutRelative,
createCohortFolderAddress,
createAddressCohortFolder,
createGroupedCohortFolderWithAdjusted,
createGroupedCohortFolderWithNupl,
createGroupedCohortFolderAgeRangeWithMatured,
createGroupedCohortFolderBasicWithMarketCap,
createGroupedCohortFolderAddress,
createGroupedAddressCohortFolder,
createUtxoProfitabilitySection,
createAddressBalanceGiniLeaf,
} from "./distribution/index.js";
import { createMarketSection } from "./market.js";
import { createNetworkSection } from "./network.js";
import { createMiningSection } from "./mining.js";
import { createCointimeSection } from "./cointime.js";
import { createInvestingSection } from "./investing.js";
import {
oracleOutputsHeatmapOption,
oraclePaymentsHeatmapOption,
} from "../../src/heatmap/oracle.js";
import {
urpdAgeBandHeatmapFolders,
urpdAllHeatmapOptions,
urpdLthHeatmapOptions,
urpdSthHeatmapOptions,
} from "../../src/heatmap/urpd.js";
// Re-export types for external consumers
export * from "./types.js";
/**
* Create partial options tree
* @returns {PartialOptionsTree}
*/
export function createPartialOptions() {
// Build cohort data
const {
cohortAll,
termShort,
termLong,
underAge,
overAge,
ageRange,
epoch,
utxosOverAmount,
addressesOverAmount,
utxosUnderAmount,
addressesUnderAmount,
utxosAmountRange,
addressesAmountRange,
typeAddressable,
typeOther,
class: class_,
profitabilityRange,
profitabilityProfit,
profitabilityLoss,
} = buildCohortData();
return [
{
name: "Explorer",
kind: "explorer",
title: "Explorer",
},
{
name: "Charts",
tree: [
createMarketSection(),
createNetworkSection(),
createMiningSection(),
{
name: "Distribution",
tree: [
createCohortFolderAll({ ...cohortAll, name: "Overview" }),
createGroupedCohortFolderWithNupl({
name: "STH vs LTH",
title: "STH vs LTH",
list: [termShort, termLong],
all: cohortAll,
}),
createCohortFolderFull(termShort),
createCohortFolderLongTerm(termLong),
{
name: "UTXO Age",
tree: [
{
name: "Under",
tree: [
createGroupedCohortFolderWithAdjusted({
name: "Compare",
title: "Under Age",
list: underAge,
all: cohortAll,
}),
...underAge.map(createCohortFolderWithAdjusted),
],
},
{
name: "Over",
tree: [
createGroupedCohortFolderWithAdjusted({
name: "Compare",
title: "Over Age",
list: overAge,
all: cohortAll,
}),
...overAge.map(createCohortFolderWithAdjusted),
],
},
{
name: "Range",
tree: [
createGroupedCohortFolderAgeRangeWithMatured({
name: "Compare",
title: "Age Ranges",
list: ageRange,
all: cohortAll,
}),
...ageRange.map(createCohortFolderAgeRangeWithMatured),
],
},
],
},
{
name: "UTXO Size",
tree: [
{
name: "Under",
tree: [
createGroupedCohortFolderBasicWithMarketCap({
name: "Compare",
title: "Under Amount",
list: utxosUnderAmount,
all: cohortAll,
}),
...utxosUnderAmount.map(
createCohortFolderBasicWithMarketCap,
),
],
},
{
name: "Over",
tree: [
createGroupedCohortFolderBasicWithMarketCap({
name: "Compare",
title: "Over Amount",
list: utxosOverAmount,
all: cohortAll,
}),
...utxosOverAmount.map(
createCohortFolderBasicWithMarketCap,
),
],
},
{
name: "Range",
tree: [
createGroupedCohortFolderBasicWithMarketCap({
name: "Compare",
title: "Amount Ranges",
list: utxosAmountRange,
all: cohortAll,
}),
...utxosAmountRange.map(
createCohortFolderBasicWithMarketCap,
),
],
},
],
},
createUtxoProfitabilitySection({
range: profitabilityRange,
profit: profitabilityProfit,
loss: profitabilityLoss,
}),
{
name: "Address Balance",
tree: [
{
name: "Under",
tree: [
createGroupedAddressCohortFolder({
name: "Compare",
title: "Under Balance",
list: addressesUnderAmount,
all: cohortAll,
}),
...addressesUnderAmount.map(createAddressCohortFolder),
],
},
{
name: "Over",
tree: [
createGroupedAddressCohortFolder({
name: "Compare",
title: "Over Balance",
list: addressesOverAmount,
all: cohortAll,
}),
...addressesOverAmount.map(createAddressCohortFolder),
],
},
{
name: "Range",
tree: [
createGroupedAddressCohortFolder({
name: "Compare",
title: "Balance Ranges",
list: addressesAmountRange,
all: cohortAll,
}),
...addressesAmountRange.map(createAddressCohortFolder),
],
},
createAddressBalanceGiniLeaf(),
],
},
{
name: "Script Type",
tree: [
createGroupedCohortFolderAddress({
name: "Compare",
title: "Script Type",
list: typeAddressable,
all: cohortAll,
}),
.../** @satisfies {readonly SpendableType[]} */ ([
"p2a",
"p2tr",
"p2wsh",
"p2wpkh",
"p2sh",
"p2ms",
"p2pkh",
"p2pk33",
"p2pk65",
"empty",
"unknown",
]).flatMap((key) => {
const addr = typeAddressable.find((t) => t.key === key);
if (addr) return [createCohortFolderAddress(addr)];
const other = typeOther.find((t) => t.key === key);
if (other) return [createCohortFolderWithoutRelative(other)];
return [];
}),
],
},
{
name: "Epoch",
tree: [
createGroupedCohortFolderWithAdjusted({
name: "Compare",
title: "Epoch",
list: epoch,
all: cohortAll,
}),
...epoch.map(createCohortFolderWithAdjusted),
],
},
{
name: "Class",
tree: [
createGroupedCohortFolderWithAdjusted({
name: "Compare",
title: "Class",
list: class_,
all: cohortAll,
}),
...class_.map(createCohortFolderWithAdjusted),
],
},
],
},
createInvestingSection(),
{
name: "Frameworks",
tree: [createCointimeSection()],
},
],
},
{
name: "Heatmaps",
tree: [
{
name: "Output Values",
tree: [oracleOutputsHeatmapOption, oraclePaymentsHeatmapOption],
},
{
name: "Price Distributions",
tree: [
...urpdAllHeatmapOptions,
{ name: "STH", tree: urpdSthHeatmapOptions },
{ name: "LTH", tree: urpdLthHeatmapOptions },
{ name: "Age Bands", tree: urpdAgeBandHeatmapFolders },
],
},
],
},
{
name: "API",
url: () => "/api",
title: "API documentation",
},
{
name: "Source",
url: () => "https://bitcoinresearchkit.org",
title: "Bitcoin Research Kit",
},
];
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-357
View File
@@ -1,357 +0,0 @@
/**
* @typedef {Object} BaseSeriesBlueprint
* @property {string} title
* @property {string} [key] - Optional key for persistence (derived from title if not provided)
* @property {boolean} [defaultActive]
*
* @typedef {Object} BaselineSeriesBlueprintSpecific
* @property {"Baseline"} type
* @property {Color} [color]
* @property {[Color, Color]} [colors]
* @property {BaselineSeriesPartialOptions} [options]
* @typedef {BaseSeriesBlueprint & BaselineSeriesBlueprintSpecific} BaselineSeriesBlueprint
*
* @typedef {Object} CandlestickSeriesBlueprintSpecific
* @property {"Candlestick"} type
* @property {[Color, Color]} [colors]
* @property {CandlestickSeriesPartialOptions} [options]
* @typedef {BaseSeriesBlueprint & CandlestickSeriesBlueprintSpecific} CandlestickSeriesBlueprint
*
* @typedef {Object} LineSeriesBlueprintSpecific
* @property {"Line"} [type]
* @property {Color} [color]
* @property {(value: number) => Color} [colorFn]
* @property {LineSeriesPartialOptions} [options]
* @typedef {BaseSeriesBlueprint & LineSeriesBlueprintSpecific} LineSeriesBlueprint
*
* @typedef {Object} HistogramSeriesBlueprintSpecific
* @property {"Histogram"} type
* @property {Color | [Color, Color]} [color] - Single color or [positive, negative] colors (defaults to green/red)
* @property {(value: number) => Color} [colorFn]
* @property {HistogramSeriesPartialOptions} [options]
* @typedef {BaseSeriesBlueprint & HistogramSeriesBlueprintSpecific} HistogramSeriesBlueprint
*
* @typedef {Object} DotsSeriesBlueprintSpecific
* @property {"Dots"} type
* @property {Color} [color]
* @property {LineSeriesPartialOptions} [options]
* @typedef {BaseSeriesBlueprint & DotsSeriesBlueprintSpecific} DotsSeriesBlueprint
*
* @typedef {Object} DotsBaselineSeriesBlueprintSpecific
* @property {"DotsBaseline"} type
* @property {Color} [color]
* @property {[Color, Color]} [colors]
* @property {BaselineSeriesPartialOptions} [options]
* @typedef {BaseSeriesBlueprint & DotsBaselineSeriesBlueprintSpecific} DotsBaselineSeriesBlueprint
*
* @typedef {Object} PriceSeriesBlueprintSpecific
* @property {"Price"} type
* @property {AnySeriesPattern} ohlcSeries - OHLC series for candlestick (>= 1h indexes)
* @property {[Color, Color]} [colors]
* @property {CandlestickSeriesPartialOptions} [options]
* @typedef {BaseSeriesBlueprint & PriceSeriesBlueprintSpecific} PriceSeriesBlueprint
*
* @typedef {BaselineSeriesBlueprint | CandlestickSeriesBlueprint | LineSeriesBlueprint | HistogramSeriesBlueprint | DotsSeriesBlueprint | DotsBaselineSeriesBlueprint | PriceSeriesBlueprint} AnySeriesBlueprint
*
* @typedef {AnySeriesBlueprint["type"]} SeriesType
*
* @typedef {{ series: AnySeriesPattern, unit?: Unit }} FetchedAnySeriesOptions
*
* @typedef {BaselineSeriesBlueprint & FetchedAnySeriesOptions} FetchedBaselineSeriesBlueprint
* @typedef {CandlestickSeriesBlueprint & FetchedAnySeriesOptions} FetchedCandlestickSeriesBlueprint
* @typedef {LineSeriesBlueprint & FetchedAnySeriesOptions} FetchedLineSeriesBlueprint
* @typedef {HistogramSeriesBlueprint & FetchedAnySeriesOptions} FetchedHistogramSeriesBlueprint
* @typedef {DotsSeriesBlueprint & FetchedAnySeriesOptions} FetchedDotsSeriesBlueprint
* @typedef {DotsBaselineSeriesBlueprint & FetchedAnySeriesOptions} FetchedDotsBaselineSeriesBlueprint
* @typedef {AnySeriesBlueprint & FetchedAnySeriesOptions} AnyFetchedSeriesBlueprint
*
* Any pattern with usd and sats sub-series (auto-expands to USD + sats)
* @typedef {{ usd: AnySeriesPattern, sats: AnySeriesPattern }} AnyPricePattern
*
* Any pattern with sats, btc, and usd sub-series (value patterns like stack)
* @typedef {{ sats: AnySeriesPattern, btc: AnySeriesPattern, usd: AnySeriesPattern }} AnyValuePattern
*
* Top pane price series - requires a price pattern with usd/sats, auto-expands to USD + sats
* @typedef {{ series: AnyPricePattern }} FetchedPriceSeriesOptions
* @typedef {LineSeriesBlueprint & FetchedPriceSeriesOptions} FetchedPriceSeriesBlueprint
*
* @typedef {Object} PartialOption
* @property {string} name
*
* @typedef {Object} ProcessedOptionAddons
* @property {string} title
* @property {string[]} path
*
* @typedef {Object} PartialExplorerOptionSpecific
* @property {"explorer"} kind
* @property {string} title
*
* @typedef {PartialOption & PartialExplorerOptionSpecific} PartialExplorerOption
*
* @typedef {Required<PartialExplorerOption> & ProcessedOptionAddons} ExplorerOption
*
* @typedef {Object} PartialChartOptionSpecific
* @property {"chart"} [kind]
* @property {string} title
* @property {FetchedPriceSeriesBlueprint[]} [top]
* @property {AnyFetchedSeriesBlueprint[]} [bottom]
*
* @typedef {PartialOption & PartialChartOptionSpecific} PartialChartOption
*
* @typedef {Object} ProcessedChartOptionAddons
* @property {() => Map<Unit, AnyFetchedSeriesBlueprint[]>} top
* @property {() => Map<Unit, AnyFetchedSeriesBlueprint[]>} bottom
*
* @typedef {Required<Omit<PartialChartOption, "top" | "bottom">> & ProcessedChartOptionAddons & ProcessedOptionAddons} ChartOption
*
* @typedef {Object} PartialHeatmapOptionSpecific
* @property {"heatmap"} kind
* @property {string} title
* @property {HeatmapPointSource} points
* @property {HeatmapGridFactory} grid
* @property {HeatmapColorFn} color
* @property {HeatmapAxis} [axis]
* @property {HeatmapDefaults} [defaults]
* @property {HeatmapTooltipFn} [tooltip]
*
* @typedef {PartialOption & PartialHeatmapOptionSpecific} PartialHeatmapOption
*
* @typedef {PartialHeatmapOption & ProcessedOptionAddons} HeatmapOption
*
* @typedef {Object} PartialUrlOptionSpecific
* @property {"link"} [kind]
* @property {() => string} url
* @property {string} title
* @property {boolean} [qrcode]
*
* @typedef {PartialOption & PartialUrlOptionSpecific} PartialUrlOption
*
* @typedef {Required<PartialUrlOption> & ProcessedOptionAddons} UrlOption
*
* @typedef {PartialExplorerOption | PartialChartOption | PartialUrlOption | PartialHeatmapOption} AnyPartialOption
*
* @typedef {ExplorerOption | ChartOption | UrlOption | HeatmapOption} Option
*
* @typedef {(AnyPartialOption | PartialOptionsGroup)[]} PartialOptionsTree
*
* @typedef {Object} PartialOptionsGroup
* @property {string} name
* @property {PartialOptionsTree} tree
*
* @typedef {Object} OptionsGroup
* @property {string} name
* @property {OptionsTree} tree
*
* @typedef {(Option | OptionsGroup)[]} OptionsTree
*
* @typedef {Object} UtxoCohortObject
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {UtxoCohortPattern} tree
*
* ============================================================================
* UTXO Cohort Pattern Types (based on brk client patterns)
* ============================================================================
*
* Patterns with adjustedSopr + percentiles + RelToMarketCap:
* - ShortTermPattern (term.short)
* @typedef {ShortTermPattern} PatternFull
*
* The "All" pattern is special - has adjustedSopr + percentiles but NO RelToMarketCap
* @typedef {AllUtxoPattern} PatternAll
*
* Patterns with adjustedSopr only (RealizedPattern4, CostBasisPattern):
* - MaxAgePattern (maxAge.*)
* @typedef {MaxAgePattern} PatternWithAdjusted
*
* Patterns with percentiles only (RealizedPattern2, CostBasisPattern2):
* - LongTermPattern (term.long)
* - AgeRangePattern (ageRange.*)
* @typedef {LongTermPattern | AgeRangePattern} PatternWithPercentiles
*
* Patterns with RelToMarketCap in relative (geAmount.*, ltAmount.*):
* @typedef {UtxoAmountPattern | AddrAmountPattern} PatternBasicWithMarketCap
*
* Patterns without RelToMarketCap in relative:
* - EpochPattern (epoch.*, year.*)
* - UtxoAmountPattern (amountRange.*)
* - OutputsRealizedSupplyUnrealizedPattern2 (addressable type.*)
* @typedef {EpochPattern | UtxoAmountPattern | EmptyPattern} PatternBasicWithoutMarketCap
*
* Patterns without relative section entirely (edge case output types):
* - EmptyPattern (type.empty, type.p2ms, type.unknown)
* @typedef {EmptyPattern} PatternWithoutRelative
*
* Union of basic patterns (for backwards compat)
* @typedef {PatternBasicWithMarketCap | PatternBasicWithoutMarketCap} PatternBasic
*
* ============================================================================
* Cohort Object Types (by capability)
* ============================================================================
*
* All cohort: adjustedSopr + percentiles but NO RelToMarketCap (special)
* @typedef {Object} CohortAll
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternAll} tree
* @property {AddrCountPattern} addressCount
* @property {AvgAmountPattern} avgAmount
*
* Full cohort: adjustedSopr + percentiles + RelToMarketCap (term.short)
* @typedef {Object} CohortFull
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternFull} tree
*
* Cohort with adjustedSopr only (maxAge.*)
* @typedef {Object} CohortWithAdjusted
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternWithAdjusted} tree
*
* Cohort with percentiles only (term.long, ageRange.*)
* @typedef {Object} CohortWithPercentiles
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternWithPercentiles} tree
*
* Long term cohort (term.long) - has nupl via RelativePattern5
* @typedef {Object} CohortLongTerm
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {LongTermPattern} tree
*
* Age range cohort (ageRange.*) - no nupl via RelativePattern2
* @typedef {Object} CohortAgeRange
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {AgeRangePattern} tree
*
* Age range cohort with matured supply
* @typedef {CohortAgeRange & { matured: FullValuePattern }} CohortAgeRangeWithMatured
*
* Basic cohort WITH RelToMarketCap (geAmount.*, ltAmount.*)
* @typedef {Object} CohortBasicWithMarketCap
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternBasicWithMarketCap} tree
*
* Basic cohort WITHOUT RelToMarketCap (epoch.*, amountRange.*, year.*, type.*)
* @typedef {Object} CohortBasicWithoutMarketCap
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternBasicWithoutMarketCap} tree
*
* Cohort without relative section (edge case types: empty, p2ms, unknown)
* @typedef {Object} CohortWithoutRelative
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternWithoutRelative} tree
*
* Union of basic cohort types
* @typedef {CohortBasicWithMarketCap | CohortBasicWithoutMarketCap} CohortBasic
*
* ============================================================================
* Extended Cohort Types (with address count)
* ============================================================================
*
* Addressable cohort with address count (for "type" cohorts - uses OutputsRealizedSupplyUnrealizedPattern2)
* @typedef {{ name: string, key: AddressableType, title: string, color: Color, tree: EmptyPattern, addressCount: AddrCountPattern, avgAmount: AvgAmountPattern, exposed: ExposedTree, reused: ReusedTree, respent: RespentTree }} CohortAddr
*
* ============================================================================
* Cohort Group Types (by capability)
* ============================================================================
*
* @typedef {Object} CohortGroupFull
* @property {string} name
* @property {string} title
* @property {readonly CohortFull[]} list
* @property {CohortAll} all
*
* @typedef {Object} CohortGroupWithAdjusted
* @property {string} name
* @property {string} title
* @property {readonly CohortWithAdjusted[]} list
* @property {CohortAll} all
*
* @typedef {Object} CohortGroupWithPercentiles
* @property {string} name
* @property {string} title
* @property {readonly CohortWithPercentiles[]} list
* @property {CohortAll} all
*
* @typedef {Object} CohortGroupLongTerm
* @property {string} name
* @property {string} title
* @property {readonly CohortLongTerm[]} list
* @property {CohortAll} all
*
* @typedef {Object} CohortGroupAgeRange
* @property {string} name
* @property {string} title
* @property {readonly CohortAgeRange[]} list
* @property {CohortAll} all
*
* @typedef {Object} CohortGroupBasicWithMarketCap
* @property {string} name
* @property {string} title
* @property {readonly CohortBasicWithMarketCap[]} list
* @property {CohortAll} all
*
* @typedef {Object} CohortGroupBasicWithoutMarketCap
* @property {string} name
* @property {string} title
* @property {readonly CohortBasicWithoutMarketCap[]} list
* @property {CohortAll} all
*
* @typedef {Object} CohortGroupWithoutRelative
* @property {string} name
* @property {string} title
* @property {readonly CohortWithoutRelative[]} list
* @property {CohortAll} all
*
* Union of basic cohort group types
* @typedef {CohortGroupBasicWithMarketCap | CohortGroupBasicWithoutMarketCap} CohortGroupBasic
*
* @typedef {Object} UtxoCohortGroupObject
* @property {string} name
* @property {string} title
* @property {readonly UtxoCohortObject[]} list
* @property {CohortAll} all
*
* @typedef {Object} AddrCohortObject
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {AddrCohortPattern} tree
* @property {AddrCountPattern} addressCount
*
* @typedef {UtxoCohortObject | AddrCohortObject | CohortWithoutRelative} CohortObject
*
* @typedef {Object} AddrCohortGroupObject
* @property {string} name
* @property {string} title
* @property {readonly AddrCohortObject[]} list
* @property {CohortAll} all
*
* @typedef {UtxoCohortGroupObject | AddrCohortGroupObject} CohortGroupObject
*
* @typedef {Object} CohortGroupAddr
* @property {string} name
* @property {string} title
* @property {readonly CohortAddr[]} list
* @property {CohortAll} all
*/
// Re-export for type consumers
export {};
-186
View File
@@ -1,186 +0,0 @@
import { localhost } from "../utils/env.js";
/**
* Walk a series tree and collect all chartable series patterns
* @param {TreeNode | null | undefined} node
* @param {Map<AnySeriesPattern, string[]>} map
* @param {string[]} path
*/
function walkSeries(node, map, path) {
if (node && "by" in node) {
const seriesNode = /** @type {AnySeriesPattern} */ (node);
if (!seriesNode.by.day1) return;
map.set(seriesNode, path);
} else if (node && typeof node === "object") {
for (const [key, value] of Object.entries(node)) {
const kn = key.toLowerCase();
if (
key === "sd24h" ||
key === "emaSlow" ||
key === "emaFast" ||
kn === "cents" ||
kn === "bps" ||
kn === "constants" ||
kn === "ohlc" ||
kn === "split" ||
kn === "spot" ||
kn.startsWith("timestamp") ||
kn.startsWith("coinyears") ||
kn.endsWith("index") ||
kn.endsWith("indexes")
)
continue;
const newPath = [...path, key];
const joined = newPath.join(".");
if (
joined.endsWith(".count.total.average") ||
joined.endsWith(".versions.v1.average") ||
joined.endsWith(".versions.v2.average") ||
joined.endsWith(".versions.v3.average")
)
continue;
walkSeries(/** @type {TreeNode | null | undefined} */ (value), map, newPath);
}
}
}
/**
* Walk partial options tree and delete referenced series from the map
* @param {PartialOptionsTree} options
* @param {Map<AnySeriesPattern, string[]>} map
*/
function walkOptions(options, map) {
for (const node of options) {
if ("tree" in node && node.tree) {
walkOptions(node.tree, map);
} else if ("top" in node || "bottom" in node) {
const chartNode = /** @type {PartialChartOption} */ (node);
markUsedBlueprints(map, chartNode.top);
markUsedBlueprints(map, chartNode.bottom);
}
}
}
/**
* @param {Map<AnySeriesPattern, string[]>} map
* @param {(AnyFetchedSeriesBlueprint | FetchedPriceSeriesBlueprint)[]} [arr]
*/
function markUsedBlueprints(map, arr) {
if (!arr) return;
for (let i = 0; i < arr.length; i++) {
const s = arr[i].series;
if (!s) continue;
const maybePriceSeries =
/** @type {{ usd?: AnySeriesPattern, sats?: AnySeriesPattern }} */ (
/** @type {unknown} */ (s)
);
if (maybePriceSeries.usd?.by && maybePriceSeries.sats?.by) {
map.delete(maybePriceSeries.usd);
map.delete(maybePriceSeries.sats);
} else {
map.delete(/** @type {AnySeriesPattern} */ (s));
}
}
}
/**
* Log unused series to console (localhost only)
* @param {TreeNode} seriesTree
* @param {PartialOptionsTree} partialOptions
*/
export function logUnused(seriesTree, partialOptions) {
if (!localhost) return;
console.log(extractTreeStructure(partialOptions));
/** @type {Map<AnySeriesPattern, string[]>} */
const all = new Map();
walkSeries(seriesTree, all, []);
walkOptions(partialOptions, all);
if (!all.size) return;
/** @type {Record<string, unknown>} */
const tree = {};
for (const path of all.values()) {
/** @type {Record<string, unknown>} */
let current = tree;
for (let i = 0; i < path.length; i++) {
const part = path[i];
if (i === path.length - 1) {
current[part] = null;
} else {
current[part] = current[part] || {};
current = /** @type {Record<string, unknown>} */ (current[part]);
}
}
}
console.log("Unused series:", { count: all.size, tree });
}
/**
* Extract tree structure from partial options (names + hierarchy, series grouped by unit)
* @param {PartialOptionsTree} options
* @returns {object[]}
*/
export function extractTreeStructure(options) {
/**
* Group series by unit
* @param {(AnyFetchedSeriesBlueprint | FetchedPriceSeriesBlueprint)[]} series
* @param {boolean} isTop
* @returns {Record<string, string[]>}
*/
function groupByUnit(series, isTop) {
/** @type {Record<string, string[]>} */
const grouped = {};
for (const s of series) {
const pattern = /** @type {AnySeriesPattern | AnyPricePattern} */ (
s.series
);
if (isTop && "usd" in pattern && "sats" in pattern) {
const title = s.title || s.key || "unnamed";
(grouped["USD"] ??= []).push(title);
(grouped["sats"] ??= []).push(title);
} else {
const unit = /** @type {AnyFetchedSeriesBlueprint} */ (s).unit;
const unitName = unit?.name || "unknown";
const title = s.title || s.key || "unnamed";
(grouped[unitName] ??= []).push(title);
}
}
return grouped;
}
/**
* @param {AnyPartialOption | PartialOptionsGroup} node
* @returns {object}
*/
function processNode(node) {
if ("tree" in node && node.tree) {
return {
name: node.name,
children: node.tree.map(processNode),
};
}
if ("top" in node || "bottom" in node) {
const chartNode = /** @type {PartialChartOption} */ (node);
const top = chartNode.top ? groupByUnit(chartNode.top, true) : undefined;
const bottom = chartNode.bottom
? groupByUnit(chartNode.bottom, false)
: undefined;
return {
name: node.name,
title: chartNode.title,
...(top && Object.keys(top).length > 0 ? { top } : {}),
...(bottom && Object.keys(bottom).length > 0 ? { bottom } : {}),
};
}
if ("url" in node) {
return { name: node.name, url: true };
}
return { name: node.name };
}
return options.map(processNode);
}