global: snapshot

This commit is contained in:
nym21
2026-03-06 14:40:52 +01:00
parent a935573ef8
commit 92cb184a5c
31 changed files with 741 additions and 1257 deletions
@@ -4,17 +4,18 @@ use brk_error::Result;
use brk_types::{Age, Cents, FundedAddressData, Sats, SupplyState};
use vecdb::unlikely;
use super::{super::cost_basis::RealizedState, base::CohortState};
use super::super::cost_basis::RealizedOps;
use super::base::CohortState;
/// Significant digits for address cost basis prices (after rounding to dollars).
const COST_BASIS_PRICE_DIGITS: i32 = 4;
pub struct AddressCohortState {
pub struct AddressCohortState<R: RealizedOps> {
pub addr_count: u64,
pub inner: CohortState,
pub inner: CohortState<R>,
}
impl AddressCohortState {
impl<R: RealizedOps> AddressCohortState<R> {
pub(crate) fn new(path: &Path, name: &str) -> Self {
Self {
addr_count: 0,
@@ -29,7 +30,7 @@ impl AddressCohortState {
self.inner.sent = Sats::ZERO;
self.inner.satblocks_destroyed = Sats::ZERO;
self.inner.satdays_destroyed = Sats::ZERO;
self.inner.realized = RealizedState::default();
self.inner.realized = R::default();
}
pub(crate) fn send(
@@ -3,7 +3,7 @@ use std::{collections::BTreeMap, path::Path};
use brk_error::Result;
use brk_types::{Age, Cents, CentsCompact, CentsSats, CentsSquaredSats, CostBasisSnapshot, Height, Sats, SupplyState};
use super::super::cost_basis::{CostBasisData, Percentiles, RealizedState, UnrealizedState};
use super::super::cost_basis::{CostBasisData, Percentiles, RealizedOps, UnrealizedState};
pub struct SendPrecomputed {
pub sats: Sats,
@@ -15,20 +15,54 @@ pub struct SendPrecomputed {
pub prev_investor_cap: CentsSquaredSats,
}
pub struct CohortState {
impl SendPrecomputed {
/// Pre-compute values for send_utxo when the same supply/prices are shared
/// across multiple cohorts (age_range, epoch, class).
pub(crate) fn new(
supply: &SupplyState,
current_price: Cents,
prev_price: Cents,
ath: Cents,
age: Age,
) -> Option<Self> {
if supply.utxo_count == 0 || supply.value == Sats::ZERO {
return None;
}
let sats = supply.value;
let current_ps = CentsSats::from_price_sats(current_price, sats);
let prev_ps = CentsSats::from_price_sats(prev_price, sats);
let ath_ps = if ath == current_price {
current_ps
} else {
CentsSats::from_price_sats(ath, sats)
};
let prev_investor_cap = prev_ps.to_investor_cap(prev_price);
Some(Self {
sats,
prev_price,
age,
current_ps,
prev_ps,
ath_ps,
prev_investor_cap,
})
}
}
pub struct CohortState<R: RealizedOps> {
pub supply: SupplyState,
pub realized: RealizedState,
pub realized: R,
pub sent: Sats,
pub satblocks_destroyed: Sats,
pub satdays_destroyed: Sats,
cost_basis_data: CostBasisData,
}
impl CohortState {
impl<R: RealizedOps> CohortState<R> {
pub(crate) fn new(path: &Path, name: &str) -> Self {
Self {
supply: SupplyState::default(),
realized: RealizedState::default(),
realized: R::default(),
sent: Sats::ZERO,
satblocks_destroyed: Sats::ZERO,
satdays_destroyed: Sats::ZERO,
@@ -47,7 +81,6 @@ impl CohortState {
}
/// Restore realized cap from cost_basis_data after import.
/// Uses the exact persisted values instead of recomputing from the map.
pub(crate) fn restore_realized_cap(&mut self) {
self.realized.set_cap_raw(self.cost_basis_data.cap_raw());
self.realized
@@ -170,38 +203,6 @@ impl CohortState {
}
}
/// Pre-computed values for send_utxo when the same supply/prices are shared
/// across multiple cohorts (age_range, epoch, year).
pub(crate) fn precompute_send(
supply: &SupplyState,
current_price: Cents,
prev_price: Cents,
ath: Cents,
age: Age,
) -> Option<SendPrecomputed> {
if supply.utxo_count == 0 || supply.value == Sats::ZERO {
return None;
}
let sats = supply.value;
let current_ps = CentsSats::from_price_sats(current_price, sats);
let prev_ps = CentsSats::from_price_sats(prev_price, sats);
let ath_ps = if ath == current_price {
current_ps
} else {
CentsSats::from_price_sats(ath, sats)
};
let prev_investor_cap = prev_ps.to_investor_cap(prev_price);
Some(SendPrecomputed {
sats,
prev_price,
age,
current_ps,
prev_ps,
ath_ps,
prev_investor_cap,
})
}
pub(crate) fn send_utxo_precomputed(
&mut self,
supply: &SupplyState,
@@ -227,7 +228,7 @@ impl CohortState {
ath: Cents,
age: Age,
) {
if let Some(pre) = Self::precompute_send(supply, current_price, prev_price, ath, age) {
if let Some(pre) = SendPrecomputed::new(supply, current_price, prev_price, ath, age) {
self.send_utxo_precomputed(supply, &pre);
} else if supply.utxo_count > 0 {
self.supply -= supply;
@@ -4,12 +4,13 @@ use brk_error::Result;
use brk_types::{Sats, SupplyState};
use derive_more::{Deref, DerefMut};
use super::{super::cost_basis::RealizedState, base::CohortState};
use super::super::cost_basis::RealizedOps;
use super::base::CohortState;
#[derive(Deref, DerefMut)]
pub struct UTXOCohortState(CohortState);
pub struct UTXOCohortState<R: RealizedOps>(pub(crate) CohortState<R>);
impl UTXOCohortState {
impl<R: RealizedOps> UTXOCohortState<R> {
pub(crate) fn new(path: &Path, name: &str) -> Self {
Self(CohortState::new(path, name))
}
@@ -24,6 +25,6 @@ impl UTXOCohortState {
self.0.sent = Sats::ZERO;
self.0.satblocks_destroyed = Sats::ZERO;
self.0.satdays_destroyed = Sats::ZERO;
self.0.realized = RealizedState::default();
self.0.realized = R::default();
}
}
@@ -2,19 +2,131 @@ use std::cmp::Ordering;
use brk_types::{Cents, CentsSats, CentsSquaredSats, Sats};
/// Realized state using u128 for raw cent*sat values internally.
/// This avoids overflow and defers division to output time for efficiency.
/// Trait for realized state operations, implemented by both Core and Full variants.
/// Core skips extra fields (value_created/destroyed, peak_regret, sent_in_profit/loss, investor_cap).
pub trait RealizedOps: Default + Clone + Send + Sync + 'static {
fn cap(&self) -> Cents;
fn profit(&self) -> Cents;
fn loss(&self) -> Cents;
fn set_cap_raw(&mut self, cap_raw: CentsSats);
fn set_investor_cap_raw(&mut self, investor_cap_raw: CentsSquaredSats);
fn reset_single_iteration_values(&mut self);
fn increment(&mut self, price: Cents, sats: Sats);
fn increment_snapshot(&mut self, price_sats: CentsSats, investor_cap: CentsSquaredSats);
fn decrement_snapshot(&mut self, price_sats: CentsSats, investor_cap: CentsSquaredSats);
fn receive(&mut self, price: Cents, sats: Sats) {
self.increment(price, sats);
}
fn send(
&mut self,
sats: Sats,
current_ps: CentsSats,
prev_ps: CentsSats,
ath_ps: CentsSats,
prev_investor_cap: CentsSquaredSats,
);
}
/// Core realized state: only cap, profit, loss (48 bytes).
/// Used by CoreCohortMetrics and MinimalCohortMetrics cohorts
/// (epoch, class, amount_range, type_ — ~50 separate cohorts).
#[derive(Debug, Default, Clone)]
pub struct CoreRealizedState {
cap_raw: u128,
profit_raw: u128,
loss_raw: u128,
}
impl RealizedOps for CoreRealizedState {
#[inline]
fn cap(&self) -> Cents {
if self.cap_raw == 0 {
return Cents::ZERO;
}
Cents::new((self.cap_raw / Sats::ONE_BTC_U128) as u64)
}
#[inline]
fn profit(&self) -> Cents {
if self.profit_raw == 0 {
return Cents::ZERO;
}
Cents::new((self.profit_raw / Sats::ONE_BTC_U128) as u64)
}
#[inline]
fn loss(&self) -> Cents {
if self.loss_raw == 0 {
return Cents::ZERO;
}
Cents::new((self.loss_raw / Sats::ONE_BTC_U128) as u64)
}
#[inline]
fn set_cap_raw(&mut self, cap_raw: CentsSats) {
self.cap_raw = cap_raw.inner();
}
#[inline]
fn set_investor_cap_raw(&mut self, _investor_cap_raw: CentsSquaredSats) {
// no-op for Core
}
#[inline]
fn reset_single_iteration_values(&mut self) {
self.profit_raw = 0;
self.loss_raw = 0;
}
#[inline]
fn increment(&mut self, price: Cents, sats: Sats) {
if sats.is_zero() {
return;
}
let price_sats = CentsSats::from_price_sats(price, sats);
self.cap_raw += price_sats.as_u128();
}
#[inline]
fn increment_snapshot(&mut self, price_sats: CentsSats, _investor_cap: CentsSquaredSats) {
self.cap_raw += price_sats.as_u128();
}
#[inline]
fn decrement_snapshot(&mut self, price_sats: CentsSats, _investor_cap: CentsSquaredSats) {
self.cap_raw -= price_sats.as_u128();
}
#[inline]
fn send(
&mut self,
_sats: Sats,
current_ps: CentsSats,
prev_ps: CentsSats,
_ath_ps: CentsSats,
_prev_investor_cap: CentsSquaredSats,
) {
match current_ps.cmp(&prev_ps) {
Ordering::Greater => {
self.profit_raw += (current_ps - prev_ps).as_u128();
}
Ordering::Less => {
self.loss_raw += (prev_ps - current_ps).as_u128();
}
Ordering::Equal => {}
}
self.cap_raw -= prev_ps.as_u128();
}
}
/// Full realized state (~160 bytes).
/// Used by BasicCohortMetrics and CompleteCohortMetrics cohorts
/// (age_range — 21 separate cohorts).
#[derive(Debug, Default, Clone)]
pub struct RealizedState {
/// Raw realized cap: Σ(price × sats)
cap_raw: u128,
core: CoreRealizedState,
/// Raw investor cap: Σ(price² × sats)
/// investor_price = investor_cap_raw / cap_raw (gives cents directly)
investor_cap_raw: CentsSquaredSats,
/// Raw realized profit (cents * sats)
profit_raw: u128,
/// Raw realized loss (cents * sats)
loss_raw: u128,
/// sell_price × sats for profit cases
profit_value_created_raw: u128,
/// cost_basis × sats for profit cases
@@ -31,43 +143,120 @@ pub struct RealizedState {
sent_in_loss: Sats,
}
impl RealizedState {
/// Get realized cap as CentsUnsigned (divides by ONE_BTC).
impl RealizedOps for RealizedState {
#[inline]
pub(crate) fn cap(&self) -> Cents {
if self.cap_raw == 0 {
return Cents::ZERO;
}
Cents::new((self.cap_raw / Sats::ONE_BTC_U128) as u64)
fn cap(&self) -> Cents {
self.core.cap()
}
/// Set cap_raw directly from persisted value.
#[inline]
pub(crate) fn set_cap_raw(&mut self, cap_raw: CentsSats) {
self.cap_raw = cap_raw.inner();
fn profit(&self) -> Cents {
self.core.profit()
}
/// Set investor_cap_raw directly from persisted value.
#[inline]
pub(crate) fn set_investor_cap_raw(&mut self, investor_cap_raw: CentsSquaredSats) {
fn loss(&self) -> Cents {
self.core.loss()
}
#[inline]
fn set_cap_raw(&mut self, cap_raw: CentsSats) {
self.core.set_cap_raw(cap_raw);
}
#[inline]
fn set_investor_cap_raw(&mut self, investor_cap_raw: CentsSquaredSats) {
self.investor_cap_raw = investor_cap_raw;
}
#[inline]
fn reset_single_iteration_values(&mut self) {
self.core.reset_single_iteration_values();
self.profit_value_created_raw = 0;
self.profit_value_destroyed_raw = 0;
self.loss_value_created_raw = 0;
self.loss_value_destroyed_raw = 0;
self.peak_regret_raw = 0;
self.sent_in_profit = Sats::ZERO;
self.sent_in_loss = Sats::ZERO;
}
#[inline]
fn increment(&mut self, price: Cents, sats: Sats) {
if sats.is_zero() {
return;
}
let price_sats = CentsSats::from_price_sats(price, sats);
self.core.cap_raw += price_sats.as_u128();
self.investor_cap_raw += price_sats.to_investor_cap(price);
}
#[inline]
fn increment_snapshot(&mut self, price_sats: CentsSats, investor_cap: CentsSquaredSats) {
self.core.cap_raw += price_sats.as_u128();
self.investor_cap_raw += investor_cap;
}
#[inline]
fn decrement_snapshot(&mut self, price_sats: CentsSats, investor_cap: CentsSquaredSats) {
self.core.cap_raw -= price_sats.as_u128();
self.investor_cap_raw -= investor_cap;
}
#[inline]
fn send(
&mut self,
sats: Sats,
current_ps: CentsSats,
prev_ps: CentsSats,
ath_ps: CentsSats,
prev_investor_cap: CentsSquaredSats,
) {
match current_ps.cmp(&prev_ps) {
Ordering::Greater => {
self.core.profit_raw += (current_ps - prev_ps).as_u128();
self.profit_value_created_raw += current_ps.as_u128();
self.profit_value_destroyed_raw += prev_ps.as_u128();
self.sent_in_profit += sats;
}
Ordering::Less => {
self.core.loss_raw += (prev_ps - current_ps).as_u128();
self.loss_value_created_raw += current_ps.as_u128();
self.loss_value_destroyed_raw += prev_ps.as_u128();
self.sent_in_loss += sats;
}
Ordering::Equal => {
// Break-even: count as profit side (arbitrary but consistent)
self.profit_value_created_raw += current_ps.as_u128();
self.profit_value_destroyed_raw += prev_ps.as_u128();
self.sent_in_profit += sats;
}
}
// Track peak regret: (peak - sell_price) × sats
self.peak_regret_raw += (ath_ps - current_ps).as_u128();
// Inline decrement to avoid recomputation
self.core.cap_raw -= prev_ps.as_u128();
self.investor_cap_raw -= prev_investor_cap;
}
}
impl RealizedState {
/// Get investor price as CentsUnsigned.
/// investor_price = Σ(price² × sats) / Σ(price × sats)
/// This is the dollar-weighted average acquisition price.
#[inline]
pub(crate) fn investor_price(&self) -> Cents {
if self.cap_raw == 0 {
if self.core.cap_raw == 0 {
return Cents::ZERO;
}
Cents::new((self.investor_cap_raw / self.cap_raw) as u64)
Cents::new((self.investor_cap_raw / self.core.cap_raw) as u64)
}
/// Get raw realized cap for aggregation.
#[inline]
pub(crate) fn cap_raw(&self) -> CentsSats {
CentsSats::new(self.cap_raw)
CentsSats::new(self.core.cap_raw)
}
/// Get raw investor cap for aggregation.
@@ -76,24 +265,6 @@ impl RealizedState {
self.investor_cap_raw
}
/// Get realized profit as CentsUnsigned.
#[inline]
pub(crate) fn profit(&self) -> Cents {
if self.profit_raw == 0 {
return Cents::ZERO;
}
Cents::new((self.profit_raw / Sats::ONE_BTC_U128) as u64)
}
/// Get realized loss as CentsUnsigned.
#[inline]
pub(crate) fn loss(&self) -> Cents {
if self.loss_raw == 0 {
return Cents::ZERO;
}
Cents::new((self.loss_raw / Sats::ONE_BTC_U128) as u64)
}
/// Get profit value created as CentsUnsigned (sell_price × sats for profit cases).
#[inline]
pub(crate) fn profit_value_created(&self) -> Cents {
@@ -104,7 +275,6 @@ impl RealizedState {
}
/// Get profit value destroyed as CentsUnsigned (cost_basis × sats for profit cases).
/// This is also known as profit_flow.
#[inline]
pub(crate) fn profit_value_destroyed(&self) -> Cents {
if self.profit_value_destroyed_raw == 0 {
@@ -123,7 +293,6 @@ impl RealizedState {
}
/// Get loss value destroyed as CentsUnsigned (cost_basis × sats for loss cases).
/// This is also known as capitulation_flow.
#[inline]
pub(crate) fn loss_value_destroyed(&self) -> Cents {
if self.loss_value_destroyed_raw == 0 {
@@ -133,8 +302,6 @@ impl RealizedState {
}
/// Get realized peak regret as CentsUnsigned.
/// This is Σ((peak - sell_price) × sats) - how much more could have been made
/// by selling at peak instead of when actually sold.
#[inline]
pub(crate) fn peak_regret(&self) -> Cents {
if self.peak_regret_raw == 0 {
@@ -154,93 +321,4 @@ impl RealizedState {
pub(crate) fn sent_in_loss(&self) -> Sats {
self.sent_in_loss
}
pub(crate) fn reset_single_iteration_values(&mut self) {
self.profit_raw = 0;
self.loss_raw = 0;
self.profit_value_created_raw = 0;
self.profit_value_destroyed_raw = 0;
self.loss_value_created_raw = 0;
self.loss_value_destroyed_raw = 0;
self.peak_regret_raw = 0;
self.sent_in_profit = Sats::ZERO;
self.sent_in_loss = Sats::ZERO;
}
/// Increment using pre-computed values (for UTXO path)
#[inline]
pub(crate) fn increment(&mut self, price: Cents, sats: Sats) {
if sats.is_zero() {
return;
}
let price_sats = CentsSats::from_price_sats(price, sats);
self.cap_raw += price_sats.as_u128();
self.investor_cap_raw += price_sats.to_investor_cap(price);
}
/// Increment using pre-computed snapshot values (for address path)
#[inline]
pub(crate) fn increment_snapshot(
&mut self,
price_sats: CentsSats,
investor_cap: CentsSquaredSats,
) {
self.cap_raw += price_sats.as_u128();
self.investor_cap_raw += investor_cap;
}
/// Decrement using pre-computed snapshot values (for address path)
#[inline]
pub(crate) fn decrement_snapshot(
&mut self,
price_sats: CentsSats,
investor_cap: CentsSquaredSats,
) {
self.cap_raw -= price_sats.as_u128();
self.investor_cap_raw -= investor_cap;
}
#[inline]
pub(crate) fn receive(&mut self, price: Cents, sats: Sats) {
self.increment(price, sats);
}
/// Send with pre-computed typed values. Inlines decrement to avoid recomputation.
#[inline]
pub(crate) fn send(
&mut self,
sats: Sats,
current_ps: CentsSats,
prev_ps: CentsSats,
ath_ps: CentsSats,
prev_investor_cap: CentsSquaredSats,
) {
match current_ps.cmp(&prev_ps) {
Ordering::Greater => {
self.profit_raw += (current_ps - prev_ps).as_u128();
self.profit_value_created_raw += current_ps.as_u128();
self.profit_value_destroyed_raw += prev_ps.as_u128();
self.sent_in_profit += sats;
}
Ordering::Less => {
self.loss_raw += (prev_ps - current_ps).as_u128();
self.loss_value_created_raw += current_ps.as_u128();
self.loss_value_destroyed_raw += prev_ps.as_u128();
self.sent_in_loss += sats;
}
Ordering::Equal => {
// Break-even: count as profit side (arbitrary but consistent)
self.profit_value_created_raw += current_ps.as_u128();
self.profit_value_destroyed_raw += prev_ps.as_u128();
self.sent_in_profit += sats;
}
}
// Track peak regret: (peak - sell_price) × sats
self.peak_regret_raw += (ath_ps - current_ps).as_u128();
// Inline decrement to avoid recomputation
self.cap_raw -= prev_ps.as_u128();
self.investor_cap_raw -= prev_investor_cap;
}
}