mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-18 02:39:43 -07:00
website: redesign part 1
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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 }),
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {};
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user