computer: distribution: feat cost basis distribution

This commit is contained in:
nym21
2026-02-05 23:10:02 +01:00
parent bbba8f4373
commit afe4123a17
30 changed files with 934 additions and 142 deletions
+1
View File
@@ -24,6 +24,7 @@ _*
/oracle*
/playground
/*.txt
/*.csv
# Logs
*.log*
Generated
+3 -1
View File
@@ -464,7 +464,6 @@ dependencies = [
"brk_types",
"color-eyre",
"derive_more",
"pco",
"rayon",
"rustc-hash",
"schemars",
@@ -483,6 +482,7 @@ dependencies = [
"fjall",
"jiff",
"minreq",
"pco",
"serde_json",
"thiserror",
"tokio",
@@ -686,7 +686,9 @@ dependencies = [
"indexmap",
"itoa",
"jiff",
"pco",
"rapidhash",
"rustc-hash",
"ryu",
"schemars",
"serde",
+1
View File
@@ -71,6 +71,7 @@ jiff = { version = "0.2.18", features = ["perf-inline", "tz-system"], default-fe
minreq = { version = "2.14.1", features = ["https", "json-using-serde"] }
owo-colors = "4.2.3"
parking_lot = "0.12.5"
pco = "1.0.0"
rayon = "1.11.0"
rustc-hash = "2.1.1"
schemars = { version = "1.2.1", features = ["indexmap2"] }
@@ -10,7 +10,7 @@ pub fn js_type_to_rust(js_type: &str) -> String {
"integer" => "i64".to_string(),
"number" => "f64".to_string(),
"boolean" => "bool".to_string(),
"*" => "serde_json::Value".to_string(),
"*" | "Object" => "serde_json::Value".to_string(),
other => other.to_string(),
}
}
+37 -1
View File
@@ -6051,7 +6051,7 @@ pub struct BrkClient {
impl BrkClient {
/// Client version.
pub const VERSION: &'static str = "v0.1.3";
pub const VERSION: &'static str = "v0.1.5";
/// Create a new client with the given base URL.
pub fn new(base_url: impl Into<String>) -> Self {
@@ -6346,6 +6346,42 @@ impl BrkClient {
}
}
/// Available cost basis cohorts
///
/// List available cohorts for cost basis distribution.
///
/// Endpoint: `GET /api/metrics/cost-basis`
pub fn get_cost_basis_cohorts(&self) -> Result<Vec<String>> {
self.base.get_json(&format!("/api/metrics/cost-basis"))
}
/// Available cost basis dates
///
/// List available dates for a cohort's cost basis distribution.
///
/// Endpoint: `GET /api/metrics/cost-basis/{cohort}/dates`
pub fn get_cost_basis_dates(&self, cohort: Cohort) -> Result<Vec<Date>> {
self.base.get_json(&format!("/api/metrics/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/metrics/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/metrics/cost-basis/{cohort}/{date}{}", query_str);
self.base.get_json(&path)
}
/// Metric count
///
/// Returns the number of metrics available per index type.
-1
View File
@@ -22,7 +22,6 @@ brk_traversable = { workspace = true }
brk_types = { workspace = true }
derive_more = { workspace = true }
tracing = { workspace = true }
pco = "1.0.0"
rayon = { workspace = true }
rustc-hash = { workspace = true }
schemars = { workspace = true }
@@ -1,13 +1,15 @@
use std::{cmp::Reverse, collections::BinaryHeap, path::Path};
use std::{cmp::Reverse, collections::BinaryHeap, fs, path::Path};
use brk_cohort::{
AGE_BOUNDARIES, ByAgeRange, ByAmountRange, ByEpoch, ByGreatEqualAmount, ByLowerThanAmount,
ByMaxAge, ByMinAge, BySpendableType, ByTerm, ByYear, Filter, Filtered, StateLevel, UTXOGroups,
ByMaxAge, ByMinAge, BySpendableType, ByTerm, ByYear, Filter, Filtered, StateLevel, TERM_NAMES,
Term, UTXOGroups,
};
use brk_error::Result;
use brk_traversable::Traversable;
use brk_types::{
CentsUnsigned, DateIndex, Dollars, Height, ONE_HOUR_IN_SEC, Sats, StoredF32, Timestamp, Version,
CentsUnsigned, CentsUnsignedCompact, CostBasisDistribution, Date, DateIndex, Dollars, Height,
ONE_HOUR_IN_SEC, Sats, StoredF32, Timestamp, Version,
};
use derive_more::{Deref, DerefMut};
use rayon::prelude::*;
@@ -25,6 +27,9 @@ use super::{super::traits::CohortVecs, vecs::UTXOCohortVecs};
const VERSION: Version = Version::new(0);
/// Significant digits for cost basis prices (after rounding to dollars).
const COST_BASIS_PRICE_DIGITS: i32 = 5;
/// All UTXO cohorts organized by filter type.
#[derive(Clone, Deref, DerefMut, Traversable)]
pub struct UTXOCohorts(pub(crate) UTXOGroups<UTXOCohortVecs>);
@@ -358,10 +363,12 @@ impl UTXOCohorts {
/// Computes on-demand by merging age_range cohorts' cost_basis_data data.
/// This avoids maintaining redundant aggregate cost_basis_data maps.
/// Computes both sat-weighted (percentiles) and USD-weighted (invested_capital) percentiles.
/// Also writes daily cost basis snapshots to states_path.
pub fn truncate_push_aggregate_percentiles(
&mut self,
dateindex: DateIndex,
spot: Dollars,
states_path: &Path,
) -> Result<()> {
// Collect (filter, entries, total_sats, total_usd) from age_range cohorts.
// Keep data in CentsUnsigned to avoid float conversions until output.
@@ -432,6 +439,7 @@ impl UTXOCohorts {
}
// K-way merge using min-heap: O(n log k) where k = number of cohorts
// Collects merged price->sats map while computing percentiles
let mut heap: BinaryHeap<Reverse<(CentsUnsigned, usize, usize)>> = BinaryHeap::new();
// Initialize heap with first entry from each cohort
@@ -457,6 +465,42 @@ impl UTXOCohorts {
let mut sats_at_price: u64 = 0;
let mut usd_at_price: u128 = 0;
// Collect merged entries during the merge (already in sorted order)
// Pre-allocate with max possible unique prices (actual count likely lower due to dedup)
let max_unique_prices = relevant.iter().map(|e| e.len()).max().unwrap_or(0);
let mut merged: Vec<(CentsUnsignedCompact, Sats)> = Vec::with_capacity(max_unique_prices);
// Finalize a price point: compute percentiles and accumulate for merged vec
let mut finalize_price = |price: CentsUnsigned, sats: u64, usd: u128| {
// Percentile computation uses exact price for accuracy
cumsum_sats += sats;
cumsum_usd += usd;
if sat_idx < PERCENTILES_LEN || usd_idx < PERCENTILES_LEN {
let dollars = price.to_dollars();
while sat_idx < PERCENTILES_LEN && cumsum_sats >= sat_targets[sat_idx] {
sat_result[sat_idx] = dollars;
sat_idx += 1;
}
while usd_idx < PERCENTILES_LEN && cumsum_usd >= usd_targets[usd_idx] {
usd_result[usd_idx] = dollars;
usd_idx += 1;
}
}
// Round to nearest dollar with N significant digits for storage
let rounded: CentsUnsignedCompact = price.round_to_dollar(COST_BASIS_PRICE_DIGITS).into();
// Merge entries with same rounded price using last_mut
if let Some((last_price, last_sats)) = merged.last_mut()
&& *last_price == rounded
{
*last_sats += Sats::from(sats);
} else {
merged.push((rounded, Sats::from(sats)));
}
};
while let Some(Reverse((price, cohort_idx, entry_idx))) = heap.pop() {
let entries = relevant[cohort_idx];
let (_, amount) = entries[entry_idx];
@@ -467,27 +511,7 @@ impl UTXOCohorts {
if let Some(prev_price) = current_price
&& prev_price != price
{
cumsum_sats += sats_at_price;
cumsum_usd += usd_at_price;
// Only convert to dollars if we still need percentiles
if sat_idx < PERCENTILES_LEN || usd_idx < PERCENTILES_LEN {
let prev_dollars = prev_price.to_dollars();
while sat_idx < PERCENTILES_LEN && cumsum_sats >= sat_targets[sat_idx] {
sat_result[sat_idx] = prev_dollars;
sat_idx += 1;
}
while usd_idx < PERCENTILES_LEN && cumsum_usd >= usd_targets[usd_idx] {
usd_result[usd_idx] = prev_dollars;
usd_idx += 1;
}
// Early exit if all percentiles found
if sat_idx >= PERCENTILES_LEN && usd_idx >= PERCENTILES_LEN {
break;
}
}
finalize_price(prev_price, sats_at_price, usd_at_price);
sats_at_price = 0;
usd_at_price = 0;
}
@@ -503,22 +527,9 @@ impl UTXOCohorts {
}
}
// Finalize last price (skip if we already found all percentiles via early exit)
if (sat_idx < PERCENTILES_LEN || usd_idx < PERCENTILES_LEN)
&& let Some(price) = current_price
{
cumsum_sats += sats_at_price;
cumsum_usd += usd_at_price;
let price_dollars = price.to_dollars();
while sat_idx < PERCENTILES_LEN && cumsum_sats >= sat_targets[sat_idx] {
sat_result[sat_idx] = price_dollars;
sat_idx += 1;
}
while usd_idx < PERCENTILES_LEN && cumsum_usd >= usd_targets[usd_idx] {
usd_result[usd_idx] = price_dollars;
usd_idx += 1;
}
// Finalize last price
if let Some(price) = current_price {
finalize_price(price, sats_at_price, usd_at_price);
}
// Push both sat-weighted and USD-weighted results
@@ -539,6 +550,20 @@ impl UTXOCohorts {
spot_pct.dateindex.truncate_push(dateindex, rank)?;
}
// Write daily cost basis snapshot
let cohort_name = match &filter {
Filter::All => "all",
Filter::Term(Term::Sth) => TERM_NAMES.short.id,
Filter::Term(Term::Lth) => TERM_NAMES.long.id,
_ => return Ok(()),
};
let date = Date::from(dateindex);
let dir = states_path.join(format!("utxo_{cohort_name}_cost_basis/by_date"));
fs::create_dir_all(&dir)?;
let path = dir.join(date.to_string());
fs::write(path, CostBasisDistribution::serialize_iter(merged.into_iter())?)?;
Ok(())
})
}
@@ -399,7 +399,7 @@ pub fn process_blocks(
.map(|c| c.to_dollars())
.unwrap_or(Dollars::NAN);
vecs.utxo_cohorts
.truncate_push_aggregate_percentiles(dateindex, spot)?;
.truncate_push_aggregate_percentiles(dateindex, spot, &vecs.states_path)?;
// Compute unrealized peak regret by age range (once per day)
// Aggregate cohorts (all, term, etc.) get values via compute_from_stateful
@@ -6,6 +6,9 @@ use vecdb::unlikely;
use super::{super::cost_basis::RealizedState, base::CohortState};
/// Significant digits for address cost basis prices (after rounding to dollars).
const COST_BASIS_PRICE_DIGITS: i32 = 4;
#[derive(Clone)]
pub struct AddressCohortState {
pub addr_count: u64,
@@ -16,7 +19,8 @@ impl AddressCohortState {
pub fn new(path: &Path, name: &str, compute_dollars: bool) -> Self {
Self {
addr_count: 0,
inner: CohortState::new(path, name, compute_dollars),
inner: CohortState::new(path, name, compute_dollars)
.with_price_rounding(COST_BASIS_PRICE_DIGITS),
}
}
@@ -4,7 +4,7 @@ use brk_error::Result;
use brk_types::{Age, CentsSats, CentsUnsigned, CostBasisSnapshot, Height, Sats, SupplyState};
use super::super::cost_basis::{
CachedUnrealizedState, Percentiles, CostBasisData, RealizedState, UnrealizedState,
CachedUnrealizedState, CostBasisData, Percentiles, RealizedState, UnrealizedState,
};
#[derive(Clone)]
@@ -16,6 +16,8 @@ pub struct CohortState {
pub satdays_destroyed: Sats,
cost_basis_data: Option<CostBasisData>,
cached_unrealized: Option<CachedUnrealizedState>,
/// If set, prices are rounded to nearest dollar with N significant digits.
price_rounding_digits: Option<i32>,
}
impl CohortState {
@@ -28,6 +30,22 @@ impl CohortState {
satdays_destroyed: Sats::ZERO,
cost_basis_data: compute_dollars.then_some(CostBasisData::create(path, name)),
cached_unrealized: None,
price_rounding_digits: None,
}
}
/// Enable price rounding for cost basis data.
pub fn with_price_rounding(mut self, digits: i32) -> Self {
self.price_rounding_digits = Some(digits);
self
}
/// Round price if rounding is enabled.
#[inline]
fn round_price(&self, price: CentsUnsigned) -> CentsUnsigned {
match self.price_rounding_digits {
Some(digits) => price.round_to_dollar(digits),
None => price,
}
}
@@ -92,19 +110,21 @@ impl CohortState {
pub fn increment_snapshot(&mut self, s: &CostBasisSnapshot) {
self.supply += &s.supply_state;
if s.supply_state.value > Sats::ZERO
&& let Some(realized) = self.realized.as_mut()
{
realized.increment_snapshot(s.price_sats, s.investor_cap);
if s.supply_state.value > Sats::ZERO && self.realized.is_some() {
let rounded_price = self.round_price(s.realized_price);
self.realized
.as_mut()
.unwrap()
.increment_snapshot(s.price_sats, s.investor_cap);
self.cost_basis_data.as_mut().unwrap().increment(
s.realized_price,
rounded_price,
s.supply_state.value,
s.price_sats,
s.investor_cap,
);
if let Some(cache) = self.cached_unrealized.as_mut() {
cache.on_receive(s.realized_price, s.supply_state.value);
cache.on_receive(rounded_price, s.supply_state.value);
}
}
}
@@ -119,19 +139,21 @@ impl CohortState {
pub fn decrement_snapshot(&mut self, s: &CostBasisSnapshot) {
self.supply -= &s.supply_state;
if s.supply_state.value > Sats::ZERO
&& let Some(realized) = self.realized.as_mut()
{
realized.decrement_snapshot(s.price_sats, s.investor_cap);
if s.supply_state.value > Sats::ZERO && self.realized.is_some() {
let rounded_price = self.round_price(s.realized_price);
self.realized
.as_mut()
.unwrap()
.decrement_snapshot(s.price_sats, s.investor_cap);
self.cost_basis_data.as_mut().unwrap().decrement(
s.realized_price,
rounded_price,
s.supply_state.value,
s.price_sats,
s.investor_cap,
);
if let Some(cache) = self.cached_unrealized.as_mut() {
cache.on_send(s.realized_price, s.supply_state.value);
cache.on_send(rounded_price, s.supply_state.value);
}
}
}
@@ -173,34 +195,36 @@ impl CohortState {
) {
self.supply += supply;
if supply.value > Sats::ZERO
&& let Some(realized) = self.realized.as_mut()
{
realized.receive(price, supply.value);
if supply.value > Sats::ZERO && self.realized.is_some() {
// Pre-compute rounded prices before mutable borrows
let current_rounded = self.round_price(current.realized_price);
let prev_rounded = self.round_price(prev.realized_price);
self.realized.as_mut().unwrap().receive(price, supply.value);
if current.supply_state.value.is_not_zero() {
self.cost_basis_data.as_mut().unwrap().increment(
current.realized_price,
current_rounded,
current.supply_state.value,
current.price_sats,
current.investor_cap,
);
if let Some(cache) = self.cached_unrealized.as_mut() {
cache.on_receive(current.realized_price, current.supply_state.value);
cache.on_receive(current_rounded, current.supply_state.value);
}
}
if prev.supply_state.value.is_not_zero() {
self.cost_basis_data.as_mut().unwrap().decrement(
prev.realized_price,
prev_rounded,
prev.supply_state.value,
prev.price_sats,
prev.investor_cap,
);
if let Some(cache) = self.cached_unrealized.as_mut() {
cache.on_send(prev.realized_price, prev.supply_state.value);
cache.on_send(prev_rounded, prev.supply_state.value);
}
}
}
@@ -275,7 +299,7 @@ impl CohortState {
self.satblocks_destroyed += age.satblocks_destroyed(supply.value);
self.satdays_destroyed += age.satdays_destroyed(supply.value);
if let Some(realized) = self.realized.as_mut() {
if self.realized.is_some() {
let sats = supply.value;
// Compute once for realized.send using typed values
@@ -284,31 +308,38 @@ impl CohortState {
let ath_ps = CentsSats::from_price_sats(ath, sats);
let prev_investor_cap = prev_ps.to_investor_cap(prev_price);
realized.send(sats, current_ps, prev_ps, ath_ps, prev_investor_cap);
// Pre-compute rounded prices before mutable borrows
let current_rounded = self.round_price(current.realized_price);
let prev_rounded = self.round_price(prev.realized_price);
self.realized
.as_mut()
.unwrap()
.send(sats, current_ps, prev_ps, ath_ps, prev_investor_cap);
if current.supply_state.value.is_not_zero() {
self.cost_basis_data.as_mut().unwrap().increment(
current.realized_price,
current_rounded,
current.supply_state.value,
current.price_sats,
current.investor_cap,
);
if let Some(cache) = self.cached_unrealized.as_mut() {
cache.on_receive(current.realized_price, current.supply_state.value);
cache.on_receive(current_rounded, current.supply_state.value);
}
}
if prev.supply_state.value.is_not_zero() {
self.cost_basis_data.as_mut().unwrap().decrement(
prev.realized_price,
prev_rounded,
prev.supply_state.value,
prev.price_sats,
prev.investor_cap,
);
if let Some(cache) = self.cached_unrealized.as_mut() {
cache.on_send(prev.realized_price, prev.supply_state.value);
cache.on_send(prev_rounded, prev.supply_state.value);
}
}
}
@@ -6,10 +6,9 @@ use std::{
};
use brk_error::{Error, Result};
use brk_types::{CentsSats, CentsSquaredSats, CentsUnsigned, CentsUnsignedCompact, Height, Sats};
use pco::{
ChunkConfig,
standalone::{simple_compress, simple_decompress},
use brk_types::{
CentsSats, CentsSquaredSats, CentsUnsigned, CentsUnsignedCompact, CostBasisDistribution,
Height, Sats,
};
use rustc_hash::FxHashMap;
use vecdb::Bytes;
@@ -73,7 +72,7 @@ impl CostBasisData {
pub fn iter(&self) -> impl Iterator<Item = (CentsUnsignedCompact, &Sats)> {
self.assert_pending_empty();
self.state.u().map.iter().map(|(&k, v)| (k, v))
self.state.u().base.map.iter().map(|(&k, v)| (k, v))
}
pub fn range(
@@ -81,21 +80,31 @@ impl CostBasisData {
bounds: (Bound<CentsUnsignedCompact>, Bound<CentsUnsignedCompact>),
) -> impl Iterator<Item = (CentsUnsignedCompact, &Sats)> {
self.assert_pending_empty();
self.state.u().map.range(bounds).map(|(&k, v)| (k, v))
self.state.u().base.map.range(bounds).map(|(&k, v)| (k, v))
}
pub fn is_empty(&self) -> bool {
self.pending.is_empty() && self.state.u().map.is_empty()
self.pending.is_empty() && self.state.u().base.map.is_empty()
}
pub fn first_key_value(&self) -> Option<(CentsUnsignedCompact, &Sats)> {
self.assert_pending_empty();
self.state.u().map.first_key_value().map(|(&k, v)| (k, v))
self.state
.u()
.base
.map
.first_key_value()
.map(|(&k, v)| (k, v))
}
pub fn last_key_value(&self) -> Option<(CentsUnsignedCompact, &Sats)> {
self.assert_pending_empty();
self.state.u().map.last_key_value().map(|(&k, v)| (k, v))
self.state
.u()
.base
.map
.last_key_value()
.map(|(&k, v)| (k, v))
}
/// Get the exact cap_raw value (not recomputed from map).
@@ -142,7 +151,7 @@ impl CostBasisData {
pub fn apply_pending(&mut self) {
for (cents, (inc, dec)) in self.pending.drain() {
let entry = self.state.um().map.entry(cents).or_default();
let entry = self.state.um().base.map.entry(cents).or_default();
*entry += inc;
if *entry < dec {
panic!(
@@ -159,7 +168,7 @@ impl CostBasisData {
}
*entry -= dec;
if *entry == Sats::ZERO {
self.state.um().map.remove(&cents);
self.state.um().base.map.remove(&cents);
}
}
@@ -214,12 +223,20 @@ impl CostBasisData {
pub fn clean(&mut self) -> Result<()> {
let _ = fs::remove_dir_all(&self.pathbuf);
fs::create_dir_all(&self.pathbuf)?;
fs::create_dir_all(self.path_by_height())?;
Ok(())
}
fn path_by_height(&self) -> PathBuf {
self.pathbuf.join("by_height")
}
fn read_dir(&self, keep_only_before: Option<Height>) -> Result<BTreeMap<Height, PathBuf>> {
Ok(fs::read_dir(&self.pathbuf)?
let by_height = self.path_by_height();
if !by_height.exists() {
return Ok(BTreeMap::new());
}
Ok(fs::read_dir(&by_height)?
.filter_map(|entry| {
let path = entry.ok()?.path();
let name = path.file_name()?.to_str()?;
@@ -257,13 +274,13 @@ impl CostBasisData {
}
fn path_state(&self, height: Height) -> PathBuf {
self.pathbuf.join(u32::from(height).to_string())
self.path_by_height().join(height.to_string())
}
}
#[derive(Clone, Default, Debug)]
struct State {
map: BTreeMap<CentsUnsignedCompact, Sats>,
base: CostBasisDistribution,
/// Exact realized cap: Σ(price × sats)
cap_raw: CentsSats,
/// Exact investor cap: Σ(price² × sats)
@@ -271,51 +288,20 @@ struct State {
}
impl State {
fn serialize(&self) -> vecdb::Result<Vec<u8>> {
let keys: Vec<u32> = self.map.keys().map(|k| k.inner()).collect();
let values: Vec<u64> = self.map.values().map(|v| u64::from(*v)).collect();
let config = ChunkConfig::default();
let compressed_keys = simple_compress(&keys, &config)?;
let compressed_values = simple_compress(&values, &config)?;
let mut buffer = Vec::new();
buffer.extend(keys.len().to_bytes());
buffer.extend(compressed_keys.len().to_bytes());
buffer.extend(compressed_values.len().to_bytes());
buffer.extend(compressed_keys);
buffer.extend(compressed_values);
fn serialize(&self) -> Result<Vec<u8>> {
let mut buffer = self.base.serialize()?;
buffer.extend(self.cap_raw.to_bytes());
buffer.extend(self.investor_cap_raw.to_bytes());
Ok(buffer)
}
fn deserialize(data: &[u8]) -> vecdb::Result<Self> {
let entry_count = usize::from_bytes(&data[0..8])?;
let keys_len = usize::from_bytes(&data[8..16])?;
let values_len = usize::from_bytes(&data[16..24])?;
let keys_start = 24;
let values_start = keys_start + keys_len;
let raw_start = values_start + values_len;
let keys: Vec<u32> = simple_decompress(&data[keys_start..values_start])?;
let values: Vec<u64> = simple_decompress(&data[values_start..raw_start])?;
let map: BTreeMap<CentsUnsignedCompact, Sats> = keys
.into_iter()
.zip(values)
.map(|(k, v)| (CentsUnsignedCompact::new(k), Sats::from(v)))
.collect();
assert_eq!(map.len(), entry_count);
let cap_raw = CentsSats::from_bytes(&data[raw_start..raw_start + 16])?;
let investor_cap_raw = CentsSquaredSats::from_bytes(&data[raw_start + 16..raw_start + 32])?;
fn deserialize(data: &[u8]) -> Result<Self> {
let (base, rest) = CostBasisDistribution::deserialize_with_rest(data)?;
let cap_raw = CentsSats::from_bytes(&rest[0..16])?;
let investor_cap_raw = CentsSquaredSats::from_bytes(&rest[16..32])?;
Ok(Self {
map,
base,
cap_raw,
investor_cap_raw,
})
@@ -1,9 +1,9 @@
mod cost_basis_data;
mod data;
mod percentiles;
mod realized;
mod unrealized;
pub use cost_basis_data::*;
pub use data::*;
pub use percentiles::*;
pub use realized::*;
pub use unrealized::*;
@@ -2,7 +2,7 @@ use std::ops::Bound;
use brk_types::{CentsUnsigned, CentsUnsignedCompact, Sats};
use super::cost_basis_data::CostBasisData;
use super::data::CostBasisData;
#[derive(Debug, Default, Clone)]
pub struct UnrealizedState {
@@ -91,9 +91,7 @@ impl CachedStateRaw {
unrealized_profit: CentsUnsigned::new(
(self.unrealized_profit / Sats::ONE_BTC_U128) as u64,
),
unrealized_loss: CentsUnsigned::new(
(self.unrealized_loss / Sats::ONE_BTC_U128) as u64,
),
unrealized_loss: CentsUnsigned::new((self.unrealized_loss / Sats::ONE_BTC_U128) as u64),
invested_capital_in_profit: CentsUnsigned::new(
(self.invested_capital_in_profit / Sats::ONE_BTC_U128) as u64,
),
@@ -118,7 +116,10 @@ impl CachedUnrealizedState {
pub fn compute_fresh(price: CentsUnsigned, cost_basis_data: &CostBasisData) -> Self {
let price: CentsUnsignedCompact = price.into();
let state = Self::compute_raw(price, cost_basis_data);
Self { state, at_price: price }
Self {
state,
at_price: price,
}
}
/// Get the current cached state as output (without price update).
@@ -233,8 +234,7 @@ impl CachedUnrealizedState {
// Non-crossing profit UTXOs: their profit increases by delta
self.state.unrealized_profit += delta * original_supply_in_profit;
// Non-crossing loss UTXOs: their loss decreases by delta
let non_crossing_loss_sats =
self.state.supply_in_loss.as_u128(); // Already excludes crossing
let non_crossing_loss_sats = self.state.supply_in_loss.as_u128(); // Already excludes crossing
self.state.unrealized_loss -= delta * non_crossing_loss_sats;
} else if new_price < old_price {
let delta = (old_price - new_price).as_u128();
@@ -276,8 +276,7 @@ impl CachedUnrealizedState {
// Non-crossing loss UTXOs: their loss increases by delta
self.state.unrealized_loss += delta * original_supply_in_loss;
// Non-crossing profit UTXOs: their profit decreases by delta
let non_crossing_profit_sats =
self.state.supply_in_profit.as_u128(); // Already excludes crossing
let non_crossing_profit_sats = self.state.supply_in_profit.as_u128(); // Already excludes crossing
self.state.unrealized_profit -= delta * non_crossing_profit_sats;
}
+4 -1
View File
@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use brk_error::Result;
use brk_indexer::Indexer;
@@ -37,6 +37,8 @@ const VERSION: Version = Version::new(22);
pub struct Vecs {
#[traversable(skip)]
db: Database,
#[traversable(skip)]
pub states_path: PathBuf,
pub supply_state: BytesVec<Height, SupplyState>,
pub any_address_indexes: AnyAddressIndexesVecs,
@@ -163,6 +165,7 @@ impl Vecs {
emptyaddressindex,
db,
states_path,
};
this.db.retain_regions(
+2
View File
@@ -13,6 +13,7 @@ bitcoincore-rpc = ["dep:bitcoincore-rpc"]
fjall = ["dep:fjall"]
jiff = ["dep:jiff"]
minreq = ["dep:minreq"]
pco = ["dep:pco"]
serde_json = ["dep:serde_json"]
tokio = ["dep:tokio"]
vecdb = ["dep:vecdb"]
@@ -23,6 +24,7 @@ bitcoincore-rpc = { workspace = true, optional = true }
fjall = { workspace = true, optional = true }
jiff = { workspace = true, optional = true }
minreq = { workspace = true, optional = true }
pco = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
thiserror = "2.0"
tokio = { workspace = true, optional = true }
+4
View File
@@ -58,6 +58,10 @@ pub enum Error {
#[error(transparent)]
BitcoinHexToArrayError(#[from] bitcoin::hex::HexToArrayError),
#[cfg(feature = "pco")]
#[error(transparent)]
Pco(#[from] pco::errors::PcoError),
#[cfg(feature = "serde_json")]
#[error(transparent)]
SerdeJSON(#[from] serde_json::Error),
+101
View File
@@ -0,0 +1,101 @@
use std::{fs, path::PathBuf};
use brk_error::{Error, Result};
use brk_types::{
CostBasisBucket, CostBasisDistribution, CostBasisFormatted, CostBasisValue, Date, DateIndex,
};
use vecdb::IterableVec;
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() {
return Err(Error::NotFound(format!("Unknown cohort '{cohort}'")));
}
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 dateindex =
DateIndex::try_from(date).map_err(|e| Error::Parse(e.to_string()))?;
let price = self
.computer()
.price
.as_ref()
.ok_or_else(|| Error::NotFound("Price data not available".to_string()))?;
let spot = *price
.cents
.split
.dateindex
.close
.iter()
.get(dateindex)
.ok_or_else(|| Error::NotFound(format!("No price data for {date}")))?;
Ok(distribution.format(bucket, value, spot))
}
}
+1
View File
@@ -1,5 +1,6 @@
mod address;
mod block;
mod cost_basis;
mod mempool;
mod metrics;
mod metrics_legacy;
+78 -1
View File
@@ -9,7 +9,8 @@ use axum::{
};
use brk_traversable::TreeNode;
use brk_types::{
DataRangeFormat, Index, IndexInfo, LimitParam, Metric, MetricCount, MetricData, MetricParam,
CostBasisCohortParam, CostBasisFormatted, CostBasisParams, CostBasisQuery, DataRangeFormat,
Date, Index, IndexInfo, LimitParam, Metric, MetricCount, MetricData, MetricParam,
MetricSelection, MetricSelectionLegacy, MetricWithIndex, Metrics, PaginatedMetrics, Pagination,
};
@@ -291,5 +292,81 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
.not_modified(),
),
)
// Cost basis distribution endpoints
.api_route(
"/api/metrics/cost-basis",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, |q| q.cost_basis_cohorts())
.await
},
|op| {
op.id("get_cost_basis_cohorts")
.metrics_tag()
.summary("Available cost basis cohorts")
.description("List available cohorts for cost basis distribution.")
.ok_response::<Vec<String>>()
.server_error()
},
),
)
.api_route(
"/api/metrics/cost-basis/{cohort}/dates",
get_with(
async |headers: HeaderMap,
Path(params): Path<CostBasisCohortParam>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Height, move |q| {
q.cost_basis_dates(&params.cohort)
})
.await
},
|op| {
op.id("get_cost_basis_dates")
.metrics_tag()
.summary("Available cost basis dates")
.description("List available dates for a cohort's cost basis distribution.")
.ok_response::<Vec<Date>>()
.not_found()
.server_error()
},
),
)
.api_route(
"/api/metrics/cost-basis/{cohort}/{date}",
get_with(
async |headers: HeaderMap,
Path(params): Path<CostBasisParams>,
Query(query): Query<CostBasisQuery>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, move |q| {
q.cost_basis_formatted(
&params.cohort,
params.date,
query.bucket,
query.value,
)
})
.await
},
|op| {
op.id("get_cost_basis")
.metrics_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)",
)
.ok_response::<CostBasisFormatted>()
.not_found()
.server_error()
},
),
)
}
}
+3 -1
View File
@@ -9,13 +9,15 @@ repository.workspace = true
[dependencies]
bitcoin = { workspace = true }
brk_error = { workspace = true, features = ["bitcoin", "jiff", "serde_json", "vecdb"] }
brk_error = { workspace = true, features = ["bitcoin", "jiff", "pco", "serde_json", "vecdb"] }
byteview = { workspace = true }
derive_more = { workspace = true }
indexmap = { workspace = true }
itoa = "1.0.17"
jiff = { workspace = true }
pco = { workspace = true }
rapidhash = "4.2.1"
rustc-hash = { workspace = true }
ryu = "1.0.22"
schemars = { workspace = true }
serde = { workspace = true }
+36
View File
@@ -17,6 +17,7 @@ use super::{CentsSats, Dollars, Sats};
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,
Pco,
@@ -61,6 +62,41 @@ impl CentsUnsigned {
pub fn to_dollars(self) -> Dollars {
Dollars::from(self.0 as f64 / 100.0)
}
/// Round to N significant digits.
/// E.g., 12345 (= $123.45) with round_to(4) → 12350 (= $123.50)
/// E.g., 12345 (= $123.45) with round_to(3) → 12300 (= $123.00)
pub fn round_to(self, digits: i32) -> Self {
let v = self.0;
let ilog10 = v.checked_ilog10().unwrap_or(0) as i32;
if ilog10 >= digits {
let log_diff = ilog10 - digits + 1;
let pow = 10u64.pow(log_diff as u32);
// Add half for rounding
Self(((v + pow / 2) / pow) * pow)
} else {
self
}
}
/// Round to nearest dollar, then apply N significant digits.
/// E.g., 12345 (= $123.45) → 12300 (= $123.00) with 5 digits
/// E.g., 1234567 (= $12345.67) → 1234600 (= $12346.00) with 5 digits
#[inline]
pub fn round_to_dollar(self, digits: i32) -> Self {
// Round to nearest dollar (nearest 100 cents)
let dollars = (self.0 + 50) / 100;
// Apply significant digit rounding to dollars, then convert back to cents
let ilog10 = dollars.checked_ilog10().unwrap_or(0) as i32;
let rounded_dollars = if ilog10 >= digits {
let log_diff = ilog10 - digits + 1;
let pow = 10u64.pow(log_diff as u32);
((dollars + pow / 2) / pow) * pow
} else {
dollars
};
Self(rounded_dollars * 100)
}
}
impl From<Dollars> for CentsUnsigned {
+38 -1
View File
@@ -1,12 +1,13 @@
use std::ops::Sub;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::{CentsUnsigned, Dollars};
/// Compact unsigned cents (u32) - memory-efficient for map keys.
/// Supports values from $0.00 to $42,949,672.95 (u32::MAX / 100).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)]
pub struct CentsUnsignedCompact(u32);
impl CentsUnsignedCompact {
@@ -42,6 +43,42 @@ impl CentsUnsignedCompact {
pub fn saturating_sub(self, rhs: Self) -> Self {
Self(self.0.saturating_sub(rhs.0))
}
/// Round to N significant digits.
/// E.g., 12345 (= $123.45) with round_to(4) → 12350 (= $123.50)
/// E.g., 12345 (= $123.45) with round_to(3) → 12300 (= $123.00)
#[inline]
pub fn round_to(self, digits: i32) -> Self {
let v = self.0;
let ilog10 = v.checked_ilog10().unwrap_or(0) as i32;
if ilog10 >= digits {
let log_diff = ilog10 - digits + 1;
let pow = 10u32.pow(log_diff as u32);
// Add half for rounding
Self(((v + pow / 2) / pow) * pow)
} else {
self
}
}
/// Round to nearest dollar, then apply N significant digits.
/// E.g., 12345 (= $123.45) → 12300 (= $123.00) with 5 digits
/// E.g., 1234567 (= $12345.67) → 1234600 (= $12346.00) with 5 digits
#[inline]
pub fn round_to_dollar(self, digits: i32) -> Self {
// Round to nearest dollar (nearest 100 cents)
let dollars = (self.0 + 50) / 100;
// Apply significant digit rounding to dollars, then convert back to cents
let ilog10 = dollars.checked_ilog10().unwrap_or(0) as i32;
let rounded_dollars = if ilog10 >= digits {
let log_diff = ilog10 - digits + 1;
let pow = 10u32.pow(log_diff as u32);
((dollars + pow / 2) / pow) * pow
} else {
dollars
};
Self(rounded_dollars * 100)
}
}
impl From<Dollars> for CentsUnsignedCompact {
+70
View File
@@ -0,0 +1,70 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use strum::Display;
use crate::CentsUnsigned;
/// Bucket type for cost basis aggregation.
/// Options: raw (no aggregation), lin200/lin500/lin1000 (linear $200/$500/$1000),
/// log10/log50/log100 (logarithmic with 10/50/100 buckets per decade).
#[derive(
Debug, Display, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum CostBasisBucket {
#[default]
Raw,
Lin200,
Lin500,
Lin1000,
Log10,
Log50,
Log100,
}
impl CostBasisBucket {
/// Returns the linear bucket size in cents, if this is a linear bucket type.
fn linear_size_cents(&self) -> Option<u64> {
match self {
Self::Lin200 => Some(20_000),
Self::Lin500 => Some(50_000),
Self::Lin1000 => Some(100_000),
_ => None,
}
}
/// Returns the number of buckets per decade, if this is a log bucket type.
fn log_buckets_per_decade(&self) -> Option<u32> {
match self {
Self::Log10 => Some(10),
Self::Log50 => Some(50),
Self::Log100 => Some(100),
_ => None,
}
}
/// Compute bucket floor for a given price in cents.
/// Returns None for Raw (no bucketing).
pub fn bucket_floor(&self, price_cents: CentsUnsigned) -> Option<CentsUnsigned> {
match self {
Self::Raw => None,
Self::Lin200 | Self::Lin500 | Self::Lin1000 => {
let size = self.linear_size_cents().unwrap();
Some((price_cents / size) * size)
}
Self::Log10 | Self::Log50 | Self::Log100 => {
if price_cents == CentsUnsigned::ZERO {
return Some(CentsUnsigned::ZERO);
}
let n = self.log_buckets_per_decade().unwrap();
// Bucket index = floor(n * log10(price))
// Floor = 10^(bucket_index / n)
let log_price = f64::from(price_cents).log10();
let bucket_idx = (n as f64 * log_price).floor() as i32;
let floor = 10_f64.powf(bucket_idx as f64 / n as f64);
Some(CentsUnsigned::from(floor.round() as u64))
}
}
}
}
@@ -0,0 +1,126 @@
use std::collections::BTreeMap;
use rustc_hash::FxHashMap;
use brk_error::Result;
use pco::{ChunkConfig, standalone::{simple_compress, simple_decompress}};
use schemars::JsonSchema;
use serde::Serialize;
use vecdb::Bytes;
use crate::{Bitcoin, CentsUnsigned, CentsUnsignedCompact, CostBasisBucket, CostBasisValue, Dollars, Sats};
/// Cost basis distribution: a map of price (cents) to sats.
#[derive(Debug, Clone, Default, Serialize, JsonSchema)]
pub struct CostBasisDistribution {
pub map: BTreeMap<CentsUnsignedCompact, 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 {
/// Deserialize from the pco-compressed format, returning remaining bytes.
pub fn deserialize_with_rest(data: &[u8]) -> Result<(Self, &[u8])> {
let entry_count = usize::from_bytes(&data[0..8])?;
let keys_len = usize::from_bytes(&data[8..16])?;
let values_len = usize::from_bytes(&data[16..24])?;
let keys_start = 24;
let values_start = keys_start + keys_len;
let rest_start = values_start + values_len;
let keys: Vec<u32> = simple_decompress(&data[keys_start..values_start])?;
let values: Vec<u64> = simple_decompress(&data[values_start..rest_start])?;
let map: BTreeMap<CentsUnsignedCompact, Sats> = keys
.into_iter()
.zip(values)
.map(|(k, v)| (CentsUnsignedCompact::new(k), Sats::from(v)))
.collect();
assert_eq!(map.len(), entry_count);
Ok((Self { map }, &data[rest_start..]))
}
/// Deserialize from the pco-compressed format.
pub fn deserialize(data: &[u8]) -> Result<Self> {
Self::deserialize_with_rest(data).map(|(s, _)| s)
}
/// Serialize to the pco-compressed format.
pub fn serialize(&self) -> Result<Vec<u8>> {
Self::serialize_iter(self.map.iter().map(|(&k, &v)| (k, v)))
}
/// Serialize from a sorted iterator of (price, sats) pairs.
pub fn serialize_iter(iter: impl Iterator<Item = (CentsUnsignedCompact, Sats)>) -> Result<Vec<u8>> {
let entries: Vec<_> = iter.collect();
let keys: Vec<u32> = entries.iter().map(|(k, _)| k.inner()).collect();
let values: Vec<u64> = entries.iter().map(|(_, v)| u64::from(*v)).collect();
let config = ChunkConfig::default();
let compressed_keys = simple_compress(&keys, &config)?;
let compressed_values = simple_compress(&values, &config)?;
let mut buffer = Vec::new();
buffer.extend(keys.len().to_bytes());
buffer.extend(compressed_keys.len().to_bytes());
buffer.extend(compressed_values.len().to_bytes());
buffer.extend(compressed_keys);
buffer.extend(compressed_values);
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: CentsUnsigned,
) -> CostBasisFormatted {
let spot = Dollars::from(spot_cents);
let needs_realized = value == CostBasisValue::Realized;
let mut result: FxHashMap<CentsUnsigned, (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 = CentsUnsigned::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 * sats),
};
(k, v)
})
.collect()
}
}
+55
View File
@@ -0,0 +1,55 @@
use std::{fmt, ops::Deref};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::{CostBasisBucket, CostBasisValue, Date};
/// Cohort identifier for cost basis distribution.
#[derive(Deserialize, JsonSchema)]
#[schemars(example = &"all", example = &"sth", example = &"lth")]
pub struct Cohort(String);
impl fmt::Display for Cohort {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<T: Into<String>> From<T> for Cohort {
fn from(s: T) -> Self {
Self(s.into())
}
}
impl Deref for Cohort {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
/// 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,
}
+15
View File
@@ -0,0 +1,15 @@
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,
}
+16 -1
View File
@@ -1,4 +1,4 @@
use std::fmt;
use std::{fmt, str::FromStr};
use jiff::{Span, Zoned, civil::Date as Date_, tz::TimeZone};
use schemars::JsonSchema;
@@ -250,6 +250,21 @@ impl fmt::Display for Date {
}
}
impl FromStr for Date {
type Err = &'static str;
/// Parse a date from YYYY-MM-DD format.
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.len() != 10 || s.as_bytes()[4] != b'-' || s.as_bytes()[7] != b'-' {
return Err("expected YYYY-MM-DD format");
}
let year: u16 = s[0..4].parse().map_err(|_| "invalid year")?;
let month: u8 = s[5..7].parse().map_err(|_| "invalid month")?;
let day: u8 = s[8..10].parse().map_err(|_| "invalid day")?;
Ok(Self::new(year, month, day))
}
}
impl Formattable for Date {
#[inline(always)]
fn may_need_escaping() -> bool {
+8
View File
@@ -40,6 +40,10 @@ mod cents_signed;
mod cents_squared_sats;
mod cents_unsigned;
mod cents_unsigned_compact;
mod cost_basis_bucket;
mod cost_basis_distribution;
mod cost_basis_params;
mod cost_basis_value;
mod datarange;
mod datarangeformat;
mod date;
@@ -216,6 +220,10 @@ pub use cents_signed::*;
pub use cents_squared_sats::*;
pub use cents_unsigned::*;
pub use cents_unsigned_compact::*;
pub use cost_basis_bucket::*;
pub use cost_basis_distribution::*;
pub use cost_basis_params::*;
pub use cost_basis_value::*;
pub use datarange::*;
pub use datarangeformat::*;
pub use date::*;
+90
View File
@@ -191,6 +191,44 @@
*
* @typedef {CentsUnsigned} Close
*/
/**
* Cohort identifier for cost basis distribution.
*
* @typedef {string} Cohort
*/
/**
* Bucket type for cost basis aggregation.
* Options: raw (no aggregation), lin200/lin500/lin1000 (linear $200/$500/$1000),
* log10/log50/log100 (logarithmic with 10/50/100 buckets per decade).
*
* @typedef {("raw"|"lin200"|"lin500"|"lin1000"|"log10"|"log50"|"log100")} 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.
*/
/**
* Value type for cost basis distribution.
* Options: supply (BTC), realized (USD, price × supply), unrealized (USD, spot × supply).
*
* @typedef {("supply"|"realized"|"unrealized")} CostBasisValue
*/
/**
* Data range with output format for API query parameters
*
@@ -7385,6 +7423,58 @@ class BrkClient extends BrkClientBase {
return this.getJson(path);
}
/**
* Available cost basis cohorts
*
* List available cohorts for cost basis distribution.
*
* Endpoint: `GET /api/metrics/cost-basis`
* @returns {Promise<string[]>}
*/
async getCostBasisCohorts() {
return this.getJson(`/api/metrics/cost-basis`);
}
/**
* Available cost basis dates
*
* List available dates for a cohort's cost basis distribution.
*
* Endpoint: `GET /api/metrics/cost-basis/{cohort}/dates`
*
* @param {Cohort} cohort
* @returns {Promise<Date[]>}
*/
async getCostBasisDates(cohort) {
return this.getJson(`/api/metrics/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/metrics/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.
* @returns {Promise<Object>}
*/
async getCostBasis(cohort, date, bucket, value) {
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/metrics/cost-basis/${cohort}/${date}${query ? '?' + query : ''}`;
return this.getJson(path);
}
/**
* Metric count
*
@@ -52,6 +52,15 @@ CentsSquaredSats = int
CentsUnsigned = int
# Closing price value for a time period
Close = CentsUnsigned
# Cohort identifier for cost basis distribution.
Cohort = str
# Bucket type for cost basis aggregation.
# Options: raw (no aggregation), lin200/lin500/lin1000 (linear $200/$500/$1000),
# log10/log50/log100 (logarithmic with 10/50/100 buckets per decade).
CostBasisBucket = Literal["raw", "lin200", "lin500", "lin1000", "log10", "log50", "log100"]
# Value type for cost basis distribution.
# Options: supply (BTC), realized (USD, price × supply), unrealized (USD, spot × supply).
CostBasisValue = Literal["supply", "realized", "unrealized"]
# Output format for API responses
Format = Literal["json", "csv"]
# Maximum number of results to return. Defaults to 100 if not specified.
@@ -360,6 +369,30 @@ class BlockTimestamp(TypedDict):
hash: BlockHash
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
value: CostBasisValue
class DataRangeFormat(TypedDict):
"""
Data range with output format for API query parameters
@@ -5512,6 +5545,39 @@ 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/metrics/cost-basis`"""
return self.get_json('/api/metrics/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/metrics/cost-basis/{cohort}/dates`"""
return self.get_json(f'/api/metrics/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/metrics/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/metrics/cost-basis/{cohort}/{date}{"?" + query if query else ""}'
return self.get_json(path)
def get_metrics_count(self) -> List[MetricCount]:
"""Metric count.