diff --git a/.gitignore b/.gitignore index 9b4aff587..3ea4b0dca 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ _* /oracle* /playground /*.txt +/*.csv # Logs *.log* diff --git a/Cargo.lock b/Cargo.lock index 6407c9d58..068175701 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 92794e01f..7585a9019 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/brk_bindgen/src/generators/rust/types.rs b/crates/brk_bindgen/src/generators/rust/types.rs index 86f09315d..0db7d677e 100644 --- a/crates/brk_bindgen/src/generators/rust/types.rs +++ b/crates/brk_bindgen/src/generators/rust/types.rs @@ -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(), } } diff --git a/crates/brk_client/src/lib.rs b/crates/brk_client/src/lib.rs index f1fe9e602..fc51944cf 100644 --- a/crates/brk_client/src/lib.rs +++ b/crates/brk_client/src/lib.rs @@ -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) -> 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> { + 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> { + 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, value: Option) -> Result { + let mut query = Vec::new(); + if let Some(v) = bucket { query.push(format!("bucket={}", v)); } + if let Some(v) = value { query.push(format!("value={}", v)); } + let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) }; + let path = format!("/api/metrics/cost-basis/{cohort}/{date}{}", query_str); + self.base.get_json(&path) + } + /// Metric count /// /// Returns the number of metrics available per index type. diff --git a/crates/brk_computer/Cargo.toml b/crates/brk_computer/Cargo.toml index 6e2a1a952..948b63f28 100644 --- a/crates/brk_computer/Cargo.toml +++ b/crates/brk_computer/Cargo.toml @@ -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 } diff --git a/crates/brk_computer/src/distribution/cohorts/utxo/groups.rs b/crates/brk_computer/src/distribution/cohorts/utxo/groups.rs index 2c8e9d6d1..49a06542c 100644 --- a/crates/brk_computer/src/distribution/cohorts/utxo/groups.rs +++ b/crates/brk_computer/src/distribution/cohorts/utxo/groups.rs @@ -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); @@ -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> = 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(()) }) } diff --git a/crates/brk_computer/src/distribution/compute/block_loop.rs b/crates/brk_computer/src/distribution/compute/block_loop.rs index 338347b30..2981b3db8 100644 --- a/crates/brk_computer/src/distribution/compute/block_loop.rs +++ b/crates/brk_computer/src/distribution/compute/block_loop.rs @@ -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 diff --git a/crates/brk_computer/src/distribution/state/cohort/address.rs b/crates/brk_computer/src/distribution/state/cohort/address.rs index fb867af91..3245f0cb2 100644 --- a/crates/brk_computer/src/distribution/state/cohort/address.rs +++ b/crates/brk_computer/src/distribution/state/cohort/address.rs @@ -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), } } diff --git a/crates/brk_computer/src/distribution/state/cohort/base.rs b/crates/brk_computer/src/distribution/state/cohort/base.rs index c5210937c..1e78908b3 100644 --- a/crates/brk_computer/src/distribution/state/cohort/base.rs +++ b/crates/brk_computer/src/distribution/state/cohort/base.rs @@ -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, cached_unrealized: Option, + /// If set, prices are rounded to nearest dollar with N significant digits. + price_rounding_digits: Option, } 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); } } } diff --git a/crates/brk_computer/src/distribution/state/cost_basis/cost_basis_data.rs b/crates/brk_computer/src/distribution/state/cost_basis/data.rs similarity index 76% rename from crates/brk_computer/src/distribution/state/cost_basis/cost_basis_data.rs rename to crates/brk_computer/src/distribution/state/cost_basis/data.rs index 7d1fa6007..016ee3394 100644 --- a/crates/brk_computer/src/distribution/state/cost_basis/cost_basis_data.rs +++ b/crates/brk_computer/src/distribution/state/cost_basis/data.rs @@ -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 { 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, Bound), ) -> impl Iterator { 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(¢s); + self.state.um().base.map.remove(¢s); } } @@ -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) -> Result> { - 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, + 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> { - let keys: Vec = self.map.keys().map(|k| k.inner()).collect(); - let values: Vec = 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> { + 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 { - 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 = simple_decompress(&data[keys_start..values_start])?; - let values: Vec = simple_decompress(&data[values_start..raw_start])?; - - let map: BTreeMap = 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 { + 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, }) diff --git a/crates/brk_computer/src/distribution/state/cost_basis/mod.rs b/crates/brk_computer/src/distribution/state/cost_basis/mod.rs index c6232f7d1..07905ee95 100644 --- a/crates/brk_computer/src/distribution/state/cost_basis/mod.rs +++ b/crates/brk_computer/src/distribution/state/cost_basis/mod.rs @@ -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::*; diff --git a/crates/brk_computer/src/distribution/state/cost_basis/unrealized.rs b/crates/brk_computer/src/distribution/state/cost_basis/unrealized.rs index 705d8543f..d1e8831ba 100644 --- a/crates/brk_computer/src/distribution/state/cost_basis/unrealized.rs +++ b/crates/brk_computer/src/distribution/state/cost_basis/unrealized.rs @@ -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; } diff --git a/crates/brk_computer/src/distribution/vecs.rs b/crates/brk_computer/src/distribution/vecs.rs index ad3c71812..b90d8c4c9 100644 --- a/crates/brk_computer/src/distribution/vecs.rs +++ b/crates/brk_computer/src/distribution/vecs.rs @@ -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, pub any_address_indexes: AnyAddressIndexesVecs, @@ -163,6 +165,7 @@ impl Vecs { emptyaddressindex, db, + states_path, }; this.db.retain_regions( diff --git a/crates/brk_error/Cargo.toml b/crates/brk_error/Cargo.toml index fd4f5d535..e6e13b5cc 100644 --- a/crates/brk_error/Cargo.toml +++ b/crates/brk_error/Cargo.toml @@ -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 } diff --git a/crates/brk_error/src/lib.rs b/crates/brk_error/src/lib.rs index a1cdad6d2..4d68da6cb 100644 --- a/crates/brk_error/src/lib.rs +++ b/crates/brk_error/src/lib.rs @@ -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), diff --git a/crates/brk_query/src/impl/cost_basis.rs b/crates/brk_query/src/impl/cost_basis.rs new file mode 100644 index 000000000..1523b41b9 --- /dev/null +++ b/crates/brk_query/src/impl/cost_basis.rs @@ -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> { + let states_path = &self.computer().distribution.states_path; + + let mut cohorts: Vec = fs::read_dir(states_path)? + .filter_map(|entry| { + let name = entry.ok()?.file_name().into_string().ok()?; + let cohort = name.strip_prefix("utxo_")?.strip_suffix("_cost_basis")?; + states_path + .join(&name) + .join("by_date") + .exists() + .then(|| cohort.to_string()) + }) + .collect(); + + cohorts.sort(); + Ok(cohorts) + } + + fn cost_basis_dir(&self, cohort: &str) -> Result { + let dir = self + .computer() + .distribution + .states_path + .join(format!("utxo_{cohort}_cost_basis/by_date")); + + if !dir.exists() { + 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 { + let path = self.cost_basis_dir(cohort)?.join(date.to_string()); + + if !path.exists() { + return Err(Error::NotFound(format!( + "No data for cohort '{cohort}' on {date}" + ))); + } + + CostBasisDistribution::deserialize(&fs::read(&path)?) + } + + /// List available dates for a cohort's cost basis distribution. + pub fn cost_basis_dates(&self, cohort: &str) -> Result> { + let dir = self.cost_basis_dir(cohort)?; + + let mut dates: Vec = fs::read_dir(&dir)? + .filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok()) + .collect(); + + dates.sort(); + Ok(dates) + } + + /// Get the formatted cost basis distribution. + pub fn cost_basis_formatted( + &self, + cohort: &str, + date: Date, + bucket: CostBasisBucket, + value: CostBasisValue, + ) -> Result { + let distribution = self.cost_basis_distribution(cohort, date)?; + let 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)) + } +} diff --git a/crates/brk_query/src/impl/mod.rs b/crates/brk_query/src/impl/mod.rs index a1f0f2b26..f3f98e575 100644 --- a/crates/brk_query/src/impl/mod.rs +++ b/crates/brk_query/src/impl/mod.rs @@ -1,5 +1,6 @@ mod address; mod block; +mod cost_basis; mod mempool; mod metrics; mod metrics_legacy; diff --git a/crates/brk_server/src/api/metrics/mod.rs b/crates/brk_server/src/api/metrics/mod.rs index 2d910c7d5..cf1b45b01 100644 --- a/crates/brk_server/src/api/metrics/mod.rs +++ b/crates/brk_server/src/api/metrics/mod.rs @@ -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 { .not_modified(), ), ) + // Cost basis distribution endpoints + .api_route( + "/api/metrics/cost-basis", + get_with( + async |headers: HeaderMap, State(state): State| { + 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::>() + .server_error() + }, + ), + ) + .api_route( + "/api/metrics/cost-basis/{cohort}/dates", + get_with( + async |headers: HeaderMap, + Path(params): Path, + State(state): State| { + state + .cached_json(&headers, CacheStrategy::Height, move |q| { + q.cost_basis_dates(¶ms.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::>() + .not_found() + .server_error() + }, + ), + ) + .api_route( + "/api/metrics/cost-basis/{cohort}/{date}", + get_with( + async |headers: HeaderMap, + Path(params): Path, + Query(query): Query, + State(state): State| { + state + .cached_json(&headers, CacheStrategy::Static, move |q| { + q.cost_basis_formatted( + ¶ms.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::() + .not_found() + .server_error() + }, + ), + ) } } diff --git a/crates/brk_types/Cargo.toml b/crates/brk_types/Cargo.toml index 31f984939..e7bdb84c2 100644 --- a/crates/brk_types/Cargo.toml +++ b/crates/brk_types/Cargo.toml @@ -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 } diff --git a/crates/brk_types/src/cents_unsigned.rs b/crates/brk_types/src/cents_unsigned.rs index b06a3860f..f4d1606f8 100644 --- a/crates/brk_types/src/cents_unsigned.rs +++ b/crates/brk_types/src/cents_unsigned.rs @@ -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 for CentsUnsigned { diff --git a/crates/brk_types/src/cents_unsigned_compact.rs b/crates/brk_types/src/cents_unsigned_compact.rs index c00e40f41..b6f80201a 100644 --- a/crates/brk_types/src/cents_unsigned_compact.rs +++ b/crates/brk_types/src/cents_unsigned_compact.rs @@ -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 for CentsUnsignedCompact { diff --git a/crates/brk_types/src/cost_basis_bucket.rs b/crates/brk_types/src/cost_basis_bucket.rs new file mode 100644 index 000000000..d6ce23165 --- /dev/null +++ b/crates/brk_types/src/cost_basis_bucket.rs @@ -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 { + 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 { + 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 { + 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)) + } + } + } +} diff --git a/crates/brk_types/src/cost_basis_distribution.rs b/crates/brk_types/src/cost_basis_distribution.rs new file mode 100644 index 000000000..eeff9095d --- /dev/null +++ b/crates/brk_types/src/cost_basis_distribution.rs @@ -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, +} + +/// Formatted cost basis output. +/// Key: price floor in USD (dollars). +/// Value: BTC (for supply) or USD (for realized/unrealized). +pub type CostBasisFormatted = BTreeMap; + +impl CostBasisDistribution { + /// 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 = simple_decompress(&data[keys_start..values_start])?; + let values: Vec = simple_decompress(&data[values_start..rest_start])?; + + let map: BTreeMap = 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::deserialize_with_rest(data).map(|(s, _)| s) + } + + /// Serialize to the pco-compressed format. + pub fn serialize(&self) -> Result> { + 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) -> Result> { + let entries: Vec<_> = iter.collect(); + let keys: Vec = entries.iter().map(|(k, _)| k.inner()).collect(); + let values: Vec = 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 = + 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() + } +} diff --git a/crates/brk_types/src/cost_basis_params.rs b/crates/brk_types/src/cost_basis_params.rs new file mode 100644 index 000000000..119b54dd6 --- /dev/null +++ b/crates/brk_types/src/cost_basis_params.rs @@ -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> From 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, +} diff --git a/crates/brk_types/src/cost_basis_value.rs b/crates/brk_types/src/cost_basis_value.rs new file mode 100644 index 000000000..77b01c3ec --- /dev/null +++ b/crates/brk_types/src/cost_basis_value.rs @@ -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, +} diff --git a/crates/brk_types/src/date.rs b/crates/brk_types/src/date.rs index bfeed252f..2ce9458af 100644 --- a/crates/brk_types/src/date.rs +++ b/crates/brk_types/src/date.rs @@ -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 { + 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 { diff --git a/crates/brk_types/src/lib.rs b/crates/brk_types/src/lib.rs index 8918c276b..933bd49d6 100644 --- a/crates/brk_types/src/lib.rs +++ b/crates/brk_types/src/lib.rs @@ -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::*; diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index 94efa78dc..48a0af67e 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -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} + */ + 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} + */ + 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} + */ + 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 * diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index 18b6bed10..8ba57aa01 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -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.