import { brk } from "../../scripts/utils/client.js"; import { numberToShortUSFormat } from "../../scripts/utils/format.js"; import { formatCompact } from "./format.js"; import { createAverageGrid } from "./grid.js"; import { DIVERGING_NEGATIVE_LUT, DIVERGING_POSITIVE_LUT, INFERNO_LUT, divergingPowerIntensityColor, powerIntensityColor, } from "./lut.js"; import { defaultTooltip } from "./tooltip/index.js"; /** @typedef {Brk.Cohort} UrpdCohort */ /** * @typedef {Object} UrpdMetric * @property {string} name * @property {string} title * @property {(bucket: Urpd["buckets"][number]) => number} getValue * @property {HeatmapColorFn} color * @property {{ valueLabel: string, formatValue: (value: number) => string }} tooltip */ /** @typedef {{ name: string, cohort: UrpdCohort }} UrpdCohortFolder */ const AGGREGATION = "log2000"; const MIN_LOG = -2; const MAX_LOG = 6; const DEFAULT_MIN_LOG = Math.log10(1_000); const DEFAULT_MAX_LOG = Math.log10(250_000); const PRICE_CHOICES = [ { label: "$0.01", key: "0.01", value: Math.log10(0.01) }, { label: "$0.1", key: "0.1", value: Math.log10(0.1) }, { label: "$1", key: "1", value: 0 }, { label: "$10", key: "10", value: 1 }, { label: "$100", key: "100", value: 2 }, { label: "$250", key: "250", value: Math.log10(250) }, { label: "$1k", key: "1k", value: Math.log10(1_000) }, { label: "$2.5k", key: "2.5k", value: Math.log10(2_500) }, { label: "$5k", key: "5k", value: Math.log10(5_000) }, { label: "$10k", key: "10k", value: Math.log10(10_000) }, { label: "$25k", key: "25k", value: Math.log10(25_000) }, { label: "$50k", key: "50k", value: Math.log10(50_000) }, { label: "$100k", key: "100k", value: Math.log10(100_000) }, { label: "$250k", key: "250k", value: Math.log10(250_000) }, { label: "$500k", key: "500k", value: Math.log10(500_000) }, { label: "$1M", key: "1M", value: Math.log10(1_000_000) }, ]; const VALUE_COLOR = powerIntensityColor(INFERNO_LUT, 0.4); const PNL_COLOR = divergingPowerIntensityColor( DIVERGING_NEGATIVE_LUT, DIVERGING_POSITIVE_LUT, 0.4, ); /** @type {UrpdMetric[]} */ const METRICS = [ { name: "supply", title: "Supply", getValue: (bucket) => bucket.supply, color: VALUE_COLOR, tooltip: { valueLabel: "Supply", formatValue: formatBitcoin, }, }, { name: "capital", title: "Capital", getValue: (bucket) => bucket.realizedCap, color: VALUE_COLOR, tooltip: { valueLabel: "Realized cap", formatValue: formatDollar, }, }, { name: "profitability", title: "Profitability", getValue: (bucket) => bucket.unrealizedPnl, color: PNL_COLOR, tooltip: { valueLabel: "Unrealized PnL", formatValue: formatSignedDollar, }, }, ]; /** @type {UrpdCohortFolder[]} */ const AGE_BANDS = [ { name: "Up to 1h", cohort: "utxos_under_1h_old" }, { name: "1h to 1d", cohort: "utxos_1h_to_1d_old" }, { name: "1d to 1w", cohort: "utxos_1d_to_1w_old" }, { name: "1w to 1m", cohort: "utxos_1w_to_1m_old" }, { name: "1m to 2m", cohort: "utxos_1m_to_2m_old" }, { name: "2m to 3m", cohort: "utxos_2m_to_3m_old" }, { name: "3m to 4m", cohort: "utxos_3m_to_4m_old" }, { name: "4m to 5m", cohort: "utxos_4m_to_5m_old" }, { name: "5m to 6m", cohort: "utxos_5m_to_6m_old" }, { name: "6m to 1y", cohort: "utxos_6m_to_1y_old" }, { name: "1y to 2y", cohort: "utxos_1y_to_2y_old" }, { name: "2y to 3y", cohort: "utxos_2y_to_3y_old" }, { name: "3y to 4y", cohort: "utxos_3y_to_4y_old" }, { name: "4y to 5y", cohort: "utxos_4y_to_5y_old" }, { name: "5y to 6y", cohort: "utxos_5y_to_6y_old" }, { name: "6y to 7y", cohort: "utxos_6y_to_7y_old" }, { name: "7y to 8y", cohort: "utxos_7y_to_8y_old" }, { name: "8y to 10y", cohort: "utxos_8y_to_10y_old" }, { name: "10y to 12y", cohort: "utxos_10y_to_12y_old" }, { name: "12y to 15y", cohort: "utxos_12y_to_15y_old" }, { name: "Over 15y", cohort: "utxos_over_15y_old" }, ]; export const urpdAllHeatmapOptions = createCohortHeatmapOptions({ cohort: "all", }); export const urpdSthHeatmapOptions = createCohortHeatmapOptions({ cohort: "sth", titlePrefix: "STH", }); export const urpdLthHeatmapOptions = createCohortHeatmapOptions({ cohort: "lth", titlePrefix: "LTH", }); export const urpdAgeBandHeatmapFolders = AGE_BANDS.map(({ name, cohort }) => ({ name, tree: createCohortHeatmapOptions({ cohort, titlePrefix: name }), })); /** * @param {Object} args * @param {UrpdCohort} args.cohort * @param {string} [args.titlePrefix] * @returns {PartialHeatmapOption[]} */ function createCohortHeatmapOptions({ cohort, titlePrefix }) { return METRICS.map((metric) => { const title = titlePrefix ? `${titlePrefix} ${metric.title} Distribution` : `${metric.title} Distribution`; return createUrpdHeatmapOption({ ...metric, cohort, title, }); }); } /** * @param {Object} args * @param {UrpdCohort} args.cohort * @param {string} args.name * @param {string} args.title * @param {(bucket: Urpd["buckets"][number]) => number} args.getValue * @param {HeatmapColorFn} args.color * @param {{ valueLabel?: string, formatValue?: (value: number) => string }} args.tooltip * @returns {PartialHeatmapOption} */ function createUrpdHeatmapOption({ cohort, name, title, getValue, color, tooltip, }) { return { kind: "heatmap", name, title, points: { fetch: (date, signal, onPoints) => fetchUrpdPoints(cohort, date, signal, getValue, onPoints), }, grid: createAverageGrid({ yMin: MIN_LOG, yMax: MAX_LOG, minCellSize: 2, }), color, axis: { y: { label: "price", choices: PRICE_CHOICES, format: formatPrice, }, }, defaults: { from: "2017", to: "today", yMin: DEFAULT_MIN_LOG, yMax: DEFAULT_MAX_LOG, }, tooltip: defaultTooltip(tooltip), }; } /** * @param {UrpdCohort} cohort * @param {string} date * @param {AbortSignal} signal * @param {(bucket: Urpd["buckets"][number]) => number} getValue * @param {(points: HeatmapPoints) => void} [onPoints] * @returns {Promise} */ async function fetchUrpdPoints(cohort, date, signal, getValue, onPoints) { /** @type {HeatmapPoints | undefined} */ let points; const urpd = await brk.getUrpdAt(cohort, date, AGGREGATION, { signal, onValue: onPoints ? (urpd) => { points = toPoints(urpd, getValue); onPoints(points); } : undefined, }); return points ?? toPoints(urpd, getValue); } /** * @param {Urpd} urpd * @param {(bucket: Urpd["buckets"][number]) => number} getValue * @returns {HeatmapPoints} */ function toPoints(urpd, getValue) { const buckets = urpd.buckets; const y = new Float64Array(buckets.length); const values = new Float64Array(buckets.length); let length = 0; for (let i = 0; i < buckets.length; i++) { const bucket = buckets[i]; const pointValue = getValue(bucket); if (bucket.priceFloor <= 0 || !Number.isFinite(pointValue)) continue; y[length] = Math.log10(bucket.priceFloor); values[length] = pointValue; length++; } return { kind: "explicit", y: y.subarray(0, length), values: values.subarray(0, length), }; } /** @param {number} value */ function formatPrice(value) { const rounded = Math.round(value); if (Math.abs(value - rounded) < 0.001) { const choice = PRICE_CHOICES.find((choice) => choice.value === rounded); if (choice) return choice.label; } const price = 10 ** value; if (price >= 1_000_000) return `$${formatCompact(price / 1_000_000)}M`; if (price >= 1_000) return `$${formatCompact(price / 1_000)}k`; return `$${formatCompact(price)}`; } /** @param {number} value */ function formatBitcoin(value) { return `${numberToShortUSFormat(value)} BTC`; } /** @param {number} value */ function formatDollar(value) { return `$${numberToShortUSFormat(value)}`; } /** @param {number} value */ function formatSignedDollar(value) { const formatted = `$${numberToShortUSFormat(Math.abs(value))}`; if (value > 0) return `+${formatted}`; if (value < 0) return `-${formatted}`; return formatted; }