heatmaps: part 21

This commit is contained in:
nym21
2026-06-01 14:25:21 +02:00
parent a61b76a4a5
commit 88c38e74f9
10 changed files with 508 additions and 329 deletions
@@ -51,7 +51,7 @@ fn generate_get_method(output: &mut String, endpoint: &Endpoint) {
}
writeln!(
output,
" * @param {{{{ signal?: AbortSignal, onValue?: (value: {}) => void }}}} [options]",
" * @param {{{{ signal?: AbortSignal, onValue?: (value: {}) => void, cache?: boolean }}}} [options]",
return_type
)
.unwrap();
@@ -60,22 +60,22 @@ fn generate_get_method(output: &mut String, endpoint: &Endpoint) {
let params = build_method_params(endpoint);
let params_with_opts = if params.is_empty() {
"{ signal, onValue } = {}".to_string()
"{ signal, onValue, cache } = {}".to_string()
} else {
format!("{}, {{ signal, onValue }} = {{}}", params)
format!("{}, {{ signal, onValue, cache }} = {{}}", params)
};
writeln!(output, " async {}({}) {{", method_name, params_with_opts).unwrap();
let path = build_path_template(&endpoint.path, &endpoint.path_params);
let fetch_call: String = if endpoint.returns_binary() {
"this.getBytes(path, { signal, onValue })".to_string()
"this.getBytes(path, { signal, onValue, cache })".to_string()
} else if endpoint.returns_json() {
"this.getJson(path, { signal, onValue })".to_string()
"this.getJson(path, { signal, onValue, cache })".to_string()
} else if endpoint.response_kind.text_is_numeric() {
"Number(await this.getText(path, { signal, onValue: onValue ? (v) => onValue(Number(v)) : undefined }))".to_string()
"Number(await this.getText(path, { signal, cache, onValue: onValue ? (v) => onValue(Number(v)) : undefined }))".to_string()
} else {
"this.getText(path, { signal, onValue })".to_string()
"this.getText(path, { signal, onValue, cache })".to_string()
};
write_path_assignment(output, endpoint, &path);
@@ -83,7 +83,7 @@ fn generate_get_method(output: &mut String, endpoint: &Endpoint) {
if endpoint.supports_csv {
writeln!(
output,
" if (format === 'csv') return this.getText(path, {{ signal, onValue }});"
" if (format === 'csv') return this.getText(path, {{ signal, onValue, cache }});"
)
.unwrap();
}
@@ -448,14 +448,17 @@ class BrkClientBase {{
/**
* @param {{string}} path
* @param {{{{ signal?: AbortSignal }}}} [options]
* @param {{{{ signal?: AbortSignal, cache?: boolean }}}} [options]
* @returns {{Promise<Response>}}
*/
async get(path, {{ signal }} = {{}}) {{
async get(path, {{ signal, cache = true }} = {{}}) {{
const url = `${{this.baseUrl}}${{path}}`;
const signals = [AbortSignal.timeout(this.timeout)];
if (signal) signals.push(signal);
const res = await fetch(url, {{ signal: AbortSignal.any(signals) }});
/** @type {{RequestInit}} */
const init = {{ signal: AbortSignal.any(signals) }};
if (!cache) init.cache = 'no-store';
const res = await fetch(url, init);
if (!res.ok) throw new BrkError(`HTTP ${{res.status}}: ${{url}}`, res.status);
return res;
}}
@@ -475,10 +478,17 @@ class BrkClientBase {{
* @template T
* @param {{string}} path
* @param {{(res: Response) => Promise<T>}} parse - Response body reader
* @param {{{{ onValue?: (value: T) => void, signal?: AbortSignal }}}} [options]
* @param {{{{ onValue?: (value: T) => void, signal?: AbortSignal, cache?: boolean }}}} [options]
* @returns {{Promise<T>}}
*/
async _getCached(path, parse, {{ onValue, signal }} = {{}}) {{
async _getCached(path, parse, {{ onValue, signal, cache = true }} = {{}}) {{
if (!cache) {{
const res = await this.get(path, {{ signal, cache }});
const value = await parse(res);
if (onValue) onValue(value);
return value;
}}
const url = `${{this.baseUrl}}${{path}}`;
/** @type {{_MemEntry<T> | undefined}} */
const memHit = this._memGet(url);
@@ -497,8 +507,8 @@ class BrkClientBase {{
this._memSet(url, netEtag, value);
if (onValue) onValue(value);
if (cloned && browserCache) {{
const cache = browserCache;
_runIdle(() => cache.put(url, cloned));
const cacheStore = browserCache;
_runIdle(() => cacheStore.put(url, cloned));
}}
return value;
}} catch {{
@@ -531,8 +541,8 @@ class BrkClientBase {{
this._memSet(url, netEtag, value);
if (onValue) onValue(value);
if (cloned && browserCache) {{
const cache = browserCache;
_runIdle(() => cache.put(url, cloned));
const cacheStore = browserCache;
_runIdle(() => cacheStore.put(url, cloned));
}}
return value;
}} catch (e) {{
@@ -546,7 +556,7 @@ class BrkClientBase {{
* Make a GET request expecting a JSON response. Cached and supports `onValue`.
* @template T
* @param {{string}} path
* @param {{{{ onValue?: (value: T) => void, signal?: AbortSignal }}}} [options]
* @param {{{{ onValue?: (value: T) => void, signal?: AbortSignal, cache?: boolean }}}} [options]
* @returns {{Promise<T>}}
*/
getJson(path, options) {{
@@ -557,7 +567,7 @@ class BrkClientBase {{
* Make a GET request expecting a text response (text/plain, text/csv, ...).
* Cached and supports `onValue`, same as `getJson`.
* @param {{string}} path
* @param {{{{ onValue?: (value: string) => void, signal?: AbortSignal }}}} [options]
* @param {{{{ onValue?: (value: string) => void, signal?: AbortSignal, cache?: boolean }}}} [options]
* @returns {{Promise<string>}}
*/
getText(path, options) {{
@@ -568,7 +578,7 @@ class BrkClientBase {{
* Make a GET request expecting binary data (application/octet-stream).
* Cached and supports `onValue`, same as `getJson`.
* @param {{string}} path
* @param {{{{ onValue?: (value: Uint8Array) => void, signal?: AbortSignal }}}} [options]
* @param {{{{ onValue?: (value: Uint8Array) => void, signal?: AbortSignal, cache?: boolean }}}} [options]
* @returns {{Promise<Uint8Array>}}
*/
getBytes(path, options) {{
+5 -2
View File
@@ -6,7 +6,7 @@ use crate::Cents;
/// Aggregation strategy for URPD buckets.
/// Options: raw (no aggregation), lin200/lin500/lin1000 (linear $200/$500/$1000),
/// log10/log50/log100/log200/log500/log1000 (logarithmic with 10/50/100/200/500/1000 buckets per decade).
/// log10/log50/log100/log200/log500/log1000/log2000 (logarithmic with 10/50/100/200/500/1000/2000 buckets per decade).
#[derive(
Debug, Display, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema,
)]
@@ -24,6 +24,7 @@ pub enum UrpdAggregation {
Log200,
Log500,
Log1000,
Log2000,
}
impl UrpdAggregation {
@@ -46,6 +47,7 @@ impl UrpdAggregation {
Self::Log200 => Some(200),
Self::Log500 => Some(500),
Self::Log1000 => Some(1000),
Self::Log2000 => Some(2000),
_ => None,
}
}
@@ -64,7 +66,8 @@ impl UrpdAggregation {
| Self::Log100
| Self::Log200
| Self::Log500
| Self::Log1000 => {
| Self::Log1000
| Self::Log2000 => {
if price_cents == Cents::ZERO {
return Cents::ZERO;
}
@@ -6,7 +6,7 @@
# Type Alias: UrpdAggregation
> **UrpdAggregation** = `"raw"` \| `"lin200"` \| `"lin500"` \| `"lin1000"` \| `"log10"` \| `"log50"` \| `"log100"` \| `"log200"`
> **UrpdAggregation** = `"raw"` \| `"lin200"` \| `"lin500"` \| `"lin1000"` \| `"log10"` \| `"log50"` \| `"log100"` \| `"log200"` \| `"log500"` \| `"log1000"` \| `"log2000"`
Defined in: [Developer/brk/modules/brk-client/index.js:1354](https://github.com/bitcoinresearchkit/brk/blob/0b871e86004ed9dd0c54dd9336049531d6fe4d23/modules/brk-client/index.js#L1354)
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -144,8 +144,8 @@ CoinbaseTag = str
CostBasisValue = Literal["supply", "realized", "unrealized"]
# Aggregation strategy for URPD buckets.
# Options: raw (no aggregation), lin200/lin500/lin1000 (linear $200/$500/$1000),
# log10/log50/log100/log200/log500/log1000 (logarithmic with 10/50/100/200/500/1000 buckets per decade).
UrpdAggregation = Literal["raw", "lin200", "lin500", "lin1000", "log10", "log50", "log100", "log200", "log500", "log1000"]
# log10/log50/log100/log200/log500/log1000/log2000 (logarithmic with 10/50/100/200/500/1000/2000 buckets per decade).
UrpdAggregation = Literal["raw", "lin200", "lin500", "lin1000", "log10", "log50", "log100", "log200", "log500", "log1000", "log2000"]
# Position of a transaction inside a `CpfpCluster.txs` array. Cluster-local,
# has no meaning outside the enclosing cluster.
CpfpClusterTxIndex = int
+1 -1
View File
@@ -2,7 +2,7 @@
* @import { IChartApi, ISeriesApi as _ISeriesApi, SeriesDefinition, SingleValueData as _SingleValueData, CandlestickData as _CandlestickData, BaselineData as _BaselineData, HistogramData as _HistogramData, SeriesType as LCSeriesType, IPaneApi, LineSeriesPartialOptions as _LineSeriesPartialOptions, HistogramSeriesPartialOptions as _HistogramSeriesPartialOptions, BaselineSeriesPartialOptions as _BaselineSeriesPartialOptions, CandlestickSeriesPartialOptions as _CandlestickSeriesPartialOptions, WhitespaceData, DeepPartial, ChartOptions, Time, LineData as _LineData, createChart as CreateLCChart, LineStyle, createSeriesMarkers as CreateSeriesMarkers, SeriesMarker, ISeriesMarkersPluginApi } from './modules/lightweight-charts/5.2.0/dist/typings.js'
*
* @import * as Brk from "./modules/brk-client/index.js"
* @import { BrkClient, Index, SeriesData } from "./modules/brk-client/index.js"
* @import { BrkClient, Index, SeriesData, Urpd } from "./modules/brk-client/index.js"
*
* @import { Options } from './options/full.js'
*
+5
View File
@@ -30,6 +30,7 @@ import {
oracleOutputsHeatmapOption,
oraclePaymentsHeatmapOption,
} from "../../src/heatmap/oracle.js";
import { urpdSupplyHeatmapOption } from "../../src/heatmap/urpd.js";
// Re-export types for external consumers
export * from "./types.js";
@@ -309,6 +310,10 @@ export function createPartialOptions() {
name: "oracle histograms",
tree: [oracleOutputsHeatmapOption, oraclePaymentsHeatmapOption],
},
{
name: "URPD",
tree: [urpdSupplyHeatmapOption],
},
],
},
+3 -1
View File
@@ -4,11 +4,13 @@ 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 }) => {
const dateRange = grid.getDateIndexRange(col);
@@ -25,7 +27,7 @@ export function defaultTooltip({
return [
date,
`${capitalize(yLabel)}: ${formatY(yRange.start)} to ${formatY(yRange.end)}`,
`${label}: ${formatNumber(value)}`,
`${label}: ${formatValue(value)}`,
].join("\n");
};
}
+149
View File
@@ -0,0 +1,149 @@
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 { defaultTooltip } from "./tooltip/index.js";
const COHORT = "all";
const AGGREGATION = "raw";
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) },
];
/** @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,
},
},
defaults: {
from: "2017",
to: "today",
yMin: DEFAULT_MIN_LOG,
yMax: DEFAULT_MAX_LOG,
},
tooltip: defaultTooltip({
valueLabel: "Supply",
averageLabel: "Avg supply",
formatValue: formatBitcoin,
}),
};
/**
* @param {string} date
* @param {AbortSignal} signal
* @param {(points: HeatmapPoints) => void} [onPoints]
* @returns {Promise<HeatmapPoints>}
*/
async function fetchUrpdSupplyPoints(date, signal, onPoints) {
/** @type {HeatmapPoints | undefined} */
let points;
const urpd = await brk.getUrpdAt(COHORT, date, AGGREGATION, {
signal,
cache: false,
onValue: onPoints
? (value) => {
points = toSupplyPoints(value);
onPoints(points);
}
: undefined,
});
return points ?? toSupplyPoints(urpd);
}
/**
* @param {Urpd} urpd
* @returns {HeatmapPoints}
*/
function toSupplyPoints(urpd) {
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];
if (bucket.priceFloor <= 0 || !Number.isFinite(bucket.supply)) continue;
y[length] = Math.log10(bucket.priceFloor);
values[length] = bucket.supply;
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 formatCompact(value) {
if (value >= 1000) return `${formatNumber(value / 1000)}k`;
return formatNumber(value);
}
/** @param {number} value */
function formatNumber(value) {
if (value >= 100) return String(Math.round(value));
if (value >= 10) return trimNumber(value.toFixed(1));
return trimNumber(value.toFixed(2));
}
/** @param {string} value */
function trimNumber(value) {
return value.replace(/\.?0+$/, "");
}