diff --git a/Cargo.lock b/Cargo.lock index 48d5e8e98..3366925ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -694,6 +694,7 @@ dependencies = [ "flate2", "jiff", "quick_cache", + "rustc-hash", "schemars", "serde", "serde_json", diff --git a/crates/brk_client/src/lib.rs b/crates/brk_client/src/lib.rs index 98981f19b..ee7411443 100644 --- a/crates/brk_client/src/lib.rs +++ b/crates/brk_client/src/lib.rs @@ -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> { - 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> { - 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, value: Option) -> Result { - 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> { + 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) -> Result { + 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> { + 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) -> Result { + 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. diff --git a/crates/brk_computer/src/distribution/cohorts/utxo/percentiles.rs b/crates/brk_computer/src/distribution/cohorts/utxo/percentiles.rs index f9d447b09..49749533d 100644 --- a/crates/brk_computer/src/distribution/cohorts/utxo/percentiles.rs +++ b/crates/brk_computer/src/distribution/cohorts/utxo/percentiles.rs @@ -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(()) } diff --git a/crates/brk_computer/src/distribution/state/cost_basis/data.rs b/crates/brk_computer/src/distribution/state/cost_basis/data.rs index 4efd0b3a4..cfb04e87e 100644 --- a/crates/brk_computer/src/distribution/state/cost_basis/data.rs +++ b/crates/brk_computer/src/distribution/state/cost_basis/data.rs @@ -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, ) -> Result> { - 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 { raw: CostBasisRaw, - map: Option, + map: Option, pending: FxHashMap, cache: Option>, rounding_digits: Option, @@ -367,7 +362,7 @@ impl CostBasisOps for CostBasisData { "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 CostBasisOps for CostBasisData { 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; diff --git a/crates/brk_query/src/impl/cost_basis.rs b/crates/brk_query/src/impl/cost_basis.rs deleted file mode 100644 index a05ab0f12..000000000 --- a/crates/brk_query/src/impl/cost_basis.rs +++ /dev/null @@ -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> { - let states_path = &self.computer().distribution.states_path; - - let mut cohorts: Vec = 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 { - 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 { - 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> { - let dir = self.cost_basis_dir(cohort)?; - - let mut dates: Vec = 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 { - 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)) - } -} diff --git a/crates/brk_query/src/impl/mod.rs b/crates/brk_query/src/impl/mod.rs index d836e2008..ad085a7bd 100644 --- a/crates/brk_query/src/impl/mod.rs +++ b/crates/brk_query/src/impl/mod.rs @@ -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; diff --git a/crates/brk_query/src/impl/urpd.rs b/crates/brk_query/src/impl/urpd.rs new file mode 100644 index 000000000..32c3625de --- /dev/null +++ b/crates/brk_query/src/impl/urpd.rs @@ -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> { + let states_path = &self.computer().distribution.states_path; + + let mut cohorts: Vec = 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 { + 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::>() + .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> { + let dir = self.urpd_dir(cohort)?; + + let mut dates: Vec = 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 { + 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 { + 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 { + 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) + } +} diff --git a/crates/brk_server/Cargo.toml b/crates/brk_server/Cargo.toml index d3ec21500..4d7773554 100644 --- a/crates/brk_server/Cargo.toml +++ b/crates/brk_server/Cargo.toml @@ -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 } diff --git a/crates/brk_server/src/api/mod.rs b/crates/brk_server/src/api/mod.rs index 8f9637c18..e836220de 100644 --- a/crates/brk_server/src/api/mod.rs +++ b/crates/brk_server/src/api/mod.rs @@ -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 { 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"))) diff --git a/crates/brk_server/src/api/openapi/mod.rs b/crates/brk_server/src/api/openapi/mod.rs index df0adc50e..db8308c65 100644 --- a/crates/brk_server/src/api/openapi/mod.rs +++ b/crates/brk_server/src/api/openapi/mod.rs @@ -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() }, diff --git a/crates/brk_server/src/api/series/cost_basis.rs b/crates/brk_server/src/api/series/cost_basis.rs new file mode 100644 index 000000000..2424ac7dc --- /dev/null +++ b/crates/brk_server/src/api/series/cost_basis.rs @@ -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; + +fn cost_basis_formatted( + q: &Query, + cohort: &Cohort, + date: Date, + agg: UrpdAggregation, + value: CostBasisValue, +) -> Result { + 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 = + 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 { + 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| { + 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::>() + .not_modified() + .server_error() + }, + ), + ) + .api_route( + "/api/series/cost-basis/{cohort}/dates", + get_with( + async |uri: Uri, + headers: HeaderMap, + Path(params): Path, + State(state): State| { + state + .cached_json(&headers, CacheStrategy::Tip, &uri, move |q| { + q.urpd_dates(¶ms.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::>() + .not_modified() + .not_found() + .server_error() + }, + ), + ) + .api_route( + "/api/series/cost-basis/{cohort}/{date}", + get_with( + async |uri: Uri, + headers: HeaderMap, + Path(params): Path, + AxumQuery(query): AxumQuery, + State(state): State| { + let strategy = state.date_cache(Version::ONE, params.date); + state + .cached_json(&headers, strategy, &uri, move |q| { + cost_basis_formatted( + q, + ¶ms.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::() + .not_modified() + .not_found() + .server_error() + }, + ), + ) + } +} diff --git a/crates/brk_server/src/api/series/mod.rs b/crates/brk_server/src/api/series/mod.rs index f8d705f5b..eba3210dc 100644 --- a/crates/brk_server/src/api/series/mod.rs +++ b/crates/brk_server/src/api/series/mod.rs @@ -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 { .not_modified(), ), ) - // Cost basis distribution endpoints - .api_route( - "/api/series/cost-basis", - get_with( - async |uri: Uri, headers: HeaderMap, State(state): State| { - 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::>() - .not_modified() - .server_error() - }, - ), - ) - .api_route( - "/api/series/cost-basis/{cohort}/dates", - get_with( - async |uri: Uri, - headers: HeaderMap, - Path(params): Path, - State(state): State| { - state - .cached_json(&headers, CacheStrategy::Tip, &uri, move |q| { - q.cost_basis_dates(¶ms.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::>() - .not_modified() - .not_found() - .server_error() - }, - ), - ) - .api_route( - "/api/series/cost-basis/{cohort}/{date}", - get_with( - async |uri: Uri, - headers: HeaderMap, - Path(params): Path, - Query(query): Query, - State(state): State| { - let strategy = state.date_cache(Version::ONE, params.date); - state - .cached_json(&headers, strategy, &uri, move |q| { - q.cost_basis_formatted( - ¶ms.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::() - .not_modified() - .not_found() - .server_error() - }, - ), - ) + .add_cost_basis_legacy_routes() } } diff --git a/crates/brk_server/src/api/urpd/mod.rs b/crates/brk_server/src/api/urpd/mod.rs new file mode 100644 index 000000000..4f0de1790 --- /dev/null +++ b/crates/brk_server/src/api/urpd/mod.rs @@ -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 { + fn add_urpd_routes(self) -> Self { + self.api_route( + "/api/urpd", + get_with( + async |uri: Uri, headers: HeaderMap, State(state): State| { + 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::>() + .not_modified() + .server_error() + }, + ), + ) + .api_route( + "/api/urpd/{cohort}/dates", + get_with( + async |uri: Uri, + headers: HeaderMap, + Path(params): Path, + State(state): State| { + state + .cached_json(&headers, CacheStrategy::Tip, &uri, move |q| { + q.urpd_dates(¶ms.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::>() + .not_modified() + .not_found() + .server_error() + }, + ), + ) + .api_route( + "/api/urpd/{cohort}", + get_with( + async |uri: Uri, + headers: HeaderMap, + Path(params): Path, + Query(query): Query, + State(state): State| { + state + .cached_json(&headers, CacheStrategy::Tip, &uri, move |q| { + q.urpd_latest(¶ms.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::() + .not_modified() + .not_found() + .server_error() + }, + ), + ) + .api_route( + "/api/urpd/{cohort}/{date}", + get_with( + async |uri: Uri, + headers: HeaderMap, + Path(params): Path, + Query(query): Query, + State(state): State| { + let strategy = state.date_cache(Version::ONE, params.date); + state + .cached_json(&headers, strategy, &uri, move |q| { + q.urpd_at(¶ms.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::() + .not_modified() + .not_found() + .server_error() + }, + ), + ) + } +} diff --git a/crates/brk_server/src/extended/transform_operation.rs b/crates/brk_server/src/extended/transform_operation.rs index 6053f3912..01c4536df 100644 --- a/crates/brk_server/src/extended/transform_operation.rs +++ b/crates/brk_server/src/extended/transform_operation.rs @@ -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") } diff --git a/crates/brk_server/src/params/cost_basis_params.rs b/crates/brk_server/src/params/cost_basis_params.rs deleted file mode 100644 index 544bffdc3..000000000 --- a/crates/brk_server/src/params/cost_basis_params.rs +++ /dev/null @@ -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, -} diff --git a/crates/brk_server/src/params/mod.rs b/crates/brk_server/src/params/mod.rs index ae53e960c..f38cb67fd 100644 --- a/crates/brk_server/src/params/mod.rs +++ b/crates/brk_server/src/params/mod.rs @@ -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::*; diff --git a/crates/brk_server/src/params/urpd_params.rs b/crates/brk_server/src/params/urpd_params.rs new file mode 100644 index 000000000..c216ce936 --- /dev/null +++ b/crates/brk_server/src/params/urpd_params.rs @@ -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, +} diff --git a/crates/brk_types/src/cohort.rs b/crates/brk_types/src/cohort.rs index ffd138659..db6d403c2 100644 --- a/crates/brk_types/src/cohort.rs +++ b/crates/brk_types/src/cohort.rs @@ -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); diff --git a/crates/brk_types/src/cost_basis_value.rs b/crates/brk_types/src/cost_basis_value.rs deleted file mode 100644 index b9fa8522d..000000000 --- a/crates/brk_types/src/cost_basis_value.rs +++ /dev/null @@ -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, -} diff --git a/crates/brk_types/src/lib.rs b/crates/brk_types/src/lib.rs index 7d022ce42..2987866f8 100644 --- a/crates/brk_types/src/lib.rs +++ b/crates/brk_types/src/lib.rs @@ -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::*; diff --git a/crates/brk_types/src/sats.rs b/crates/brk_types/src/sats.rs index b13d51ae0..fd498e3a1 100644 --- a/crates/brk_types/src/sats.rs +++ b/crates/brk_types/src/sats.rs @@ -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 } diff --git a/crates/brk_types/src/urpd.rs b/crates/brk_types/src/urpd.rs new file mode 100644 index 000000000..d0d5aa334 --- /dev/null +++ b/crates/brk_types/src/urpd.rs @@ -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, +} + +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 = + 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 = 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, + } + } +} diff --git a/crates/brk_types/src/urpd_aggregation.rs b/crates/brk_types/src/urpd_aggregation.rs index 421764083..eff98f56d 100644 --- a/crates/brk_types/src/urpd_aggregation.rs +++ b/crates/brk_types/src/urpd_aggregation.rs @@ -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 { match self { diff --git a/crates/brk_types/src/urpd_bucket.rs b/crates/brk_types/src/urpd_bucket.rs new file mode 100644 index 000000000..35f07fdfd --- /dev/null +++ b/crates/brk_types/src/urpd_bucket.rs @@ -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, +} diff --git a/crates/brk_types/src/urpd_raw.rs b/crates/brk_types/src/urpd_raw.rs index a9d061f55..e2a9de866 100644 --- a/crates/brk_types/src/urpd_raw.rs +++ b/crates/brk_types/src/urpd_raw.rs @@ -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, } -/// Formatted cost basis output. -/// Key: price floor in USD (dollars). -/// Value: BTC (for supply) or USD (for realized/unrealized). -pub type CostBasisFormatted = BTreeMap; - -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 = - 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() - } } diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index 41dabd372..701627a86 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -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} - */ - 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} - */ - 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} - */ - 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} + */ + 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} + */ + 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} + */ + 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} + */ + 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) * diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index 8127417f6..f94c9e5d7 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -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). diff --git a/website/scripts/panes/chart.js b/website/scripts/panes/chart.js index 27febb323..3236589f2 100644 --- a/website/scripts/panes/chart.js +++ b/website/scripts/panes/chart.js @@ -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) ?? []), ]); diff --git a/website/scripts/utils/chart/index.js b/website/scripts/utils/chart/index.js index 00b564f0c..7943c09f5 100644 --- a/website/scripts/utils/chart/index.js +++ b/website/scripts/utils/chart/index.js @@ -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, diff --git a/website/scripts/utils/colors.js b/website/scripts/utils/colors.js index ca3017d90..05e15e42b 100644 --- a/website/scripts/utils/colors.js +++ b/website/scripts/utils/colors.js @@ -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")),