global: MASSIVE snapshot

This commit is contained in:
nym21
2026-01-02 19:08:20 +01:00
parent ac6175688d
commit 3e9b1cc2b2
462 changed files with 34975 additions and 20072 deletions
@@ -0,0 +1,485 @@
/** Chain section builder - typed tree-based patterns */
/**
* Create Chain section
* @param {PartialContext} ctx
* @returns {PartialOptionsGroup}
*/
export function createChainSection(ctx) {
const { colors, brk, s, createPriceLine } = ctx;
const { blocks, transactions, pools, inputs, outputs, market, scripts, supply } = brk.tree.computed;
const { indexed } = brk.tree;
/**
* Create sum/cumulative series from a BlockCountPattern
* @template T
* @param {BlockCountPattern<T>} pattern
* @param {string} name
* @param {Color} [sumColor]
* @param {Color} [cumulativeColor]
* @param {Unit} unit
*/
const fromBlockCount = (pattern, name, unit, sumColor, cumulativeColor) => [
s({ metric: pattern.base, name: `${name} sum`, color: sumColor, unit }),
s({ metric: pattern.cumulative, name: `${name} cumulative`, color: cumulativeColor ?? colors.blue, unit, defaultActive: false }),
];
/**
* Create series from BlockSizePattern (has average, min, max, percentiles)
* @template T
* @param {BlockSizePattern<T>} pattern
* @param {string} name
* @param {Unit} unit
*/
const fromBlockSize = (pattern, name, unit) => [
s({ metric: pattern.average, name: `${name} avg`, unit }),
s({ metric: pattern.sum, name: `${name} sum`, color: colors.blue, unit, defaultActive: false }),
s({ metric: pattern.cumulative, name: `${name} cumulative`, color: colors.indigo, unit, defaultActive: false }),
s({ metric: pattern.min, name: `${name} min`, color: colors.red, unit, defaultActive: false }),
s({ metric: pattern.max, name: `${name} max`, color: colors.green, unit, defaultActive: false }),
s({ metric: pattern.pct10, name: `${name} pct10`, color: colors.rose, unit, defaultActive: false }),
s({ metric: pattern.pct25, name: `${name} pct25`, color: colors.pink, unit, defaultActive: false }),
s({ metric: pattern.median, name: `${name} median`, color: colors.purple, unit, defaultActive: false }),
s({ metric: pattern.pct75, name: `${name} pct75`, color: colors.violet, unit, defaultActive: false }),
s({ metric: pattern.pct90, name: `${name} pct90`, color: colors.fuchsia, unit, defaultActive: false }),
];
/**
* Create series from BlockIntervalPattern (has average, min, max, percentiles)
* @template T
* @param {BlockIntervalPattern<T>} pattern
* @param {string} name
* @param {Unit} unit
*/
const fromBlockInterval = (pattern, name, unit) => [
s({ metric: pattern.average, name: `${name} avg`, unit }),
s({ metric: pattern.min, name: `${name} min`, color: colors.red, unit, defaultActive: false }),
s({ metric: pattern.max, name: `${name} max`, color: colors.green, unit, defaultActive: false }),
s({ metric: pattern.pct10, name: `${name} pct10`, color: colors.rose, unit, defaultActive: false }),
s({ metric: pattern.pct25, name: `${name} pct25`, color: colors.pink, unit, defaultActive: false }),
s({ metric: pattern.median, name: `${name} median`, color: colors.purple, unit, defaultActive: false }),
s({ metric: pattern.pct75, name: `${name} pct75`, color: colors.violet, unit, defaultActive: false }),
s({ metric: pattern.pct90, name: `${name} pct90`, color: colors.fuchsia, unit, defaultActive: false }),
];
/**
* Create series from BitcoinPattern (has base, cumulative)
* @template T
* @param {BitcoinPattern<T>} pattern
* @param {string} name
* @param {Unit} unit
* @param {Color} [sumColor]
* @param {Color} [cumulativeColor]
*/
const fromBitcoin = (pattern, name, unit, sumColor, cumulativeColor) => [
s({ metric: pattern.base, name: `${name}`, color: sumColor, unit }),
s({ metric: pattern.cumulative, name: `${name} cumulative`, color: cumulativeColor ?? colors.blue, unit, defaultActive: false }),
];
/**
* Create series from CoinbasePattern (has sats, bitcoin, dollars as BitcoinPattern)
* BitcoinPattern has .base and .cumulative (no .sum)
* @param {CoinbasePattern} pattern
* @param {string} name
* @param {Color} sumColor
* @param {Color} cumulativeColor
*/
const fromCoinbase = (pattern, name, sumColor, cumulativeColor) => [
s({ metric: pattern.sats.base, name: `${name}`, color: sumColor, unit: "sats" }),
s({ metric: pattern.sats.cumulative, name: `${name} cumulative`, color: cumulativeColor, unit: "sats", defaultActive: false }),
s({ metric: pattern.bitcoin.base, name: `${name}`, color: sumColor, unit: "btc" }),
s({ metric: pattern.bitcoin.cumulative, name: `${name} cumulative`, color: cumulativeColor, unit: "btc", defaultActive: false }),
s({ metric: pattern.dollars.base, name: `${name}`, color: sumColor, unit: "usd" }),
s({ metric: pattern.dollars.cumulative, name: `${name} cumulative`, color: cumulativeColor, unit: "usd", defaultActive: false }),
];
/**
* Create series from ValuePattern (has sats, bitcoin, dollars as BlockCountPattern)
* BlockCountPattern has .base, .sum, and .cumulative
* @param {ValuePattern} pattern
* @param {string} name
* @param {Color} sumColor
* @param {Color} cumulativeColor
*/
const fromValuePattern = (pattern, name, sumColor, cumulativeColor) => [
s({ metric: pattern.sats.base, name: `${name}`, color: sumColor, unit: "sats" }),
s({ metric: pattern.sats.cumulative, name: `${name} cumulative`, color: cumulativeColor, unit: "sats", defaultActive: false }),
s({ metric: pattern.bitcoin.base, name: `${name}`, color: sumColor, unit: "btc" }),
s({ metric: pattern.bitcoin.cumulative, name: `${name} cumulative`, color: cumulativeColor, unit: "btc", defaultActive: false }),
s({ metric: pattern.dollars.base, name: `${name}`, color: sumColor, unit: "usd" }),
s({ metric: pattern.dollars.cumulative, name: `${name} cumulative`, color: cumulativeColor, unit: "usd", defaultActive: false }),
];
/**
* Create series from RewardPattern (has .base as Indexes2<Sats>, plus bitcoin/dollars as BlockCountPattern, sats as SatsPattern)
* Note: SatsPattern only has cumulative and sum, so we use pattern.base for raw sats
* @param {RewardPattern} pattern
* @param {string} name
* @param {Color} sumColor
* @param {Color} cumulativeColor
*/
const fromRewardPattern = (pattern, name, sumColor, cumulativeColor) => [
s({ metric: pattern.base, name: `${name}`, color: sumColor, unit: "sats" }),
s({ metric: pattern.sats.cumulative, name: `${name} cumulative`, color: cumulativeColor, unit: "sats", defaultActive: false }),
s({ metric: pattern.bitcoin.base, name: `${name}`, color: sumColor, unit: "btc" }),
s({ metric: pattern.bitcoin.cumulative, name: `${name} cumulative`, color: cumulativeColor, unit: "btc", defaultActive: false }),
s({ metric: pattern.dollars.base, name: `${name}`, color: sumColor, unit: "usd" }),
s({ metric: pattern.dollars.cumulative, name: `${name} cumulative`, color: cumulativeColor, unit: "usd", defaultActive: false }),
];
// Build pools tree dynamically
const poolEntries = Object.entries(pools.vecs);
const poolsTree = poolEntries.map(([key, pool]) => {
const poolName = brk.POOL_ID_TO_POOL_NAME[/** @type {keyof typeof brk.POOL_ID_TO_POOL_NAME} */ (key.toLowerCase())] || key;
return {
name: poolName,
tree: [
{
name: "Dominance",
title: `Mining Dominance of ${poolName}`,
bottom: [
s({ metric: pool._1dDominance.base, name: "1d", color: colors.rose, unit: "percentage", defaultActive: false }),
s({ metric: pool._1wDominance, name: "1w", color: colors.red, unit: "percentage", defaultActive: false }),
s({ metric: pool._1mDominance, name: "1m", unit: "percentage" }),
s({ metric: pool._1yDominance, name: "1y", color: colors.lime, unit: "percentage", defaultActive: false }),
s({ metric: pool.dominance.base, name: "all time", color: colors.teal, unit: "percentage", defaultActive: false }),
],
},
{
name: "Blocks mined",
title: `Blocks mined by ${poolName}`,
bottom: [
s({ metric: pool.blocksMined.base, name: "Sum", unit: "count" }),
s({ metric: pool.blocksMined.cumulative, name: "Cumulative", color: colors.blue, unit: "count" }),
s({ metric: pool._1wBlocksMined, name: "1w Sum", color: colors.red, unit: "count", defaultActive: false }),
s({ metric: pool._1mBlocksMined, name: "1m Sum", color: colors.pink, unit: "count", defaultActive: false }),
s({ metric: pool._1yBlocksMined, name: "1y Sum", color: colors.purple, unit: "count", defaultActive: false }),
],
},
{
name: "Rewards",
title: `Rewards collected by ${poolName}`,
bottom: [
...fromValuePattern(pool.coinbase, "coinbase", colors.orange, colors.red),
...fromRewardPattern(pool.subsidy, "subsidy", colors.lime, colors.emerald),
...fromRewardPattern(pool.fee, "fee", colors.cyan, colors.indigo),
],
},
{
name: "Days since block",
title: `Days since ${poolName} mined a block`,
bottom: [
s({ metric: pool.daysSinceBlock, name: "Since block", unit: "days" }),
],
},
],
};
});
return {
name: "Chain",
tree: [
// Block
{
name: "Block",
tree: [
{
name: "Count",
title: "Block Count",
bottom: [
...fromBlockCount(blocks.count.blockCount, "Block", "count"),
s({ metric: blocks.count.blockCountTarget, name: "Target", color: colors.gray, unit: "count", options: { lineStyle: 4 } }),
s({ metric: blocks.count._1wBlockCount, name: "1w sum", color: colors.red, unit: "count", defaultActive: false }),
s({ metric: blocks.count._1mBlockCount, name: "1m sum", color: colors.pink, unit: "count", defaultActive: false }),
s({ metric: blocks.count._1yBlockCount, name: "1y sum", color: colors.purple, unit: "count", defaultActive: false }),
],
},
{
name: "Interval",
title: "Block Interval",
bottom: [
s({ metric: blocks.interval.interval, name: "Interval", unit: "secs" }),
...fromBlockInterval(blocks.interval.blockInterval, "Interval", "secs"),
createPriceLine({ unit: "secs", name: "Target", number: 600 }),
],
},
{
name: "Size",
title: "Block Size",
bottom: [
s({ metric: blocks.size.vbytes, name: "vbytes raw", unit: "vb" }),
s({ metric: indexed.block.weight, name: "weight raw", unit: "wu" }),
...fromBlockSize(blocks.size.blockSize, "size", "bytes"),
...fromBlockSize(blocks.weight.blockWeight, "weight", "wu"),
...fromBlockSize(blocks.size.blockVbytes, "vbytes", "vb"),
],
},
],
},
// Transaction
{
name: "Transaction",
tree: [
{
name: "Count",
title: "Transaction Count",
bottom: fromBitcoin(transactions.count.txCount, "Count", "count"),
},
{
name: "Volume",
title: "Transaction Volume",
bottom: [
s({ metric: transactions.volume.sentSum.sats, name: "Sent", unit: "sats" }),
s({ metric: transactions.volume.sentSum.bitcoin.base, name: "Sent", unit: "btc" }),
s({ metric: transactions.volume.sentSum.dollars, name: "Sent", unit: "usd" }),
s({ metric: transactions.volume.annualizedVolume, name: "annualized", color: colors.red, unit: "sats", defaultActive: false }),
s({ metric: transactions.volume.annualizedVolumeBtc, name: "annualized", color: colors.red, unit: "btc", defaultActive: false }),
s({ metric: transactions.volume.annualizedVolumeUsd, name: "annualized", color: colors.lime, unit: "usd", defaultActive: false }),
],
},
{
name: "Size",
title: "Transaction Size",
bottom: [
...fromBlockInterval(transactions.size.txWeight, "weight", "wu"),
...fromBlockInterval(transactions.size.txVsize, "vsize", "vb"),
],
},
{
name: "Versions",
title: "Transaction Versions",
bottom: [
...fromBlockCount(transactions.versions.txV1, "v1", "count", colors.orange, colors.red),
...fromBlockCount(transactions.versions.txV2, "v2", "count", colors.cyan, colors.blue),
...fromBlockCount(transactions.versions.txV3, "v3", "count", colors.lime, colors.green),
],
},
{
name: "Velocity",
title: "Transactions Velocity",
bottom: [
s({ metric: supply.velocity.btc, name: "bitcoin", unit: "ratio" }),
s({ metric: supply.velocity.usd, name: "dollars", color: colors.emerald, unit: "ratio" }),
],
},
{
name: "Speed",
title: "Transactions Per Second",
bottom: [
s({ metric: transactions.volume.txPerSec, name: "Transactions", unit: "/sec" }),
],
},
],
},
// Input
{
name: "Input",
tree: [
{
name: "Count",
title: "Transaction Input Count",
bottom: [
...fromBlockSize(inputs.count.count, "Input", "count"),
],
},
{
name: "Speed",
title: "Inputs Per Second",
bottom: [
s({ metric: transactions.volume.inputsPerSec, name: "Inputs", unit: "/sec" }),
],
},
],
},
// Output
{
name: "Output",
tree: [
{
name: "Count",
title: "Transaction Output Count",
bottom: [
...fromBlockSize(outputs.count.count, "Output", "count"),
],
},
{
name: "Speed",
title: "Outputs Per Second",
bottom: [
s({ metric: transactions.volume.outputsPerSec, name: "Outputs", unit: "/sec" }),
],
},
],
},
// UTXO
{
name: "UTXO",
tree: [
{
name: "Count",
title: "UTXO Count",
bottom: [
s({ metric: outputs.count.utxoCount.base, name: "Count", unit: "count" }),
],
},
],
},
// Coinbase
{
name: "Coinbase",
title: "Coinbase Rewards",
bottom: fromCoinbase(blocks.rewards.coinbase, "Coinbase", colors.orange, colors.red),
},
// Subsidy
{
name: "Subsidy",
title: "Block Subsidy",
bottom: [
...fromCoinbase(blocks.rewards.subsidy, "Subsidy", colors.lime, colors.emerald),
s({ metric: blocks.rewards.subsidyDominance, name: "Dominance", color: colors.purple, unit: "percentage", defaultActive: false }),
],
},
// Fee
{
name: "Fee",
tree: [
{
name: "Total",
title: "Transaction Fees",
bottom: [
s({ metric: transactions.fees.fee.sats.sum, name: "Sum", unit: "sats" }),
s({ metric: transactions.fees.fee.sats.cumulative, name: "Cumulative", color: colors.blue, unit: "sats", defaultActive: false }),
s({ metric: transactions.fees.fee.bitcoin.sum, name: "Sum", unit: "btc" }),
s({ metric: transactions.fees.fee.bitcoin.cumulative, name: "Cumulative", color: colors.blue, unit: "btc", defaultActive: false }),
s({ metric: transactions.fees.fee.dollars.sum, name: "Sum", unit: "usd" }),
s({ metric: transactions.fees.fee.dollars.cumulative, name: "Cumulative", color: colors.blue, unit: "usd", defaultActive: false }),
s({ metric: blocks.rewards.feeDominance, name: "Dominance", color: colors.purple, unit: "percentage", defaultActive: false }),
],
},
{
name: "Rate",
title: "Fee Rate",
bottom: [
s({ metric: transactions.fees.feeRate.base, name: "Rate", unit: "sat/vb" }),
s({ metric: transactions.fees.feeRate.average, name: "Average", color: colors.blue, unit: "sat/vb" }),
s({ metric: transactions.fees.feeRate.median, name: "Median", color: colors.purple, unit: "sat/vb" }),
s({ metric: transactions.fees.feeRate.min, name: "Min", color: colors.red, unit: "sat/vb", defaultActive: false }),
s({ metric: transactions.fees.feeRate.max, name: "Max", color: colors.green, unit: "sat/vb", defaultActive: false }),
s({ metric: transactions.fees.feeRate.pct10, name: "pct10", color: colors.rose, unit: "sat/vb", defaultActive: false }),
s({ metric: transactions.fees.feeRate.pct25, name: "pct25", color: colors.pink, unit: "sat/vb", defaultActive: false }),
s({ metric: transactions.fees.feeRate.pct75, name: "pct75", color: colors.violet, unit: "sat/vb", defaultActive: false }),
s({ metric: transactions.fees.feeRate.pct90, name: "pct90", color: colors.fuchsia, unit: "sat/vb", defaultActive: false }),
],
},
],
},
// Mining
{
name: "Mining",
tree: [
{
name: "Hashrate",
title: "Network Hashrate",
bottom: [
s({ metric: blocks.mining.hashRate, name: "Hashrate", unit: "h/s" }),
s({ metric: blocks.mining.hashRate1wSma, name: "1w SMA", color: colors.red, unit: "h/s", defaultActive: false }),
s({ metric: blocks.mining.hashRate1mSma, name: "1m SMA", color: colors.orange, unit: "h/s", defaultActive: false }),
s({ metric: blocks.mining.hashRate2mSma, name: "2m SMA", color: colors.yellow, unit: "h/s", defaultActive: false }),
s({ metric: blocks.mining.hashRate1ySma, name: "1y SMA", color: colors.lime, unit: "h/s", defaultActive: false }),
],
},
{
name: "Difficulty",
title: "Network Difficulty",
bottom: [
s({ metric: blocks.mining.difficulty, name: "Difficulty", unit: "difficulty" }),
s({ metric: blocks.mining.difficultyAdjustment, name: "Adjustment", color: colors.orange, unit: "percentage", defaultActive: false }),
s({ metric: blocks.mining.difficultyAsHash, name: "As hash", color: colors.default, unit: "h/s", defaultActive: false, options: { lineStyle: 1 } }),
s({ metric: blocks.difficulty.blocksBeforeNextDifficultyAdjustment, name: "Blocks until adj.", color: colors.indigo, unit: "blocks", defaultActive: false }),
s({ metric: blocks.difficulty.daysBeforeNextDifficultyAdjustment, name: "Days until adj.", color: colors.purple, unit: "days", defaultActive: false }),
],
},
{
name: "Hash Price",
title: "Hash Price",
bottom: [
s({ metric: blocks.mining.hashPriceThs, name: "TH/s", color: colors.emerald, unit: "usd/(th/s)/day" }),
s({ metric: blocks.mining.hashPricePhs, name: "PH/s", color: colors.emerald, unit: "usd/(ph/s)/day" }),
s({ metric: blocks.mining.hashPriceRebound, name: "Rebound", color: colors.yellow, unit: "percentage" }),
s({ metric: blocks.mining.hashPriceThsMin, name: "TH/s Min", color: colors.red, unit: "usd/(th/s)/day", options: { lineStyle: 1 } }),
s({ metric: blocks.mining.hashPricePhsMin, name: "PH/s Min", color: colors.red, unit: "usd/(ph/s)/day", options: { lineStyle: 1 } }),
],
},
{
name: "Hash Value",
title: "Hash Value",
bottom: [
s({ metric: blocks.mining.hashValueThs, name: "TH/s", color: colors.orange, unit: "sats/(th/s)/day" }),
s({ metric: blocks.mining.hashValuePhs, name: "PH/s", color: colors.orange, unit: "sats/(ph/s)/day" }),
s({ metric: blocks.mining.hashValueRebound, name: "Rebound", color: colors.yellow, unit: "percentage" }),
s({ metric: blocks.mining.hashValueThsMin, name: "TH/s Min", color: colors.red, unit: "sats/(th/s)/day", options: { lineStyle: 1 } }),
s({ metric: blocks.mining.hashValuePhsMin, name: "PH/s Min", color: colors.red, unit: "sats/(ph/s)/day", options: { lineStyle: 1 } }),
],
},
{
name: "Halving",
title: "Halving Info",
bottom: [
s({ metric: blocks.halving.blocksBeforeNextHalving, name: "Blocks until halving", unit: "blocks" }),
s({ metric: blocks.halving.daysBeforeNextHalving, name: "Days until halving", color: colors.orange, unit: "days" }),
s({ metric: blocks.halving.halvingepoch, name: "Halving epoch", color: colors.purple, unit: "epoch", defaultActive: false }),
],
},
{
name: "Puell Multiple",
title: "Puell Multiple",
bottom: [
s({ metric: market.indicators.puellMultiple, name: "Puell Multiple", unit: "ratio" }),
createPriceLine({ unit: "ratio", number: 1 }),
],
},
],
},
// Pools
{
name: "Pools",
tree: poolsTree,
},
// Unspendable
{
name: "Unspendable",
tree: [
{
name: "OP_RETURN",
tree: [
{
name: "Outputs",
title: "OP_RETURN Outputs",
bottom: fromBitcoin(scripts.count.opreturnCount, "Count", "count"),
},
],
},
],
},
// Inflation
{
name: "Inflation",
title: "Inflation Rate",
bottom: [
s({ metric: supply.inflation.indexes, name: "Rate", unit: "percentage" }),
],
},
],
};
}
@@ -0,0 +1,313 @@
/**
* Address cohort folder builder
* Creates option trees for address-based cohorts (has addrCount)
* Address cohorts use _0satsPattern which has PricePaidPattern (no percentiles)
*/
import {
createSingleSupplySeries,
createGroupedSupplyTotalSeries,
createGroupedSupplyInProfitSeries,
createGroupedSupplyInLossSeries,
createUtxoCountSeries,
createAddressCountSeries,
createRealizedPriceSeries,
createRealizedPriceRatioSeries,
} from "./shared.js";
/**
* Create a cohort folder for address cohorts
* Includes address count section (addrCount exists on AddressCohortObject)
* @param {PartialContext} ctx
* @param {AddressCohortObject | AddressCohortGroupObject} args
* @returns {PartialOptionsGroup}
*/
export function createAddressCohortFolder(ctx, args) {
const list = "list" in args ? args.list : [args];
const useGroupName = "list" in args;
const isSingle = !("list" in args);
const title = args.title ? `${useGroupName ? "by" : "of"} ${args.title}` : "";
return {
name: args.name || "all",
tree: [
// Supply section
isSingle
? {
name: "supply",
title: `Supply ${title}`,
bottom: createSingleSupplySeries(ctx, /** @type {AddressCohortObject} */ (args), title),
}
: {
name: "supply",
tree: [
{
name: "total",
title: `Supply ${title}`,
bottom: createGroupedSupplyTotalSeries(ctx, list),
},
{
name: "in profit",
title: `Supply In Profit ${title}`,
bottom: createGroupedSupplyInProfitSeries(ctx, list),
},
{
name: "in loss",
title: `Supply In Loss ${title}`,
bottom: createGroupedSupplyInLossSeries(ctx, list),
},
],
},
// UTXO count
{
name: "utxo count",
title: `UTXO Count ${title}`,
bottom: createUtxoCountSeries(ctx, list, useGroupName),
},
// Address count (ADDRESS COHORTS ONLY - fully type safe!)
{
name: "address count",
title: `Address Count ${title}`,
bottom: createAddressCountSeries(ctx, list, useGroupName),
},
// Realized section
{
name: "Realized",
tree: [
...(useGroupName
? [
{
name: "Price",
title: `Realized Price ${title}`,
top: createRealizedPriceSeries(ctx, list),
},
{
name: "Ratio",
title: `Realized Price Ratio ${title}`,
bottom: createRealizedPriceRatioSeries(ctx, list),
},
]
: createRealizedPriceOptions(ctx, /** @type {AddressCohortObject} */ (args), title)),
{
name: "capitalization",
title: `Realized Capitalization ${title}`,
bottom: createRealizedCapWithExtras(ctx, list, args, useGroupName, title),
},
...(!useGroupName ? createRealizedPnlSection(ctx, /** @type {AddressCohortObject} */ (args), title) : []),
],
},
// Unrealized section
...createUnrealizedSection(ctx, list, useGroupName, title),
// Price paid section (no percentiles for address cohorts)
...createPricePaidSection(ctx, list, useGroupName, title),
// Activity section
...createActivitySection(ctx, list, useGroupName, title),
],
};
}
/**
* Create realized price options for single cohort
* @param {PartialContext} ctx
* @param {AddressCohortObject} args
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createRealizedPriceOptions(ctx, args, title) {
const { s } = ctx;
const { tree, color } = args;
return [
{
name: "price",
title: `Realized Price ${title}`,
top: [s({ metric: tree.realized.realizedPrice, name: "realized", color })],
},
];
}
/**
* Create realized cap with extras
* @param {PartialContext} ctx
* @param {readonly AddressCohortObject[]} list
* @param {AddressCohortObject | AddressCohortGroupObject} args
* @param {boolean} useGroupName
* @param {string} title
* @returns {AnyFetchedSeriesBlueprint[]}
*/
function createRealizedCapWithExtras(ctx, list, args, useGroupName, title) {
const { colors, s, createPriceLine } = ctx;
const isSingle = !("list" in args);
return list.flatMap(({ color, name, tree }) => [
s({ metric: tree.realized.realizedCap, name: useGroupName ? name : "Capitalization", color }),
...(isSingle
? [
/** @type {AnyFetchedSeriesBlueprint} */ ({
type: "Baseline",
metric: tree.realized.realizedCap30dDelta,
title: "30d change",
defaultActive: false,
}),
createPriceLine({ unit: "usd", defaultActive: false }),
]
: []),
// RealizedPattern (address cohorts) doesn't have realizedCapRelToOwnMarketCap
]);
}
/**
* Create realized PnL section for single cohort
* @param {PartialContext} ctx
* @param {AddressCohortObject} args
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createRealizedPnlSection(ctx, args, title) {
const { colors, s } = ctx;
const { tree } = args;
return [
{
name: "pnl",
title: `Realized Profit And Loss ${title}`,
bottom: [
s({ metric: tree.realized.realizedProfit.base, name: "Profit", color: colors.green }),
s({ metric: tree.realized.realizedLoss.base, name: "Loss", color: colors.red, defaultActive: false }),
// RealizedPattern (address cohorts) doesn't have realizedProfitToLossRatio
s({ metric: tree.realized.totalRealizedPnl.base, name: "Total", color: colors.default, defaultActive: false }),
s({ metric: tree.realized.negRealizedLoss.base, name: "Negative Loss", color: colors.red }),
],
},
];
}
/**
* Create unrealized section
* @param {PartialContext} ctx
* @param {readonly AddressCohortObject[]} list
* @param {boolean} useGroupName
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createUnrealizedSection(ctx, list, useGroupName, title) {
const { colors, s } = ctx;
return [
{
name: "Unrealized",
tree: [
{
name: "nupl",
title: `Net Unrealized Profit/Loss ${title}`,
bottom: list.flatMap(({ color, name, tree }) => [
/** @type {AnyFetchedSeriesBlueprint} */ ({
type: "Baseline",
metric: tree.unrealized.netUnrealizedPnl,
title: useGroupName ? name : "NUPL",
colors: [colors.red, colors.green],
options: { baseValue: { price: 0 } },
}),
]),
},
{
name: "profit",
title: `Unrealized Profit ${title}`,
bottom: list.flatMap(({ color, name, tree }) => [
s({ metric: tree.unrealized.unrealizedProfit, name: useGroupName ? name : "Profit", color }),
]),
},
{
name: "loss",
title: `Unrealized Loss ${title}`,
bottom: list.flatMap(({ color, name, tree }) => [
s({ metric: tree.unrealized.unrealizedLoss, name: useGroupName ? name : "Loss", color }),
]),
},
],
},
];
}
/**
* Create price paid section (no percentiles for address cohorts)
* @param {PartialContext} ctx
* @param {readonly AddressCohortObject[]} list
* @param {boolean} useGroupName
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createPricePaidSection(ctx, list, useGroupName, title) {
const { s } = ctx;
return [
{
name: "Price Paid",
tree: [
{
name: "min",
title: `Min Price Paid ${title}`,
top: list.map(({ color, name, tree }) =>
s({ metric: tree.pricePaid.minPricePaid, name: useGroupName ? name : "Min", color }),
),
},
{
name: "max",
title: `Max Price Paid ${title}`,
top: list.map(({ color, name, tree }) =>
s({ metric: tree.pricePaid.maxPricePaid, name: useGroupName ? name : "Max", color }),
),
},
],
},
];
}
/**
* Create activity section
* @param {PartialContext} ctx
* @param {readonly AddressCohortObject[]} list
* @param {boolean} useGroupName
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createActivitySection(ctx, list, useGroupName, title) {
const { s } = ctx;
return [
{
name: "Activity",
tree: [
{
name: "coinblocks destroyed",
title: `Coinblocks Destroyed ${title}`,
bottom: list.flatMap(({ color, name, tree }) => [
s({
metric: tree.activity.coinblocksDestroyed.base,
name: useGroupName ? name : "Coinblocks",
color,
}),
]),
},
{
name: "coindays destroyed",
title: `Coindays Destroyed ${title}`,
bottom: list.flatMap(({ color, name, tree }) => [
s({
metric: tree.activity.coindaysDestroyed.base,
name: useGroupName ? name : "Coindays",
color,
}),
]),
},
],
},
];
}
@@ -0,0 +1,227 @@
/** Build cohort data arrays from brk.tree */
import {
termColors,
maxAgeColors,
minAgeColors,
ageRangeColors,
epochColors,
geAmountColors,
ltAmountColors,
amountRangeColors,
spendableTypeColors,
} from "../colors/index.js";
/**
* @template {Record<string, any>} T
* @param {T} obj
* @returns {[keyof T & string, T[keyof T & string]][]}
*/
const entries = (obj) => /** @type {[keyof T & string, T[keyof T & string]][]} */ (Object.entries(obj));
/**
* Build all cohort data from brk tree
* @param {Colors} colors
* @param {BrkClient} brk
*/
export function buildCohortData(colors, brk) {
const utxoCohorts = brk.tree.computed.distribution.utxoCohorts;
const addressCohorts = brk.tree.computed.distribution.addressCohorts;
const {
TERM_NAMES,
EPOCH_NAMES,
MAX_AGE_NAMES,
MIN_AGE_NAMES,
AGE_RANGE_NAMES,
GE_AMOUNT_NAMES,
LT_AMOUNT_NAMES,
AMOUNT_RANGE_NAMES,
SPENDABLE_TYPE_NAMES,
} = brk;
// Base cohort representing "all"
/** @type {UtxoCohortObject} */
const cohortAll = {
name: "",
title: "",
color: colors.orange,
tree: utxoCohorts.all,
};
/** @type {UtxoCohortObject} */
const cohortAllForComparison = {
name: "all",
title: "",
color: colors.default,
tree: utxoCohorts.all,
};
// Term cohorts (short/long term holders)
/** @type {readonly UtxoCohortObject[]} */
const terms = entries(utxoCohorts.term).map(([key, tree]) => {
const names = TERM_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[termColors[key]],
tree,
};
});
// Max age cohorts (up to X time)
/** @type {readonly UtxoCohortObject[]} */
const upToDate = entries(utxoCohorts.maxAge).map(([key, tree]) => {
const names = MAX_AGE_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[maxAgeColors[key]],
tree,
};
});
// Min age cohorts (from X time)
/** @type {readonly UtxoCohortObject[]} */
const fromDate = entries(utxoCohorts.minAge).map(([key, tree]) => {
const names = MIN_AGE_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[minAgeColors[key]],
tree,
};
});
// Age range cohorts
/** @type {readonly UtxoCohortObject[]} */
const dateRange = entries(utxoCohorts.ageRange).map(([key, tree]) => {
const names = AGE_RANGE_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[ageRangeColors[key]],
tree,
};
});
// Epoch cohorts
/** @type {readonly UtxoCohortObject[]} */
const epoch = entries(utxoCohorts.epoch).map(([key, tree]) => {
const names = EPOCH_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[epochColors[key]],
tree,
};
});
// UTXOs above amount
/** @type {readonly UtxoCohortObject[]} */
const utxosAboveAmount = entries(utxoCohorts.geAmount).map(([key, tree]) => {
const names = GE_AMOUNT_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[geAmountColors[key]],
tree,
};
});
// Addresses above amount
/** @type {readonly AddressCohortObject[]} */
const addressesAboveAmount = entries(addressCohorts.geAmount).map(
([key, tree]) => {
const names = GE_AMOUNT_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[geAmountColors[key]],
tree,
};
},
);
// UTXOs under amount
/** @type {readonly UtxoCohortObject[]} */
const utxosUnderAmount = entries(utxoCohorts.ltAmount).map(([key, tree]) => {
const names = LT_AMOUNT_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[ltAmountColors[key]],
tree,
};
});
// Addresses under amount
/** @type {readonly AddressCohortObject[]} */
const addressesUnderAmount = entries(addressCohorts.ltAmount).map(
([key, tree]) => {
const names = LT_AMOUNT_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[ltAmountColors[key]],
tree,
};
},
);
// UTXOs amount ranges
/** @type {readonly UtxoCohortObject[]} */
const utxosAmountRanges = entries(utxoCohorts.amountRange).map(
([key, tree]) => {
const names = AMOUNT_RANGE_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[amountRangeColors[key]],
tree,
};
},
);
// Addresses amount ranges
/** @type {readonly AddressCohortObject[]} */
const addressesAmountRanges = entries(addressCohorts.amountRange).map(
([key, tree]) => {
const names = AMOUNT_RANGE_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[amountRangeColors[key]],
tree,
};
},
);
// Spendable type cohorts
/** @type {readonly UtxoCohortObject[]} */
const type = entries(utxoCohorts.type).map(([key, tree]) => {
const names = SPENDABLE_TYPE_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[spendableTypeColors[key]],
tree,
};
});
return {
cohortAll,
cohortAllForComparison,
terms,
upToDate,
fromDate,
dateRange,
epoch,
utxosAboveAmount,
addressesAboveAmount,
utxosUnderAmount,
addressesUnderAmount,
utxosAmountRanges,
addressesAmountRanges,
type,
};
}
@@ -0,0 +1,25 @@
/**
* Cohort module - exports all cohort-related functionality
*/
// Cohort data builder
export { buildCohortData } from "./data.js";
// Cohort folder builders (type-safe!)
export { createUtxoCohortFolder, createAgeCohortFolder, createAmountCohortFolder } from "./utxo.js";
export { createAddressCohortFolder } from "./address.js";
// Shared helpers
export {
createSingleSupplySeries,
createGroupedSupplyTotalSeries,
createGroupedSupplyInProfitSeries,
createGroupedSupplyInLossSeries,
createUtxoCountSeries,
createAddressCountSeries,
createRealizedPriceSeries,
createRealizedPriceRatioSeries,
createRealizedCapSeries,
createPricePaidMinMaxSeries,
createPricePercentilesSeries,
} from "./shared.js";
@@ -0,0 +1,216 @@
/** Shared cohort chart section builders */
/**
* Create supply section for a single cohort
* @param {PartialContext} ctx
* @param {CohortObject} cohort
* @param {string} title
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createSingleSupplySeries(ctx, cohort, title) {
const { colors, s, createPriceLine } = ctx;
const { tree, color, name } = cohort;
return [
s({ metric: tree.supply.supply.sats, name: "Supply", color: colors.default }),
s({ metric: tree.supply.supply.bitcoin, name: "Supply", color: colors.default }),
s({ metric: tree.supply.supply.dollars, name: "Supply", color: colors.default }),
...("supplyRelToCirculatingSupply" in tree.relative
? [s({ metric: tree.relative.supplyRelToCirculatingSupply, name: "Supply", color: colors.default })]
: []),
s({ metric: tree.unrealized.supplyInProfit.sats, name: "In Profit", color: colors.green }),
s({ metric: tree.unrealized.supplyInProfit.bitcoin, name: "In Profit", color: colors.green }),
s({ metric: tree.unrealized.supplyInProfit.dollars, name: "In Profit", color: colors.green }),
s({ metric: tree.unrealized.supplyInLoss.sats, name: "In Loss", color: colors.red }),
s({ metric: tree.unrealized.supplyInLoss.bitcoin, name: "In Loss", color: colors.red }),
s({ metric: tree.unrealized.supplyInLoss.dollars, name: "In Loss", color: colors.red }),
s({ metric: tree.supply.supplyHalf.sats, name: "half", color: colors.gray, options: { lineStyle: 4 } }),
s({ metric: tree.supply.supplyHalf.bitcoin, name: "half", color: colors.gray, options: { lineStyle: 4 } }),
s({ metric: tree.supply.supplyHalf.dollars, name: "half", color: colors.gray, options: { lineStyle: 4 } }),
...("supplyInProfitRelToCirculatingSupply" in tree.relative
? [
s({ metric: tree.relative.supplyInProfitRelToCirculatingSupply, name: "In Profit", color: colors.green }),
s({ metric: tree.relative.supplyInLossRelToCirculatingSupply, name: "In Loss", color: colors.red }),
]
: []),
s({ metric: tree.relative.supplyInProfitRelToOwnSupply, name: "In Profit", color: colors.green }),
s({ metric: tree.relative.supplyInLossRelToOwnSupply, name: "In Loss", color: colors.red }),
createPriceLine({ unit: "%self", number: 100, lineStyle: 0, color: colors.default }),
createPriceLine({ unit: "%self", number: 50 }),
];
}
/**
* Create supply total series for grouped cohorts
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createGroupedSupplyTotalSeries(ctx, list) {
const { s, constant100 } = ctx;
return list.flatMap(({ color, name, tree }) => [
s({ metric: tree.supply.supply.sats, name, color }),
s({ metric: tree.supply.supply.bitcoin, name, color }),
s({ metric: tree.supply.supply.dollars, name, color }),
"supplyRelToCirculatingSupply" in tree.relative
? s({ metric: tree.relative.supplyRelToCirculatingSupply, name, color })
: s({ unit: "%all", metric: constant100, name, color }),
]);
}
/**
* Create supply in profit series for grouped cohorts
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createGroupedSupplyInProfitSeries(ctx, list) {
const { s } = ctx;
return list.flatMap(({ color, name, tree }) => [
s({ metric: tree.unrealized.supplyInProfit.sats, name, color }),
s({ metric: tree.unrealized.supplyInProfit.bitcoin, name, color }),
s({ metric: tree.unrealized.supplyInProfit.dollars, name, color }),
...("supplyInProfitRelToCirculatingSupply" in tree.relative
? [s({ metric: tree.relative.supplyInProfitRelToCirculatingSupply, name, color })]
: []),
]);
}
/**
* Create supply in loss series for grouped cohorts
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createGroupedSupplyInLossSeries(ctx, list) {
const { s } = ctx;
return list.flatMap(({ color, name, tree }) => [
s({ metric: tree.unrealized.supplyInLoss.sats, name, color }),
s({ metric: tree.unrealized.supplyInLoss.bitcoin, name, color }),
s({ metric: tree.unrealized.supplyInLoss.dollars, name, color }),
...("supplyInLossRelToCirculatingSupply" in tree.relative
? [s({ metric: tree.relative.supplyInLossRelToCirculatingSupply, name, color })]
: []),
]);
}
/**
* Create UTXO count series
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @param {boolean} useGroupName
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createUtxoCountSeries(ctx, list, useGroupName) {
const { s } = ctx;
return list.flatMap(({ color, name, tree }) => [
s({ metric: tree.supply.utxoCount, name: useGroupName ? name : "Count", color }),
]);
}
/**
* Create address count series (for address cohorts only)
* @param {PartialContext} ctx
* @param {readonly AddressCohortObject[]} list
* @param {boolean} useGroupName
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createAddressCountSeries(ctx, list, useGroupName) {
const { s, colors } = ctx;
return list.flatMap(({ color, name, tree }) => [
s({
metric: tree.addrCount,
name: useGroupName ? name : "Count",
color: useGroupName ? color : colors.orange,
}),
]);
}
/**
* Create realized price series for grouped cohorts
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createRealizedPriceSeries(ctx, list) {
const { s } = ctx;
return list.map(({ color, name, tree }) =>
s({ metric: tree.realized.realizedPrice, name, color }),
);
}
/**
* Create realized price ratio series for grouped cohorts
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createRealizedPriceRatioSeries(ctx, list) {
const { s, createPriceLine } = ctx;
return [
...list.map(({ color, name, tree }) =>
s({ metric: tree.realized.realizedPriceExtra.ratio, name, color }),
),
createPriceLine({ unit: "ratio", number: 1 }),
];
}
/**
* Create realized capitalization series
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @param {boolean} useGroupName
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createRealizedCapSeries(ctx, list, useGroupName) {
const { s } = ctx;
return list.flatMap(({ color, name, tree }) => [
s({ metric: tree.realized.realizedCap, name: useGroupName ? name : "Capitalization", color }),
]);
}
/**
* Create price paid min/max series (available on all cohorts)
* @param {PartialContext} ctx
* @param {readonly CohortObject[]} list
* @param {boolean} useGroupName
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createPricePaidMinMaxSeries(ctx, list, useGroupName) {
const { s } = ctx;
return list.flatMap(({ color, name, tree }) => [
s({ metric: tree.pricePaid.minPricePaid, name: useGroupName ? `${name} min` : "Min", color }),
s({ metric: tree.pricePaid.maxPricePaid, name: useGroupName ? `${name} max` : "Max", color }),
]);
}
/**
* Create price percentile series (only for cohorts with PricePaidPattern2)
* @param {PartialContext} ctx
* @param {readonly CohortWithPricePercentiles[]} list
* @param {boolean} useGroupName
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function createPricePercentilesSeries(ctx, list, useGroupName) {
const { s, colors } = ctx;
return list.flatMap(({ color, name, tree }) => {
const pp = tree.pricePaid.pricePercentiles;
return [
s({ metric: pp.pct10, name: useGroupName ? `${name} p10` : "p10", color, defaultActive: false }),
s({ metric: pp.pct25, name: useGroupName ? `${name} p25` : "p25", color, defaultActive: false }),
s({ metric: pp.pct50, name: useGroupName ? `${name} p50` : "p50", color }),
s({ metric: pp.pct75, name: useGroupName ? `${name} p75` : "p75", color, defaultActive: false }),
s({ metric: pp.pct90, name: useGroupName ? `${name} p90` : "p90", color, defaultActive: false }),
];
});
}
@@ -0,0 +1,6 @@
/**
* Cohort-related type definitions
* Types are defined in ../types.js, this file exists for documentation
*/
export {};
@@ -0,0 +1,487 @@
/**
* UTXO cohort folder builders
* Creates option trees for UTXO-based cohorts (no addrCount)
*
* Two main builders:
* - createAgeCohortFolder: For term, maxAge, minAge, ageRange, epoch (has price percentiles)
* - createAmountCohortFolder: For geAmount, ltAmount, amountRange, type (no price percentiles)
*/
import {
createSingleSupplySeries,
createGroupedSupplyTotalSeries,
createGroupedSupplyInProfitSeries,
createGroupedSupplyInLossSeries,
createUtxoCountSeries,
createRealizedPriceSeries,
createRealizedPriceRatioSeries,
createRealizedCapSeries,
createPricePaidMinMaxSeries,
createPricePercentilesSeries,
} from "./shared.js";
/**
* Create a cohort folder for age-based UTXO cohorts (term, maxAge, minAge, ageRange, epoch)
* These cohorts have price percentiles via PricePaidPattern2
* @param {PartialContext} ctx
* @param {AgeCohortObject | AgeCohortGroupObject} args
* @returns {PartialOptionsGroup}
*/
export function createAgeCohortFolder(ctx, args) {
const list = "list" in args ? args.list : [args];
const useGroupName = "list" in args;
const isSingle = !("list" in args);
const title = args.title ? `${useGroupName ? "by" : "of"} ${args.title}` : "";
return {
name: args.name || "all",
tree: [
...createSupplySection(ctx, list, args, useGroupName, isSingle, title),
createUtxoCountSection(ctx, list, useGroupName, title),
createRealizedSection(ctx, list, args, useGroupName, isSingle, title),
...createUnrealizedSection(ctx, list, useGroupName, title),
...createPricePaidSectionWithPercentiles(ctx, list, useGroupName, title),
...createActivitySection(ctx, list, useGroupName, title),
],
};
}
/**
* Create a cohort folder for amount-based UTXO cohorts (geAmount, ltAmount, amountRange, type)
* These cohorts have only min/max price paid via PricePaidPattern
* @param {PartialContext} ctx
* @param {AmountCohortObject | AmountCohortGroupObject} args
* @returns {PartialOptionsGroup}
*/
export function createAmountCohortFolder(ctx, args) {
const list = "list" in args ? args.list : [args];
const useGroupName = "list" in args;
const isSingle = !("list" in args);
const title = args.title ? `${useGroupName ? "by" : "of"} ${args.title}` : "";
return {
name: args.name || "all",
tree: [
...createSupplySection(ctx, list, args, useGroupName, isSingle, title),
createUtxoCountSection(ctx, list, useGroupName, title),
createRealizedSection(ctx, list, args, useGroupName, isSingle, title),
...createUnrealizedSection(ctx, list, useGroupName, title),
...createPricePaidSectionBasic(ctx, list, useGroupName, title),
...createActivitySection(ctx, list, useGroupName, title),
],
};
}
// Keep the generic version for backwards compatibility
/**
* Create a cohort folder for UTXO cohorts (generic, uses runtime check for percentiles)
* @deprecated Use createAgeCohortFolder or createAmountCohortFolder for type safety
* @param {PartialContext} ctx
* @param {UtxoCohortObject | UtxoCohortGroupObject} args
* @returns {PartialOptionsGroup}
*/
export function createUtxoCohortFolder(ctx, args) {
const list = "list" in args ? args.list : [args];
const useGroupName = "list" in args;
const isSingle = !("list" in args);
const title = args.title ? `${useGroupName ? "by" : "of"} ${args.title}` : "";
// Runtime check for percentiles
const hasPercentiles = "pricePercentiles" in list[0].tree.pricePaid;
return {
name: args.name || "all",
tree: [
...createSupplySection(ctx, list, args, useGroupName, isSingle, title),
createUtxoCountSection(ctx, list, useGroupName, title),
createRealizedSection(ctx, list, args, useGroupName, isSingle, title),
...createUnrealizedSection(ctx, list, useGroupName, title),
...(hasPercentiles
? createPricePaidSectionWithPercentiles(ctx, /** @type {readonly AgeCohortObject[]} */ (list), useGroupName, title)
: createPricePaidSectionBasic(ctx, list, useGroupName, title)),
...createActivitySection(ctx, list, useGroupName, title),
],
};
}
/**
* Create supply section
* @param {PartialContext} ctx
* @param {readonly UtxoCohortObject[]} list
* @param {UtxoCohortObject | UtxoCohortGroupObject} args
* @param {boolean} useGroupName
* @param {boolean} isSingle
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createSupplySection(ctx, list, args, useGroupName, isSingle, title) {
return [
isSingle
? {
name: "supply",
title: `Supply ${title}`,
bottom: createSingleSupplySeries(ctx, /** @type {UtxoCohortObject} */ (args), title),
}
: {
name: "supply",
tree: [
{
name: "total",
title: `Supply ${title}`,
bottom: createGroupedSupplyTotalSeries(ctx, list),
},
{
name: "in profit",
title: `Supply In Profit ${title}`,
bottom: createGroupedSupplyInProfitSeries(ctx, list),
},
{
name: "in loss",
title: `Supply In Loss ${title}`,
bottom: createGroupedSupplyInLossSeries(ctx, list),
},
],
},
];
}
/**
* Create UTXO count section
* @param {PartialContext} ctx
* @param {readonly UtxoCohortObject[]} list
* @param {boolean} useGroupName
* @param {string} title
* @returns {PartialChartOption}
*/
function createUtxoCountSection(ctx, list, useGroupName, title) {
return {
name: "utxo count",
title: `UTXO Count ${title}`,
bottom: createUtxoCountSeries(ctx, list, useGroupName),
};
}
/**
* Create realized section
* @param {PartialContext} ctx
* @param {readonly UtxoCohortObject[]} list
* @param {UtxoCohortObject | UtxoCohortGroupObject} args
* @param {boolean} useGroupName
* @param {boolean} isSingle
* @param {string} title
* @returns {PartialOptionsGroup}
*/
function createRealizedSection(ctx, list, args, useGroupName, isSingle, title) {
return {
name: "Realized",
tree: [
...(useGroupName
? [
{
name: "Price",
title: `Realized Price ${title}`,
top: createRealizedPriceSeries(ctx, list),
},
{
name: "Ratio",
title: `Realized Price Ratio ${title}`,
bottom: createRealizedPriceRatioSeries(ctx, list),
},
]
: createRealizedPriceOptions(ctx, /** @type {UtxoCohortObject} */ (args), title)),
{
name: "capitalization",
title: `Realized Capitalization ${title}`,
bottom: createRealizedCapWithExtras(ctx, list, args, useGroupName, title),
},
...(!useGroupName ? createRealizedPnlSection(ctx, /** @type {UtxoCohortObject} */ (args), title) : []),
],
};
}
/**
* Create realized price options for single cohort
* @param {PartialContext} ctx
* @param {UtxoCohortObject} args
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createRealizedPriceOptions(ctx, args, title) {
const { s } = ctx;
const { tree, color } = args;
return [
{
name: "price",
title: `Realized Price ${title}`,
top: [s({ metric: tree.realized.realizedPrice, name: "realized", color })],
},
];
}
/**
* Create realized cap with extras
* @param {PartialContext} ctx
* @param {readonly UtxoCohortObject[]} list
* @param {UtxoCohortObject | UtxoCohortGroupObject} args
* @param {boolean} useGroupName
* @param {string} title
* @returns {AnyFetchedSeriesBlueprint[]}
*/
function createRealizedCapWithExtras(ctx, list, args, useGroupName, title) {
const { colors, s, createPriceLine } = ctx;
const isSingle = !("list" in args);
return list.flatMap(({ color, name, tree }) => [
s({
metric: tree.realized.realizedCap,
name: useGroupName ? name : "Capitalization",
color,
}),
...(isSingle
? [
/** @type {AnyFetchedSeriesBlueprint} */ ({
type: "Baseline",
metric: tree.realized.realizedCap30dDelta,
title: "30d change",
defaultActive: false,
}),
createPriceLine({ unit: "usd", defaultActive: false }),
]
: []),
...(isSingle && "realizedCapRelToOwnMarketCap" in tree.realized
? [
/** @type {AnyFetchedSeriesBlueprint} */ ({
type: "Baseline",
metric: tree.realized.realizedCapRelToOwnMarketCap,
title: "ratio",
options: { baseValue: { price: 100 } },
colors: [colors.red, colors.green],
}),
createPriceLine({ unit: "%cmcap", defaultActive: true, number: 100 }),
]
: []),
]);
}
/**
* Create realized PnL section for single cohort
* @param {PartialContext} ctx
* @param {UtxoCohortObject} args
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createRealizedPnlSection(ctx, args, title) {
const { colors, s } = ctx;
const { tree } = args;
return [
{
name: "pnl",
title: `Realized Profit And Loss ${title}`,
bottom: [
s({
metric: tree.realized.realizedProfit.base,
name: "Profit",
color: colors.green,
}),
s({
metric: tree.realized.realizedLoss.base,
name: "Loss",
color: colors.red,
defaultActive: false,
}),
...("realizedProfitToLossRatio" in tree.realized
? [
s({
metric: tree.realized.realizedProfitToLossRatio,
name: "profit / loss",
color: colors.yellow,
}),
]
: []),
s({
metric: tree.realized.totalRealizedPnl.base,
name: "Total",
color: colors.default,
defaultActive: false,
}),
s({
metric: tree.realized.negRealizedLoss.base,
name: "Negative Loss",
color: colors.red,
}),
],
},
];
}
/**
* Create unrealized section
* @param {PartialContext} ctx
* @param {readonly UtxoCohortObject[]} list
* @param {boolean} useGroupName
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createUnrealizedSection(ctx, list, useGroupName, title) {
const { colors, s, createPriceLine } = ctx;
return [
{
name: "Unrealized",
tree: [
{
name: "nupl",
title: `Net Unrealized Profit/Loss ${title}`,
bottom: list.flatMap(({ color, name, tree }) => [
/** @type {AnyFetchedSeriesBlueprint} */ ({
type: "Baseline",
metric: tree.unrealized.netUnrealizedPnl,
title: useGroupName ? name : "NUPL",
colors: [colors.red, colors.green],
options: { baseValue: { price: 0 } },
}),
]),
},
{
name: "profit",
title: `Unrealized Profit ${title}`,
bottom: list.flatMap(({ color, name, tree }) => [
s({
metric: tree.unrealized.unrealizedProfit,
name: useGroupName ? name : "Profit",
color,
}),
]),
},
{
name: "loss",
title: `Unrealized Loss ${title}`,
bottom: list.flatMap(({ color, name, tree }) => [
s({
metric: tree.unrealized.unrealizedLoss,
name: useGroupName ? name : "Loss",
color,
}),
]),
},
],
},
];
}
/**
* Create price paid section for cohorts WITH percentiles (age cohorts)
* @param {PartialContext} ctx
* @param {readonly AgeCohortObject[]} list
* @param {boolean} useGroupName
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createPricePaidSectionWithPercentiles(ctx, list, useGroupName, title) {
const { s } = ctx;
return [
{
name: "Price Paid",
tree: [
{
name: "min",
title: `Min Price Paid ${title}`,
top: list.map(({ color, name, tree }) =>
s({ metric: tree.pricePaid.minPricePaid, name: useGroupName ? name : "Min", color }),
),
},
{
name: "max",
title: `Max Price Paid ${title}`,
top: list.map(({ color, name, tree }) =>
s({ metric: tree.pricePaid.maxPricePaid, name: useGroupName ? name : "Max", color }),
),
},
{
name: "percentiles",
title: `Price Paid Percentiles ${title}`,
top: createPricePercentilesSeries(ctx, list, useGroupName),
},
],
},
];
}
/**
* Create price paid section for cohorts WITHOUT percentiles (amount cohorts)
* @param {PartialContext} ctx
* @param {readonly UtxoCohortObject[]} list
* @param {boolean} useGroupName
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createPricePaidSectionBasic(ctx, list, useGroupName, title) {
const { s } = ctx;
return [
{
name: "Price Paid",
tree: [
{
name: "min",
title: `Min Price Paid ${title}`,
top: list.map(({ color, name, tree }) =>
s({ metric: tree.pricePaid.minPricePaid, name: useGroupName ? name : "Min", color }),
),
},
{
name: "max",
title: `Max Price Paid ${title}`,
top: list.map(({ color, name, tree }) =>
s({ metric: tree.pricePaid.maxPricePaid, name: useGroupName ? name : "Max", color }),
),
},
],
},
];
}
/**
* Create activity section
* @param {PartialContext} ctx
* @param {readonly UtxoCohortObject[]} list
* @param {boolean} useGroupName
* @param {string} title
* @returns {PartialOptionsTree}
*/
function createActivitySection(ctx, list, useGroupName, title) {
const { s } = ctx;
return [
{
name: "Activity",
tree: [
{
name: "coinblocks destroyed",
title: `Coinblocks Destroyed ${title}`,
bottom: list.flatMap(({ color, name, tree }) => [
s({
metric: tree.activity.coinblocksDestroyed.base,
name: useGroupName ? name : "Coinblocks",
color,
}),
]),
},
{
name: "coindays destroyed",
title: `Coindays Destroyed ${title}`,
bottom: list.flatMap(({ color, name, tree }) => [
s({
metric: tree.activity.coindaysDestroyed.base,
name: useGroupName ? name : "Coindays",
color,
}),
]),
},
],
},
];
}
@@ -0,0 +1,306 @@
/** Cointime section builder - typed tree-based patterns */
/**
* Create price with ratio options for cointime prices
* @param {PartialContext} ctx
* @param {Object} args
* @param {string} args.name
* @param {string} args.title
* @param {string} args.legend
* @param {MetricAccessor<any>} args.price
* @param {ActivePriceRatioPattern} args.ratio
* @param {Color} [args.color]
* @returns {PartialOptionsTree}
*/
function createCointimePriceWithRatioOptions(ctx, { name, title, legend, price, ratio, color }) {
const { s, colors, createPriceLine } = ctx;
// Percentile USD mappings
const percentileUsdMap = [
{ name: "pct99", prop: ratio.ratioPct99Usd, color: colors.rose },
{ name: "pct98", prop: ratio.ratioPct98Usd, color: colors.pink },
{ name: "pct95", prop: ratio.ratioPct95Usd, color: colors.fuchsia },
{ name: "pct5", prop: ratio.ratioPct5Usd, color: colors.cyan },
{ name: "pct2", prop: ratio.ratioPct2Usd, color: colors.sky },
{ name: "pct1", prop: ratio.ratioPct1Usd, color: colors.blue },
];
// Percentile ratio mappings
const percentileMap = [
{ name: "pct99", prop: ratio.ratioPct99, color: colors.rose },
{ name: "pct98", prop: ratio.ratioPct98, color: colors.pink },
{ name: "pct95", prop: ratio.ratioPct95, color: colors.fuchsia },
{ name: "pct5", prop: ratio.ratioPct5, color: colors.cyan },
{ name: "pct2", prop: ratio.ratioPct2, color: colors.sky },
{ name: "pct1", prop: ratio.ratioPct1, color: colors.blue },
];
// SD patterns by window
const sdPatterns = [
{ nameAddon: "all", titleAddon: "", sd: ratio.ratioSd },
{ nameAddon: "4y", titleAddon: "4y", sd: ratio.ratio4ySd },
{ nameAddon: "2y", titleAddon: "2y", sd: ratio.ratio2ySd },
{ nameAddon: "1y", titleAddon: "1y", sd: ratio.ratio1ySd },
];
/** @param {Ratio1ySdPattern} sd */
const getSdBands = (sd) => [
{ name: "0σ", prop: sd._0sdUsd, color: colors.lime },
{ name: "+0.5σ", prop: sd.p05sdUsd, color: colors.yellow },
{ name: "+1σ", prop: sd.p1sdUsd, color: colors.amber },
{ name: "+1.5σ", prop: sd.p15sdUsd, color: colors.orange },
{ name: "+2σ", prop: sd.p2sdUsd, color: colors.red },
{ name: "+2.5σ", prop: sd.p25sdUsd, color: colors.rose },
{ name: "+3σ", prop: sd.p3sd, color: colors.pink },
{ name: "0.5σ", prop: sd.m05sdUsd, color: colors.teal },
{ name: "1σ", prop: sd.m1sdUsd, color: colors.cyan },
{ name: "1.5σ", prop: sd.m15sdUsd, color: colors.sky },
{ name: "2σ", prop: sd.m2sdUsd, color: colors.blue },
{ name: "2.5σ", prop: sd.m25sdUsd, color: colors.indigo },
{ name: "3σ", prop: sd.m3sd, color: colors.violet },
];
return [
{
name: "price",
title,
top: [s({ metric: price, name: legend, color, unit: "usd" })],
},
{
name: "Ratio",
title: `${title} Ratio`,
top: [
s({ metric: price, name: legend, color, unit: "usd" }),
...percentileUsdMap.map(({ name: pctName, prop, color: pctColor }) =>
s({
metric: prop,
name: pctName,
color: pctColor,
defaultActive: false,
unit: "usd",
options: { lineStyle: 1 },
}),
),
],
bottom: [
s({ metric: ratio.ratio, name: "ratio", color, unit: "ratio" }),
s({ metric: ratio.ratio1wSma, name: "1w sma", color: colors.lime, unit: "ratio" }),
s({ metric: ratio.ratio1mSma, name: "1m sma", color: colors.teal, unit: "ratio" }),
s({ metric: ratio.ratio1ySd.sma, name: "1y sma", color: colors.sky, unit: "ratio" }),
s({ metric: ratio.ratio2ySd.sma, name: "2y sma", color: colors.indigo, unit: "ratio" }),
s({ metric: ratio.ratio4ySd.sma, name: "4y sma", color: colors.purple, unit: "ratio" }),
s({ metric: ratio.ratioSd.sma, name: "all sma", color: colors.rose, unit: "ratio" }),
...percentileMap.map(({ name: pctName, prop, color: pctColor }) =>
s({
metric: prop,
name: pctName,
color: pctColor,
defaultActive: false,
unit: "ratio",
options: { lineStyle: 1 },
}),
),
createPriceLine({ unit: "ratio", number: 1 }),
],
},
{
name: "ZScores",
tree: sdPatterns.map(({ nameAddon, titleAddon, sd }) => ({
name: nameAddon,
title: `${title} ${titleAddon} Z-Score`,
top: getSdBands(sd).map(({ name: bandName, prop, color: bandColor }) =>
s({ metric: prop, name: bandName, color: bandColor, unit: "usd" }),
),
bottom: [
s({ metric: sd.zscore, name: "zscore", color, unit: "sd" }),
createPriceLine({ unit: "sd", number: 3 }),
createPriceLine({ unit: "sd", number: 2 }),
createPriceLine({ unit: "sd", number: 1 }),
createPriceLine({ unit: "sd", number: 0 }),
createPriceLine({ unit: "sd", number: -1 }),
createPriceLine({ unit: "sd", number: -2 }),
createPriceLine({ unit: "sd", number: -3 }),
],
})),
},
];
}
/**
* Create Cointime section
* @param {PartialContext} ctx
* @returns {PartialOptionsGroup}
*/
export function createCointimeSection(ctx) {
const { colors, brk, s } = ctx;
const { cointime, distribution, supply } = brk.tree.computed;
const { pricing, cap, activity, supply: cointimeSupply, adjusted } = cointime;
const utxoCohorts = distribution.utxoCohorts;
// Cointime prices data
const cointimePrices = [
{
price: pricing.trueMarketMean,
ratio: pricing.trueMarketMeanRatio,
name: "True market mean",
title: "true market mean",
color: colors.blue,
},
{
price: pricing.vaultedPrice,
ratio: pricing.vaultedPriceRatio,
name: "Vaulted",
title: "vaulted price",
color: colors.lime,
},
{
price: pricing.activePrice,
ratio: pricing.activePriceRatio,
name: "Active",
title: "active price",
color: colors.rose,
},
{
price: pricing.cointimePrice,
ratio: pricing.cointimePriceRatio,
name: "cointime",
title: "cointime price",
color: colors.yellow,
},
];
// Cointime capitalizations data
const cointimeCapitalizations = [
{ metric: cap.vaultedCap, name: "vaulted", title: "vaulted Capitalization", color: colors.lime },
{ metric: cap.activeCap, name: "active", title: "active Capitalization", color: colors.rose },
{ metric: cap.cointimeCap, name: "cointime", title: "cointime Capitalization", color: colors.yellow },
{ metric: cap.investorCap, name: "investor", title: "investor Capitalization", color: colors.fuchsia },
{ metric: cap.thermoCap, name: "thermo", title: "thermo Capitalization", color: colors.emerald },
];
return {
name: "Cointime",
tree: [
// Prices
{
name: "Prices",
tree: [
{
name: "Compare",
title: "Compare Cointime Prices",
top: cointimePrices.map(({ price, name, color }) =>
s({ metric: price, name, color, unit: "usd" }),
),
},
...cointimePrices.map(({ price, ratio, name, color, title }) => ({
name,
tree: createCointimePriceWithRatioOptions(ctx, {
price,
ratio,
legend: name,
color,
name,
title,
}),
})),
],
},
// Capitalization
{
name: "Capitalization",
tree: [
{
name: "Compare",
title: "Compare Cointime Capitalizations",
bottom: [
s({ metric: supply.marketCap.height, name: "Market", color: colors.default, unit: "usd" }),
s({ metric: utxoCohorts.all.realized.realizedCap, name: "Realized", color: colors.orange, unit: "usd" }),
...cointimeCapitalizations.map(({ metric, name, color }) =>
s({ metric, name, color, unit: "usd" }),
),
],
},
...cointimeCapitalizations.map(({ metric, name, color, title }) => ({
name,
title,
bottom: [
s({ metric, name, color, unit: "usd" }),
s({ metric: supply.marketCap.height, name: "Market", color: colors.default, unit: "usd" }),
s({ metric: utxoCohorts.all.realized.realizedCap, name: "Realized", color: colors.orange, unit: "usd" }),
],
})),
],
},
// Supply
{
name: "Supply",
title: "Cointime Supply",
bottom: /** @type {const} */ ([
[utxoCohorts.all.supply.supply, "all", colors.orange],
[cointimeSupply.vaultedSupply, "vaulted", colors.lime],
[cointimeSupply.activeSupply, "active", colors.rose],
]).flatMap(([supplyItem, name, color]) => [
s({ metric: supplyItem.sats, name, color, unit: "sats" }),
s({ metric: supplyItem.bitcoin, name, color, unit: "btc" }),
s({ metric: supplyItem.dollars, name, color, unit: "usd" }),
]),
},
// Liveliness & Vaultedness
{
name: "Liveliness & Vaultedness",
title: "Liveliness & Vaultedness",
bottom: [
s({ metric: activity.liveliness, name: "Liveliness", color: colors.rose, unit: "ratio" }),
s({ metric: activity.vaultedness, name: "Vaultedness", color: colors.lime, unit: "ratio" }),
s({ metric: activity.activityToVaultednessRatio, name: "Liveliness / Vaultedness", color: colors.purple, unit: "ratio" }),
],
},
// Coinblocks
{
name: "Coinblocks",
title: "Coinblocks",
bottom: [
// Destroyed comes from the all cohort's activity
s({ metric: utxoCohorts.all.activity.coinblocksDestroyed.base, name: "Destroyed", color: colors.red, unit: "coinblocks" }),
s({ metric: utxoCohorts.all.activity.coinblocksDestroyed.cumulative, name: "Cumulative Destroyed", color: colors.red, defaultActive: false, unit: "coinblocks" }),
// Created and stored from cointime
s({ metric: activity.coinblocksCreated.base, name: "created", color: colors.orange, unit: "coinblocks" }),
s({ metric: activity.coinblocksCreated.cumulative, name: "Cumulative created", color: colors.orange, defaultActive: false, unit: "coinblocks" }),
s({ metric: activity.coinblocksStored.base, name: "stored", color: colors.green, unit: "coinblocks" }),
s({ metric: activity.coinblocksStored.cumulative, name: "Cumulative stored", color: colors.green, defaultActive: false, unit: "coinblocks" }),
],
},
// Adjusted metrics
{
name: "Adjusted",
tree: [
// Inflation
{
name: "inflation",
title: "Cointime-Adjusted inflation rate",
bottom: [
s({ metric: supply.inflation.indexes, name: "base", color: colors.orange, unit: "percentage" }),
s({ metric: adjusted.cointimeAdjInflationRate, name: "adjusted", color: colors.purple, unit: "percentage" }),
],
},
// Velocity
{
name: "Velocity",
title: "Cointime-Adjusted transactions velocity",
bottom: [
s({ metric: supply.velocity.btc, name: "btc", color: colors.orange, unit: "ratio" }),
s({ metric: adjusted.cointimeAdjTxBtcVelocity, name: "adj. btc", color: colors.red, unit: "ratio" }),
s({ metric: supply.velocity.usd, name: "usd", color: colors.emerald, unit: "ratio" }),
s({ metric: adjusted.cointimeAdjTxUsdVelocity, name: "adj. usd", color: colors.lime, unit: "ratio" }),
],
},
],
},
],
};
}
@@ -0,0 +1,152 @@
/** Cohort color mappings */
/** @type {Readonly<Record<string, ColorName>>} */
export const termColors = {
short: "yellow",
long: "fuchsia",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const maxAgeColors = {
_1w: "red",
_1m: "orange",
_2m: "amber",
_3m: "yellow",
_4m: "lime",
_5m: "green",
_6m: "teal",
_1y: "sky",
_2y: "indigo",
_3y: "violet",
_4y: "purple",
_5y: "fuchsia",
_6y: "pink",
_7y: "red",
_8y: "orange",
_10y: "amber",
_12y: "yellow",
_15y: "lime",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const minAgeColors = {
_1d: "red",
_1w: "orange",
_1m: "yellow",
_2m: "lime",
_3m: "green",
_4m: "teal",
_5m: "cyan",
_6m: "blue",
_1y: "indigo",
_2y: "violet",
_3y: "purple",
_4y: "fuchsia",
_5y: "pink",
_6y: "rose",
_7y: "red",
_8y: "orange",
_10y: "yellow",
_12y: "lime",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const ageRangeColors = {
upTo1d: "pink",
_1dTo1w: "red",
_1wTo1m: "orange",
_1mTo2m: "yellow",
_2mTo3m: "yellow",
_3mTo4m: "lime",
_4mTo5m: "lime",
_5mTo6m: "lime",
_6mTo1y: "green",
_1yTo2y: "cyan",
_2yTo3y: "blue",
_3yTo4y: "indigo",
_4yTo5y: "violet",
_5yTo6y: "purple",
_6yTo7y: "purple",
_7yTo8y: "fuchsia",
_8yTo10y: "fuchsia",
_10yTo12y: "pink",
_12yTo15y: "red",
from15y: "orange",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const epochColors = {
_0: "red",
_1: "yellow",
_2: "orange",
_3: "lime",
_4: "green",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const geAmountColors = {
_1sat: "orange",
_10sats: "orange",
_100sats: "yellow",
_1kSats: "lime",
_10kSats: "green",
_100kSats: "cyan",
_1mSats: "blue",
_10mSats: "indigo",
_1btc: "purple",
_10btc: "violet",
_100btc: "fuchsia",
_1kBtc: "pink",
_10kBtc: "red",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const ltAmountColors = {
_10sats: "orange",
_100sats: "yellow",
_1kSats: "lime",
_10kSats: "green",
_100kSats: "cyan",
_1mSats: "blue",
_10mSats: "indigo",
_1btc: "purple",
_10btc: "violet",
_100btc: "fuchsia",
_1kBtc: "pink",
_10kBtc: "red",
_100kBtc: "orange",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const amountRangeColors = {
_0sats: "red",
_1satTo10sats: "orange",
_10satsTo100sats: "yellow",
_100satsTo1kSats: "lime",
_1kSatsTo10kSats: "green",
_10kSatsTo100kSats: "cyan",
_100kSatsTo1mSats: "blue",
_1mSatsTo10mSats: "indigo",
_10mSatsTo1btc: "purple",
_1btcTo10btc: "violet",
_10btcTo100btc: "fuchsia",
_100btcTo1kBtc: "pink",
_1kBtcTo10kBtc: "red",
_10kBtcTo100kBtc: "orange",
_100kBtcOrMore: "yellow",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const spendableTypeColors = {
p2pk65: "red",
p2pk33: "orange",
p2pkh: "yellow",
p2ms: "lime",
p2sh: "green",
p2wpkh: "teal",
p2wsh: "blue",
p2tr: "indigo",
p2a: "purple",
unknown: "violet",
empty: "fuchsia",
};
@@ -0,0 +1,14 @@
// Re-export all color mappings
export {
termColors,
maxAgeColors,
minAgeColors,
ageRangeColors,
epochColors,
geAmountColors,
ltAmountColors,
amountRangeColors,
spendableTypeColors,
} from "./cohorts.js";
export { averageColors, dcaColors } from "./misc.js";
@@ -0,0 +1,42 @@
/** Miscellaneous color mappings for DCA and averages */
/**
* Moving average period colors
* Format: [periodId, days, colorName]
* @type {readonly [string, number, ColorName][]}
*/
export const averageColors = [
["1w", 7, "red"],
["8d", 8, "orange"],
["13d", 13, "amber"],
["21d", 21, "yellow"],
["1m", 30, "lime"],
["34d", 34, "green"],
["55d", 55, "emerald"],
["89d", 89, "teal"],
["144d", 144, "cyan"],
["200d", 200, "sky"],
["1y", 365, "blue"],
["2y", 730, "indigo"],
["200w", 1400, "violet"],
["4y", 1460, "purple"],
];
/**
* DCA class colors by year
* Format: [year, colorName, defaultActive]
* @type {readonly [number, ColorName, boolean][]}
*/
export const dcaColors = [
[2015, "pink", false],
[2016, "red", false],
[2017, "orange", true],
[2018, "yellow", true],
[2019, "green", true],
[2020, "teal", true],
[2021, "sky", true],
[2022, "blue", true],
[2023, "purple", true],
[2024, "fuchsia", true],
[2025, "pink", true],
];
@@ -0,0 +1,141 @@
/** Constant helpers for creating price lines and reference lines */
/**
* Get constant pattern by number dynamically from tree
* Examples: 0 → constant0, 38.2 → constant382, -1 → constantMinus1
* @param {BrkClient["tree"]["computed"]["constants"]} constants
* @param {number} num
* @returns {Constant0Pattern<any>}
*/
export function getConstant(constants, num) {
const key =
num >= 0
? `constant${String(num).replace(".", "")}`
: `constantMinus${Math.abs(num)}`;
const constant = /** @type {Constant0Pattern<any> | undefined} */ (
/** @type {Record<string, Constant0Pattern<any>>} */ (constants)[key]
);
if (!constant) throw new Error(`Unknown constant: ${num} (key: ${key})`);
return constant;
}
/**
* Flatten a Constant0Pattern into a simple MetricAccessor
* Constant0Pattern has { dateindex: { by: {...} }, height: { by: {...} }, ... }
* This flattens it to { by: { dateindex: MetricNode, height: MetricNode, ... } }
* @param {Constant0Pattern<any>} pattern
* @returns {MetricAccessor<any>}
*/
export function flattenConstant(pattern) {
return {
by: {
dateindex: pattern.dateindex.by.dateindex,
decadeindex: pattern.decadeindex.by.decadeindex,
height: pattern.height.by.height,
monthindex: pattern.monthindex.by.monthindex,
quarterindex: pattern.quarterindex.by.quarterindex,
semesterindex: pattern.semesterindex.by.semesterindex,
weekindex: pattern.weekindex.by.weekindex,
yearindex: pattern.yearindex.by.yearindex,
},
indexes() {
return /** @type {Index[]} */ (Object.keys(this.by));
},
};
}
/**
* Create a price line series (horizontal reference line)
* @param {Object} args
* @param {BrkClient["tree"]["computed"]["constants"]} args.constants
* @param {Colors} args.colors
* @param {number} [args.number]
* @param {string} [args.name]
* @param {boolean} [args.defaultActive]
* @param {number} [args.lineStyle]
* @param {Color} [args.color]
* @param {Unit} args.unit
* @returns {FetchedLineSeriesBlueprint}
*/
export function createPriceLine({
constants,
colors,
number = 0,
unit,
defaultActive,
color,
name,
lineStyle,
}) {
return {
metric: flattenConstant(getConstant(constants, number)),
title: name ?? `${number}`,
unit,
defaultActive,
color: color ?? colors.gray,
options: {
lineStyle: lineStyle ?? 4,
lastValueVisible: false,
crosshairMarkerVisible: false,
},
};
}
/**
* Create multiple price lines from an array of numbers
* @param {Object} args
* @param {BrkClient["tree"]["computed"]["constants"]} args.constants
* @param {Colors} args.colors
* @param {number[]} args.numbers
* @param {Unit} args.unit
* @returns {FetchedLineSeriesBlueprint[]}
*/
export function createPriceLines({ constants, colors, numbers, unit }) {
return numbers.map((number) => ({
metric: flattenConstant(getConstant(constants, number)),
title: `${number}`,
unit,
defaultActive: !number,
color: colors.gray,
options: {
lineStyle: 4,
lastValueVisible: false,
crosshairMarkerVisible: false,
},
}));
}
/**
* Create a constant line series
* @param {Object} args
* @param {Colors} args.colors
* @param {Constant0Pattern<any>} args.constant
* @param {string} args.name
* @param {Unit} args.unit
* @param {Color} [args.color]
* @param {number} [args.lineStyle]
* @param {boolean} [args.defaultActive]
* @returns {FetchedLineSeriesBlueprint}
*/
export function line({
colors,
constant,
name,
unit,
color,
lineStyle,
defaultActive,
}) {
return {
metric: flattenConstant(constant),
title: name,
unit,
defaultActive,
color: color ?? colors.gray,
options: {
lineStyle: lineStyle ?? 4,
lastValueVisible: false,
crosshairMarkerVisible: false,
},
};
}
@@ -0,0 +1,44 @@
import { s, fromBlockCount, fromBitcoin, fromBlockSize } from "./series.js";
import {
getConstant,
flattenConstant,
createPriceLine,
createPriceLines,
line,
} from "./constants.js";
/**
* Create a context object with all dependencies for building partial options
* @param {Object} args
* @param {Colors} args.colors
* @param {BrkClient} args.brk
* @returns {PartialContext}
*/
export function createContext({ colors, brk }) {
const constants = brk.tree.computed.constants;
const constant100 = flattenConstant(constants.constant100);
return {
colors,
brk,
constants,
constant100,
// Series helpers
s,
fromBlockCount: (pattern, title, color) =>
fromBlockCount(colors, pattern, title, color),
fromBitcoin: (pattern, title, color) =>
fromBitcoin(colors, pattern, title, color),
fromBlockSize: (pattern, title, color) =>
fromBlockSize(colors, pattern, title, color),
// Constant helpers
getConstant: (num) => getConstant(constants, num),
flattenConstant,
createPriceLine: (args) => createPriceLine({ constants, colors, ...args }),
createPriceLines: (args) =>
createPriceLines({ constants, colors, ...args }),
line: (args) => line({ colors, ...args }),
};
}
@@ -0,0 +1,357 @@
/** Partial options - Main entry point */
import { localhost } from "../../utils/env.js";
import { createContext } from "./context.js";
import {
buildCohortData,
createUtxoCohortFolder,
createAddressCohortFolder,
} from "./cohorts/index.js";
import { createMarketSection } from "./market.js";
import { createChainSection } from "./chain.js";
import { createCointimeSection } from "./cointime.js";
// Re-export types for external consumers
export * from "./types.js";
/**
* Create partial options tree
* @param {Object} args
* @param {Colors} args.colors
* @param {BrkClient} args.brk
* @returns {PartialOptionsTree}
*/
export function createPartialOptions({ colors, brk }) {
// Create context with all helpers
const ctx = createContext({ colors, brk });
// Build cohort data
const {
cohortAll,
cohortAllForComparison,
terms,
upToDate,
fromDate,
dateRange,
epoch,
utxosAboveAmount,
addressesAboveAmount,
utxosUnderAmount,
addressesUnderAmount,
utxosAmountRanges,
addressesAmountRanges,
type,
} = buildCohortData(colors, brk);
// Helper to map UTXO cohorts
const mapUtxoCohorts = (/** @type {any} */ cohort) => createUtxoCohortFolder(ctx, cohort);
// Helper to map Address cohorts
const mapAddressCohorts = (/** @type {any} */ cohort) => createAddressCohortFolder(ctx, cohort);
return [
// Debug explorer (localhost only)
...(localhost
? [
{
kind: /** @type {const} */ ("explorer"),
name: "Explorer",
title: "Debug explorer",
},
]
: []),
// Charts section
{
name: "Charts",
tree: [
// Market section
createMarketSection(ctx),
// Chain section
createChainSection(ctx),
// Cohorts section
{
name: "Cohorts",
tree: [
// All UTXOs
createUtxoCohortFolder(ctx, cohortAll),
// Terms (STH/LTH)
{
name: "terms",
tree: [
createUtxoCohortFolder(ctx, {
name: "Compare",
title: "UTXOs Term",
list: [...terms, cohortAllForComparison],
}),
...terms.map(mapUtxoCohorts),
],
},
// Epochs
{
name: "Epochs",
tree: [
createUtxoCohortFolder(ctx, {
name: "Compare",
title: "Epoch",
list: [...epoch, cohortAllForComparison],
}),
...epoch.map(mapUtxoCohorts),
],
},
// Types
{
name: "types",
tree: [
createUtxoCohortFolder(ctx, {
name: "Compare",
title: "Type",
list: [...type, cohortAllForComparison],
}),
...type.map(mapUtxoCohorts),
],
},
// UTXOs Up to age
{
name: "UTXOs Up to age",
tree: [
createUtxoCohortFolder(ctx, {
name: "Compare",
title: "UTXOs Up To Age",
list: [...upToDate, cohortAllForComparison],
}),
...upToDate.map(mapUtxoCohorts),
],
},
// UTXOs from age
{
name: "UTXOs from age",
tree: [
createUtxoCohortFolder(ctx, {
name: "Compare",
title: "UTXOs from age",
list: [...fromDate, cohortAllForComparison],
}),
...fromDate.map(mapUtxoCohorts),
],
},
// UTXOs age ranges
{
name: "UTXOs age Ranges",
tree: [
createUtxoCohortFolder(ctx, {
name: "Compare",
title: "UTXOs Age Range",
list: [...dateRange, cohortAllForComparison],
}),
...dateRange.map(mapUtxoCohorts),
],
},
// UTXOs under amounts
{
name: "UTXOs under amounts",
tree: [
createUtxoCohortFolder(ctx, {
name: "Compare",
title: "UTXOs under amount",
list: [...utxosUnderAmount, cohortAllForComparison],
}),
...utxosUnderAmount.map(mapUtxoCohorts),
],
},
// UTXOs above amounts
{
name: "UTXOs Above Amounts",
tree: [
createUtxoCohortFolder(ctx, {
name: "Compare",
title: "UTXOs Above Amount",
list: [...utxosAboveAmount, cohortAllForComparison],
}),
...utxosAboveAmount.map(mapUtxoCohorts),
],
},
// UTXOs between amounts
{
name: "UTXOs between amounts",
tree: [
createUtxoCohortFolder(ctx, {
name: "Compare",
title: "UTXOs between amounts",
list: [...utxosAmountRanges, cohortAllForComparison],
}),
...utxosAmountRanges.map(mapUtxoCohorts),
],
},
// Addresses under amount (TYPE SAFE - uses createAddressCohortFolder!)
{
name: "Addresses under amount",
tree: [
createAddressCohortFolder(ctx, {
name: "Compare",
title: "Addresses under Amount",
list: addressesUnderAmount,
}),
...addressesUnderAmount.map(mapAddressCohorts),
],
},
// Addresses above amount (TYPE SAFE - uses createAddressCohortFolder!)
{
name: "Addresses above amount",
tree: [
createAddressCohortFolder(ctx, {
name: "Compare",
title: "Addresses above amount",
list: addressesAboveAmount,
}),
...addressesAboveAmount.map(mapAddressCohorts),
],
},
// Addresses between amounts (TYPE SAFE - uses createAddressCohortFolder!)
{
name: "Addresses between amounts",
tree: [
createAddressCohortFolder(ctx, {
name: "Compare",
title: "Addresses between amounts",
list: addressesAmountRanges,
}),
...addressesAmountRanges.map(mapAddressCohorts),
],
},
],
},
// Cointime section
createCointimeSection(ctx),
],
},
// Table section
{
kind: /** @type {const} */ ("table"),
title: "Table",
name: "Table",
},
// Simulations section
{
name: "Simulations",
tree: [
{
kind: /** @type {const} */ ("simulation"),
name: "Save In Bitcoin",
title: "Save In Bitcoin",
},
],
},
// Tools section
{
name: "Tools",
tree: [
{
name: "Documentation",
tree: [
{
name: "API",
url: () => "/api",
title: "API documentation",
},
{
name: "MCP",
url: () =>
"https://github.com/bitcoinresearchkit/brk/blob/main/crates/brk_mcp/README.md#brk_mcp",
title: "Model Context Protocol documentation",
},
{
name: "Crate",
url: () => "/crate",
title: "View on crates.io",
},
{
name: "Source",
url: () => "/github",
title: "Source code and issues",
},
{
name: "Changelog",
url: () => "/changelog",
title: "Release notes and changelog",
},
],
},
{
name: "Hosting",
tree: [
{
name: "Status",
url: () => "/status",
title: "Service status and uptime",
},
{
name: "Self-host",
url: () => "/install",
title: "Install and run yourself",
},
{
name: "Service",
url: () => "/service",
title: "Hosted service offering",
},
],
},
{
name: "Community",
tree: [
{
name: "Discord",
url: () => "/discord",
title: "Join the Discord server",
},
{
name: "GitHub",
url: () => "/github",
title: "Source code and issues",
},
{
name: "Nostr",
url: () => "/nostr",
title: "Follow on Nostr",
},
],
},
],
},
// Donate
{
name: "Donate",
qrcode: true,
url: () => "bitcoin:bc1q098zsm89m7kgyze338vfejhpdt92ua9p3peuve",
title: "Bitcoin address for donations",
},
// Share
{
name: "Share",
qrcode: true,
url: () => window.location.href,
title: "Share",
},
];
}
@@ -0,0 +1,480 @@
/** Market section builder - typed tree-based patterns */
/**
* Convert period ID to readable name
* @param {string} id
* @param {boolean} [compoundAdjective]
*/
function periodIdToName(id, compoundAdjective) {
const suffix = compoundAdjective || parseInt(id) === 1 ? "" : "s";
return id
.replace("d", ` day${suffix}`)
.replace("w", ` week${suffix}`)
.replace("m", ` month${suffix}`)
.replace("y", ` year${suffix}`);
}
/**
* Create price with ratio options (for moving averages)
* @param {PartialContext} ctx
* @param {Object} args
* @param {string} args.name
* @param {string} args.title
* @param {string} args.legend
* @param {EmaRatioPattern} args.ratio
* @param {Color} [args.color]
* @returns {PartialOptionsTree}
*/
function createPriceWithRatioOptions(ctx, { name, title, legend, ratio, color }) {
const { s, colors, createPriceLine } = ctx;
const priceMetric = ratio.price;
// Percentile USD mappings
const percentileUsdMap = [
{ name: "pct99", prop: ratio.ratioPct99Usd, color: colors.rose },
{ name: "pct98", prop: ratio.ratioPct98Usd, color: colors.pink },
{ name: "pct95", prop: ratio.ratioPct95Usd, color: colors.fuchsia },
{ name: "pct5", prop: ratio.ratioPct5Usd, color: colors.cyan },
{ name: "pct2", prop: ratio.ratioPct2Usd, color: colors.sky },
{ name: "pct1", prop: ratio.ratioPct1Usd, color: colors.blue },
];
// Percentile ratio mappings
const percentileMap = [
{ name: "pct99", prop: ratio.ratioPct99, color: colors.rose },
{ name: "pct98", prop: ratio.ratioPct98, color: colors.pink },
{ name: "pct95", prop: ratio.ratioPct95, color: colors.fuchsia },
{ name: "pct5", prop: ratio.ratioPct5, color: colors.cyan },
{ name: "pct2", prop: ratio.ratioPct2, color: colors.sky },
{ name: "pct1", prop: ratio.ratioPct1, color: colors.blue },
];
// SD patterns by window
const sdPatterns = [
{ nameAddon: "all", titleAddon: "", sd: ratio.ratioSd },
{ nameAddon: "4y", titleAddon: "4y", sd: ratio.ratio4ySd },
{ nameAddon: "2y", titleAddon: "2y", sd: ratio.ratio2ySd },
{ nameAddon: "1y", titleAddon: "1y", sd: ratio.ratio1ySd },
];
/** @param {Ratio1ySdPattern} sd */
const getSdBands = (sd) => [
{ name: "0σ", prop: sd._0sdUsd, color: colors.lime },
{ name: "+0.5σ", prop: sd.p05sdUsd, color: colors.yellow },
{ name: "+1σ", prop: sd.p1sdUsd, color: colors.amber },
{ name: "+1.5σ", prop: sd.p15sdUsd, color: colors.orange },
{ name: "+2σ", prop: sd.p2sdUsd, color: colors.red },
{ name: "+2.5σ", prop: sd.p25sdUsd, color: colors.rose },
{ name: "+3σ", prop: sd.p3sd, color: colors.pink },
{ name: "0.5σ", prop: sd.m05sdUsd, color: colors.teal },
{ name: "1σ", prop: sd.m1sdUsd, color: colors.cyan },
{ name: "1.5σ", prop: sd.m15sdUsd, color: colors.sky },
{ name: "2σ", prop: sd.m2sdUsd, color: colors.blue },
{ name: "2.5σ", prop: sd.m25sdUsd, color: colors.indigo },
{ name: "3σ", prop: sd.m3sd, color: colors.violet },
];
return [
{
name: "price",
title,
top: [s({ metric: priceMetric, name: legend, color, unit: "usd" })],
},
{
name: "Ratio",
title: `${title} Ratio`,
top: [
s({ metric: priceMetric, name: legend, color, unit: "usd" }),
...percentileUsdMap.map(({ name: pctName, prop, color: pctColor }) =>
s({
metric: prop,
name: pctName,
color: pctColor,
defaultActive: false,
unit: "usd",
options: { lineStyle: 1 },
}),
),
],
bottom: [
s({ metric: ratio.ratio, name: "ratio", color, unit: "ratio" }),
s({ metric: ratio.ratio1wSma, name: "1w sma", color: colors.lime, unit: "ratio" }),
s({ metric: ratio.ratio1mSma, name: "1m sma", color: colors.teal, unit: "ratio" }),
s({ metric: ratio.ratio1ySd.sma, name: "1y sma", color: colors.sky, unit: "ratio" }),
s({ metric: ratio.ratio2ySd.sma, name: "2y sma", color: colors.indigo, unit: "ratio" }),
s({ metric: ratio.ratio4ySd.sma, name: "4y sma", color: colors.purple, unit: "ratio" }),
s({ metric: ratio.ratioSd.sma, name: "all sma", color: colors.rose, unit: "ratio" }),
...percentileMap.map(({ name: pctName, prop, color: pctColor }) =>
s({
metric: prop,
name: pctName,
color: pctColor,
defaultActive: false,
unit: "ratio",
options: { lineStyle: 1 },
}),
),
createPriceLine({ unit: "ratio", number: 1 }),
],
},
{
name: "ZScores",
tree: sdPatterns.map(({ nameAddon, titleAddon, sd }) => ({
name: nameAddon,
title: `${title} ${titleAddon} Z-Score`,
top: getSdBands(sd).map(({ name: bandName, prop, color: bandColor }) =>
s({ metric: prop, name: bandName, color: bandColor, unit: "usd" }),
),
bottom: [
s({ metric: sd.zscore, name: "zscore", color, unit: "sd" }),
createPriceLine({ unit: "sd", number: 3 }),
createPriceLine({ unit: "sd", number: 2 }),
createPriceLine({ unit: "sd", number: 1 }),
createPriceLine({ unit: "sd", number: 0 }),
createPriceLine({ unit: "sd", number: -1 }),
createPriceLine({ unit: "sd", number: -2 }),
createPriceLine({ unit: "sd", number: -3 }),
],
})),
},
];
}
/**
* Build averages data array from market patterns
* @param {Colors} colors
* @param {MarketMovingAverage} ma
*/
function buildAverages(colors, ma) {
return /** @type {const} */ ([
["1w", 7, "red", ma.price1wSma, ma.price1wEma],
["8d", 8, "orange", ma.price8dSma, ma.price8dEma],
["13d", 13, "amber", ma.price13dSma, ma.price13dEma],
["21d", 21, "yellow", ma.price21dSma, ma.price21dEma],
["1m", 30, "lime", ma.price1mSma, ma.price1mEma],
["34d", 34, "green", ma.price34dSma, ma.price34dEma],
["55d", 55, "emerald", ma.price55dSma, ma.price55dEma],
["89d", 89, "teal", ma.price89dSma, ma.price89dEma],
["144d", 144, "cyan", ma.price144dSma, ma.price144dEma],
["200d", 200, "sky", ma.price200dSma, ma.price200dEma],
["1y", 365, "blue", ma.price1ySma, ma.price1yEma],
["2y", 730, "indigo", ma.price2ySma, ma.price2yEma],
["200w", 1400, "violet", ma.price200wSma, ma.price200wEma],
["4y", 1460, "purple", ma.price4ySma, ma.price4yEma],
]).map(([id, days, colorKey, sma, ema]) => ({
id,
name: periodIdToName(id, true),
days,
color: colors[colorKey],
sma,
ema,
}));
}
/**
* Build DCA classes data array
* @param {Colors} colors
* @param {MarketDca} dca
*/
function buildDcaClasses(colors, dca) {
return /** @type {const} */ ([
[2015, "pink", false, dca.dcaClass2015AvgPrice, dca.dcaClass2015Returns, dca.dcaClass2015Stack],
[2016, "red", false, dca.dcaClass2016AvgPrice, dca.dcaClass2016Returns, dca.dcaClass2016Stack],
[2017, "orange", true, dca.dcaClass2017AvgPrice, dca.dcaClass2017Returns, dca.dcaClass2017Stack],
[2018, "yellow", true, dca.dcaClass2018AvgPrice, dca.dcaClass2018Returns, dca.dcaClass2018Stack],
[2019, "green", true, dca.dcaClass2019AvgPrice, dca.dcaClass2019Returns, dca.dcaClass2019Stack],
[2020, "teal", true, dca.dcaClass2020AvgPrice, dca.dcaClass2020Returns, dca.dcaClass2020Stack],
[2021, "sky", true, dca.dcaClass2021AvgPrice, dca.dcaClass2021Returns, dca.dcaClass2021Stack],
[2022, "blue", true, dca.dcaClass2022AvgPrice, dca.dcaClass2022Returns, dca.dcaClass2022Stack],
[2023, "purple", true, dca.dcaClass2023AvgPrice, dca.dcaClass2023Returns, dca.dcaClass2023Stack],
[2024, "fuchsia", true, dca.dcaClass2024AvgPrice, dca.dcaClass2024Returns, dca.dcaClass2024Stack],
[2025, "pink", true, dca.dcaClass2025AvgPrice, dca.dcaClass2025Returns, dca.dcaClass2025Stack],
]).map(([year, colorKey, defaultActive, avgPrice, returns, stack]) => ({
year,
color: colors[colorKey],
defaultActive,
avgPrice,
returns,
stack,
}));
}
/**
* Create Market section
* @param {PartialContext} ctx
* @returns {PartialOptionsGroup}
*/
export function createMarketSection(ctx) {
const { colors, brk, s, createPriceLine } = ctx;
const { market, supply } = brk.tree.computed;
const { movingAverage, ath, returns, volatility, range, dca, lookback } = market;
const averages = buildAverages(colors, movingAverage);
const dcaClasses = buildDcaClasses(colors, dca);
return {
name: "Market",
tree: [
// Price (empty chart, shows candlesticks by default)
{
name: "Price",
title: "Bitcoin Price",
},
// Capitalization
{
name: "Capitalization",
title: "Market Capitalization",
bottom: [s({ metric: supply.marketCap.indexes, name: "Capitalization", unit: "usd" })],
},
// All Time High
{
name: "All Time High",
title: "All Time High",
top: [s({ metric: ath.priceAth, name: "ath", unit: "usd" })],
bottom: [
s({ metric: ath.priceDrawdown, name: "Drawdown", color: colors.red, unit: "percentage" }),
s({ metric: ath.daysSincePriceAth, name: "since", unit: "days" }),
s({ metric: ath.maxDaysBetweenPriceAths, name: "Max", color: colors.red, unit: "days" }),
s({ metric: ath.maxYearsBetweenPriceAths, name: "Max", color: colors.red, unit: "years" }),
],
},
// Averages
{
name: "Averages",
tree: [
{ nameAddon: "Simple", metricAddon: /** @type {const} */ ("sma") },
{ nameAddon: "Exponential", metricAddon: /** @type {const} */ ("ema") },
].map(({ nameAddon, metricAddon }) => ({
name: nameAddon,
tree: [
{
name: "Compare",
title: `Market Price ${nameAddon} Moving Averages`,
top: averages.map(({ id, color, sma, ema }) =>
s({
metric: (metricAddon === "sma" ? sma : ema).price,
name: id,
color,
unit: "usd",
}),
),
},
...averages.map(({ name, color, sma, ema }) => ({
name,
tree: createPriceWithRatioOptions(ctx, {
ratio: metricAddon === "sma" ? sma : ema,
name,
title: `${name} Market Price ${nameAddon} Moving Average`,
legend: "average",
color,
}),
})),
],
})),
},
// Performance
{
name: "Performance",
tree: /** @type {const} */ ([
["1d", returns._1dPriceReturns, undefined],
["1w", returns._1wPriceReturns, undefined],
["1m", returns._1mPriceReturns, undefined],
["3m", returns._3mPriceReturns, undefined],
["6m", returns._6mPriceReturns, undefined],
["1y", returns._1yPriceReturns, undefined],
["2y", returns._2yPriceReturns, returns._2yCagr],
["3y", returns._3yPriceReturns, returns._3yCagr],
["4y", returns._4yPriceReturns, returns._4yCagr],
["5y", returns._5yPriceReturns, returns._5yCagr],
["6y", returns._6yPriceReturns, returns._6yCagr],
["8y", returns._8yPriceReturns, returns._8yCagr],
["10y", returns._10yPriceReturns, returns._10yCagr],
]).map(([id, priceReturns, cagr]) => {
const name = periodIdToName(id, true);
return {
name,
title: `${name} Performance`,
bottom: [
/** @type {AnyFetchedSeriesBlueprint} */ ({
metric: priceReturns,
title: "total",
type: "Baseline",
unit: "percentage",
}),
...(cagr
? [
/** @type {AnyFetchedSeriesBlueprint} */ ({
metric: cagr,
title: "cagr",
type: "Baseline",
colors: [colors.lime, colors.pink],
unit: "percentage",
}),
]
: []),
createPriceLine({ unit: "percentage" }),
],
};
}),
},
// Indicators
{
name: "Indicators",
tree: [
// Volatility
{
name: "Volatility",
title: "Bitcoin Price Volatility Index",
bottom: [
s({ metric: volatility.price1wVolatility, name: "1w", color: colors.red, unit: "percentage" }),
s({ metric: volatility.price1mVolatility, name: "1m", color: colors.orange, unit: "percentage" }),
s({ metric: volatility.price1yVolatility, name: "1y", color: colors.lime, unit: "percentage" }),
],
},
// MinMax
{
name: "MinMax",
tree: [
{ id: "1w", title: "1 Week", min: range.price1wMin, max: range.price1wMax },
{ id: "2w", title: "2 Week", min: range.price2wMin, max: range.price2wMax },
{ id: "1m", title: "1 Month", min: range.price1mMin, max: range.price1mMax },
{ id: "1y", title: "1 Year", min: range.price1yMin, max: range.price1yMax },
].map(({ id, title, min, max }) => ({
name: id,
title: `Bitcoin Price ${title} MinMax Bands`,
top: [
s({ metric: min, name: "min", color: colors.red, unit: "usd" }),
s({ metric: max, name: "max", color: colors.green, unit: "usd" }),
],
})),
},
// True range
{
name: "True range",
title: "Bitcoin Price True Range",
bottom: [s({ metric: range.priceTrueRange, name: "value", color: colors.yellow, unit: "usd" })],
},
// Choppiness
{
name: "Choppiness",
title: "Bitcoin Price Choppiness Index",
bottom: [
s({ metric: range.price2wChoppinessIndex, name: "2w", color: colors.red, unit: "index" }),
createPriceLine({ unit: "index", number: 61.8 }),
createPriceLine({ unit: "index", number: 38.2 }),
],
},
// Mayer multiple
{
name: "Mayer multiple",
title: "Mayer multiple",
top: [
s({ metric: movingAverage.price200dSma.price, name: "200d sma", color: colors.yellow, unit: "usd" }),
s({ metric: movingAverage.price200dSmaX24, name: "200d sma x2.4", color: colors.green, unit: "usd" }),
s({ metric: movingAverage.price200dSmaX08, name: "200d sma x0.8", color: colors.red, unit: "usd" }),
],
},
],
},
// Investing
{
name: "Investing",
tree: [
// DCA vs Lump sum
{
name: "DCA vs Lump sum",
tree: [
.../** @type {const} */ ([
["1w", dca._1wDcaAvgPrice, lookback.price1wAgo, dca._1wDcaReturns, returns._1wPriceReturns],
["1m", dca._1mDcaAvgPrice, lookback.price1mAgo, dca._1mDcaReturns, returns._1mPriceReturns],
["3m", dca._3mDcaAvgPrice, lookback.price3mAgo, dca._3mDcaReturns, returns._3mPriceReturns],
["6m", dca._6mDcaAvgPrice, lookback.price6mAgo, dca._6mDcaReturns, returns._6mPriceReturns],
["1y", dca._1yDcaAvgPrice, lookback.price1yAgo, dca._1yDcaReturns, returns._1yPriceReturns],
]).map(([id, dcaAvgPrice, priceAgo, dcaReturns, priceReturns]) => {
const name = periodIdToName(id, true);
return {
name,
tree: [
{
name: "price",
title: `${name} DCA vs Lump Sum (Price)`,
top: [
s({ metric: dcaAvgPrice, name: "DCA avg", color: colors.green, unit: "usd" }),
s({ metric: priceAgo, name: "Lump sum", color: colors.orange, unit: "usd" }),
],
},
{
name: "returns",
title: `${name} DCA vs Lump Sum (Returns)`,
bottom: [
/** @type {AnyFetchedSeriesBlueprint} */ ({
metric: dcaReturns,
title: "DCA",
type: "Baseline",
unit: "percentage",
}),
/** @type {AnyFetchedSeriesBlueprint} */ ({
metric: priceReturns,
title: "Lump sum",
type: "Baseline",
colors: [colors.lime, colors.red],
unit: "percentage",
}),
createPriceLine({ unit: "percentage" }),
],
},
],
};
}),
],
},
// DCA classes
{
name: "DCA classes",
tree: [
{
name: "Average price",
title: "DCA Average Price by Year",
top: dcaClasses.map(({ year, color, defaultActive, avgPrice }) =>
s({ metric: avgPrice, name: `${year}`, color, defaultActive, unit: "usd" }),
),
},
{
name: "Returns",
title: "DCA Returns by Year",
bottom: dcaClasses.map(({ year, color, defaultActive, returns }) =>
/** @type {AnyFetchedSeriesBlueprint} */ ({
metric: returns,
title: `${year}`,
type: "Baseline",
color,
defaultActive,
unit: "percentage",
}),
),
},
{
name: "Stack",
title: "DCA Stack by Year",
bottom: dcaClasses.map(({ year, color, defaultActive, stack }) =>
s({ metric: stack, name: `${year}`, color, defaultActive, unit: "sats" }),
),
},
],
},
],
},
],
};
}
@@ -0,0 +1,181 @@
/** Series helpers for creating chart series blueprints */
/**
* Create a single series from a tree accessor
* @param {Object} args
* @param {MetricAccessor<any>} args.metric - Tree accessor with .by property
* @param {string} args.name - Display name for the series
* @param {Color} [args.color]
* @param {Unit} [args.unit]
* @param {boolean} [args.defaultActive]
* @param {LineSeriesPartialOptions} [args.options]
* @returns {AnyFetchedSeriesBlueprint}
*/
export function s({ metric, name, color, defaultActive, unit, options }) {
return {
metric,
title: name,
color,
unit,
defaultActive,
options,
};
}
/**
* Create series from a BlockCountPattern ({ base, sum, cumulative })
* @param {Colors} colors
* @param {BlockCountPattern<any>} pattern
* @param {string} title
* @param {Color} [color]
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function fromBlockCount(colors, pattern, title, color) {
return [
{ metric: pattern.base, title, color: color ?? colors.default },
{
metric: pattern.sum,
title: `${title} (sum)`,
color: colors.red,
defaultActive: false,
},
{
metric: pattern.cumulative,
title: `${title} (cum.)`,
color: colors.cyan,
defaultActive: false,
},
];
}
/**
* Create series from a BitcoinPattern ({ base, sum, cumulative, average, min, max, median, pct* })
* @param {Colors} colors
* @param {BitcoinPattern<any>} pattern
* @param {string} title
* @param {Color} [color]
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function fromBitcoin(colors, pattern, title, color) {
return [
{ metric: pattern.base, title, color: color ?? colors.default },
{ metric: pattern.average, title: "Average", defaultActive: false },
{
metric: pattern.sum,
title: `${title} (sum)`,
color: colors.red,
defaultActive: false,
},
{
metric: pattern.cumulative,
title: `${title} (cum.)`,
color: colors.cyan,
defaultActive: false,
},
{
metric: pattern.max,
title: "Max",
color: colors.pink,
defaultActive: false,
},
{
metric: pattern.min,
title: "Min",
color: colors.green,
defaultActive: false,
},
{
metric: pattern.median,
title: "Median",
color: colors.amber,
defaultActive: false,
},
{
metric: pattern.pct75,
title: "pct75",
color: colors.red,
defaultActive: false,
},
{
metric: pattern.pct25,
title: "pct25",
color: colors.yellow,
defaultActive: false,
},
{
metric: pattern.pct90,
title: "pct90",
color: colors.rose,
defaultActive: false,
},
{
metric: pattern.pct10,
title: "pct10",
color: colors.lime,
defaultActive: false,
},
];
}
/**
* Create series from a BlockSizePattern ({ sum, cumulative, average, min, max, median, pct* })
* @param {Colors} colors
* @param {BlockSizePattern<any>} pattern
* @param {string} title
* @param {Color} [color]
* @returns {AnyFetchedSeriesBlueprint[]}
*/
export function fromBlockSize(colors, pattern, title, color) {
return [
{ metric: pattern.sum, title, color: color ?? colors.default },
{ metric: pattern.average, title: "Average", defaultActive: false },
{
metric: pattern.cumulative,
title: `${title} (cum.)`,
color: colors.cyan,
defaultActive: false,
},
{
metric: pattern.max,
title: "Max",
color: colors.pink,
defaultActive: false,
},
{
metric: pattern.min,
title: "Min",
color: colors.green,
defaultActive: false,
},
{
metric: pattern.median,
title: "Median",
color: colors.amber,
defaultActive: false,
},
{
metric: pattern.pct75,
title: "pct75",
color: colors.red,
defaultActive: false,
},
{
metric: pattern.pct25,
title: "pct25",
color: colors.yellow,
defaultActive: false,
},
{
metric: pattern.pct90,
title: "pct90",
color: colors.rose,
defaultActive: false,
},
{
metric: pattern.pct10,
title: "pct10",
color: colors.lime,
defaultActive: false,
},
];
}
@@ -0,0 +1,185 @@
/**
* @typedef {Object} BaseSeriesBlueprint
* @property {string} title
* @property {boolean} [defaultActive]
*
* @typedef {Object} BaselineSeriesBlueprintSpecific
* @property {"Baseline"} type
* @property {Color} [color]
* @property {[Color, Color]} [colors]
* @property {BaselineSeriesPartialOptions} [options]
* @property {Accessor<BaselineData[]>} [data]
* @typedef {BaseSeriesBlueprint & BaselineSeriesBlueprintSpecific} BaselineSeriesBlueprint
*
* @typedef {Object} CandlestickSeriesBlueprintSpecific
* @property {"Candlestick"} type
* @property {Color} [color]
* @property {CandlestickSeriesPartialOptions} [options]
* @property {Accessor<CandlestickData[]>} [data]
* @typedef {BaseSeriesBlueprint & CandlestickSeriesBlueprintSpecific} CandlestickSeriesBlueprint
*
* @typedef {Object} LineSeriesBlueprintSpecific
* @property {"Line"} [type]
* @property {Color} [color]
* @property {LineSeriesPartialOptions} [options]
* @property {Accessor<LineData[]>} [data]
* @typedef {BaseSeriesBlueprint & LineSeriesBlueprintSpecific} LineSeriesBlueprint
*
* @typedef {Object} HistogramSeriesBlueprintSpecific
* @property {"Histogram"} type
* @property {Color} color
* @property {HistogramSeriesPartialOptions} [options]
* @property {Accessor<HistogramData[]>} [data]
* @typedef {BaseSeriesBlueprint & HistogramSeriesBlueprintSpecific} HistogramSeriesBlueprint
*
* @typedef {BaselineSeriesBlueprint | CandlestickSeriesBlueprint | LineSeriesBlueprint | HistogramSeriesBlueprint} AnySeriesBlueprint
*
* @typedef {AnySeriesBlueprint["type"]} SeriesType
*
* @typedef {{ metric: MetricAccessor<any>, unit?: Unit }} FetchedAnySeriesOptions
*
* @typedef {BaselineSeriesBlueprint & FetchedAnySeriesOptions} FetchedBaselineSeriesBlueprint
* @typedef {CandlestickSeriesBlueprint & FetchedAnySeriesOptions} FetchedCandlestickSeriesBlueprint
* @typedef {LineSeriesBlueprint & FetchedAnySeriesOptions} FetchedLineSeriesBlueprint
* @typedef {HistogramSeriesBlueprint & FetchedAnySeriesOptions} FetchedHistogramSeriesBlueprint
* @typedef {AnySeriesBlueprint & FetchedAnySeriesOptions} AnyFetchedSeriesBlueprint
*
* @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 {AnyFetchedSeriesBlueprint[]} [top]
* @property {AnyFetchedSeriesBlueprint[]} [bottom]
*
* @typedef {PartialOption & PartialChartOptionSpecific} PartialChartOption
*
* @typedef {Object} ProcessedChartOptionAddons
* @property {Record<Unit, AnyFetchedSeriesBlueprint[]>} top
* @property {Record<Unit, AnyFetchedSeriesBlueprint[]>} bottom
*
* @typedef {Required<Omit<PartialChartOption, "top" | "bottom">> & ProcessedChartOptionAddons & ProcessedOptionAddons} ChartOption
*
* @typedef {Object} PartialTableOptionSpecific
* @property {"table"} kind
* @property {string} title
*
* @typedef {PartialOption & PartialTableOptionSpecific} PartialTableOption
*
* @typedef {Required<PartialTableOption> & ProcessedOptionAddons} TableOption
*
* @typedef {Object} PartialSimulationOptionSpecific
* @property {"simulation"} kind
* @property {string} title
*
* @typedef {PartialOption & PartialSimulationOptionSpecific} PartialSimulationOption
*
* @typedef {Required<PartialSimulationOption> & ProcessedOptionAddons} SimulationOption
*
* @typedef {Object} PartialUrlOptionSpecific
* @property {"url"} [kind]
* @property {() => string} url
* @property {string} title
* @property {boolean} [qrcode]
*
* @typedef {PartialOption & PartialUrlOptionSpecific} PartialUrlOption
*
* @typedef {Required<PartialUrlOption> & ProcessedOptionAddons} UrlOption
*
* @typedef {PartialExplorerOption | PartialChartOption | PartialTableOption | PartialSimulationOption | PartialUrlOption} AnyPartialOption
*
* @typedef {ExplorerOption | ChartOption | TableOption | SimulationOption | UrlOption} 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
*
* Age cohorts (term, maxAge, minAge, ageRange, epoch) - have price percentiles
* @typedef {Object} AgeCohortObject
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {PatternWithPricePercentiles} tree
*
* Amount cohorts (geAmount, ltAmount, amountRange, type) - no price percentiles
* @typedef {Object} AmountCohortObject
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {UtxoAmountPattern} tree
*
* @typedef {Object} AddressCohortObject
* @property {string} name
* @property {string} title
* @property {Color} color
* @property {AddressCohortPattern} tree
*
* @typedef {UtxoCohortObject | AddressCohortObject} CohortObject
*
* @typedef {Object} UtxoCohortGroupObject
* @property {string} name
* @property {string} title
* @property {readonly UtxoCohortObject[]} list
*
* @typedef {Object} AgeCohortGroupObject
* @property {string} name
* @property {string} title
* @property {readonly AgeCohortObject[]} list
*
* @typedef {Object} AmountCohortGroupObject
* @property {string} name
* @property {string} title
* @property {readonly AmountCohortObject[]} list
*
* @typedef {Object} AddressCohortGroupObject
* @property {string} name
* @property {string} title
* @property {readonly AddressCohortObject[]} list
*
* @typedef {UtxoCohortGroupObject | AddressCohortGroupObject} CohortGroupObject
*
* @typedef {Object} PartialContext
* @property {Colors} colors
* @property {BrkClient} brk
* @property {BrkClient["tree"]["computed"]["constants"]} constants
* @property {(args: { metric: MetricAccessor<any>, name: string, color?: Color, defaultActive?: boolean, unit?: Unit, options?: LineSeriesPartialOptions }) => AnyFetchedSeriesBlueprint} s
* @property {(pattern: BlockCountPattern<any>, title: string, color?: Color) => AnyFetchedSeriesBlueprint[]} fromBlockCount
* @property {(pattern: BitcoinPattern<any>, title: string, color?: Color) => AnyFetchedSeriesBlueprint[]} fromBitcoin
* @property {(pattern: BlockSizePattern<any>, title: string, color?: Color) => AnyFetchedSeriesBlueprint[]} fromBlockSize
* @property {(num: number) => Constant0Pattern<any>} getConstant
* @property {(pattern: Constant0Pattern<any>) => MetricAccessor<any>} flattenConstant
* @property {(args: { number?: number, name?: string, defaultActive?: boolean, lineStyle?: number, color?: Color, unit: Unit }) => FetchedLineSeriesBlueprint} createPriceLine
* @property {(args: { numbers: number[], unit: Unit }) => FetchedLineSeriesBlueprint[]} createPriceLines
* @property {(args: { constant: Constant0Pattern<any>, name: string, unit: Unit, color?: Color, lineStyle?: number, defaultActive?: boolean }) => FetchedLineSeriesBlueprint} line
* @property {MetricAccessor<any>} constant100
*/
// Re-export for type consumers
export {};