mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-05-19 14:24:47 -07:00
global: fixes
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ use super::{
|
|||||||
metrics::AvgAmountMetrics,
|
metrics::AvgAmountMetrics,
|
||||||
};
|
};
|
||||||
|
|
||||||
const VERSION: Version = Version::new(23);
|
const VERSION: Version = Version::new(24);
|
||||||
|
|
||||||
#[derive(Traversable)]
|
#[derive(Traversable)]
|
||||||
pub struct AddrMetricsVecs<M: StorageMode = Rw> {
|
pub struct AddrMetricsVecs<M: StorageMode = Rw> {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use brk_rpc::Client;
|
use brk_rpc::Client;
|
||||||
|
use brk_types::FeeRate;
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
use self::projected_blocks::verify::Verifier;
|
use self::projected_blocks::verify::Verifier;
|
||||||
@@ -72,6 +74,11 @@ impl Rebuilder {
|
|||||||
|
|
||||||
self.dirty.store(false, Ordering::Release);
|
self.dirty.store(false, Ordering::Release);
|
||||||
|
|
||||||
|
let min_fee = client.get_mempool_min_fee().unwrap_or_else(|e| {
|
||||||
|
warn!("getmempoolinfo failed, falling back to FeeRate::MIN: {e}");
|
||||||
|
FeeRate::MIN
|
||||||
|
});
|
||||||
|
|
||||||
let built = {
|
let built = {
|
||||||
let entries = entries.read();
|
let entries = entries.read();
|
||||||
let entries_slice = entries.entries();
|
let entries_slice = entries.entries();
|
||||||
@@ -82,7 +89,7 @@ impl Rebuilder {
|
|||||||
#[cfg(not(debug_assertions))]
|
#[cfg(not(debug_assertions))]
|
||||||
let _ = client;
|
let _ = client;
|
||||||
|
|
||||||
Snapshot::build(blocks, entries_slice)
|
Snapshot::build(blocks, entries_slice, min_fee)
|
||||||
};
|
};
|
||||||
|
|
||||||
*self.snapshot.write() = Arc::new(built);
|
*self.snapshot.write() = Arc::new(built);
|
||||||
|
|||||||
@@ -2,21 +2,74 @@ use brk_types::{FeeRate, RecommendedFees};
|
|||||||
|
|
||||||
use super::stats::BlockStats;
|
use super::stats::BlockStats;
|
||||||
|
|
||||||
/// Compute recommended fees from block stats (mempool.space style).
|
/// Output rounding granularity in sat/vB. mempool.space's
|
||||||
pub fn compute_recommended_fees(stats: &[BlockStats]) -> RecommendedFees {
|
/// `/api/v1/fees/recommended` uses `1.0`; their `/precise`
|
||||||
|
/// variant uses `0.001`. bitview always emits precise.
|
||||||
|
const MIN_INCREMENT: FeeRate = FeeRate::new(0.001);
|
||||||
|
/// `getPreciseRecommendedFee` adds this to `fastestFee` and
|
||||||
|
/// half of it to `halfHourFee`, then floors them. Compensates
|
||||||
|
/// for sub-1-sat/vB fees mined by hashrate that ignores the
|
||||||
|
/// relay floor.
|
||||||
|
const PRIORITY_FACTOR: FeeRate = FeeRate::new(0.5);
|
||||||
|
const MIN_FASTEST_FEE: FeeRate = FeeRate::new(1.0);
|
||||||
|
const MIN_HALF_HOUR_FEE: FeeRate = FeeRate::new(0.5);
|
||||||
|
|
||||||
|
/// Literal port of mempool.space's `getPreciseRecommendedFee`
|
||||||
|
/// (backend/src/api/fee-api.ts). `min_fee` is bitcoind's live
|
||||||
|
/// `mempoolminfee` in sat/vB and acts as a floor for every tier
|
||||||
|
/// while the mempool is purging by fee.
|
||||||
|
pub fn compute_recommended_fees(stats: &[BlockStats], min_fee: FeeRate) -> RecommendedFees {
|
||||||
|
let purge_rate = min_fee.ceil_to(MIN_INCREMENT);
|
||||||
|
let minimum_fee = purge_rate.max(MIN_INCREMENT);
|
||||||
|
|
||||||
|
let first = stats.first().map_or(minimum_fee, |b| {
|
||||||
|
optimize_median_fee(b, stats.get(1), None, minimum_fee)
|
||||||
|
});
|
||||||
|
let second = stats.get(1).map_or(minimum_fee, |b| {
|
||||||
|
optimize_median_fee(b, stats.get(2), Some(first), minimum_fee)
|
||||||
|
});
|
||||||
|
let third = stats.get(2).map_or(minimum_fee, |b| {
|
||||||
|
optimize_median_fee(b, stats.get(3), Some(second), minimum_fee)
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut fastest = minimum_fee.max(first);
|
||||||
|
let mut half_hour = minimum_fee.max(second);
|
||||||
|
let mut hour = minimum_fee.max(third);
|
||||||
|
let economy = third.clamp(minimum_fee, minimum_fee * 2.0);
|
||||||
|
|
||||||
|
fastest = fastest.max(half_hour).max(hour).max(economy);
|
||||||
|
half_hour = half_hour.max(hour).max(economy);
|
||||||
|
hour = hour.max(economy);
|
||||||
|
|
||||||
|
let fastest = (fastest + PRIORITY_FACTOR).max(MIN_FASTEST_FEE);
|
||||||
|
let half_hour = (half_hour + PRIORITY_FACTOR / 2.0).max(MIN_HALF_HOUR_FEE);
|
||||||
|
|
||||||
RecommendedFees {
|
RecommendedFees {
|
||||||
fastest_fee: median_fee_for_block(stats, 0),
|
fastest_fee: fastest.round_milli(),
|
||||||
half_hour_fee: median_fee_for_block(stats, 2),
|
half_hour_fee: half_hour.round_milli(),
|
||||||
hour_fee: median_fee_for_block(stats, 5),
|
hour_fee: hour.round_milli(),
|
||||||
economy_fee: median_fee_for_block(stats, 7),
|
economy_fee: economy.round_milli(),
|
||||||
minimum_fee: FeeRate::MIN,
|
minimum_fee: minimum_fee.round_milli(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the median fee rate for block N.
|
/// Pick the fee for one projected block, smoothing toward the
|
||||||
fn median_fee_for_block(stats: &[BlockStats], block_index: usize) -> FeeRate {
|
/// previous tier and discounting partially-full final blocks.
|
||||||
stats
|
fn optimize_median_fee(
|
||||||
.get(block_index)
|
block: &BlockStats,
|
||||||
.map(|s| s.median_fee_rate())
|
next_block: Option<&BlockStats>,
|
||||||
.unwrap_or_else(|| FeeRate::MIN)
|
previous_fee: Option<FeeRate>,
|
||||||
|
min_fee: FeeRate,
|
||||||
|
) -> FeeRate {
|
||||||
|
let median = block.median_fee_rate();
|
||||||
|
let use_fee = previous_fee.map_or(median, |prev| FeeRate::mean(median, prev));
|
||||||
|
let vsize = u64::from(block.total_vsize);
|
||||||
|
if vsize <= 500_000 || median < min_fee {
|
||||||
|
return min_fee;
|
||||||
|
}
|
||||||
|
if vsize <= 950_000 && next_block.is_none() {
|
||||||
|
let multiplier = (vsize - 500_000) as f64 / 500_000.0;
|
||||||
|
return (use_fee * multiplier).round_to(MIN_INCREMENT).max(min_fee);
|
||||||
|
}
|
||||||
|
use_fee.ceil_to(MIN_INCREMENT).max(min_fee)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||||
|
|
||||||
use brk_types::RecommendedFees;
|
use brk_types::{FeeRate, RecommendedFees};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
super::block_builder::Package,
|
super::block_builder::Package,
|
||||||
@@ -27,13 +27,15 @@ pub struct Snapshot {
|
|||||||
|
|
||||||
impl Snapshot {
|
impl Snapshot {
|
||||||
/// Build a snapshot from packages grouped by projected block.
|
/// Build a snapshot from packages grouped by projected block.
|
||||||
pub fn build(blocks: Vec<Vec<Package>>, entries: &[Option<Entry>]) -> Self {
|
/// `min_fee` is bitcoind's live `mempoolminfee`, used as the floor
|
||||||
|
/// for every recommended-fee tier.
|
||||||
|
pub fn build(blocks: Vec<Vec<Package>>, entries: &[Option<Entry>], min_fee: FeeRate) -> Self {
|
||||||
let block_stats: Vec<BlockStats> = blocks
|
let block_stats: Vec<BlockStats> = blocks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|block| stats::compute_block_stats(block, entries))
|
.map(|block| stats::compute_block_stats(block, entries))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let fees = fees::compute_recommended_fees(&block_stats);
|
let fees = fees::compute_recommended_fees(&block_stats, min_fee);
|
||||||
|
|
||||||
let blocks: Vec<Vec<TxIndex>> = blocks
|
let blocks: Vec<Vec<TxIndex>> = blocks
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
use std::{
|
||||||
|
collections::hash_map::Entry as MapEntry,
|
||||||
|
hash::{DefaultHasher, Hash, Hasher},
|
||||||
|
};
|
||||||
|
|
||||||
use brk_types::{AddrBytes, AddrMempoolStats, Transaction, TxOut, Txid};
|
use brk_types::{AddrBytes, AddrMempoolStats, Transaction, TxOut, Txid};
|
||||||
use derive_more::Deref;
|
use derive_more::Deref;
|
||||||
@@ -51,7 +54,6 @@ impl AddrTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, tx: &Transaction, txid: &Txid, is_addition: bool) {
|
fn update(&mut self, tx: &Transaction, txid: &Txid, is_addition: bool) {
|
||||||
// Inputs: track sending
|
|
||||||
for txin in &tx.input {
|
for txin in &tx.input {
|
||||||
let Some(prevout) = txin.prevout.as_ref() else {
|
let Some(prevout) = txin.prevout.as_ref() else {
|
||||||
continue;
|
continue;
|
||||||
@@ -59,33 +61,57 @@ impl AddrTracker {
|
|||||||
let Some(bytes) = prevout.addr_bytes() else {
|
let Some(bytes) = prevout.addr_bytes() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
self.apply(bytes, txid, is_addition, |stats| {
|
||||||
let (stats, txids) = self.0.entry(bytes).or_default();
|
if is_addition {
|
||||||
if is_addition {
|
stats.sending(prevout);
|
||||||
txids.insert(txid.clone());
|
} else {
|
||||||
stats.sending(prevout);
|
stats.sent(prevout);
|
||||||
} else {
|
}
|
||||||
txids.remove(txid);
|
});
|
||||||
stats.sent(prevout);
|
|
||||||
}
|
|
||||||
stats.update_tx_count(txids.len() as u32);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outputs: track receiving
|
|
||||||
for txout in &tx.output {
|
for txout in &tx.output {
|
||||||
let Some(bytes) = txout.addr_bytes() else {
|
let Some(bytes) = txout.addr_bytes() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
self.apply(bytes, txid, is_addition, |stats| {
|
||||||
|
if is_addition {
|
||||||
|
stats.receiving(txout);
|
||||||
|
} else {
|
||||||
|
stats.received(txout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let (stats, txids) = self.0.entry(bytes).or_default();
|
fn apply(
|
||||||
if is_addition {
|
&mut self,
|
||||||
txids.insert(txid.clone());
|
bytes: AddrBytes,
|
||||||
stats.receiving(txout);
|
txid: &Txid,
|
||||||
} else {
|
is_addition: bool,
|
||||||
txids.remove(txid);
|
update_stats: impl FnOnce(&mut AddrMempoolStats),
|
||||||
stats.received(txout);
|
) {
|
||||||
|
let mut entry = match self.0.entry(bytes) {
|
||||||
|
MapEntry::Occupied(e) => e,
|
||||||
|
MapEntry::Vacant(v) => {
|
||||||
|
if !is_addition {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
v.insert_entry(Default::default())
|
||||||
}
|
}
|
||||||
stats.update_tx_count(txids.len() as u32);
|
};
|
||||||
|
let (stats, txids) = entry.get_mut();
|
||||||
|
if is_addition {
|
||||||
|
txids.insert(txid.clone());
|
||||||
|
} else {
|
||||||
|
txids.remove(txid);
|
||||||
|
}
|
||||||
|
update_stats(stats);
|
||||||
|
let len = txids.len();
|
||||||
|
if len == 0 {
|
||||||
|
entry.remove();
|
||||||
|
} else {
|
||||||
|
stats.update_tx_count(len as u32);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ use std::{thread::sleep, time::Duration};
|
|||||||
|
|
||||||
use bitcoin::{consensus::encode, hex::FromHex};
|
use bitcoin::{consensus::encode, hex::FromHex};
|
||||||
use brk_error::{Error, Result};
|
use brk_error::{Error, Result};
|
||||||
use brk_types::{Bitcoin, BlockHash, Height, MempoolEntryInfo, Sats, Txid, Vout};
|
use brk_types::{Bitcoin, BlockHash, FeeRate, Height, MempoolEntryInfo, Sats, Txid, Vout};
|
||||||
use corepc_types::v30::{
|
use corepc_types::v30::{
|
||||||
GetBlockCount, GetBlockHash, GetBlockHeader, GetBlockHeaderVerbose, GetBlockVerboseOne,
|
GetBlockCount, GetBlockHash, GetBlockHeader, GetBlockHeaderVerbose, GetBlockVerboseOne,
|
||||||
GetBlockVerboseZero, GetBlockchainInfo, GetRawMempool, GetRawMempoolVerbose, GetTxOut,
|
GetBlockVerboseZero, GetBlockchainInfo, GetMempoolInfo, GetRawMempool, GetRawMempoolVerbose,
|
||||||
|
GetTxOut,
|
||||||
};
|
};
|
||||||
use rustc_hash::FxHashMap;
|
use rustc_hash::FxHashMap;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -172,6 +173,15 @@ impl Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Live `mempoolminfee` in sat/vB, already maxed against `minrelaytxfee`
|
||||||
|
/// per Core's contract. Wallets must pay at least this rate or bitcoind
|
||||||
|
/// will reject the broadcast; rises above the relay floor when the
|
||||||
|
/// mempool is purging by fee.
|
||||||
|
pub fn get_mempool_min_fee(&self) -> Result<FeeRate> {
|
||||||
|
let r: GetMempoolInfo = self.0.call_with_retry("getmempoolinfo", &[])?;
|
||||||
|
Ok(FeeRate::from(r.mempool_min_fee * 100_000.0))
|
||||||
|
}
|
||||||
|
|
||||||
/// Get txids of all transactions in a memory pool
|
/// Get txids of all transactions in a memory pool
|
||||||
pub fn get_raw_mempool(&self) -> Result<Vec<Txid>> {
|
pub fn get_raw_mempool(&self) -> Result<Vec<Txid>> {
|
||||||
let r: GetRawMempool = self.0.call_with_retry("getrawmempool", &[])?;
|
let r: GetRawMempool = self.0.call_with_retry("getrawmempool", &[])?;
|
||||||
|
|||||||
@@ -119,10 +119,7 @@ fn cost_basis_formatted(
|
|||||||
FxHashMap::with_capacity_and_hasher(raw.map.len(), Default::default());
|
FxHashMap::with_capacity_and_hasher(raw.map.len(), Default::default());
|
||||||
for (&price_cents, &sats) in &raw.map {
|
for (&price_cents, &sats) in &raw.map {
|
||||||
let price = Cents::from(price_cents);
|
let price = Cents::from(price_cents);
|
||||||
let key = match agg {
|
let key = agg.bucket_floor(price);
|
||||||
UrpdAggregation::Raw => price,
|
|
||||||
_ => agg.bucket_floor(price).unwrap_or(price),
|
|
||||||
};
|
|
||||||
let entry = bucketed.entry(key).or_insert((Sats::ZERO, Dollars::ZERO));
|
let entry = bucketed.entry(key).or_insert((Sats::ZERO, Dollars::ZERO));
|
||||||
entry.0 += sats;
|
entry.0 += sats;
|
||||||
if needs_realized {
|
if needs_realized {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
cmp::Ordering,
|
cmp::Ordering,
|
||||||
ops::{Add, AddAssign, Div},
|
ops::{Add, AddAssign, Div, Mul},
|
||||||
};
|
};
|
||||||
|
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
@@ -24,9 +24,35 @@ pub struct FeeRate(f64);
|
|||||||
impl FeeRate {
|
impl FeeRate {
|
||||||
pub const MIN: Self = Self(0.1);
|
pub const MIN: Self = Self(0.1);
|
||||||
|
|
||||||
pub fn new(fr: f64) -> Self {
|
pub const fn new(fr: f64) -> Self {
|
||||||
Self(fr)
|
Self(fr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Round up to the nearest multiple of `nearest`.
|
||||||
|
#[inline]
|
||||||
|
pub fn ceil_to(self, nearest: Self) -> Self {
|
||||||
|
Self((self.0 / nearest.0).ceil() * nearest.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round to the nearest multiple of `nearest`.
|
||||||
|
#[inline]
|
||||||
|
pub fn round_to(self, nearest: Self) -> Self {
|
||||||
|
Self((self.0 / nearest.0).round() * nearest.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round to 3 decimal places via `(x * 1000).round() / 1000`.
|
||||||
|
/// Divide-by-integer at the end is more numerically stable
|
||||||
|
/// than `round_to(0.001)`'s closing `* 0.001`.
|
||||||
|
#[inline]
|
||||||
|
pub fn round_milli(self) -> Self {
|
||||||
|
Self((self.0 * 1000.0).round() / 1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arithmetic mean of two rates.
|
||||||
|
#[inline]
|
||||||
|
pub fn mean(a: Self, b: Self) -> Self {
|
||||||
|
Self((a.0 + b.0) / 2.0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<(Sats, VSize)> for FeeRate {
|
impl From<(Sats, VSize)> for FeeRate {
|
||||||
@@ -88,6 +114,20 @@ impl Div<usize> for FeeRate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Div<f64> for FeeRate {
|
||||||
|
type Output = Self;
|
||||||
|
fn div(self, rhs: f64) -> Self::Output {
|
||||||
|
Self(self.0 / rhs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mul<f64> for FeeRate {
|
||||||
|
type Output = Self;
|
||||||
|
fn mul(self, rhs: f64) -> Self::Output {
|
||||||
|
Self(self.0 * rhs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<usize> for FeeRate {
|
impl From<usize> for FeeRate {
|
||||||
#[inline]
|
#[inline]
|
||||||
fn from(value: usize) -> Self {
|
fn from(value: usize) -> Self {
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use rustc_hash::FxHashMap;
|
use rustc_hash::FxHashMap;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{Bitcoin, Cents, Cohort, Date, Dollars, Sats, UrpdAggregation, UrpdBucket, UrpdRaw};
|
use crate::{
|
||||||
|
Bitcoin, Cents, CentsSats, CentsSigned, Cohort, Date, Dollars, Sats, UrpdAggregation,
|
||||||
|
UrpdBucket, UrpdRaw,
|
||||||
|
};
|
||||||
|
|
||||||
/// UTXO Realized Price Distribution for a cohort on a specific date.
|
/// UTXO Realized Price Distribution for a cohort on a specific date.
|
||||||
///
|
///
|
||||||
/// Supply is grouped by the close price at which each UTXO was last moved.
|
/// Supply is grouped by the close price at which each UTXO was last moved.
|
||||||
/// Each bucket exposes three values derived from the same `(price_floor, supply)`
|
/// Each bucket exposes three values: supply in BTC, realized cap contribution
|
||||||
/// pairs: supply in BTC, realized cap contribution in USD (`price_floor * supply`),
|
/// in USD (sum of `realized_price * supply` over the coins in the bucket), and
|
||||||
/// and unrealized P&L against that date's close in USD
|
/// unrealized P&L in USD (`close * supply - realized_cap`, can be negative).
|
||||||
/// (`(close - price_floor) * supply`, can be negative).
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
|
||||||
pub struct Urpd {
|
pub struct Urpd {
|
||||||
pub cohort: Cohort,
|
pub cohort: Cohort,
|
||||||
@@ -26,6 +26,12 @@ pub struct Urpd {
|
|||||||
pub buckets: Vec<UrpdBucket>,
|
pub buckets: Vec<UrpdBucket>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Copy)]
|
||||||
|
struct BucketAccum {
|
||||||
|
supply: Sats,
|
||||||
|
realized_cap: CentsSats,
|
||||||
|
}
|
||||||
|
|
||||||
impl Urpd {
|
impl Urpd {
|
||||||
/// Build from the raw on-disk distribution plus context.
|
/// Build from the raw on-disk distribution plus context.
|
||||||
pub fn build(
|
pub fn build(
|
||||||
@@ -35,39 +41,44 @@ impl Urpd {
|
|||||||
raw: &UrpdRaw,
|
raw: &UrpdRaw,
|
||||||
aggregation: UrpdAggregation,
|
aggregation: UrpdAggregation,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut agg: FxHashMap<Cents, Sats> =
|
let mut agg: FxHashMap<Cents, BucketAccum> =
|
||||||
FxHashMap::with_capacity_and_hasher(raw.map.len(), Default::default());
|
FxHashMap::with_capacity_and_hasher(raw.map.len(), Default::default());
|
||||||
for (&price_cents, &sats) in &raw.map {
|
for (&price_cents, &sats) in &raw.map {
|
||||||
let price = Cents::from(price_cents);
|
let price = Cents::from(price_cents);
|
||||||
let key = match aggregation {
|
let key = aggregation.bucket_floor(price);
|
||||||
UrpdAggregation::Raw => price,
|
let slot = agg.entry(key).or_default();
|
||||||
_ => aggregation.bucket_floor(price).unwrap_or(price),
|
slot.supply += sats;
|
||||||
};
|
slot.realized_cap += CentsSats::from_price_sats(price, sats);
|
||||||
*agg.entry(key).or_insert(Sats::ZERO) += sats;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let sorted: BTreeMap<Cents, Sats> = agg.into_iter().collect();
|
let mut sorted: Vec<_> = agg.into_iter().collect();
|
||||||
|
sorted.sort_unstable_by_key(|&(price, _)| price);
|
||||||
|
|
||||||
let close = Dollars::from(close_cents);
|
let close = Dollars::from(close_cents);
|
||||||
|
let total_supply: Sats = raw.map.values().copied().sum();
|
||||||
|
|
||||||
let mut total_sats = Sats::ZERO;
|
let buckets = sorted
|
||||||
let mut buckets = Vec::with_capacity(sorted.len());
|
.into_iter()
|
||||||
for (price_floor_cents, supply) in sorted {
|
.map(|(price_floor_cents, slot)| {
|
||||||
total_sats += supply;
|
let realized_cap_cents = slot.realized_cap.to_cents();
|
||||||
let price_floor = Dollars::from(price_floor_cents);
|
let close_mc_cents = CentsSats::from_price_sats(close_cents, slot.supply).to_cents();
|
||||||
buckets.push(UrpdBucket {
|
let pnl = CentsSigned::from(close_mc_cents.inner())
|
||||||
price_floor,
|
- CentsSigned::from(realized_cap_cents.inner());
|
||||||
supply: Bitcoin::from(supply),
|
UrpdBucket {
|
||||||
realized_cap: price_floor * supply,
|
price_floor: Dollars::from(price_floor_cents),
|
||||||
unrealized_pnl: (close - price_floor) * supply,
|
supply: Bitcoin::from(slot.supply),
|
||||||
});
|
realized_cap: Dollars::from(realized_cap_cents),
|
||||||
}
|
unrealized_pnl: Dollars::from(pnl),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
cohort,
|
cohort,
|
||||||
date,
|
date,
|
||||||
aggregation,
|
aggregation,
|
||||||
close,
|
close,
|
||||||
total_supply: Bitcoin::from(total_sats),
|
total_supply: Bitcoin::from(total_supply),
|
||||||
buckets,
|
buckets,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,26 +46,24 @@ impl UrpdAggregation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute bucket floor for a given price in cents.
|
/// Compute the bucket floor for a price in cents.
|
||||||
/// Returns None for Raw (no bucketing).
|
/// `Raw` is the identity (no bucketing).
|
||||||
pub fn bucket_floor(&self, price_cents: Cents) -> Option<Cents> {
|
pub fn bucket_floor(&self, price_cents: Cents) -> Cents {
|
||||||
match self {
|
match self {
|
||||||
Self::Raw => None,
|
Self::Raw => price_cents,
|
||||||
Self::Lin200 | Self::Lin500 | Self::Lin1000 => {
|
Self::Lin200 | Self::Lin500 | Self::Lin1000 => {
|
||||||
let size = self.linear_size_cents().unwrap();
|
let size = self.linear_size_cents().unwrap();
|
||||||
Some((price_cents / size) * size)
|
(price_cents / size) * size
|
||||||
}
|
}
|
||||||
Self::Log10 | Self::Log50 | Self::Log100 | Self::Log200 => {
|
Self::Log10 | Self::Log50 | Self::Log100 | Self::Log200 => {
|
||||||
if price_cents == Cents::ZERO {
|
if price_cents == Cents::ZERO {
|
||||||
return Some(Cents::ZERO);
|
return Cents::ZERO;
|
||||||
}
|
}
|
||||||
let n = self.log_buckets_per_decade().unwrap();
|
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 log_price = f64::from(price_cents).log10();
|
||||||
let bucket_idx = (n as f64 * log_price).floor() as i32;
|
let bucket_idx = (n as f64 * log_price).floor() as i32;
|
||||||
let floor = 10_f64.powf(bucket_idx as f64 / n as f64);
|
let floor = 10_f64.powf(bucket_idx as f64 / n as f64);
|
||||||
Some(Cents::from(floor.round() as u64))
|
Cents::from(floor.round() as u64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ use crate::{Bitcoin, Dollars};
|
|||||||
/// A single bucket in a URPD snapshot.
|
/// A single bucket in a URPD snapshot.
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
|
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
|
||||||
pub struct UrpdBucket {
|
pub struct UrpdBucket {
|
||||||
/// Inclusive lower bound of the bucket, in USD.
|
/// Lower bound of the bucket, in USD. Equals the exact realized price for `Raw`.
|
||||||
pub price_floor: Dollars,
|
pub price_floor: Dollars,
|
||||||
/// Supply held with a last-move price inside this bucket, in BTC.
|
/// Supply held with a last-move price inside this bucket, in BTC.
|
||||||
pub supply: Bitcoin,
|
pub supply: Bitcoin,
|
||||||
/// Realized cap contribution in USD: `price_floor * supply`.
|
/// Realized cap contribution in USD: sum of `realized_price * supply` over the coins in this bucket.
|
||||||
pub realized_cap: Dollars,
|
pub realized_cap: Dollars,
|
||||||
/// Unrealized P&L in USD against the close on the snapshot date: `(close - price_floor) * supply`. Can be negative.
|
/// Unrealized P&L in USD against the close on the snapshot date: `close * supply - realized_cap`. Can be negative.
|
||||||
pub unrealized_pnl: Dollars,
|
pub unrealized_pnl: Dollars,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1187,10 +1187,9 @@ replaced it. Omitted on the root of an RBF response.
|
|||||||
* UTXO Realized Price Distribution for a cohort on a specific date.
|
* UTXO Realized Price Distribution for a cohort on a specific date.
|
||||||
*
|
*
|
||||||
* Supply is grouped by the close price at which each UTXO was last moved.
|
* Supply is grouped by the close price at which each UTXO was last moved.
|
||||||
* Each bucket exposes three values derived from the same `(price_floor, supply)`
|
* Each bucket exposes three values: supply in BTC, realized cap contribution
|
||||||
* pairs: supply in BTC, realized cap contribution in USD (`price_floor * supply`),
|
* in USD (sum of `realized_price * supply` over the coins in the bucket), and
|
||||||
* and unrealized P&L against that date's close in USD
|
* unrealized P&L in USD (`close * supply - realized_cap`, can be negative).
|
||||||
* (`(close - price_floor) * supply`, can be negative).
|
|
||||||
*
|
*
|
||||||
* @typedef {Object} Urpd
|
* @typedef {Object} Urpd
|
||||||
* @property {Cohort} cohort
|
* @property {Cohort} cohort
|
||||||
@@ -1211,10 +1210,10 @@ replaced it. Omitted on the root of an RBF response.
|
|||||||
* A single bucket in a URPD snapshot.
|
* A single bucket in a URPD snapshot.
|
||||||
*
|
*
|
||||||
* @typedef {Object} UrpdBucket
|
* @typedef {Object} UrpdBucket
|
||||||
* @property {Dollars} priceFloor - Inclusive lower bound of the bucket, in USD.
|
* @property {Dollars} priceFloor - Lower bound of the bucket, in USD. Equals the exact realized price for `Raw`.
|
||||||
* @property {Bitcoin} supply - Supply held with a last-move price inside this bucket, in BTC.
|
* @property {Bitcoin} supply - Supply held with a last-move price inside this bucket, in BTC.
|
||||||
* @property {Dollars} realizedCap - Realized cap contribution in USD: `price_floor * supply`.
|
* @property {Dollars} realizedCap - Realized cap contribution in USD: sum of `realized_price * supply` over the coins in this bucket.
|
||||||
* @property {Dollars} unrealizedPnl - Unrealized P&L in USD against the close on the snapshot date: `(close - price_floor) * supply`. Can be negative.
|
* @property {Dollars} unrealizedPnl - Unrealized P&L in USD against the close on the snapshot date: `close * supply - realized_cap`. Can be negative.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Path parameters for per-cohort URPD endpoints.
|
* Path parameters for per-cohort URPD endpoints.
|
||||||
@@ -7272,7 +7271,7 @@ function createTransferPattern(client, acc) {
|
|||||||
* @extends BrkClientBase
|
* @extends BrkClientBase
|
||||||
*/
|
*/
|
||||||
class BrkClient extends BrkClientBase {
|
class BrkClient extends BrkClientBase {
|
||||||
VERSION = "v0.3.0-beta.6";
|
VERSION = "v0.3.0-beta.7";
|
||||||
|
|
||||||
INDEXES = /** @type {const} */ ([
|
INDEXES = /** @type {const} */ ([
|
||||||
"minute10",
|
"minute10",
|
||||||
|
|||||||
@@ -40,5 +40,5 @@
|
|||||||
"url": "git+https://github.com/bitcoinresearchkit/brk.git"
|
"url": "git+https://github.com/bitcoinresearchkit/brk.git"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.3.0-beta.6"
|
"version": "0.3.0-beta.7"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1573,10 +1573,10 @@ class UrpdBucket(TypedDict):
|
|||||||
A single bucket in a URPD snapshot.
|
A single bucket in a URPD snapshot.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
price_floor: Inclusive lower bound of the bucket, in USD.
|
price_floor: Lower bound of the bucket, in USD. Equals the exact realized price for `Raw`.
|
||||||
supply: Supply held with a last-move price inside this bucket, in BTC.
|
supply: Supply held with a last-move price inside this bucket, in BTC.
|
||||||
realized_cap: Realized cap contribution in USD: `price_floor * supply`.
|
realized_cap: Realized cap contribution in USD: sum of `realized_price * supply` over the coins in this bucket.
|
||||||
unrealized_pnl: Unrealized P&L in USD against the close on the snapshot date: `(close - price_floor) * supply`. Can be negative.
|
unrealized_pnl: Unrealized P&L in USD against the close on the snapshot date: `close * supply - realized_cap`. Can be negative.
|
||||||
"""
|
"""
|
||||||
price_floor: Dollars
|
price_floor: Dollars
|
||||||
supply: Bitcoin
|
supply: Bitcoin
|
||||||
@@ -1588,10 +1588,9 @@ class Urpd(TypedDict):
|
|||||||
UTXO Realized Price Distribution for a cohort on a specific date.
|
UTXO Realized Price Distribution for a cohort on a specific date.
|
||||||
|
|
||||||
Supply is grouped by the close price at which each UTXO was last moved.
|
Supply is grouped by the close price at which each UTXO was last moved.
|
||||||
Each bucket exposes three values derived from the same `(price_floor, supply)`
|
Each bucket exposes three values: supply in BTC, realized cap contribution
|
||||||
pairs: supply in BTC, realized cap contribution in USD (`price_floor * supply`),
|
in USD (sum of `realized_price * supply` over the coins in the bucket), and
|
||||||
and unrealized P&L against that date's close in USD
|
unrealized P&L in USD (`close * supply - realized_cap`, can be negative).
|
||||||
(`(close - price_floor) * supply`, can be negative).
|
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
aggregation: Aggregation strategy applied to the buckets.
|
aggregation: Aggregation strategy applied to the buckets.
|
||||||
@@ -6523,7 +6522,7 @@ class SeriesTree:
|
|||||||
class BrkClient(BrkClientBase):
|
class BrkClient(BrkClientBase):
|
||||||
"""Main BRK client with series tree and API methods."""
|
"""Main BRK client with series tree and API methods."""
|
||||||
|
|
||||||
VERSION = "v0.3.0-beta.6"
|
VERSION = "v0.3.0-beta.7"
|
||||||
|
|
||||||
INDEXES = [
|
INDEXES = [
|
||||||
"minute10",
|
"minute10",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "brk-client"
|
name = "brk-client"
|
||||||
version = "0.3.0-beta.6"
|
version = "0.3.0-beta.7"
|
||||||
description = "Bitcoin on-chain analytics client — thousands of metrics, block explorer, and address index"
|
description = "Bitcoin on-chain analytics client — thousands of metrics, block explorer, and address index"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
Reference in New Issue
Block a user