mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-13 16:33:30 -07:00
global: cost basis -> urpd
This commit is contained in:
Generated
+1
@@ -694,6 +694,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"jiff",
|
||||
"quick_cache",
|
||||
"rustc-hash",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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(¶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::<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,
|
||||
¶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::<CostBasisFormatted>()
|
||||
.not_modified()
|
||||
.not_found()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(¶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::<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(
|
||||
¶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::<CostBasisFormatted>()
|
||||
.not_modified()
|
||||
.not_found()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
)
|
||||
.add_cost_basis_legacy_routes()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¶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::<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(¶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::<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(¶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::<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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
*
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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) ?? []),
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")),
|
||||
|
||||
Reference in New Issue
Block a user