mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-08 14:11:56 -07:00
heatmaps: part 22
This commit is contained in:
@@ -25,12 +25,16 @@ import { createNetworkSection } from "./network.js";
|
||||
import { createMiningSection } from "./mining.js";
|
||||
import { createCointimeSection } from "./cointime.js";
|
||||
import { createInvestingSection } from "./investing.js";
|
||||
import { demoHeatmapOption } from "../../src/heatmap/demo.js";
|
||||
import {
|
||||
oracleOutputsHeatmapOption,
|
||||
oraclePaymentsHeatmapOption,
|
||||
} from "../../src/heatmap/oracle.js";
|
||||
import { urpdSupplyHeatmapOption } from "../../src/heatmap/urpd.js";
|
||||
import {
|
||||
urpdAgeBandHeatmapFolders,
|
||||
urpdAllHeatmapOptions,
|
||||
urpdLthHeatmapOptions,
|
||||
urpdSthHeatmapOptions,
|
||||
} from "../../src/heatmap/urpd.js";
|
||||
|
||||
// Re-export types for external consumers
|
||||
export * from "./types.js";
|
||||
@@ -305,14 +309,18 @@ export function createPartialOptions() {
|
||||
{
|
||||
name: "Heatmaps",
|
||||
tree: [
|
||||
demoHeatmapOption,
|
||||
{
|
||||
name: "oracle histograms",
|
||||
name: "Output Values",
|
||||
tree: [oracleOutputsHeatmapOption, oraclePaymentsHeatmapOption],
|
||||
},
|
||||
{
|
||||
name: "URPD",
|
||||
tree: [urpdSupplyHeatmapOption],
|
||||
name: "Price Distributions",
|
||||
tree: [
|
||||
...urpdAllHeatmapOptions,
|
||||
{ name: "STH", tree: urpdSthHeatmapOptions },
|
||||
{ name: "LTH", tree: urpdLthHeatmapOptions },
|
||||
{ name: "Age Bands", tree: urpdAgeBandHeatmapFolders },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -34,13 +34,13 @@ export function createDateControls(option, onChange) {
|
||||
const persistedFrom = createHeatmapPersistedValue(
|
||||
option,
|
||||
"from",
|
||||
"hm_from",
|
||||
"from",
|
||||
rangeChoiceLabel(defaultFromChoice),
|
||||
);
|
||||
const persistedTo = createHeatmapPersistedValue(
|
||||
option,
|
||||
"to",
|
||||
"hm_to",
|
||||
"to",
|
||||
rangeChoiceLabel(defaultToChoice),
|
||||
);
|
||||
|
||||
|
||||
@@ -18,24 +18,24 @@ export function createYControls(option, onChange) {
|
||||
choices,
|
||||
String(option.defaults?.yMin ?? ""),
|
||||
fallbackMinChoice,
|
||||
axisChoiceKey,
|
||||
axisChoiceValueKey,
|
||||
);
|
||||
const defaultMaxChoice = findChoiceByKey(
|
||||
choices,
|
||||
String(option.defaults?.yMax ?? ""),
|
||||
fallbackMaxChoice,
|
||||
axisChoiceKey,
|
||||
axisChoiceValueKey,
|
||||
);
|
||||
const persistedMin = createHeatmapPersistedValue(
|
||||
option,
|
||||
"y-min",
|
||||
"hm_y_min",
|
||||
"min",
|
||||
axisChoiceKey(defaultMinChoice),
|
||||
);
|
||||
const persistedMax = createHeatmapPersistedValue(
|
||||
option,
|
||||
"y-max",
|
||||
"hm_y_max",
|
||||
"max",
|
||||
axisChoiceKey(defaultMaxChoice),
|
||||
);
|
||||
|
||||
@@ -105,10 +105,15 @@ export function createYControls(option, onChange) {
|
||||
|
||||
/** @param {HeatmapAxisChoice} choice */
|
||||
function axisChoiceKey(choice) {
|
||||
return String(choice.value);
|
||||
return choice.key ?? choice.label;
|
||||
}
|
||||
|
||||
/** @param {HeatmapAxisChoice} choice */
|
||||
function axisChoiceLabel(choice) {
|
||||
return choice.label;
|
||||
}
|
||||
|
||||
/** @param {HeatmapAxisChoice} choice */
|
||||
function axisChoiceValueKey(choice) {
|
||||
return String(choice.value);
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { createAverageGrid } from "./grid.js";
|
||||
import { INFERNO_LUT, intensityColor } from "./lut.js";
|
||||
import { GENESIS_DATE, todayISODate } from "./time.js";
|
||||
import { defaultTooltip } from "./tooltip/index.js";
|
||||
|
||||
const ROWS = 160;
|
||||
const DAY_MS = 86_400_000;
|
||||
const GENESIS_TIME = Date.parse(`${GENESIS_DATE}T00:00:00Z`);
|
||||
|
||||
/** @satisfies {PartialHeatmapOption} */
|
||||
export const demoHeatmapOption = {
|
||||
kind: "heatmap",
|
||||
name: "Demo",
|
||||
title: "Heatmap Demo",
|
||||
points: {
|
||||
fetch: fetchDemoPoints,
|
||||
},
|
||||
grid: createAverageGrid({ yMin: 0, yMax: 1, nativeRows: ROWS }),
|
||||
color: intensityColor(INFERNO_LUT),
|
||||
tooltip: defaultTooltip(),
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} date
|
||||
* @param {AbortSignal} signal
|
||||
* @returns {Promise<HeatmapPoints>}
|
||||
*/
|
||||
async function fetchDemoPoints(date, signal) {
|
||||
throwIfAborted(signal);
|
||||
|
||||
const values = new Float32Array(ROWS);
|
||||
const endTime = Date.parse(`${todayISODate()}T00:00:00Z`);
|
||||
const time = Date.parse(`${date}T00:00:00Z`);
|
||||
const x = Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
(time - GENESIS_TIME) / Math.max(DAY_MS, endTime - GENESIS_TIME),
|
||||
),
|
||||
);
|
||||
|
||||
for (let row = 0; row < ROWS; row++) {
|
||||
const y = row / (ROWS - 1);
|
||||
const ridge = Math.exp(-((y - (0.75 - x * 0.45)) ** 2) / 0.01);
|
||||
const blob = Math.exp(
|
||||
-(((x - 0.72) ** 2) / 0.018 + ((y - 0.28) ** 2) / 0.028),
|
||||
);
|
||||
const floor = x * 0.18 + (1 - y) * 0.12;
|
||||
values[row] = Math.min(1, Math.max(0, ridge * 0.65 + blob * 0.45 + floor));
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "implicit",
|
||||
yStart: 0,
|
||||
yStep: 1 / (ROWS - 1),
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {AbortSignal} signal */
|
||||
function throwIfAborted(signal) {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException("The operation was aborted.", "AbortError");
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,9 @@ export function createAverageGrid({
|
||||
const sums = new Float64Array(cols * rows);
|
||||
const counts = new Uint32Array(cols * rows);
|
||||
const maxByCol = new Float64Array(cols);
|
||||
const magnitudeMaxByCol = new Float64Array(cols);
|
||||
let maxValue = 0;
|
||||
let magnitudeMaxValue = 0;
|
||||
const ySpan = yMax - yMin;
|
||||
|
||||
/** @param {number} dateIndex */
|
||||
@@ -77,14 +79,22 @@ export function createAverageGrid({
|
||||
/** @param {number} col */
|
||||
function updateColumnMax(col) {
|
||||
let max = 0;
|
||||
let magnitudeMax = 0;
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const index = row * cols + col;
|
||||
if (counts[index]) max = Math.max(max, sums[index] / counts[index]);
|
||||
if (counts[index]) {
|
||||
const value = sums[index] / counts[index];
|
||||
max = Math.max(max, value);
|
||||
magnitudeMax = Math.max(magnitudeMax, Math.abs(value));
|
||||
}
|
||||
}
|
||||
maxByCol[col] = max;
|
||||
magnitudeMaxByCol[col] = magnitudeMax;
|
||||
maxValue = 0;
|
||||
magnitudeMaxValue = 0;
|
||||
for (let c = 0; c < cols; c++) {
|
||||
maxValue = Math.max(maxValue, maxByCol[c]);
|
||||
magnitudeMaxValue = Math.max(magnitudeMaxValue, magnitudeMaxByCol[c]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,8 +127,14 @@ export function createAverageGrid({
|
||||
}
|
||||
if (!dirty) return undefined;
|
||||
const previousMax = maxValue;
|
||||
const previousMagnitudeMax = magnitudeMaxValue;
|
||||
updateColumnMax(col);
|
||||
return { col, maxChanged: maxValue !== previousMax };
|
||||
return {
|
||||
col,
|
||||
maxChanged:
|
||||
maxValue !== previousMax ||
|
||||
magnitudeMaxValue !== previousMagnitudeMax,
|
||||
};
|
||||
},
|
||||
getValue(col, row) {
|
||||
if (col < 0 || col >= cols || row < 0 || row >= rows) {
|
||||
@@ -131,8 +147,15 @@ export function createAverageGrid({
|
||||
if (col < 0 || col >= cols || row < 0 || row >= rows) return 0;
|
||||
return counts[row * cols + col];
|
||||
},
|
||||
getMaxValue() {
|
||||
return maxValue;
|
||||
getMaxValue(col) {
|
||||
if (col === undefined) return maxValue;
|
||||
if (col < 0 || col >= cols) return 0;
|
||||
return maxByCol[col];
|
||||
},
|
||||
getMagnitudeMaxValue(col) {
|
||||
if (col === undefined) return magnitudeMaxValue;
|
||||
if (col < 0 || col >= cols) return 0;
|
||||
return magnitudeMaxByCol[col];
|
||||
},
|
||||
getDateIndexRange(col) {
|
||||
if (col < 0 || col >= cols || dates.length === 0) {
|
||||
|
||||
@@ -53,7 +53,7 @@ export function init() {
|
||||
yMin = range.yMin;
|
||||
yMax = range.yMax;
|
||||
hideTooltip();
|
||||
rebuildGrid();
|
||||
rebuildAndLoadVisibleDates();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -102,12 +102,18 @@ export function setOption(option) {
|
||||
function resizeAndRebuild() {
|
||||
if (!canvas || !renderer) return;
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
if (renderer.resize(width, height)) rebuildGrid();
|
||||
if (renderer.resize(width, height)) rebuildAndLoadVisibleDates();
|
||||
}
|
||||
|
||||
function loadRange() {
|
||||
if (!currentOption || !loader) return;
|
||||
loader.load({ option: currentOption, from, to });
|
||||
loader.setRange({ option: currentOption, from, to });
|
||||
rebuildAndLoadVisibleDates();
|
||||
}
|
||||
|
||||
function rebuildAndLoadVisibleDates() {
|
||||
rebuildGrid();
|
||||
loadVisibleDates();
|
||||
}
|
||||
|
||||
function rebuildGrid() {
|
||||
@@ -132,14 +138,41 @@ function rebuildGrid() {
|
||||
yMax,
|
||||
});
|
||||
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const points = loader.getPoint(dates[i]);
|
||||
if (points) currentGrid.add(i, points);
|
||||
for (const dateIndex of getVisibleDateIndexes(currentGrid)) {
|
||||
const points = loader.getPoint(dates[dateIndex]);
|
||||
if (points) currentGrid.add(dateIndex, points);
|
||||
}
|
||||
|
||||
paint();
|
||||
}
|
||||
|
||||
function loadVisibleDates() {
|
||||
if (!currentOption || !loader || !currentGrid) return;
|
||||
loader.load({
|
||||
option: currentOption,
|
||||
dateIndexes: getVisibleDateIndexes(currentGrid),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HeatmapGrid} grid
|
||||
* @returns {number[]}
|
||||
*/
|
||||
function getVisibleDateIndexes(grid) {
|
||||
/** @type {number[]} */
|
||||
const indexes = [];
|
||||
let previousDateIndex = -1;
|
||||
for (let col = 0; col < grid.cols; col++) {
|
||||
const dateIndex = grid.getDateIndexRange(col).end;
|
||||
if (!Number.isInteger(dateIndex) || dateIndex === previousDateIndex) {
|
||||
continue;
|
||||
}
|
||||
previousDateIndex = dateIndex;
|
||||
indexes.push(dateIndex);
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} dateIndex
|
||||
* @param {HeatmapPoints} points
|
||||
|
||||
@@ -37,23 +37,41 @@ export function createHeatmapLoader({ addDateToGrid, rebuildGrid, paint }) {
|
||||
* @param {string} args.from
|
||||
* @param {string} args.to
|
||||
*/
|
||||
load({ option, from, to }) {
|
||||
setRange({ option, from, to }) {
|
||||
abortController?.abort();
|
||||
generation += 1;
|
||||
activeOption = option;
|
||||
dates = dateRange(from, to);
|
||||
},
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {HeatmapOption} args.option
|
||||
* @param {readonly number[]} args.dateIndexes
|
||||
*/
|
||||
load({ option, dateIndexes }) {
|
||||
abortController?.abort();
|
||||
const controller = new AbortController();
|
||||
const currentGeneration = ++generation;
|
||||
activeOption = option;
|
||||
abortController = controller;
|
||||
dates = dateRange(from, to);
|
||||
|
||||
/** @type {{ date: string, dateIndex: number }[]} */
|
||||
const missing = [];
|
||||
for (let dateIndex = 0; dateIndex < dates.length; dateIndex++) {
|
||||
let previousDateIndex = -1;
|
||||
for (const dateIndex of dateIndexes) {
|
||||
if (
|
||||
dateIndex === previousDateIndex ||
|
||||
dateIndex < 0 ||
|
||||
dateIndex >= dates.length
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
previousDateIndex = dateIndex;
|
||||
const date = dates[dateIndex];
|
||||
if (!pointsByDate.has(date)) missing.push({ date, dateIndex });
|
||||
}
|
||||
|
||||
if (!missing.length) {
|
||||
rebuildGrid();
|
||||
abortController = undefined;
|
||||
return;
|
||||
}
|
||||
@@ -90,8 +108,6 @@ export function createHeatmapLoader({ addDateToGrid, rebuildGrid, paint }) {
|
||||
}
|
||||
});
|
||||
|
||||
rebuildGrid();
|
||||
|
||||
void Promise.all(workers).then(() => {
|
||||
if (isCurrentLoad(option, controller, currentGeneration)) {
|
||||
if (needsRebuild) {
|
||||
|
||||
+106
-1
@@ -12,6 +12,25 @@ const INFERNO_STOPS = [
|
||||
|
||||
export const INFERNO_LUT = createColorLut(INFERNO_STOPS);
|
||||
|
||||
const DIVERGING_NEGATIVE_STOPS = [
|
||||
[0, 0, 0, 0],
|
||||
[0.25, 60, 0, 0],
|
||||
[0.5, 140, 10, 0],
|
||||
[0.75, 200, 30, 10],
|
||||
[1, 240, 60, 20],
|
||||
];
|
||||
|
||||
const DIVERGING_POSITIVE_STOPS = [
|
||||
[0, 0, 0, 0],
|
||||
[0.25, 0, 40, 0],
|
||||
[0.5, 0, 110, 10],
|
||||
[0.75, 10, 180, 20],
|
||||
[1, 30, 230, 50],
|
||||
];
|
||||
|
||||
export const DIVERGING_NEGATIVE_LUT = createColorLut(DIVERGING_NEGATIVE_STOPS);
|
||||
export const DIVERGING_POSITIVE_LUT = createColorLut(DIVERGING_POSITIVE_STOPS);
|
||||
|
||||
/**
|
||||
* @param {ArrayLike<number>} lut
|
||||
* @returns {HeatmapColorFn}
|
||||
@@ -31,7 +50,7 @@ export function intensityColor(lut) {
|
||||
export function logIntensityColor(lut) {
|
||||
return (value, context) => {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0x00000000;
|
||||
const max = context.grid.getMaxValue();
|
||||
const max = context.grid.getMaxValue(context.col);
|
||||
if (max <= 0) return 0x00000000;
|
||||
const t = Math.log2(value + 1) / Math.log2(max + 1);
|
||||
const index = Math.min(255, Math.max(0, Math.round(t * 255)));
|
||||
@@ -39,6 +58,92 @@ export function logIntensityColor(lut) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArrayLike<number>} lut
|
||||
* @returns {HeatmapColorFn}
|
||||
*/
|
||||
export function linearIntensityColor(lut) {
|
||||
return (value, context) => {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0x00000000;
|
||||
const cap = context.grid.getMaxValue(context.col);
|
||||
if (cap <= 0) return 0x00000000;
|
||||
const t = Math.min(1, value / cap);
|
||||
const index = Math.min(255, Math.max(0, Math.round(t * 255)));
|
||||
return lut[index] ?? 0x00000000;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArrayLike<number>} lut
|
||||
* @param {number} [exponent]
|
||||
* @returns {HeatmapColorFn}
|
||||
*/
|
||||
export function powerIntensityColor(lut, exponent = 0.4) {
|
||||
return (value, context) => {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0x00000000;
|
||||
const cap = context.grid.getMaxValue(context.col);
|
||||
if (cap <= 0) return 0x00000000;
|
||||
const t = Math.pow(Math.min(1, value / cap), exponent);
|
||||
const index = Math.min(255, Math.max(0, Math.round(t * 255)));
|
||||
return lut[index] ?? 0x00000000;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArrayLike<number>} negativeLut
|
||||
* @param {ArrayLike<number>} positiveLut
|
||||
* @param {number} [exponent]
|
||||
* @returns {HeatmapColorFn}
|
||||
*/
|
||||
export function divergingPowerIntensityColor(
|
||||
negativeLut,
|
||||
positiveLut,
|
||||
exponent = 0.4,
|
||||
) {
|
||||
return (value, context) => {
|
||||
if (!Number.isFinite(value) || value === 0) return 0x00000000;
|
||||
const cap = context.grid.getMagnitudeMaxValue(context.col);
|
||||
if (cap <= 0) return 0x00000000;
|
||||
const t = Math.pow(Math.min(1, Math.abs(value) / cap), exponent);
|
||||
const index = Math.min(255, Math.max(0, Math.round(t * 255)));
|
||||
const lut = value < 0 ? negativeLut : positiveLut;
|
||||
return lut[index] ?? 0x00000000;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArrayLike<number>} lut
|
||||
* @param {{ knee?: number, max?: number }} [options]
|
||||
* @returns {HeatmapColorFn}
|
||||
*/
|
||||
export function softIntensityColor(lut, { knee = 0.15, max = 1 } = {}) {
|
||||
return (value, context) => {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0x00000000;
|
||||
const cap = context.grid.getMaxValue(context.col);
|
||||
if (cap <= 0) return 0x00000000;
|
||||
const ratio = Math.min(1, value / cap);
|
||||
const t = (ratio / (ratio + knee)) * max;
|
||||
const index = Math.min(255, Math.max(0, Math.round(t * 255)));
|
||||
return lut[index] ?? 0x00000000;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArrayLike<number>} lut
|
||||
* @returns {HeatmapColorFn}
|
||||
*/
|
||||
export function smoothLogIntensityColor(lut) {
|
||||
return (value, context) => {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0x00000000;
|
||||
const cap = context.grid.getMaxValue(context.col);
|
||||
if (cap <= 0) return 0x00000000;
|
||||
const u = Math.log1p(value) / Math.log1p(cap);
|
||||
const t = u * u * (3 - 2 * u);
|
||||
const index = Math.min(255, Math.max(0, Math.round(t * 255)));
|
||||
return lut[index] ?? 0x00000000;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number[][]} stops - Tuples of [position, red, green, blue].
|
||||
*/
|
||||
|
||||
@@ -7,28 +7,28 @@ const BINS = 2400;
|
||||
const MIN_LOG = -8;
|
||||
const BINS_PER_DECADE = 200;
|
||||
const AMOUNT_CHOICES = [
|
||||
{ label: "1 sat", value: -8 },
|
||||
{ label: "10 sats", value: -7 },
|
||||
{ label: "100 sats", value: -6 },
|
||||
{ label: "1k sats", value: -5 },
|
||||
{ label: "10k sats", value: -4 },
|
||||
{ label: "100k sats", value: -3 },
|
||||
{ label: "0.01 BTC", value: -2 },
|
||||
{ label: "0.1 BTC", value: -1 },
|
||||
{ label: "1 BTC", value: 0 },
|
||||
{ label: "10 BTC", value: 1 },
|
||||
{ label: "100 BTC", value: 2 },
|
||||
{ label: "1k BTC", value: 3 },
|
||||
{ label: "10k BTC", value: 4 },
|
||||
{ label: "1 sat", key: "1sat", value: -8 },
|
||||
{ label: "10 sats", key: "10sats", value: -7 },
|
||||
{ label: "100 sats", key: "100sats", value: -6 },
|
||||
{ label: "1k sats", key: "1ksats", value: -5 },
|
||||
{ label: "10k sats", key: "10ksats", value: -4 },
|
||||
{ label: "100k sats", key: "100ksats", value: -3 },
|
||||
{ label: "0.01 BTC", key: "0.01btc", value: -2 },
|
||||
{ label: "0.1 BTC", key: "0.1btc", value: -1 },
|
||||
{ label: "1 BTC", key: "1btc", value: 0 },
|
||||
{ label: "10 BTC", key: "10btc", value: 1 },
|
||||
{ label: "100 BTC", key: "100btc", value: 2 },
|
||||
{ label: "1k BTC", key: "1kbtc", value: 3 },
|
||||
{ label: "10k BTC", key: "10kbtc", value: 4 },
|
||||
];
|
||||
|
||||
export const oracleOutputsHeatmapOption = createOracleHeatmapOption(
|
||||
"outputs",
|
||||
"outputs",
|
||||
"All",
|
||||
);
|
||||
export const oraclePaymentsHeatmapOption = createOracleHeatmapOption(
|
||||
"payments",
|
||||
"payments",
|
||||
"Payments",
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -41,7 +41,7 @@ function createOracleHeatmapOption(mode, name) {
|
||||
kind: "heatmap",
|
||||
name,
|
||||
title:
|
||||
mode === "outputs" ? "Output Value Histogram" : "Payment Value Histogram",
|
||||
mode === "outputs" ? "All Output Values" : "Payment Output Values",
|
||||
points: {
|
||||
fetch: (date, signal, onPoints) =>
|
||||
fetchOraclePoints(mode, date, signal, onPoints),
|
||||
@@ -74,8 +74,8 @@ function createOracleHeatmapOption(mode, name) {
|
||||
},
|
||||
tooltip: defaultTooltip(
|
||||
mode === "outputs"
|
||||
? { valueLabel: "Outputs", averageLabel: "Avg outputs" }
|
||||
: { valueLabel: "Payment signal", averageLabel: "Avg payment signal" },
|
||||
? { valueLabel: "Outputs" }
|
||||
: { valueLabel: "Payment signal" },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,13 +3,11 @@ import { numberToShortUSFormat } from "../../../scripts/utils/format.js";
|
||||
/**
|
||||
* @param {Object} [args]
|
||||
* @param {string} [args.valueLabel]
|
||||
* @param {string} [args.averageLabel]
|
||||
* @param {(value: number) => string} [args.formatValue]
|
||||
* @returns {HeatmapTooltipFn}
|
||||
*/
|
||||
export function defaultTooltip({
|
||||
valueLabel = "Value",
|
||||
averageLabel = "Avg value",
|
||||
formatValue = formatNumber,
|
||||
} = {}) {
|
||||
return ({ option, grid, col, row }) => {
|
||||
@@ -18,16 +16,13 @@ export function defaultTooltip({
|
||||
const value = grid.getValue(col, row);
|
||||
const yLabel = option.axis?.y?.label ?? "y";
|
||||
const formatY = option.axis?.y?.format ?? formatNumber;
|
||||
const label = grid.getCount(col, row) > 1 ? averageLabel : valueLabel;
|
||||
|
||||
const from = grid.dates[dateRange.start] ?? "";
|
||||
const to = grid.dates[dateRange.end] ?? from;
|
||||
const date = from === to ? from : `${from} to ${to}`;
|
||||
const date = grid.dates[dateRange.end] ?? grid.dates[dateRange.start] ?? "";
|
||||
|
||||
return [
|
||||
date,
|
||||
`${capitalize(yLabel)}: ${formatY(yRange.start)} to ${formatY(yRange.end)}`,
|
||||
`${label}: ${formatValue(value)}`,
|
||||
`${valueLabel}: ${formatValue(value)}`,
|
||||
].join("\n");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
* @property {(dateIndex: number, points: HeatmapPoints) => HeatmapGridAddResult | undefined} add
|
||||
* @property {(col: number, row: number) => number} getValue
|
||||
* @property {(col: number, row: number) => number} getCount
|
||||
* @property {() => number} getMaxValue
|
||||
* @property {(col?: number) => number} getMaxValue
|
||||
* @property {(col?: number) => number} getMagnitudeMaxValue
|
||||
* @property {(col: number) => HeatmapRange} getDateIndexRange
|
||||
* @property {(row: number) => HeatmapRange} getYRange
|
||||
*
|
||||
@@ -47,6 +48,7 @@
|
||||
*
|
||||
* @typedef {Object} HeatmapAxisChoice
|
||||
* @property {string} label
|
||||
* @property {string} [key]
|
||||
* @property {number} value
|
||||
*
|
||||
* @typedef {Object} HeatmapAxis
|
||||
|
||||
+205
-57
@@ -1,96 +1,230 @@
|
||||
import { brk } from "../../scripts/utils/client.js";
|
||||
import { numberToShortUSFormat } from "../../scripts/utils/format.js";
|
||||
import { createAverageGrid } from "./grid.js";
|
||||
import { INFERNO_LUT, logIntensityColor } from "./lut.js";
|
||||
import {
|
||||
DIVERGING_NEGATIVE_LUT,
|
||||
DIVERGING_POSITIVE_LUT,
|
||||
INFERNO_LUT,
|
||||
divergingPowerIntensityColor,
|
||||
powerIntensityColor,
|
||||
} from "./lut.js";
|
||||
import { defaultTooltip } from "./tooltip/index.js";
|
||||
|
||||
const COHORT = "all";
|
||||
const AGGREGATION = "raw";
|
||||
/** @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", value: Math.log10(0.01) },
|
||||
{ label: "$0.1", value: Math.log10(0.1) },
|
||||
{ label: "$1", value: 0 },
|
||||
{ label: "$10", value: 1 },
|
||||
{ label: "$100", value: 2 },
|
||||
{ label: "$250", value: Math.log10(250) },
|
||||
{ label: "$1k", value: Math.log10(1_000) },
|
||||
{ label: "$2.5k", value: Math.log10(2_500) },
|
||||
{ label: "$5k", value: Math.log10(5_000) },
|
||||
{ label: "$10k", value: Math.log10(10_000) },
|
||||
{ label: "$25k", value: Math.log10(25_000) },
|
||||
{ label: "$50k", value: Math.log10(50_000) },
|
||||
{ label: "$100k", value: Math.log10(100_000) },
|
||||
{ label: "$250k", value: Math.log10(250_000) },
|
||||
{ label: "$500k", value: Math.log10(500_000) },
|
||||
{ label: "$1M", value: Math.log10(1_000_000) },
|
||||
{ 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,
|
||||
);
|
||||
|
||||
/** @satisfies {PartialHeatmapOption} */
|
||||
export const urpdSupplyHeatmapOption = {
|
||||
kind: "heatmap",
|
||||
name: "Supply",
|
||||
title: "URPD Supply",
|
||||
points: {
|
||||
fetch: (date, signal, onPoints) =>
|
||||
fetchUrpdSupplyPoints(date, signal, onPoints),
|
||||
},
|
||||
grid: createAverageGrid({
|
||||
yMin: MIN_LOG,
|
||||
yMax: MAX_LOG,
|
||||
}),
|
||||
color: logIntensityColor(INFERNO_LUT),
|
||||
axis: {
|
||||
y: {
|
||||
label: "price",
|
||||
choices: PRICE_CHOICES,
|
||||
format: formatPrice,
|
||||
/** @type {UrpdMetric[]} */
|
||||
const METRICS = [
|
||||
{
|
||||
name: "supply",
|
||||
title: "Supply",
|
||||
getValue: (bucket) => bucket.supply,
|
||||
color: VALUE_COLOR,
|
||||
tooltip: {
|
||||
valueLabel: "Supply",
|
||||
formatValue: formatBitcoin,
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
from: "2017",
|
||||
to: "today",
|
||||
yMin: DEFAULT_MIN_LOG,
|
||||
yMax: DEFAULT_MAX_LOG,
|
||||
{
|
||||
name: "capital",
|
||||
title: "Capital",
|
||||
getValue: (bucket) => bucket.realizedCap,
|
||||
color: VALUE_COLOR,
|
||||
tooltip: {
|
||||
valueLabel: "Realized cap",
|
||||
formatValue: formatDollar,
|
||||
},
|
||||
},
|
||||
tooltip: defaultTooltip({
|
||||
valueLabel: "Supply",
|
||||
averageLabel: "Avg supply",
|
||||
formatValue: formatBitcoin,
|
||||
}),
|
||||
};
|
||||
{
|
||||
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,
|
||||
}),
|
||||
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 fetchUrpdSupplyPoints(date, signal, onPoints) {
|
||||
async function fetchUrpdPoints(cohort, date, signal, getValue, onPoints) {
|
||||
/** @type {HeatmapPoints | undefined} */
|
||||
let points;
|
||||
const urpd = await brk.getUrpdAt(COHORT, date, AGGREGATION, {
|
||||
const urpd = await brk.getUrpdAt(cohort, date, AGGREGATION, {
|
||||
signal,
|
||||
cache: false,
|
||||
onValue: onPoints
|
||||
? (value) => {
|
||||
points = toSupplyPoints(value);
|
||||
? (urpd) => {
|
||||
points = toPoints(urpd, getValue);
|
||||
onPoints(points);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return points ?? toSupplyPoints(urpd);
|
||||
return points ?? toPoints(urpd, getValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Urpd} urpd
|
||||
* @param {(bucket: Urpd["buckets"][number]) => number} getValue
|
||||
* @returns {HeatmapPoints}
|
||||
*/
|
||||
function toSupplyPoints(urpd) {
|
||||
function toPoints(urpd, getValue) {
|
||||
const buckets = urpd.buckets;
|
||||
const y = new Float64Array(buckets.length);
|
||||
const values = new Float64Array(buckets.length);
|
||||
@@ -98,9 +232,10 @@ function toSupplyPoints(urpd) {
|
||||
|
||||
for (let i = 0; i < buckets.length; i++) {
|
||||
const bucket = buckets[i];
|
||||
if (bucket.priceFloor <= 0 || !Number.isFinite(bucket.supply)) continue;
|
||||
const pointValue = getValue(bucket);
|
||||
if (bucket.priceFloor <= 0 || !Number.isFinite(pointValue)) continue;
|
||||
y[length] = Math.log10(bucket.priceFloor);
|
||||
values[length] = bucket.supply;
|
||||
values[length] = pointValue;
|
||||
length++;
|
||||
}
|
||||
|
||||
@@ -130,6 +265,19 @@ 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;
|
||||
}
|
||||
|
||||
/** @param {number} value */
|
||||
function formatCompact(value) {
|
||||
if (value >= 1000) return `${formatNumber(value / 1000)}k`;
|
||||
|
||||
Reference in New Issue
Block a user