Files
brk/website/src/heatmap/urpd.js
T
2026-06-01 18:03:41 +02:00

281 lines
8.0 KiB
JavaScript

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<HeatmapPoints>}
*/
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;
}