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
@@ -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 }),
),
},
],
};
}