mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-18 02:39:43 -07:00
computer: distribution: feat cost basis distribution
This commit is contained in:
@@ -24,6 +24,7 @@ _*
|
||||
/oracle*
|
||||
/playground
|
||||
/*.txt
|
||||
/*.csv
|
||||
|
||||
# Logs
|
||||
*.log*
|
||||
|
||||
Generated
+3
-1
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-53
@@ -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(¢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<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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
mod address;
|
||||
mod block;
|
||||
mod cost_basis;
|
||||
mod mempool;
|
||||
mod metrics;
|
||||
mod metrics_legacy;
|
||||
|
||||
@@ -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(¶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::<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(
|
||||
¶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::<CostBasisFormatted>()
|
||||
.not_found()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user