global: cost basis -> urpd

This commit is contained in:
nym21
2026-04-22 22:23:52 +02:00
parent 84e924b77e
commit 3faa989691
30 changed files with 950 additions and 507 deletions
Generated
+1
View File
@@ -694,6 +694,7 @@ dependencies = [
"flate2",
"jiff",
"quick_cache",
"rustc-hash",
"schemars",
"serde",
"serde_json",
+48 -36
View File
@@ -9097,42 +9097,6 @@ impl BrkClient {
}
}
/// Available cost basis cohorts
///
/// List available cohorts for cost basis distribution.
///
/// Endpoint: `GET /api/series/cost-basis`
pub fn get_cost_basis_cohorts(&self) -> Result<Vec<String>> {
self.base.get_json(&format!("/api/series/cost-basis"))
}
/// Available cost basis dates
///
/// List available dates for a cohort's cost basis distribution.
///
/// Endpoint: `GET /api/series/cost-basis/{cohort}/dates`
pub fn get_cost_basis_dates(&self, cohort: Cohort) -> Result<Vec<Date>> {
self.base.get_json(&format!("/api/series/cost-basis/{cohort}/dates"))
}
/// Cost basis distribution
///
/// Get the cost basis distribution for a cohort on a specific date.
///
/// Query params:
/// - `bucket`: raw (default), lin200, lin500, lin1000, log10, log50, log100
/// - `value`: supply (default, in BTC), realized (USD), unrealized (USD)
///
/// Endpoint: `GET /api/series/cost-basis/{cohort}/{date}`
pub fn get_cost_basis(&self, cohort: Cohort, date: &str, bucket: Option<CostBasisBucket>, value: Option<CostBasisValue>) -> Result<serde_json::Value> {
let mut query = Vec::new();
if let Some(v) = bucket { query.push(format!("bucket={}", v)); }
if let Some(v) = value { query.push(format!("value={}", v)); }
let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) };
let path = format!("/api/series/cost-basis/{cohort}/{date}{}", query_str);
self.base.get_json(&path)
}
/// Series count
///
/// Returns the number of series available per index type.
@@ -9370,6 +9334,54 @@ impl BrkClient {
self.base.get_json(&format!("/api/tx/{txid}/status"))
}
/// Available URPD cohorts
///
/// Cohorts for which URPD data is available. Returns names like `all`, `sth`, `lth`, `utxos_under_1h_old`.
///
/// Endpoint: `GET /api/urpd`
pub fn list_urpd_cohorts(&self) -> Result<Vec<Cohort>> {
self.base.get_json(&format!("/api/urpd"))
}
/// Latest URPD
///
/// URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served.
///
/// See the URPD tag description for the response shape and `agg` options.
///
/// Endpoint: `GET /api/urpd/{cohort}`
pub fn get_urpd(&self, cohort: Cohort, agg: Option<UrpdAggregation>) -> Result<Urpd> {
let mut query = Vec::new();
if let Some(v) = agg { query.push(format!("agg={}", v)); }
let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) };
let path = format!("/api/urpd/{cohort}{}", query_str);
self.base.get_json(&path)
}
/// Available URPD dates
///
/// Dates for which a URPD snapshot is available for the cohort. One entry per UTC day, sorted ascending.
///
/// Endpoint: `GET /api/urpd/{cohort}/dates`
pub fn list_urpd_dates(&self, cohort: Cohort) -> Result<Vec<Date>> {
self.base.get_json(&format!("/api/urpd/{cohort}/dates"))
}
/// URPD at date
///
/// URPD for a (cohort, date) pair. Returns `{ cohort, date, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`.
///
/// See the URPD tag description for unit conventions and `agg` options.
///
/// Endpoint: `GET /api/urpd/{cohort}/{date}`
pub fn get_urpd_at(&self, cohort: Cohort, date: &str, agg: Option<UrpdAggregation>) -> Result<Urpd> {
let mut query = Vec::new();
if let Some(v) = agg { query.push(format!("agg={}", v)); }
let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) };
let path = format!("/api/urpd/{cohort}/{date}{}", query_str);
self.base.get_json(&path)
}
/// Block (v1)
///
/// Returns block details with extras by hash.
@@ -1,9 +1,9 @@
use std::{cmp::Reverse, collections::BinaryHeap, fs, path::Path};
use brk_cohort::{AGE_RANGE_NAMES, Filtered, PROFITABILITY_RANGE_COUNT, TERM_NAMES};
use brk_cohort::{AGE_RANGE_NAMES, CohortContext, Filtered, PROFITABILITY_RANGE_COUNT, TERM_NAMES};
use rayon::prelude::*;
use brk_error::Result;
use brk_types::{BasisPoints16, Cents, CentsCompact, CostBasisDistribution, Date, Dollars, Sats};
use brk_types::{BasisPoints16, Cents, CentsCompact, UrpdRaw, Date, Dollars, Sats};
use crate::distribution::metrics::{CostBasis, ProfitabilityMetrics};
@@ -78,7 +78,8 @@ impl UTXOCohorts {
merged.push((rounded, sats));
}
}
write_distribution(states_path, name.id, date, merged)
let full = CohortContext::Utxo.prefixed(name.id);
write_distribution(states_path, &full, date, merged)
})?;
let maps: Vec<_> = self
@@ -150,15 +151,15 @@ fn push_profitability(
fn write_distribution(
states_path: &Path,
name: &str,
full_name: &str,
date: Date,
merged: Vec<(CentsCompact, Sats)>,
) -> Result<()> {
let dir = states_path.join(format!("utxo_{name}_cost_basis/by_date"));
let dir = states_path.join(full_name).join("urpd");
fs::create_dir_all(&dir)?;
fs::write(
dir.join(date.to_string()),
CostBasisDistribution::serialize_iter(merged.into_iter())?,
UrpdRaw::serialize_iter(merged.into_iter())?,
)?;
Ok(())
}
@@ -6,7 +6,7 @@ use std::{
use brk_error::{Error, Result};
use brk_types::{
Cents, CentsCompact, CentsSats, CentsSquaredSats, CostBasisDistribution, Height, Sats,
Cents, CentsCompact, CentsSats, CentsSquaredSats, UrpdRaw, Height, Sats,
};
use rustc_hash::FxHashMap;
use vecdb::{Bytes, unlikely};
@@ -78,23 +78,18 @@ pub struct CostBasisRaw {
}
impl CostBasisRaw {
pub(super) fn path_by_height(&self) -> PathBuf {
self.pathbuf.join("by_height")
}
pub(super) fn path_state(&self, height: Height) -> PathBuf {
self.path_by_height().join(height.to_string())
self.pathbuf.join(height.to_string())
}
pub(super) fn read_dir(
&self,
keep_only_before: Option<Height>,
) -> Result<BTreeMap<Height, PathBuf>> {
let by_height = self.path_by_height();
if !by_height.exists() {
if !self.pathbuf.exists() {
return Ok(BTreeMap::new());
}
Ok(fs::read_dir(&by_height)?
Ok(fs::read_dir(&self.pathbuf)?
.filter_map(|entry| {
let path = entry.ok()?.path();
let name = path.file_name()?.to_str()?;
@@ -150,7 +145,7 @@ impl CostBasisRaw {
impl CostBasisOps for CostBasisRaw {
fn create(path: &Path, name: &str) -> Self {
Self {
pathbuf: path.join(format!("{name}_cost_basis")),
pathbuf: path.join(name).join("cost_basis"),
state: None,
pending_cap: PendingCapDelta::default(),
}
@@ -170,7 +165,7 @@ impl CostBasisOps for CostBasisRaw {
self.state = Some(if data.len() == 16 {
RawState::deserialize(&data)?
} else {
let (_, rest) = CostBasisDistribution::deserialize_with_rest(&data)?;
let (_, rest) = UrpdRaw::deserialize_with_rest(&data)?;
RawState::deserialize(rest)?
});
self.pending_cap = PendingCapDelta::default();
@@ -219,7 +214,7 @@ impl CostBasisOps for CostBasisRaw {
fn clean(&mut self) -> Result<()> {
let _ = fs::remove_dir_all(&self.pathbuf);
fs::create_dir_all(self.path_by_height())?;
fs::create_dir_all(&self.pathbuf)?;
Ok(())
}
@@ -245,7 +240,7 @@ impl CostBasisOps for CostBasisRaw {
#[derive(Clone, Debug)]
pub struct CostBasisData<S: Accumulate> {
raw: CostBasisRaw,
map: Option<CostBasisDistribution>,
map: Option<UrpdRaw>,
pending: FxHashMap<CentsCompact, PendingDelta>,
cache: Option<CachedUnrealizedState<S>>,
rounding_digits: Option<i32>,
@@ -367,7 +362,7 @@ impl<S: Accumulate> CostBasisOps for CostBasisData<S> {
"No cost basis state found at or before height".into(),
))?;
let data = fs::read(path)?;
let (base, rest) = CostBasisDistribution::deserialize_with_rest(&data)?;
let (base, rest) = UrpdRaw::deserialize_with_rest(&data)?;
self.map = Some(base);
self.raw.state = Some(RawState::deserialize(rest)?);
debug_assert!(
@@ -449,7 +444,7 @@ impl<S: Accumulate> CostBasisOps for CostBasisData<S> {
fn init(&mut self) {
self.raw.init();
self.map.replace(CostBasisDistribution::default());
self.map.replace(UrpdRaw::default());
self.pending.clear();
self.cache = None;
self.capitalized_cap_raw = CentsSquaredSats::ZERO;
-98
View File
@@ -1,98 +0,0 @@
use std::{fs, path::PathBuf};
use brk_error::{Error, Result};
use brk_types::{
CostBasisBucket, CostBasisDistribution, CostBasisFormatted, CostBasisValue, Date, Day1,
};
use vecdb::ReadableOptionVec;
use crate::Query;
impl Query {
/// List available cohorts for cost basis distribution.
pub fn cost_basis_cohorts(&self) -> Result<Vec<String>> {
let states_path = &self.computer().distribution.states_path;
let mut cohorts: Vec<String> = fs::read_dir(states_path)?
.filter_map(|entry| {
let name = entry.ok()?.file_name().into_string().ok()?;
let cohort = name.strip_prefix("utxo_")?.strip_suffix("_cost_basis")?;
states_path
.join(&name)
.join("by_date")
.exists()
.then(|| cohort.to_string())
})
.collect();
cohorts.sort();
Ok(cohorts)
}
fn cost_basis_dir(&self, cohort: &str) -> Result<PathBuf> {
let dir = self
.computer()
.distribution
.states_path
.join(format!("utxo_{cohort}_cost_basis/by_date"));
if !dir.exists() {
let valid = self.cost_basis_cohorts().unwrap_or_default().join(", ");
return Err(Error::NotFound(format!(
"Unknown cohort '{cohort}'. Available: {valid}"
)));
}
Ok(dir)
}
/// Get the cost basis distribution for a cohort on a specific date.
pub fn cost_basis_distribution(
&self,
cohort: &str,
date: Date,
) -> Result<CostBasisDistribution> {
let path = self.cost_basis_dir(cohort)?.join(date.to_string());
if !path.exists() {
return Err(Error::NotFound(format!(
"No data for cohort '{cohort}' on {date}"
)));
}
CostBasisDistribution::deserialize(&fs::read(&path)?)
}
/// List available dates for a cohort's cost basis distribution.
pub fn cost_basis_dates(&self, cohort: &str) -> Result<Vec<Date>> {
let dir = self.cost_basis_dir(cohort)?;
let mut dates: Vec<Date> = fs::read_dir(&dir)?
.filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok())
.collect();
dates.sort();
Ok(dates)
}
/// Get the formatted cost basis distribution.
pub fn cost_basis_formatted(
&self,
cohort: &str,
date: Date,
bucket: CostBasisBucket,
value: CostBasisValue,
) -> Result<CostBasisFormatted> {
let distribution = self.cost_basis_distribution(cohort, date)?;
let day1 = Day1::try_from(date).map_err(|e| Error::Parse(e.to_string()))?;
let price = &self.computer().prices;
let spot = price
.split
.close
.cents
.day1
.collect_one_flat(day1)
.ok_or_else(|| Error::NotFound(format!("No price data for {date}")))?;
Ok(distribution.format(bucket, value, spot))
}
}
+1 -1
View File
@@ -1,11 +1,11 @@
mod addr;
mod block;
mod cost_basis;
mod mempool;
mod mining;
mod price;
mod series;
mod tx;
mod urpd;
pub use block::BLOCK_TXS_PAGE_SIZE;
pub use series::ResolvedQuery;
+107
View File
@@ -0,0 +1,107 @@
use std::{fs, path::PathBuf};
use brk_error::{Error, Result};
use brk_types::{Cohort, Date, Day1, Urpd, UrpdAggregation, UrpdRaw};
use vecdb::ReadableOptionVec;
use crate::Query;
impl Query {
/// Available cohorts for URPD.
pub fn urpd_cohorts(&self) -> Result<Vec<Cohort>> {
let states_path = &self.computer().distribution.states_path;
let mut cohorts: Vec<Cohort> = fs::read_dir(states_path)?
.filter_map(|entry| {
let name = entry.ok()?.file_name().into_string().ok()?;
states_path
.join(&name)
.join("urpd")
.exists()
.then(|| Cohort::from(name))
})
.collect();
cohorts.sort_by(|a, b| a.to_string().cmp(&b.to_string()));
Ok(cohorts)
}
pub(crate) fn urpd_dir(&self, cohort: &str) -> Result<PathBuf> {
let dir = self
.computer()
.distribution
.states_path
.join(cohort)
.join("urpd");
if !dir.exists() {
let valid = self
.urpd_cohorts()
.unwrap_or_default()
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(", ");
return Err(Error::NotFound(format!(
"Unknown cohort '{cohort}'. Available: {valid}"
)));
}
Ok(dir)
}
/// Available dates for a cohort.
pub fn urpd_dates(&self, cohort: &Cohort) -> Result<Vec<Date>> {
let dir = self.urpd_dir(cohort)?;
let mut dates: Vec<Date> = fs::read_dir(&dir)?
.filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok())
.collect();
dates.sort();
Ok(dates)
}
/// Raw URPD data for a cohort on a specific date.
pub fn urpd_raw(&self, cohort: &Cohort, date: Date) -> Result<UrpdRaw> {
let path = self.urpd_dir(cohort)?.join(date.to_string());
if !path.exists() {
return Err(Error::NotFound(format!(
"No URPD for cohort '{cohort}' on {date}"
)));
}
UrpdRaw::deserialize(&fs::read(&path)?)
}
/// URPD for a cohort on a specific date.
pub fn urpd_at(
&self,
cohort: &Cohort,
date: Date,
agg: UrpdAggregation,
) -> Result<Urpd> {
let raw = self.urpd_raw(cohort, date)?;
let day1 = Day1::try_from(date).map_err(|e| Error::Parse(e.to_string()))?;
let close = self
.computer()
.prices
.split
.close
.cents
.day1
.collect_one_flat(day1)
.ok_or_else(|| Error::NotFound(format!("No price data for {date}")))?;
Ok(Urpd::build(cohort.clone(), date, close, &raw, agg))
}
/// URPD for the most recently available date in a cohort.
pub fn urpd_latest(&self, cohort: &Cohort, agg: UrpdAggregation) -> Result<Urpd> {
let dates = self.urpd_dates(cohort)?;
let date = *dates.last().ok_or_else(|| {
Error::NotFound(format!("No URPD available for cohort '{cohort}'"))
})?;
self.urpd_at(cohort, date, agg)
}
}
+1
View File
@@ -32,6 +32,7 @@ vecdb = { workspace = true }
zstd = "0.13"
jiff = { workspace = true }
quick_cache = "0.6.21"
rustc-hash = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+3 -1
View File
@@ -15,7 +15,7 @@ use crate::{
Error,
api::{
mempool_space::MempoolSpaceRoutes, metrics::ApiMetricsLegacyRoutes,
series::ApiSeriesRoutes, server::ServerRoutes,
series::ApiSeriesRoutes, server::ServerRoutes, urpd::ApiUrpdRoutes,
},
extended::{ResponseExtended, TransformResponseExtended},
};
@@ -27,6 +27,7 @@ mod metrics;
mod openapi;
mod series;
mod server;
mod urpd;
pub use openapi::*;
@@ -38,6 +39,7 @@ impl ApiRoutes for ApiRouter<AppState> {
fn add_api_routes(self) -> Self {
self.add_server_routes()
.add_series_routes()
.add_urpd_routes()
.add_metrics_legacy_routes()
.add_mempool_space_routes()
.route("/api/server", get(Redirect::temporary("/api#tag/server")))
+19 -1
View File
@@ -175,9 +175,27 @@ All errors return structured JSON with a consistent format:
),
..Default::default()
},
Tag {
name: "URPD".to_string(),
description: Some(
"UTXO Realized Price Distribution. For each (cohort, date) pair, supply is \
grouped by the close price at which each UTXO was last moved. One snapshot is \
emitted per UTC day.\n\n\
Each bucket carries `supply` (BTC), `realized_cap` (USD, = `price_floor * supply`), \
and `unrealized_pnl` (USD, = `(close - price_floor) * supply`, can be negative).\n\n\
Aggregate with the `agg` query parameter (alias `bucket`):\n\
- `raw`: one bucket per rounded price (default).\n\
- `lin200` / `lin500` / `lin1000`: linear buckets, $200 / $500 / $1000 wide.\n\
- `log10` / `log50` / `log100` / `log200`: logarithmic buckets, N bins per price decade.\n\n\
Discovery flow: `GET /api/urpd` (cohorts), `GET /api/urpd/{cohort}` (latest), \
`GET /api/urpd/{cohort}/dates` (history), `GET /api/urpd/{cohort}/{date}` (specific)."
.to_string(),
),
..Default::default()
},
Tag {
name: "Metrics".to_string(),
description: Some("Deprecated use Series".to_string()),
description: Some("Deprecated - use Series".to_string()),
extensions: [("deprecated".to_string(), serde_json::Value::Bool(true))].into(),
..Default::default()
},
@@ -0,0 +1,203 @@
//! Deprecated `/api/series/cost-basis/*` routes.
//! Sunset date: 2027-01-01. Delete this file and its registration in `mod.rs` together.
use std::collections::BTreeMap;
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::{Path, Query as AxumQuery, State},
http::{HeaderMap, Uri},
};
use brk_error::{Error, Result};
use brk_query::Query;
use brk_types::{Bitcoin, Cents, Cohort, Date, Day1, Dollars, Sats, UrpdAggregation, Version};
use rustc_hash::FxHashMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use vecdb::ReadableOptionVec;
use crate::{AppState, CacheStrategy, extended::TransformResponseExtended};
#[derive(Deserialize, JsonSchema)]
pub(super) struct CostBasisParams {
pub cohort: Cohort,
#[schemars(with = "String", example = &"2024-01-01")]
pub date: Date,
}
#[derive(Deserialize, JsonSchema)]
pub(super) struct CostBasisCohortParam {
pub cohort: Cohort,
}
#[derive(Deserialize, JsonSchema)]
pub(super) struct CostBasisQuery {
#[serde(default)]
pub bucket: UrpdAggregation,
#[serde(default)]
pub value: CostBasisValue,
}
/// Value type for the deprecated cost-basis distribution output.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub(super) enum CostBasisValue {
#[default]
Supply,
Realized,
Unrealized,
}
/// Formatted cost basis output.
/// Key: price floor in USD. Value: BTC (for supply) or USD (for realized/unrealized).
type CostBasisFormatted = BTreeMap<Dollars, f64>;
fn cost_basis_formatted(
q: &Query,
cohort: &Cohort,
date: Date,
agg: UrpdAggregation,
value: CostBasisValue,
) -> Result<CostBasisFormatted> {
let raw = q.urpd_raw(cohort, date)?;
let day1 = Day1::try_from(date).map_err(|e| Error::Parse(e.to_string()))?;
let spot_cents = q
.computer()
.prices
.split
.close
.cents
.day1
.collect_one_flat(day1)
.ok_or_else(|| Error::NotFound(format!("No price data for {date}")))?;
let spot = Dollars::from(spot_cents);
let needs_realized = value == CostBasisValue::Realized;
let mut bucketed: FxHashMap<Cents, (Sats, Dollars)> =
FxHashMap::with_capacity_and_hasher(raw.map.len(), Default::default());
for (&price_cents, &sats) in &raw.map {
let price = Cents::from(price_cents);
let key = match agg {
UrpdAggregation::Raw => price,
_ => agg.bucket_floor(price).unwrap_or(price),
};
let entry = bucketed.entry(key).or_insert((Sats::ZERO, Dollars::ZERO));
entry.0 += sats;
if needs_realized {
entry.1 += Dollars::from(price) * sats;
}
}
Ok(bucketed
.into_iter()
.map(|(cents, (sats, realized))| {
let k = Dollars::from(cents);
let v = match value {
CostBasisValue::Supply => f64::from(Bitcoin::from(sats)),
CostBasisValue::Realized => f64::from(realized),
CostBasisValue::Unrealized => f64::from((spot - k) * sats),
};
(k, v)
})
.collect())
}
pub(super) trait ApiCostBasisLegacyRoutes {
fn add_cost_basis_legacy_routes(self) -> Self;
}
impl ApiCostBasisLegacyRoutes for ApiRouter<AppState> {
fn add_cost_basis_legacy_routes(self) -> Self {
self.api_route(
"/api/series/cost-basis",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, &uri, |q| q.urpd_cohorts())
.await
},
|op| {
op.id("get_cost_basis_cohorts")
.series_tag()
.deprecated()
.summary("Available cost basis cohorts (deprecated)")
.description(
"**DEPRECATED** - Use `GET /api/urpd` instead.\n\n\
Sunset date: 2027-01-01.",
)
.json_response::<Vec<Cohort>>()
.not_modified()
.server_error()
},
),
)
.api_route(
"/api/series/cost-basis/{cohort}/dates",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<CostBasisCohortParam>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| {
q.urpd_dates(&params.cohort)
})
.await
},
|op| {
op.id("get_cost_basis_dates")
.series_tag()
.deprecated()
.summary("Available cost basis dates (deprecated)")
.description(
"**DEPRECATED** - Use `GET /api/urpd/{cohort}/dates` instead.\n\n\
Sunset date: 2027-01-01.",
)
.json_response::<Vec<Date>>()
.not_modified()
.not_found()
.server_error()
},
),
)
.api_route(
"/api/series/cost-basis/{cohort}/{date}",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<CostBasisParams>,
AxumQuery(query): AxumQuery<CostBasisQuery>,
State(state): State<AppState>| {
let strategy = state.date_cache(Version::ONE, params.date);
state
.cached_json(&headers, strategy, &uri, move |q| {
cost_basis_formatted(
q,
&params.cohort,
params.date,
query.bucket,
query.value,
)
})
.await
},
|op| {
op.id("get_cost_basis")
.series_tag()
.deprecated()
.summary("Cost basis distribution (deprecated)")
.description(
"**DEPRECATED** - Use `GET /api/urpd/{cohort}/{date}` instead. \
The new endpoint returns supply, realized cap, and unrealized P&L \
per bucket in one response.\n\n\
Sunset date: 2027-01-01.",
)
.json_response::<CostBasisFormatted>()
.not_modified()
.not_found()
.server_error()
},
),
)
}
}
+6 -85
View File
@@ -9,20 +9,22 @@ use axum::{
};
use brk_traversable::TreeNode;
use brk_types::{
CostBasisFormatted, DataRangeFormat, Date, IndexInfo, PaginatedSeries, Pagination, SearchQuery,
SeriesCount, SeriesData, SeriesInfo, SeriesNameWithIndex, SeriesSelection, Version,
DataRangeFormat, IndexInfo, PaginatedSeries, Pagination, SearchQuery, SeriesCount, SeriesData,
SeriesInfo, SeriesNameWithIndex, SeriesSelection,
};
use crate::{
CacheStrategy,
cache::CACHE_CONTROL,
extended::TransformResponseExtended,
params::{CostBasisCohortParam, CostBasisParams, CostBasisQuery, SeriesParam},
params::SeriesParam,
};
use self::cost_basis::ApiCostBasisLegacyRoutes;
use super::AppState;
mod bulk;
mod cost_basis;
mod data;
pub mod legacy;
@@ -330,87 +332,6 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
.not_modified(),
),
)
// Cost basis distribution endpoints
.api_route(
"/api/series/cost-basis",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, &uri, |q| q.cost_basis_cohorts())
.await
},
|op| {
op.id("get_cost_basis_cohorts")
.series_tag()
.summary("Available cost basis cohorts")
.description("List available cohorts for cost basis distribution.")
.json_response::<Vec<String>>()
.not_modified()
.server_error()
},
),
)
.api_route(
"/api/series/cost-basis/{cohort}/dates",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<CostBasisCohortParam>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| {
q.cost_basis_dates(&params.cohort)
})
.await
},
|op| {
op.id("get_cost_basis_dates")
.series_tag()
.summary("Available cost basis dates")
.description("List available dates for a cohort's cost basis distribution.")
.json_response::<Vec<Date>>()
.not_modified()
.not_found()
.server_error()
},
),
)
.api_route(
"/api/series/cost-basis/{cohort}/{date}",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<CostBasisParams>,
Query(query): Query<CostBasisQuery>,
State(state): State<AppState>| {
let strategy = state.date_cache(Version::ONE, params.date);
state
.cached_json(&headers, strategy, &uri, move |q| {
q.cost_basis_formatted(
&params.cohort,
params.date,
query.bucket,
query.value,
)
})
.await
},
|op| {
op.id("get_cost_basis")
.series_tag()
.summary("Cost basis distribution")
.description(
"Get the cost basis distribution for a cohort on a specific date.\n\n\
Query params:\n\
- `bucket`: raw (default), lin200, lin500, lin1000, log10, log50, log100\n\
- `value`: supply (default, in BTC), realized (USD), unrealized (USD)",
)
.json_response::<CostBasisFormatted>()
.not_modified()
.not_found()
.server_error()
},
),
)
.add_cost_basis_legacy_routes()
}
}
+135
View File
@@ -0,0 +1,135 @@
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, Uri},
};
use brk_types::{Cohort, Date, Urpd, Version};
use crate::{
CacheStrategy,
extended::TransformResponseExtended,
params::{UrpdCohortParam, UrpdParams, UrpdQuery},
};
use super::AppState;
pub trait ApiUrpdRoutes {
fn add_urpd_routes(self) -> Self;
}
impl ApiUrpdRoutes for ApiRouter<AppState> {
fn add_urpd_routes(self) -> Self {
self.api_route(
"/api/urpd",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, &uri, |q| q.urpd_cohorts())
.await
},
|op| {
op.id("list_urpd_cohorts")
.urpd_tag()
.summary("Available URPD cohorts")
.description(
"Cohorts for which URPD data is available. Returns names like \
`all`, `sth`, `lth`, `utxos_under_1h_old`.",
)
.json_response::<Vec<Cohort>>()
.not_modified()
.server_error()
},
),
)
.api_route(
"/api/urpd/{cohort}/dates",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<UrpdCohortParam>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| {
q.urpd_dates(&params.cohort)
})
.await
},
|op| {
op.id("list_urpd_dates")
.urpd_tag()
.summary("Available URPD dates")
.description(
"Dates for which a URPD snapshot is available for the cohort. \
One entry per UTC day, sorted ascending.",
)
.json_response::<Vec<Date>>()
.not_modified()
.not_found()
.server_error()
},
),
)
.api_route(
"/api/urpd/{cohort}",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<UrpdCohortParam>,
Query(query): Query<UrpdQuery>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Tip, &uri, move |q| {
q.urpd_latest(&params.cohort, query.aggregation)
})
.await
},
|op| {
op.id("get_urpd")
.urpd_tag()
.summary("Latest URPD")
.description(
"URPD for the most recent available date in the cohort. \
The response's `date` field echoes which date was served.\n\n\
See the URPD tag description for the response shape and `agg` options.",
)
.json_response::<Urpd>()
.not_modified()
.not_found()
.server_error()
},
),
)
.api_route(
"/api/urpd/{cohort}/{date}",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<UrpdParams>,
Query(query): Query<UrpdQuery>,
State(state): State<AppState>| {
let strategy = state.date_cache(Version::ONE, params.date);
state
.cached_json(&headers, strategy, &uri, move |q| {
q.urpd_at(&params.cohort, params.date, query.aggregation)
})
.await
},
|op| {
op.id("get_urpd_at")
.urpd_tag()
.summary("URPD at date")
.description(
"URPD for a (cohort, date) pair. Returns \
`{ cohort, date, aggregation, close, total_supply, buckets }` where \
each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`.\n\n\
See the URPD tag description for unit conventions and `agg` options.",
)
.json_response::<Urpd>()
.not_modified()
.not_found()
.server_error()
},
),
)
}
}
@@ -15,6 +15,7 @@ pub trait TransformResponseExtended<'t> {
fn transactions_tag(self) -> Self;
fn server_tag(self) -> Self;
fn series_tag(self) -> Self;
fn urpd_tag(self) -> Self;
fn metrics_tag(self) -> Self;
/// Mark operation as deprecated
@@ -82,6 +83,10 @@ impl<'t> TransformResponseExtended<'t> for TransformOperation<'t> {
self.tag("Series")
}
fn urpd_tag(self) -> Self {
self.tag("URPD")
}
fn metrics_tag(self) -> Self {
self.tag("Metrics")
}
@@ -1,29 +0,0 @@
use schemars::JsonSchema;
use serde::Deserialize;
use brk_types::{Cohort, CostBasisBucket, CostBasisValue, Date};
/// Path parameters for cost basis distribution endpoint.
#[derive(Deserialize, JsonSchema)]
pub struct CostBasisParams {
pub cohort: Cohort,
#[schemars(with = "String", example = &"2024-01-01")]
pub date: Date,
}
/// Path parameters for cost basis dates endpoint.
#[derive(Deserialize, JsonSchema)]
pub struct CostBasisCohortParam {
pub cohort: Cohort,
}
/// Query parameters for cost basis distribution endpoint.
#[derive(Deserialize, JsonSchema)]
pub struct CostBasisQuery {
/// Bucket type for aggregation. Default: raw (no aggregation).
#[serde(default)]
pub bucket: CostBasisBucket,
/// Value type to return. Default: supply.
#[serde(default)]
pub value: CostBasisValue,
}
+2 -2
View File
@@ -4,7 +4,6 @@ mod block_count_param;
mod blockhash_param;
mod blockhash_start_index;
mod blockhash_tx_index;
mod cost_basis_params;
mod height_param;
mod limit_param;
mod pool_slug_param;
@@ -15,6 +14,7 @@ mod tx_index_param;
mod txid_param;
mod txid_vout;
mod txids_param;
mod urpd_params;
mod validate_addr_param;
pub use addr_param::*;
@@ -23,7 +23,6 @@ pub use block_count_param::*;
pub use blockhash_param::*;
pub use blockhash_start_index::*;
pub use blockhash_tx_index::*;
pub use cost_basis_params::*;
pub use height_param::*;
pub use limit_param::*;
pub use pool_slug_param::*;
@@ -34,4 +33,5 @@ pub use tx_index_param::*;
pub use txid_param::*;
pub use txid_vout::*;
pub use txids_param::*;
pub use urpd_params::*;
pub use validate_addr_param::*;
@@ -0,0 +1,26 @@
use schemars::JsonSchema;
use serde::Deserialize;
use brk_types::{Cohort, Date, UrpdAggregation};
/// Path parameters for `/api/urpd/{cohort}/{date}`.
#[derive(Deserialize, JsonSchema)]
pub struct UrpdParams {
pub cohort: Cohort,
#[schemars(with = "String", example = &"2024-01-01")]
pub date: Date,
}
/// Path parameters for per-cohort URPD endpoints.
#[derive(Deserialize, JsonSchema)]
pub struct UrpdCohortParam {
pub cohort: Cohort,
}
/// Query parameters for URPD endpoints.
#[derive(Deserialize, JsonSchema)]
pub struct UrpdQuery {
/// Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias.
#[serde(default, rename = "agg", alias = "bucket")]
pub aggregation: UrpdAggregation,
}
+9 -8
View File
@@ -1,17 +1,18 @@
use std::{fmt, ops::Deref};
use schemars::JsonSchema;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
/// Cohort identifier for cost basis distribution.
#[derive(Deserialize, JsonSchema)]
/// URPD cohort identifier. Use `GET /api/urpd` to list available cohorts.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[schemars(extend("enum" = [
"all", "sth", "lth",
"under_1h_old", "1h_to_1d_old", "1d_to_1w_old", "1w_to_1m_old",
"1m_to_2m_old", "2m_to_3m_old", "3m_to_4m_old", "4m_to_5m_old", "5m_to_6m_old",
"6m_to_1y_old", "1y_to_2y_old", "2y_to_3y_old", "3y_to_4y_old", "4y_to_5y_old",
"5y_to_6y_old", "6y_to_7y_old", "7y_to_8y_old", "8y_to_10y_old",
"10y_to_12y_old", "12y_to_15y_old", "over_15y_old",
"utxos_under_1h_old", "utxos_1h_to_1d_old", "utxos_1d_to_1w_old", "utxos_1w_to_1m_old",
"utxos_1m_to_2m_old", "utxos_2m_to_3m_old", "utxos_3m_to_4m_old", "utxos_4m_to_5m_old",
"utxos_5m_to_6m_old", "utxos_6m_to_1y_old", "utxos_1y_to_2y_old", "utxos_2y_to_3y_old",
"utxos_3y_to_4y_old", "utxos_4y_to_5y_old", "utxos_5y_to_6y_old", "utxos_6y_to_7y_old",
"utxos_7y_to_8y_old", "utxos_8y_to_10y_old", "utxos_10y_to_12y_old", "utxos_12y_to_15y_old",
"utxos_over_15y_old",
]))]
pub struct Cohort(String);
-17
View File
@@ -1,17 +0,0 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use strum::Display;
/// Value type for cost basis distribution.
/// Options: supply (BTC), realized (USD, price × supply), unrealized (USD, spot × supply).
#[derive(
Debug, Display, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum CostBasisValue {
#[default]
Supply,
Realized,
Unrealized,
}
+8 -6
View File
@@ -44,9 +44,6 @@ mod cents_signed;
mod cents_squared_sats;
mod cohort;
mod coinbase_tag;
mod cost_basis_bucket;
mod cost_basis_distribution;
mod cost_basis_value;
mod cpfp;
mod data_range;
mod data_range_format;
@@ -182,6 +179,10 @@ mod txout_spend;
mod type_index;
mod unit;
mod unknown_output_index;
mod urpd;
mod urpd_aggregation;
mod urpd_bucket;
mod urpd_raw;
mod utxo;
mod vin;
mod vout;
@@ -234,9 +235,6 @@ pub use cents_signed::*;
pub use cents_squared_sats::*;
pub use cohort::*;
pub use coinbase_tag::*;
pub use cost_basis_bucket::*;
pub use cost_basis_distribution::*;
pub use cost_basis_value::*;
pub use cpfp::*;
pub use data_range::*;
pub use data_range_format::*;
@@ -372,6 +370,10 @@ pub use txout_spend::*;
pub use type_index::*;
pub use unit::*;
pub use unknown_output_index::*;
pub use urpd::*;
pub use urpd_aggregation::*;
pub use urpd_bucket::*;
pub use urpd_raw::*;
pub use utxo::*;
pub use vin::*;
pub use vout::*;
+6
View File
@@ -62,6 +62,7 @@ impl Sats {
pub const FIFTY_BTC: Self = Self(50_00_000_000);
pub const ONE_BTC_U64: u64 = 1_00_000_000;
pub const ONE_BTC_U128: u128 = 1_00_000_000;
pub const ONE_BTC_I128: i128 = 1_00_000_000;
pub fn new(sats: u64) -> Self {
Self(sats)
@@ -80,6 +81,11 @@ impl Sats {
self.0 as u128
}
#[inline(always)]
pub const fn as_i128(self) -> i128 {
self.0 as i128
}
pub fn is_max(&self) -> bool {
*self == Self::MAX
}
+74
View File
@@ -0,0 +1,74 @@
use std::collections::BTreeMap;
use rustc_hash::FxHashMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{Bitcoin, Cents, Cohort, Date, Dollars, Sats, UrpdAggregation, UrpdBucket, UrpdRaw};
/// UTXO Realized Price Distribution for a cohort on a specific date.
///
/// Supply is grouped by the close price at which each UTXO was last moved.
/// Each bucket exposes three values derived from the same `(price_floor, supply)`
/// pairs: supply in BTC, realized cap contribution in USD (`price_floor * supply`),
/// and unrealized P&L against that date's close in USD
/// (`(close - price_floor) * supply`, can be negative).
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct Urpd {
pub cohort: Cohort,
pub date: Date,
/// Aggregation strategy applied to the buckets.
pub aggregation: UrpdAggregation,
/// Close price on `date`, in USD. Anchor for `unrealized_pnl`.
pub close: Dollars,
/// Sum of `supply` across all buckets, in BTC.
pub total_supply: Bitcoin,
pub buckets: Vec<UrpdBucket>,
}
impl Urpd {
/// Build from the raw on-disk distribution plus context.
pub fn build(
cohort: Cohort,
date: Date,
close_cents: Cents,
raw: &UrpdRaw,
aggregation: UrpdAggregation,
) -> Self {
let mut agg: FxHashMap<Cents, Sats> =
FxHashMap::with_capacity_and_hasher(raw.map.len(), Default::default());
for (&price_cents, &sats) in &raw.map {
let price = Cents::from(price_cents);
let key = match aggregation {
UrpdAggregation::Raw => price,
_ => aggregation.bucket_floor(price).unwrap_or(price),
};
*agg.entry(key).or_insert(Sats::ZERO) += sats;
}
let sorted: BTreeMap<Cents, Sats> = agg.into_iter().collect();
let close = Dollars::from(close_cents);
let mut total_sats = Sats::ZERO;
let mut buckets = Vec::with_capacity(sorted.len());
for (price_floor_cents, supply) in sorted {
total_sats += supply;
let price_floor = Dollars::from(price_floor_cents);
buckets.push(UrpdBucket {
price_floor,
supply: Bitcoin::from(supply),
realized_cap: price_floor * supply,
unrealized_pnl: (close - price_floor) * supply,
});
}
Self {
cohort,
date,
aggregation,
close,
total_supply: Bitcoin::from(total_sats),
buckets,
}
}
}
+3 -3
View File
@@ -4,7 +4,7 @@ use strum::Display;
use crate::Cents;
/// Bucket type for cost basis aggregation.
/// Aggregation strategy for URPD buckets.
/// Options: raw (no aggregation), lin200/lin500/lin1000 (linear $200/$500/$1000),
/// log10/log50/log100/log200 (logarithmic with 10/50/100/200 buckets per decade).
#[derive(
@@ -12,7 +12,7 @@ use crate::Cents;
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum CostBasisBucket {
pub enum UrpdAggregation {
#[default]
Raw,
Lin200,
@@ -24,7 +24,7 @@ pub enum CostBasisBucket {
Log200,
}
impl CostBasisBucket {
impl UrpdAggregation {
/// Returns the linear bucket size in cents, if this is a linear bucket type.
fn linear_size_cents(&self) -> Option<u64> {
match self {
+17
View File
@@ -0,0 +1,17 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{Bitcoin, Dollars};
/// A single bucket in a URPD snapshot.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct UrpdBucket {
/// Inclusive lower bound of the bucket, in USD.
pub price_floor: Dollars,
/// Supply held with a last-move price inside this bucket, in BTC.
pub supply: Bitcoin,
/// Realized cap contribution in USD: `price_floor * supply`.
pub realized_cap: Dollars,
/// Unrealized P&L in USD against the close on the snapshot date: `(close - price_floor) * supply`. Can be negative.
pub unrealized_pnl: Dollars,
}
+7 -63
View File
@@ -1,7 +1,5 @@
use std::collections::BTreeMap;
use rustc_hash::FxHashMap;
use brk_error::Result;
use pco::{
ChunkConfig,
@@ -11,25 +9,21 @@ use schemars::JsonSchema;
use serde::Serialize;
use vecdb::Bytes;
use crate::{Bitcoin, Cents, CentsCompact, CostBasisBucket, CostBasisValue, Dollars, Sats};
use crate::{CentsCompact, Sats};
/// Cost basis distribution: a map of price (cents) to sats.
/// Raw on-disk URPD: a map of price (cents) to supply (sats).
/// Processed into [`crate::Urpd`] for API responses.
#[derive(Debug, Clone, Default, Serialize, JsonSchema)]
pub struct CostBasisDistribution {
pub struct UrpdRaw {
pub map: BTreeMap<CentsCompact, Sats>,
}
/// Formatted cost basis output.
/// Key: price floor in USD (dollars).
/// Value: BTC (for supply) or USD (for realized/unrealized).
pub type CostBasisFormatted = BTreeMap<Dollars, f64>;
impl CostBasisDistribution {
impl UrpdRaw {
/// Deserialize from the pco-compressed format, returning remaining bytes.
pub fn deserialize_with_rest(data: &[u8]) -> Result<(Self, &[u8])> {
if data.len() < 24 {
return Err(brk_error::Error::Deserialization(format!(
"CostBasisDistribution: data too short ({} bytes, need >= 24)",
"UrpdRaw: data too short ({} bytes, need >= 24)",
data.len()
)));
}
@@ -43,7 +37,7 @@ impl CostBasisDistribution {
if data.len() < rest_start {
return Err(brk_error::Error::Deserialization(format!(
"CostBasisDistribution: data too short ({} bytes, need >= {})",
"UrpdRaw: data too short ({} bytes, need >= {})",
data.len(),
rest_start
)));
@@ -92,54 +86,4 @@ impl CostBasisDistribution {
Ok(buffer)
}
/// Format the distribution with optional bucketing and value transformation.
///
/// - `bucket`: How to aggregate prices (raw, linear, or logarithmic)
/// - `value`: What value to compute (supply, realized, or unrealized)
/// - `spot_cents`: Current spot price in cents (required for unrealized)
pub fn format(
&self,
bucket: CostBasisBucket,
value: CostBasisValue,
spot_cents: Cents,
) -> CostBasisFormatted {
let spot = Dollars::from(spot_cents);
let needs_realized = value == CostBasisValue::Realized;
let mut result: FxHashMap<Cents, (Sats, Dollars)> =
FxHashMap::with_capacity_and_hasher(self.map.len(), Default::default());
// Aggregate into buckets
for (&price_cents, &sats) in &self.map {
let price_cents_u = Cents::from(price_cents);
let bucket_key = match bucket {
CostBasisBucket::Raw => price_cents_u,
_ => bucket.bucket_floor(price_cents_u).unwrap_or(price_cents_u),
};
let entry = result
.entry(bucket_key)
.or_insert((Sats::ZERO, Dollars::ZERO));
entry.0 += sats;
// Only compute realized value if needed
if needs_realized {
entry.1 += Dollars::from(price_cents_u) * sats;
}
}
// Convert to final output based on value type
result
.into_iter()
.map(|(cents, (sats, realized))| {
let k = Dollars::from(cents);
let v = match value {
CostBasisValue::Supply => f64::from(Bitcoin::from(sats)),
CostBasisValue::Realized => f64::from(realized),
CostBasisValue::Unrealized => f64::from((spot - k) * sats),
};
(k, v)
})
.collect()
}
}
+132 -76
View File
@@ -325,9 +325,9 @@ Matches mempool.space/bitcoin-cli behavior.
* @typedef {Dollars} Close
*/
/**
* Cohort identifier for cost basis distribution.
* URPD cohort identifier. Use `GET /api/urpd` to list available cohorts.
*
* @typedef {("all"|"sth"|"lth"|"under_1h_old"|"1h_to_1d_old"|"1d_to_1w_old"|"1w_to_1m_old"|"1m_to_2m_old"|"2m_to_3m_old"|"3m_to_4m_old"|"4m_to_5m_old"|"5m_to_6m_old"|"6m_to_1y_old"|"1y_to_2y_old"|"2y_to_3y_old"|"3y_to_4y_old"|"4y_to_5y_old"|"5y_to_6y_old"|"6y_to_7y_old"|"7y_to_8y_old"|"8y_to_10y_old"|"10y_to_12y_old"|"12y_to_15y_old"|"over_15y_old")} Cohort
* @typedef {("all"|"sth"|"lth"|"utxos_under_1h_old"|"utxos_1h_to_1d_old"|"utxos_1d_to_1w_old"|"utxos_1w_to_1m_old"|"utxos_1m_to_2m_old"|"utxos_2m_to_3m_old"|"utxos_3m_to_4m_old"|"utxos_4m_to_5m_old"|"utxos_5m_to_6m_old"|"utxos_6m_to_1y_old"|"utxos_1y_to_2y_old"|"utxos_2y_to_3y_old"|"utxos_3y_to_4y_old"|"utxos_4y_to_5y_old"|"utxos_5y_to_6y_old"|"utxos_6y_to_7y_old"|"utxos_7y_to_8y_old"|"utxos_8y_to_10y_old"|"utxos_10y_to_12y_old"|"utxos_12y_to_15y_old"|"utxos_over_15y_old")} Cohort
*/
/**
* Coinbase scriptSig tag for pool identification.
@@ -341,35 +341,21 @@ Matches mempool.space/bitcoin-cli behavior.
* @typedef {string} CoinbaseTag
*/
/**
* Bucket type for cost basis aggregation.
* Options: raw (no aggregation), lin200/lin500/lin1000 (linear $200/$500/$1000),
* log10/log50/log100/log200 (logarithmic with 10/50/100/200 buckets per decade).
*
* @typedef {("raw"|"lin200"|"lin500"|"lin1000"|"log10"|"log50"|"log100"|"log200")} CostBasisBucket
*/
/**
* Path parameters for cost basis dates endpoint.
*
* @typedef {Object} CostBasisCohortParam
* @property {Cohort} cohort
*/
/**
* Path parameters for cost basis distribution endpoint.
*
* @typedef {Object} CostBasisParams
* @property {Cohort} cohort
* @property {string} date
*/
/**
* Query parameters for cost basis distribution endpoint.
*
* @typedef {Object} CostBasisQuery
* @property {CostBasisBucket=} bucket - Bucket type for aggregation. Default: raw (no aggregation).
* @property {CostBasisValue=} value - Value type to return. Default: supply.
* @property {UrpdAggregation=} bucket
* @property {CostBasisValue=} value
*/
/**
* Value type for cost basis distribution.
* Options: supply (BTC), realized (USD, price × supply), unrealized (USD, spot × supply).
* Value type for the deprecated cost-basis distribution output.
*
* @typedef {("supply"|"realized"|"unrealized")} CostBasisValue
*/
@@ -1159,6 +1145,58 @@ Matches mempool.space/bitcoin-cli behavior.
/** @typedef {number[]} U8x33 */
/** @typedef {number[]} U8x65 */
/** @typedef {TypeIndex} UnknownOutputIndex */
/**
* UTXO Realized Price Distribution for a cohort on a specific date.
*
* Supply is grouped by the close price at which each UTXO was last moved.
* Each bucket exposes three values derived from the same `(price_floor, supply)`
* pairs: supply in BTC, realized cap contribution in USD (`price_floor * supply`),
* and unrealized P&L against that date's close in USD
* (`(close - price_floor) * supply`, can be negative).
*
* @typedef {Object} Urpd
* @property {Cohort} cohort
* @property {Date} date
* @property {UrpdAggregation} aggregation - Aggregation strategy applied to the buckets.
* @property {Dollars} close - Close price on `date`, in USD. Anchor for `unrealized_pnl`.
* @property {Bitcoin} totalSupply - Sum of `supply` across all buckets, in BTC.
* @property {UrpdBucket[]} buckets
*/
/**
* Aggregation strategy for URPD buckets.
* Options: raw (no aggregation), lin200/lin500/lin1000 (linear $200/$500/$1000),
* log10/log50/log100/log200 (logarithmic with 10/50/100/200 buckets per decade).
*
* @typedef {("raw"|"lin200"|"lin500"|"lin1000"|"log10"|"log50"|"log100"|"log200")} UrpdAggregation
*/
/**
* A single bucket in a URPD snapshot.
*
* @typedef {Object} UrpdBucket
* @property {Dollars} priceFloor - Inclusive lower bound of the bucket, in USD.
* @property {Bitcoin} supply - Supply held with a last-move price inside this bucket, in BTC.
* @property {Dollars} realizedCap - Realized cap contribution in USD: `price_floor * supply`.
* @property {Dollars} unrealizedPnl - Unrealized P&L in USD against the close on the snapshot date: `(close - price_floor) * supply`. Can be negative.
*/
/**
* Path parameters for per-cohort URPD endpoints.
*
* @typedef {Object} UrpdCohortParam
* @property {Cohort} cohort
*/
/**
* Path parameters for `/api/urpd/{cohort}/{date}`.
*
* @typedef {Object} UrpdParams
* @property {Cohort} cohort
* @property {string} date
*/
/**
* Query parameters for URPD endpoints.
*
* @typedef {Object} UrpdQuery
* @property {UrpdAggregation=} agg - Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias.
*/
/**
* Unspent transaction output
*
@@ -10464,63 +10502,6 @@ class BrkClient extends BrkClientBase {
return this.getJson(path, { signal, onUpdate });
}
/**
* Available cost basis cohorts
*
* List available cohorts for cost basis distribution.
*
* Endpoint: `GET /api/series/cost-basis`
* @param {{ signal?: AbortSignal, onUpdate?: (value: string[]) => void }} [options]
* @returns {Promise<string[]>}
*/
async getCostBasisCohorts({ signal, onUpdate } = {}) {
const path = `/api/series/cost-basis`;
return this.getJson(path, { signal, onUpdate });
}
/**
* Available cost basis dates
*
* List available dates for a cohort's cost basis distribution.
*
* Endpoint: `GET /api/series/cost-basis/{cohort}/dates`
*
* @param {Cohort} cohort
* @param {{ signal?: AbortSignal, onUpdate?: (value: Date[]) => void }} [options]
* @returns {Promise<Date[]>}
*/
async getCostBasisDates(cohort, { signal, onUpdate } = {}) {
const path = `/api/series/cost-basis/${cohort}/dates`;
return this.getJson(path, { signal, onUpdate });
}
/**
* Cost basis distribution
*
* Get the cost basis distribution for a cohort on a specific date.
*
* Query params:
* - `bucket`: raw (default), lin200, lin500, lin1000, log10, log50, log100
* - `value`: supply (default, in BTC), realized (USD), unrealized (USD)
*
* Endpoint: `GET /api/series/cost-basis/{cohort}/{date}`
*
* @param {Cohort} cohort
* @param {string} date
* @param {CostBasisBucket=} [bucket] - Bucket type for aggregation. Default: raw (no aggregation).
* @param {CostBasisValue=} [value] - Value type to return. Default: supply.
* @param {{ signal?: AbortSignal, onUpdate?: (value: Object) => void }} [options]
* @returns {Promise<Object>}
*/
async getCostBasis(cohort, date, bucket, value, { signal, onUpdate } = {}) {
const params = new URLSearchParams();
if (bucket !== undefined) params.set('bucket', String(bucket));
if (value !== undefined) params.set('value', String(value));
const query = params.toString();
const path = `/api/series/cost-basis/${cohort}/${date}${query ? '?' + query : ''}`;
return this.getJson(path, { signal, onUpdate });
}
/**
* Series count
*
@@ -10903,6 +10884,81 @@ class BrkClient extends BrkClientBase {
return this.getJson(path, { signal, onUpdate });
}
/**
* Available URPD cohorts
*
* Cohorts for which URPD data is available. Returns names like `all`, `sth`, `lth`, `utxos_under_1h_old`.
*
* Endpoint: `GET /api/urpd`
* @param {{ signal?: AbortSignal, onUpdate?: (value: Cohort[]) => void }} [options]
* @returns {Promise<Cohort[]>}
*/
async listUrpdCohorts({ signal, onUpdate } = {}) {
const path = `/api/urpd`;
return this.getJson(path, { signal, onUpdate });
}
/**
* Latest URPD
*
* URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served.
*
* See the URPD tag description for the response shape and `agg` options.
*
* Endpoint: `GET /api/urpd/{cohort}`
*
* @param {Cohort} cohort
* @param {UrpdAggregation=} [agg] - Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias.
* @param {{ signal?: AbortSignal, onUpdate?: (value: Urpd) => void }} [options]
* @returns {Promise<Urpd>}
*/
async getUrpd(cohort, agg, { signal, onUpdate } = {}) {
const params = new URLSearchParams();
if (agg !== undefined) params.set('agg', String(agg));
const query = params.toString();
const path = `/api/urpd/${cohort}${query ? '?' + query : ''}`;
return this.getJson(path, { signal, onUpdate });
}
/**
* Available URPD dates
*
* Dates for which a URPD snapshot is available for the cohort. One entry per UTC day, sorted ascending.
*
* Endpoint: `GET /api/urpd/{cohort}/dates`
*
* @param {Cohort} cohort
* @param {{ signal?: AbortSignal, onUpdate?: (value: Date[]) => void }} [options]
* @returns {Promise<Date[]>}
*/
async listUrpdDates(cohort, { signal, onUpdate } = {}) {
const path = `/api/urpd/${cohort}/dates`;
return this.getJson(path, { signal, onUpdate });
}
/**
* URPD at date
*
* URPD for a (cohort, date) pair. Returns `{ cohort, date, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`.
*
* See the URPD tag description for unit conventions and `agg` options.
*
* Endpoint: `GET /api/urpd/{cohort}/{date}`
*
* @param {Cohort} cohort
* @param {string} date
* @param {UrpdAggregation=} [agg] - Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias.
* @param {{ signal?: AbortSignal, onUpdate?: (value: Urpd) => void }} [options]
* @returns {Promise<Urpd>}
*/
async getUrpdAt(cohort, date, agg, { signal, onUpdate } = {}) {
const params = new URLSearchParams();
if (agg !== undefined) params.set('agg', String(agg));
const query = params.toString();
const path = `/api/urpd/${cohort}/${date}${query ? '?' + query : ''}`;
return this.getJson(path, { signal, onUpdate });
}
/**
* Block (v1)
*
+110 -54
View File
@@ -82,8 +82,8 @@ CentsSigned = int
CentsSquaredSats = int
# Closing price value for a time period
Close = Dollars
# Cohort identifier for cost basis distribution.
Cohort = Literal["all", "sth", "lth", "under_1h_old", "1h_to_1d_old", "1d_to_1w_old", "1w_to_1m_old", "1m_to_2m_old", "2m_to_3m_old", "3m_to_4m_old", "4m_to_5m_old", "5m_to_6m_old", "6m_to_1y_old", "1y_to_2y_old", "2y_to_3y_old", "3y_to_4y_old", "4y_to_5y_old", "5y_to_6y_old", "6y_to_7y_old", "7y_to_8y_old", "8y_to_10y_old", "10y_to_12y_old", "12y_to_15y_old", "over_15y_old"]
# URPD cohort identifier. Use `GET /api/urpd` to list available cohorts.
Cohort = Literal["all", "sth", "lth", "utxos_under_1h_old", "utxos_1h_to_1d_old", "utxos_1d_to_1w_old", "utxos_1w_to_1m_old", "utxos_1m_to_2m_old", "utxos_2m_to_3m_old", "utxos_3m_to_4m_old", "utxos_4m_to_5m_old", "utxos_5m_to_6m_old", "utxos_6m_to_1y_old", "utxos_1y_to_2y_old", "utxos_2y_to_3y_old", "utxos_3y_to_4y_old", "utxos_4y_to_5y_old", "utxos_5y_to_6y_old", "utxos_6y_to_7y_old", "utxos_7y_to_8y_old", "utxos_8y_to_10y_old", "utxos_10y_to_12y_old", "utxos_12y_to_15y_old", "utxos_over_15y_old"]
# Coinbase scriptSig tag for pool identification.
#
# Stored as a fixed 101-byte record (1 byte length + 100 bytes data).
@@ -92,13 +92,12 @@ Cohort = Literal["all", "sth", "lth", "under_1h_old", "1h_to_1d_old", "1d_to_1w_
#
# Bitcoin consensus limits coinbase scriptSig to 2-100 bytes.
CoinbaseTag = str
# Bucket type for cost basis aggregation.
# Value type for the deprecated cost-basis distribution output.
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 (logarithmic with 10/50/100/200 buckets per decade).
CostBasisBucket = Literal["raw", "lin200", "lin500", "lin1000", "log10", "log50", "log100", "log200"]
# Value type for cost basis distribution.
# Options: supply (BTC), realized (USD, price × supply), unrealized (USD, spot × supply).
CostBasisValue = Literal["supply", "realized", "unrealized"]
UrpdAggregation = Literal["raw", "lin200", "lin500", "lin1000", "log10", "log50", "log100", "log200"]
# Virtual size in vbytes (weight / 4, rounded up). Max block vsize is ~1,000,000 vB.
VSize = int
# Date in YYYYMMDD format stored as u32
@@ -622,27 +621,14 @@ class BlockTimestamp(TypedDict):
timestamp: str
class CostBasisCohortParam(TypedDict):
"""
Path parameters for cost basis dates endpoint.
"""
cohort: Cohort
class CostBasisParams(TypedDict):
"""
Path parameters for cost basis distribution endpoint.
"""
cohort: Cohort
date: str
class CostBasisQuery(TypedDict):
"""
Query parameters for cost basis distribution endpoint.
Attributes:
bucket: Bucket type for aggregation. Default: raw (no aggregation).
value: Value type to return. Default: supply.
"""
bucket: CostBasisBucket
bucket: UrpdAggregation
value: CostBasisValue
class CpfpEntry(TypedDict):
@@ -1511,6 +1497,65 @@ class TxidVout(TypedDict):
txid: Txid
vout: Vout
class UrpdBucket(TypedDict):
"""
A single bucket in a URPD snapshot.
Attributes:
price_floor: Inclusive lower bound of the bucket, in USD.
supply: Supply held with a last-move price inside this bucket, in BTC.
realized_cap: Realized cap contribution in USD: `price_floor * supply`.
unrealized_pnl: Unrealized P&L in USD against the close on the snapshot date: `(close - price_floor) * supply`. Can be negative.
"""
price_floor: Dollars
supply: Bitcoin
realized_cap: Dollars
unrealized_pnl: Dollars
class Urpd(TypedDict):
"""
UTXO Realized Price Distribution for a cohort on a specific date.
Supply is grouped by the close price at which each UTXO was last moved.
Each bucket exposes three values derived from the same `(price_floor, supply)`
pairs: supply in BTC, realized cap contribution in USD (`price_floor * supply`),
and unrealized P&L against that date's close in USD
(`(close - price_floor) * supply`, can be negative).
Attributes:
aggregation: Aggregation strategy applied to the buckets.
close: Close price on `date`, in USD. Anchor for `unrealized_pnl`.
total_supply: Sum of `supply` across all buckets, in BTC.
"""
cohort: Cohort
date: Date
aggregation: UrpdAggregation
close: Dollars
total_supply: Bitcoin
buckets: List[UrpdBucket]
class UrpdCohortParam(TypedDict):
"""
Path parameters for per-cohort URPD endpoints.
"""
cohort: Cohort
class UrpdParams(TypedDict):
"""
Path parameters for `/api/urpd/{cohort}/{date}`.
"""
cohort: Cohort
date: str
class UrpdQuery(TypedDict):
"""
Query parameters for URPD endpoints.
Attributes:
agg: Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias.
"""
agg: UrpdAggregation
class Utxo(TypedDict):
"""
Unspent transaction output
@@ -7790,39 +7835,6 @@ class BrkClient(BrkClientBase):
return self.get_text(path)
return self.get_json(path)
def get_cost_basis_cohorts(self) -> List[str]:
"""Available cost basis cohorts.
List available cohorts for cost basis distribution.
Endpoint: `GET /api/series/cost-basis`"""
return self.get_json('/api/series/cost-basis')
def get_cost_basis_dates(self, cohort: Cohort) -> List[Date]:
"""Available cost basis dates.
List available dates for a cohort's cost basis distribution.
Endpoint: `GET /api/series/cost-basis/{cohort}/dates`"""
return self.get_json(f'/api/series/cost-basis/{cohort}/dates')
def get_cost_basis(self, cohort: Cohort, date: str, bucket: Optional[CostBasisBucket] = None, value: Optional[CostBasisValue] = None) -> dict:
"""Cost basis distribution.
Get the cost basis distribution for a cohort on a specific date.
Query params:
- `bucket`: raw (default), lin200, lin500, lin1000, log10, log50, log100
- `value`: supply (default, in BTC), realized (USD), unrealized (USD)
Endpoint: `GET /api/series/cost-basis/{cohort}/{date}`"""
params = []
if bucket is not None: params.append(f'bucket={bucket}')
if value is not None: params.append(f'value={value}')
query = '&'.join(params)
path = f'/api/series/cost-basis/{cohort}/{date}{"?" + query if query else ""}'
return self.get_json(path)
def get_series_count(self) -> List[SeriesCount]:
"""Series count.
@@ -8035,6 +8047,50 @@ class BrkClient(BrkClientBase):
Endpoint: `GET /api/tx/{txid}/status`"""
return self.get_json(f'/api/tx/{txid}/status')
def list_urpd_cohorts(self) -> List[Cohort]:
"""Available URPD cohorts.
Cohorts for which URPD data is available. Returns names like `all`, `sth`, `lth`, `utxos_under_1h_old`.
Endpoint: `GET /api/urpd`"""
return self.get_json('/api/urpd')
def get_urpd(self, cohort: Cohort, agg: Optional[UrpdAggregation] = None) -> Urpd:
"""Latest URPD.
URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served.
See the URPD tag description for the response shape and `agg` options.
Endpoint: `GET /api/urpd/{cohort}`"""
params = []
if agg is not None: params.append(f'agg={agg}')
query = '&'.join(params)
path = f'/api/urpd/{cohort}{"?" + query if query else ""}'
return self.get_json(path)
def list_urpd_dates(self, cohort: Cohort) -> List[Date]:
"""Available URPD dates.
Dates for which a URPD snapshot is available for the cohort. One entry per UTC day, sorted ascending.
Endpoint: `GET /api/urpd/{cohort}/dates`"""
return self.get_json(f'/api/urpd/{cohort}/dates')
def get_urpd_at(self, cohort: Cohort, date: str, agg: Optional[UrpdAggregation] = None) -> Urpd:
"""URPD at date.
URPD for a (cohort, date) pair. Returns `{ cohort, date, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`.
See the URPD tag description for unit conventions and `agg` options.
Endpoint: `GET /api/urpd/{cohort}/{date}`"""
params = []
if agg is not None: params.append(f'agg={agg}')
query = '&'.join(params)
path = f'/api/urpd/{cohort}/{date}{"?" + query if query else ""}'
return self.get_json(path)
def get_block_v1(self, hash: BlockHash) -> BlockInfoV1:
"""Block (v1).
+1 -1
View File
@@ -58,7 +58,7 @@ export function init() {
title: "Price",
series: spot.sats,
ohlcSeries: ohlc.sats,
colors: /** @type {const} */ ([colors.bi.p1[1], colors.bi.p1[0]]),
colors: /** @type {const} */ ([colors.default, colors.background]),
}),
...(optionTop.get(Unit.sats) ?? []),
]);
+8 -5
View File
@@ -840,13 +840,13 @@ export function createChart({ parent, brk, fitContent }) {
defaultActive,
options,
}) {
const upColor = customColors?.[0] ?? colors.bi.p1[0];
const downColor = customColors?.[1] ?? colors.bi.p1[1];
const upColor = customColors?.[0] ?? colors.background;
const downColor = customColors?.[1] ?? colors.default;
const candlestickISeries = /** @type {CandlestickISeries} */ (
ichart.addSeries(
/** @type {SeriesDefinition<'Candlestick'>} */ (CandlestickSeries),
{ visible: false, borderVisible: false, ...options },
{ visible: false, borderVisible: true, ...options },
paneIndex,
)
);
@@ -889,10 +889,13 @@ export function createChart({ parent, brk, fitContent }) {
candlestickISeries.applyOptions({
visible: active && !showLine,
lastValueVisible: highlighted,
priceLineColor: colors.default.highlight(highlighted),
upColor: upColor.highlight(highlighted),
downColor: downColor.highlight(highlighted),
wickUpColor: upColor.highlight(highlighted),
wickDownColor: downColor.highlight(highlighted),
wickUpColor: colors.default.highlight(highlighted),
wickDownColor: colors.default.highlight(highlighted),
borderUpColor: colors.default.highlight(highlighted),
borderDownColor: colors.default.highlight(highlighted),
});
lineISeries.applyOptions({
visible: active && showLine,
+1
View File
@@ -125,6 +125,7 @@ function seq(keys) {
export const colors = {
transparent: createColor(() => "transparent"),
default: createColor(() => getLightDarkValue("--color")),
background: createColor(() => getLightDarkValue("--background-color")),
gray: createColor(() => getColor("gray")),
border: createColor(() => getLightDarkValue("--border-color")),
offBorder: createColor(() => getLightDarkValue("--off-border-color")),