diff --git a/crates/brk_client/src/lib.rs b/crates/brk_client/src/lib.rs index eb413dcfc..c1cc12089 100644 --- a/crates/brk_client/src/lib.rs +++ b/crates/brk_client/src/lib.rs @@ -1591,6 +1591,32 @@ impl _1m1w1y24hBpsPercentRatioPattern { } } +/// Pattern struct for repeated tree structure. +pub struct CoindaysCoinyearsDormancySentVelocityPattern { + pub coindays_destroyed: CumulativeRawSumPattern, + pub coindays_destroyed_supply_adjusted: MetricPattern1, + pub coinyears_destroyed: MetricPattern1, + pub coinyears_destroyed_supply_adjusted: MetricPattern1, + pub dormancy: MetricPattern1, + pub sent: RawSumPattern3, + pub velocity: MetricPattern1, +} + +impl CoindaysCoinyearsDormancySentVelocityPattern { + /// Create a new pattern node with accumulated metric name. + pub fn new(client: Arc, acc: String) -> Self { + Self { + coindays_destroyed: CumulativeRawSumPattern::new(client.clone(), _m(&acc, "coindays_destroyed")), + coindays_destroyed_supply_adjusted: MetricPattern1::new(client.clone(), _m(&acc, "coindays_destroyed_supply_adjusted")), + coinyears_destroyed: MetricPattern1::new(client.clone(), _m(&acc, "coinyears_destroyed")), + coinyears_destroyed_supply_adjusted: MetricPattern1::new(client.clone(), _m(&acc, "coinyears_destroyed_supply_adjusted")), + dormancy: MetricPattern1::new(client.clone(), _m(&acc, "dormancy")), + sent: RawSumPattern3::new(client.clone(), _m(&acc, "sent")), + velocity: MetricPattern1::new(client.clone(), _m(&acc, "velocity")), + } + } +} + /// Pattern struct for repeated tree structure. pub struct CumulativeDistributionRawRelSumValuePattern { pub cumulative: MetricPattern1, @@ -2225,26 +2251,6 @@ impl CentsRelUsdPattern2 { } } -/// Pattern struct for repeated tree structure. -pub struct CoindaysDormancySentVelocityPattern { - pub coindays_destroyed: CumulativeRawSumPattern, - pub dormancy: MetricPattern1, - pub sent: RawSumPattern3, - pub velocity: MetricPattern1, -} - -impl CoindaysDormancySentVelocityPattern { - /// Create a new pattern node with accumulated metric name. - pub fn new(client: Arc, acc: String) -> Self { - Self { - coindays_destroyed: CumulativeRawSumPattern::new(client.clone(), _m(&acc, "coindays_destroyed")), - dormancy: MetricPattern1::new(client.clone(), _m(&acc, "dormancy")), - sent: RawSumPattern3::new(client.clone(), _m(&acc, "sent")), - velocity: MetricPattern1::new(client.clone(), _m(&acc, "velocity")), - } - } -} - /// Pattern struct for repeated tree structure. pub struct CumulativeNegativeRawSumPattern { pub cumulative: MetricPattern1, @@ -4245,6 +4251,7 @@ pub struct MetricsTree_Cointime_Cap { pub vaulted_cap: CentsUsdPattern, pub active_cap: CentsUsdPattern, pub cointime_cap: CentsUsdPattern, + pub aviv: BpsRatioPattern, } impl MetricsTree_Cointime_Cap { @@ -4255,6 +4262,7 @@ impl MetricsTree_Cointime_Cap { vaulted_cap: CentsUsdPattern::new(client.clone(), "vaulted_cap".to_string()), active_cap: CentsUsdPattern::new(client.clone(), "active_cap".to_string()), cointime_cap: CentsUsdPattern::new(client.clone(), "cointime_cap".to_string()), + aviv: BpsRatioPattern::new(client.clone(), "aviv_ratio".to_string()), } } } @@ -6796,7 +6804,7 @@ impl MetricsTree_Distribution_UtxoCohorts { pub struct MetricsTree_Distribution_UtxoCohorts_All { pub supply: MetricsTree_Distribution_UtxoCohorts_All_Supply, pub outputs: UtxoPattern3, - pub activity: CoindaysDormancySentVelocityPattern, + pub activity: CoindaysCoinyearsDormancySentVelocityPattern, pub realized: CapGrossInvestorLossMvrvNetNuplPeakPriceProfitSentSoprPattern, pub cost_basis: InvestedMaxMinPercentilesPattern, pub unrealized: MetricsTree_Distribution_UtxoCohorts_All_Unrealized, @@ -6807,7 +6815,7 @@ impl MetricsTree_Distribution_UtxoCohorts_All { Self { supply: MetricsTree_Distribution_UtxoCohorts_All_Supply::new(client.clone(), format!("{base_path}_supply")), outputs: UtxoPattern3::new(client.clone(), "utxo_count".to_string()), - activity: CoindaysDormancySentVelocityPattern::new(client.clone(), "".to_string()), + activity: CoindaysCoinyearsDormancySentVelocityPattern::new(client.clone(), "".to_string()), realized: CapGrossInvestorLossMvrvNetNuplPeakPriceProfitSentSoprPattern::new(client.clone(), "".to_string()), cost_basis: InvestedMaxMinPercentilesPattern::new(client.clone(), "".to_string()), unrealized: MetricsTree_Distribution_UtxoCohorts_All_Unrealized::new(client.clone(), format!("{base_path}_unrealized")), @@ -6923,7 +6931,7 @@ pub struct MetricsTree_Distribution_UtxoCohorts_Sth { pub realized: CapGrossInvestorLossMvrvNetNuplPeakPriceProfitSentSoprPattern, pub supply: DeltaHalvedRelTotalPattern2, pub outputs: UtxoPattern3, - pub activity: CoindaysDormancySentVelocityPattern, + pub activity: CoindaysCoinyearsDormancySentVelocityPattern, pub cost_basis: InvestedMaxMinPercentilesPattern, pub unrealized: GrossInvestedInvestorLossNetProfitSentimentPattern2, } @@ -6934,7 +6942,7 @@ impl MetricsTree_Distribution_UtxoCohorts_Sth { realized: CapGrossInvestorLossMvrvNetNuplPeakPriceProfitSentSoprPattern::new(client.clone(), "sth".to_string()), supply: DeltaHalvedRelTotalPattern2::new(client.clone(), "sth_supply".to_string()), outputs: UtxoPattern3::new(client.clone(), "sth_utxo_count".to_string()), - activity: CoindaysDormancySentVelocityPattern::new(client.clone(), "sth".to_string()), + activity: CoindaysCoinyearsDormancySentVelocityPattern::new(client.clone(), "sth".to_string()), cost_basis: InvestedMaxMinPercentilesPattern::new(client.clone(), "sth".to_string()), unrealized: GrossInvestedInvestorLossNetProfitSentimentPattern2::new(client.clone(), "sth".to_string()), } @@ -6945,7 +6953,7 @@ impl MetricsTree_Distribution_UtxoCohorts_Sth { pub struct MetricsTree_Distribution_UtxoCohorts_Lth { pub supply: DeltaHalvedRelTotalPattern2, pub outputs: UtxoPattern3, - pub activity: CoindaysDormancySentVelocityPattern, + pub activity: CoindaysCoinyearsDormancySentVelocityPattern, pub realized: MetricsTree_Distribution_UtxoCohorts_Lth_Realized, pub cost_basis: InvestedMaxMinPercentilesPattern, pub unrealized: GrossInvestedInvestorLossNetProfitSentimentPattern2, @@ -6956,7 +6964,7 @@ impl MetricsTree_Distribution_UtxoCohorts_Lth { Self { supply: DeltaHalvedRelTotalPattern2::new(client.clone(), "lth_supply".to_string()), outputs: UtxoPattern3::new(client.clone(), "lth_utxo_count".to_string()), - activity: CoindaysDormancySentVelocityPattern::new(client.clone(), "lth".to_string()), + activity: CoindaysCoinyearsDormancySentVelocityPattern::new(client.clone(), "lth".to_string()), realized: MetricsTree_Distribution_UtxoCohorts_Lth_Realized::new(client.clone(), format!("{base_path}_realized")), cost_basis: InvestedMaxMinPercentilesPattern::new(client.clone(), "lth".to_string()), unrealized: GrossInvestedInvestorLossNetProfitSentimentPattern2::new(client.clone(), "lth".to_string()), diff --git a/crates/brk_computer/src/cointime/cap/compute.rs b/crates/brk_computer/src/cointime/cap/compute.rs index d2bd15faa..29e13545e 100644 --- a/crates/brk_computer/src/cointime/cap/compute.rs +++ b/crates/brk_computer/src/cointime/cap/compute.rs @@ -64,6 +64,14 @@ impl Vecs { exit, )?; + // AVIV = active_cap / investor_cap + self.aviv.compute_ratio( + starting_indexes, + &self.active_cap.cents.height, + &self.investor_cap.cents.height, + exit, + )?; + Ok(()) } } diff --git a/crates/brk_computer/src/cointime/cap/import.rs b/crates/brk_computer/src/cointime/cap/import.rs index ea3b122c8..9b59cff90 100644 --- a/crates/brk_computer/src/cointime/cap/import.rs +++ b/crates/brk_computer/src/cointime/cap/import.rs @@ -3,7 +3,7 @@ use brk_types::Version; use vecdb::Database; use super::Vecs; -use crate::{indexes, internal::FiatPerBlock}; +use crate::{indexes, internal::{FiatPerBlock, RatioPerBlock}}; impl Vecs { pub(crate) fn forced_import( @@ -17,6 +17,7 @@ impl Vecs { vaulted_cap: FiatPerBlock::forced_import(db, "vaulted_cap", version, indexes)?, active_cap: FiatPerBlock::forced_import(db, "active_cap", version, indexes)?, cointime_cap: FiatPerBlock::forced_import(db, "cointime_cap", version, indexes)?, + aviv: RatioPerBlock::forced_import(db, "aviv", version, indexes)?, }) } } diff --git a/crates/brk_computer/src/cointime/cap/vecs.rs b/crates/brk_computer/src/cointime/cap/vecs.rs index ab657bd15..6b5267c3d 100644 --- a/crates/brk_computer/src/cointime/cap/vecs.rs +++ b/crates/brk_computer/src/cointime/cap/vecs.rs @@ -1,8 +1,8 @@ use brk_traversable::Traversable; -use brk_types::Cents; +use brk_types::{BasisPoints32, Cents}; use vecdb::{Rw, StorageMode}; -use crate::internal::FiatPerBlock; +use crate::internal::{FiatPerBlock, RatioPerBlock}; #[derive(Traversable)] pub struct Vecs { @@ -11,4 +11,5 @@ pub struct Vecs { pub vaulted_cap: FiatPerBlock, pub active_cap: FiatPerBlock, pub cointime_cap: FiatPerBlock, + pub aviv: RatioPerBlock, } diff --git a/crates/brk_computer/src/distribution/cohorts/utxo/vecs/mod.rs b/crates/brk_computer/src/distribution/cohorts/utxo/vecs/mod.rs index c7c805874..a5775b7cf 100644 --- a/crates/brk_computer/src/distribution/cohorts/utxo/vecs/mod.rs +++ b/crates/brk_computer/src/distribution/cohorts/utxo/vecs/mod.rs @@ -64,14 +64,14 @@ pub struct UTXOCohortVecs { state_starting_height: Option, #[traversable(skip)] - pub state: Option>>, + pub state: Option>>, #[traversable(flatten)] pub metrics: M, } impl UTXOCohortVecs { - pub(crate) fn new(state: Option>>, metrics: M) -> Self { + pub(crate) fn new(state: Option>>, metrics: M) -> Self { Self { state_starting_height: None, state, diff --git a/crates/brk_computer/src/distribution/metrics/activity/core.rs b/crates/brk_computer/src/distribution/metrics/activity/core.rs index eb824598e..abe66a680 100644 --- a/crates/brk_computer/src/distribution/metrics/activity/core.rs +++ b/crates/brk_computer/src/distribution/metrics/activity/core.rs @@ -5,7 +5,7 @@ use vecdb::{AnyStoredVec, AnyVec, Exit, Rw, StorageMode, WritableVec}; use crate::{ blocks, - distribution::{metrics::ImportConfig, state::{CohortState, RealizedOps}}, + distribution::{metrics::ImportConfig, state::{CohortState, CostBasisOps, RealizedOps}}, internal::PerBlockWithSum24h, }; @@ -35,7 +35,7 @@ impl ActivityCore { pub(crate) fn truncate_push( &mut self, height: Height, - state: &CohortState, + state: &CohortState, ) -> Result<()> { self.sent.raw.height.truncate_push(height, state.sent)?; self.coindays_destroyed.raw.height.truncate_push( diff --git a/crates/brk_computer/src/distribution/metrics/activity/full.rs b/crates/brk_computer/src/distribution/metrics/activity/full.rs index ca3a92a2a..48356e152 100644 --- a/crates/brk_computer/src/distribution/metrics/activity/full.rs +++ b/crates/brk_computer/src/distribution/metrics/activity/full.rs @@ -2,11 +2,11 @@ use brk_error::Result; use brk_traversable::Traversable; use brk_types::{Bitcoin, Height, Indexes, Sats, StoredF32, StoredF64, Version}; use derive_more::{Deref, DerefMut}; -use vecdb::{AnyStoredVec, Exit, ReadableVec, Rw, StorageMode}; +use vecdb::{AnyStoredVec, Exit, ReadableCloneableVec, ReadableVec, Rw, StorageMode}; -use crate::internal::{ComputedPerBlock, RollingWindowsFrom1w}; +use crate::internal::{ComputedPerBlock, Identity, LazyPerBlock, RollingWindowsFrom1w}; -use crate::{blocks, distribution::{metrics::ImportConfig, state::{CohortState, RealizedOps}}}; +use crate::{blocks, distribution::{metrics::ImportConfig, state::{CohortState, CostBasisOps, RealizedOps}}}; use super::ActivityCore; @@ -25,20 +25,37 @@ pub struct ActivityFull { #[traversable(wrap = "sent", rename = "sum")] pub sent_sum_extended: RollingWindowsFrom1w, + pub coinyears_destroyed: LazyPerBlock, + pub dormancy: ComputedPerBlock, pub velocity: ComputedPerBlock, + pub coindays_destroyed_supply_adjusted: ComputedPerBlock, + pub coinyears_destroyed_supply_adjusted: ComputedPerBlock, } impl ActivityFull { pub(crate) fn forced_import(cfg: &ImportConfig) -> Result { let v1 = Version::ONE; + let coindays_destroyed_sum: RollingWindowsFrom1w = + cfg.import("coindays_destroyed", v1)?; + + let coinyears_destroyed = LazyPerBlock::from_computed::>( + &cfg.name("coinyears_destroyed"), + v1, + coindays_destroyed_sum._1y.height.read_only_boxed_clone(), + &coindays_destroyed_sum._1y, + ); + Ok(Self { inner: ActivityCore::forced_import(cfg)?, coindays_destroyed_cumulative: cfg.import("coindays_destroyed_cumulative", v1)?, - coindays_destroyed_sum: cfg.import("coindays_destroyed", v1)?, + coindays_destroyed_sum, sent_sum_extended: cfg.import("sent", v1)?, + coinyears_destroyed, dormancy: cfg.import("dormancy", v1)?, velocity: cfg.import("velocity", v1)?, + coindays_destroyed_supply_adjusted: cfg.import("coindays_destroyed_supply_adjusted", v1)?, + coinyears_destroyed_supply_adjusted: cfg.import("coinyears_destroyed_supply_adjusted", v1)?, }) } @@ -49,7 +66,7 @@ impl ActivityFull { pub(crate) fn full_truncate_push( &mut self, height: Height, - state: &CohortState, + state: &CohortState, ) -> Result<()> { self.inner.truncate_push(height, state) } @@ -58,6 +75,8 @@ impl ActivityFull { let mut vecs = self.inner.collect_vecs_mut(); vecs.push(&mut self.dormancy.height); vecs.push(&mut self.velocity.height); + vecs.push(&mut self.coindays_destroyed_supply_adjusted.height); + vecs.push(&mut self.coinyears_destroyed_supply_adjusted.height); vecs } @@ -142,6 +161,38 @@ impl ActivityFull { exit, )?; + // Supply-Adjusted CDD = sum_24h(CDD) / circulating_supply + self.coindays_destroyed_supply_adjusted.height.compute_transform2( + starting_indexes.height, + &self.inner.coindays_destroyed.sum._24h.height, + supply_total_sats, + |(i, cdd_24h, supply_sats, ..)| { + let supply = f64::from(Bitcoin::from(supply_sats)); + if supply == 0.0 { + (i, StoredF32::from(0.0f32)) + } else { + (i, StoredF32::from((f64::from(cdd_24h) / supply) as f32)) + } + }, + exit, + )?; + + // Supply-Adjusted CYD = CYD / circulating_supply (CYD = 1y rolling sum of CDD) + self.coinyears_destroyed_supply_adjusted.height.compute_transform2( + starting_indexes.height, + &self.coinyears_destroyed.height, + supply_total_sats, + |(i, cdd_1y, supply_sats, ..)| { + let supply = f64::from(Bitcoin::from(supply_sats)); + if supply == 0.0 { + (i, StoredF32::from(0.0f32)) + } else { + (i, StoredF32::from((f64::from(cdd_1y) / supply) as f32)) + } + }, + exit, + )?; + Ok(()) } } diff --git a/crates/brk_computer/src/distribution/metrics/activity/mod.rs b/crates/brk_computer/src/distribution/metrics/activity/mod.rs index e86af7873..379245b1c 100644 --- a/crates/brk_computer/src/distribution/metrics/activity/mod.rs +++ b/crates/brk_computer/src/distribution/metrics/activity/mod.rs @@ -8,7 +8,7 @@ use brk_error::Result; use brk_types::{Height, Indexes, Version}; use vecdb::Exit; -use crate::{blocks, distribution::state::{CohortState, RealizedOps}}; +use crate::{blocks, distribution::state::{CohortState, CostBasisOps, RealizedOps}}; pub trait ActivityLike: Send + Sync { fn as_core(&self) -> &ActivityCore; @@ -17,7 +17,7 @@ pub trait ActivityLike: Send + Sync { fn truncate_push( &mut self, height: Height, - state: &CohortState, + state: &CohortState, ) -> Result<()>; fn validate_computed_versions(&mut self, base_version: Version) -> Result<()>; fn compute_from_stateful( @@ -38,7 +38,7 @@ impl ActivityLike for ActivityCore { fn as_core(&self) -> &ActivityCore { self } fn as_core_mut(&mut self) -> &mut ActivityCore { self } fn min_len(&self) -> usize { self.min_len() } - fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { + fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { self.truncate_push(height, state) } fn validate_computed_versions(&mut self, base_version: Version) -> Result<()> { @@ -56,7 +56,7 @@ impl ActivityLike for ActivityFull { fn as_core(&self) -> &ActivityCore { &self.inner } fn as_core_mut(&mut self) -> &mut ActivityCore { &mut self.inner } fn min_len(&self) -> usize { self.full_min_len() } - fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { + fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { self.full_truncate_push(height, state) } fn validate_computed_versions(&mut self, base_version: Version) -> Result<()> { diff --git a/crates/brk_computer/src/distribution/metrics/mod.rs b/crates/brk_computer/src/distribution/metrics/mod.rs index c541f0245..dc3bc8b74 100644 --- a/crates/brk_computer/src/distribution/metrics/mod.rs +++ b/crates/brk_computer/src/distribution/metrics/mod.rs @@ -66,35 +66,43 @@ use brk_error::Result; use brk_types::{Cents, Height, Indexes, Version}; use vecdb::{AnyStoredVec, Exit, StorageMode}; -use crate::{blocks, distribution::state::{CohortState, CoreRealizedState, MinimalRealizedState, RealizedOps, RealizedState}, prices}; +use crate::{blocks, distribution::state::{CohortState, CostBasisData, CostBasisOps, CostBasisRaw, CoreRealizedState, MinimalRealizedState, RealizedOps, RealizedState}, prices}; pub trait CohortMetricsState { type Realized: RealizedOps; + type CostBasis: CostBasisOps; } impl CohortMetricsState for TypeCohortMetrics { type Realized = MinimalRealizedState; + type CostBasis = CostBasisData; } impl CohortMetricsState for MinimalCohortMetrics { type Realized = MinimalRealizedState; + type CostBasis = CostBasisRaw; } impl CohortMetricsState for CoreCohortMetrics { type Realized = CoreRealizedState; + type CostBasis = CostBasisData; } impl CohortMetricsState for BasicCohortMetrics { type Realized = RealizedState; + type CostBasis = CostBasisData; } impl CohortMetricsState for ExtendedCohortMetrics { type Realized = RealizedState; + type CostBasis = CostBasisData; } impl CohortMetricsState for ExtendedAdjustedCohortMetrics { type Realized = RealizedState; + type CostBasis = CostBasisData; } impl CohortMetricsState for AllCohortMetrics { type Realized = RealizedState; + type CostBasis = CostBasisData; } -pub trait CohortMetricsBase: CohortMetricsState + Send + Sync { +pub trait CohortMetricsBase: CohortMetricsState + Send + Sync { type ActivityVecs: ActivityLike; type RealizedVecs: RealizedLike; type UnrealizedVecs: UnrealizedLike; @@ -134,7 +142,7 @@ pub trait CohortMetricsBase: CohortMetricsState + Send &mut self, height: Height, height_price: Cents, - state: &mut CohortState, + state: &mut CohortState, ) -> Result<()> { state.apply_pending(); let unrealized_state = state.compute_unrealized_state(height_price); @@ -154,7 +162,7 @@ pub trait CohortMetricsBase: CohortMetricsState + Send .min(self.unrealized().min_stateful_height_len()) } - fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { + fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { self.supply_mut().truncate_push(height, state)?; self.outputs_mut().truncate_push(height, state)?; self.activity_mut().truncate_push(height, state)?; diff --git a/crates/brk_computer/src/distribution/metrics/outputs/base.rs b/crates/brk_computer/src/distribution/metrics/outputs/base.rs index 2d927fe93..6e1d4a424 100644 --- a/crates/brk_computer/src/distribution/metrics/outputs/base.rs +++ b/crates/brk_computer/src/distribution/metrics/outputs/base.rs @@ -3,7 +3,7 @@ use brk_traversable::Traversable; use brk_types::{Height, Indexes, StoredU64, Version}; use vecdb::{AnyStoredVec, AnyVec, Exit, Rw, StorageMode, WritableVec}; -use crate::{distribution::state::{CohortState, RealizedOps}, internal::ComputedPerBlock}; +use crate::{distribution::state::{CohortState, CostBasisOps, RealizedOps}, internal::ComputedPerBlock}; use crate::distribution::metrics::ImportConfig; @@ -24,7 +24,7 @@ impl OutputsBase { self.utxo_count.height.len() } - pub(crate) fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { + pub(crate) fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { self.utxo_count .height .truncate_push(height, StoredU64::from(state.supply.utxo_count))?; diff --git a/crates/brk_computer/src/distribution/metrics/realized/core.rs b/crates/brk_computer/src/distribution/metrics/realized/core.rs index 3f0c15e93..aa3d870c2 100644 --- a/crates/brk_computer/src/distribution/metrics/realized/core.rs +++ b/crates/brk_computer/src/distribution/metrics/realized/core.rs @@ -8,7 +8,7 @@ use vecdb::{ use crate::{ blocks, - distribution::state::{CohortState, RealizedOps}, + distribution::state::{CohortState, CostBasisOps, RealizedOps}, internal::{ AmountPerBlockWithSum24h, ComputedPerBlock, FiatRollingDelta1m, LazyPerBlock, NegCentsUnsignedToDollars, PerBlockWithSum24h, RatioCents64, @@ -92,7 +92,7 @@ impl RealizedCore { .min(self.sent.in_loss.raw.sats.height.len()) } - pub(crate) fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { + pub(crate) fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { self.minimal.truncate_push(height, state)?; self.sent .in_profit diff --git a/crates/brk_computer/src/distribution/metrics/realized/full.rs b/crates/brk_computer/src/distribution/metrics/realized/full.rs index 33917b1f1..c079eeb72 100644 --- a/crates/brk_computer/src/distribution/metrics/realized/full.rs +++ b/crates/brk_computer/src/distribution/metrics/realized/full.rs @@ -12,7 +12,7 @@ use vecdb::{ use crate::{ blocks, - distribution::state::{CohortState, RealizedState}, + distribution::state::{CohortState, CostBasisData, RealizedState}, internal::{ CentsUnsignedToDollars, ComputedPerBlock, ComputedPerBlockCumulative, FiatPerBlock, FiatRollingDelta1m, FiatRollingDeltaExcept1m, LazyPerBlock, PercentPerBlock, @@ -295,7 +295,7 @@ impl RealizedFull { pub(crate) fn truncate_push( &mut self, height: Height, - state: &CohortState, + state: &CohortState, ) -> Result<()> { self.core.truncate_push(height, state)?; self.profit diff --git a/crates/brk_computer/src/distribution/metrics/realized/minimal.rs b/crates/brk_computer/src/distribution/metrics/realized/minimal.rs index e6366a8e9..e9c493d33 100644 --- a/crates/brk_computer/src/distribution/metrics/realized/minimal.rs +++ b/crates/brk_computer/src/distribution/metrics/realized/minimal.rs @@ -10,7 +10,7 @@ use vecdb::{ use crate::{ blocks, - distribution::state::{CohortState, RealizedOps}, + distribution::state::{CohortState, CostBasisOps, RealizedOps}, internal::{ ComputedPerBlock, FiatPerBlock, FiatPerBlockWithSum24h, Identity, LazyPerBlock, PerBlockWithSum24h, Price, RatioPerBlock, @@ -83,7 +83,7 @@ impl RealizedMinimal { .min(self.sopr.value_destroyed.raw.height.len()) } - pub(crate) fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { + pub(crate) fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { self.cap.cents.height.truncate_push(height, state.realized.cap())?; self.profit.raw.cents.height.truncate_push(height, state.realized.profit())?; self.loss.raw.cents.height.truncate_push(height, state.realized.loss())?; diff --git a/crates/brk_computer/src/distribution/metrics/realized/mod.rs b/crates/brk_computer/src/distribution/metrics/realized/mod.rs index a8b76a128..67eb484c1 100644 --- a/crates/brk_computer/src/distribution/metrics/realized/mod.rs +++ b/crates/brk_computer/src/distribution/metrics/realized/mod.rs @@ -12,7 +12,7 @@ use brk_error::Result; use brk_types::{Height, Indexes}; use vecdb::Exit; -use crate::{blocks, distribution::state::{CohortState, RealizedState}}; +use crate::{blocks, distribution::state::{CohortState, CostBasisData, RealizedState}}; /// Polymorphic dispatch for realized metric types. /// @@ -23,7 +23,7 @@ pub trait RealizedLike: Send + Sync { fn as_core(&self) -> &RealizedCore; fn as_core_mut(&mut self) -> &mut RealizedCore; fn min_stateful_height_len(&self) -> usize; - fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()>; + fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()>; fn compute_rest_part1(&mut self, blocks: &blocks::Vecs, starting_indexes: &Indexes, exit: &Exit) -> Result<()>; fn compute_from_stateful( &mut self, @@ -37,7 +37,7 @@ impl RealizedLike for RealizedCore { fn as_core(&self) -> &RealizedCore { self } fn as_core_mut(&mut self) -> &mut RealizedCore { self } fn min_stateful_height_len(&self) -> usize { self.min_stateful_height_len() } - fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { + fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { self.truncate_push(height, state) } fn compute_rest_part1(&mut self, blocks: &blocks::Vecs, starting_indexes: &Indexes, exit: &Exit) -> Result<()> { @@ -52,7 +52,7 @@ impl RealizedLike for RealizedFull { fn as_core(&self) -> &RealizedCore { &self.core } fn as_core_mut(&mut self) -> &mut RealizedCore { &mut self.core } fn min_stateful_height_len(&self) -> usize { self.min_stateful_height_len() } - fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { + fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { self.truncate_push(height, state) } fn compute_rest_part1(&mut self, blocks: &blocks::Vecs, starting_indexes: &Indexes, exit: &Exit) -> Result<()> { diff --git a/crates/brk_computer/src/distribution/metrics/supply/base.rs b/crates/brk_computer/src/distribution/metrics/supply/base.rs index c6b23e8a7..9e89b378f 100644 --- a/crates/brk_computer/src/distribution/metrics/supply/base.rs +++ b/crates/brk_computer/src/distribution/metrics/supply/base.rs @@ -3,7 +3,7 @@ use brk_traversable::Traversable; use brk_types::{Height, Indexes, Version}; use vecdb::{AnyStoredVec, AnyVec, Exit, Rw, StorageMode, WritableVec}; -use crate::{distribution::state::{CohortState, RealizedOps}, prices}; +use crate::{distribution::state::{CohortState, CostBasisOps, RealizedOps}, prices}; use crate::internal::{ AmountPerBlock, HalveCents, HalveDollars, HalveSats, HalveSatsToBitcoin, @@ -40,7 +40,7 @@ impl SupplyBase { self.total.sats.height.len() } - pub(crate) fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { + pub(crate) fn truncate_push(&mut self, height: Height, state: &CohortState) -> Result<()> { self.total.sats.height.truncate_push(height, state.supply.value)?; Ok(()) } diff --git a/crates/brk_computer/src/distribution/state/cohort/address.rs b/crates/brk_computer/src/distribution/state/cohort/address.rs index da6ed2491..94afbd961 100644 --- a/crates/brk_computer/src/distribution/state/cohort/address.rs +++ b/crates/brk_computer/src/distribution/state/cohort/address.rs @@ -4,7 +4,7 @@ use brk_error::Result; use brk_types::{Age, Cents, FundedAddressData, Sats, SupplyState}; use vecdb::unlikely; -use super::super::cost_basis::RealizedOps; +use super::super::cost_basis::{CostBasisRaw, RealizedOps}; use super::base::CohortState; /// Significant digits for address cost basis prices (after rounding to dollars). @@ -12,7 +12,7 @@ const COST_BASIS_PRICE_DIGITS: i32 = 4; pub struct AddressCohortState { pub addr_count: u64, - pub inner: CohortState, + pub inner: CohortState, } impl AddressCohortState { diff --git a/crates/brk_computer/src/distribution/state/cohort/base.rs b/crates/brk_computer/src/distribution/state/cohort/base.rs index 2636e4097..ce948eca6 100644 --- a/crates/brk_computer/src/distribution/state/cohort/base.rs +++ b/crates/brk_computer/src/distribution/state/cohort/base.rs @@ -1,9 +1,9 @@ -use std::{collections::BTreeMap, path::Path}; +use std::path::Path; use brk_error::Result; use brk_types::{Age, Cents, CentsCompact, CentsSats, CentsSquaredSats, CostBasisSnapshot, Height, Sats, SupplyState}; -use super::super::cost_basis::{CostBasisData, PendingDelta, RealizedOps, UnrealizedState}; +use super::super::cost_basis::{CostBasisData, CostBasisOps, PendingDelta, RealizedOps, UnrealizedState}; pub struct SendPrecomputed { pub sats: Sats, @@ -49,54 +49,50 @@ impl SendPrecomputed { } } -pub struct CohortState { +pub struct CohortState { pub supply: SupplyState, pub realized: R, pub sent: Sats, pub satdays_destroyed: Sats, - cost_basis_data: CostBasisData, + cost_basis: C, } -impl CohortState { +impl CohortState { pub(crate) fn new(path: &Path, name: &str) -> Self { Self { supply: SupplyState::default(), realized: R::default(), sent: Sats::ZERO, satdays_destroyed: Sats::ZERO, - cost_basis_data: CostBasisData::create(path, name), + cost_basis: C::create(path, name), } } /// Enable price rounding for cost basis data. pub(crate) fn with_price_rounding(mut self, digits: i32) -> Self { - self.cost_basis_data = self.cost_basis_data.with_price_rounding(digits); + self.cost_basis = self.cost_basis.with_price_rounding(digits); self } pub(crate) fn import_at_or_before(&mut self, height: Height) -> Result { - self.cost_basis_data.import_at_or_before(height) + self.cost_basis.import_at_or_before(height) } - /// Restore realized cap from cost_basis_data after import. + /// Restore realized cap from cost_basis after import. pub(crate) fn restore_realized_cap(&mut self) { - self.realized.set_cap_raw(self.cost_basis_data.cap_raw()); + self.realized.set_cap_raw(self.cost_basis.cap_raw()); self.realized - .set_investor_cap_raw(self.cost_basis_data.investor_cap_raw()); + .set_investor_cap_raw(self.cost_basis.investor_cap_raw()); } pub(crate) fn reset_cost_basis_data_if_needed(&mut self) -> Result<()> { - self.cost_basis_data.clean()?; - self.cost_basis_data.init(); + self.cost_basis.clean()?; + self.cost_basis.init(); Ok(()) } - pub(crate) fn for_each_cost_basis_pending(&self, f: impl FnMut(&CentsCompact, &PendingDelta)) { - self.cost_basis_data.for_each_pending(f); - } - pub(crate) fn apply_pending(&mut self) { - self.cost_basis_data.apply_pending(); + self.cost_basis.apply_pending(); } pub(crate) fn reset_single_iteration_values(&mut self) { @@ -113,7 +109,7 @@ impl CohortState { if s.supply_state.value > Sats::ZERO { self.realized .increment_snapshot(s.price_sats, s.investor_cap); - self.cost_basis_data.increment( + self.cost_basis.increment( s.realized_price, s.supply_state.value, s.price_sats, @@ -128,7 +124,7 @@ impl CohortState { if s.supply_state.value > Sats::ZERO { self.realized .decrement_snapshot(s.price_sats, s.investor_cap); - self.cost_basis_data.decrement( + self.cost_basis.decrement( s.realized_price, s.supply_state.value, s.price_sats, @@ -153,7 +149,7 @@ impl CohortState { if supply.value > Sats::ZERO { self.realized.receive(snapshot.realized_price, supply.value); - self.cost_basis_data.increment( + self.cost_basis.increment( snapshot.realized_price, supply.value, snapshot.price_sats, @@ -175,7 +171,7 @@ impl CohortState { self.realized.receive(price, supply.value); if current.supply_state.value.is_not_zero() { - self.cost_basis_data.increment( + self.cost_basis.increment( current.realized_price, current.supply_state.value, current.price_sats, @@ -184,7 +180,7 @@ impl CohortState { } if prev.supply_state.value.is_not_zero() { - self.cost_basis_data.decrement( + self.cost_basis.decrement( prev.realized_price, prev.supply_state.value, prev.price_sats, @@ -208,7 +204,7 @@ impl CohortState { self.realized .send(pre.sats, pre.current_ps, pre.prev_ps, pre.ath_ps, pre.prev_investor_cap); - self.cost_basis_data + self.cost_basis .decrement(pre.prev_price, pre.sats, pre.prev_ps, pre.prev_investor_cap); } @@ -262,7 +258,7 @@ impl CohortState { .send(sats, current_ps, prev_ps, ath_ps, prev_investor_cap); if current.supply_state.value.is_not_zero() { - self.cost_basis_data.increment( + self.cost_basis.increment( current.realized_price, current.supply_state.value, current.price_sats, @@ -271,7 +267,7 @@ impl CohortState { } if prev.supply_state.value.is_not_zero() { - self.cost_basis_data.decrement( + self.cost_basis.decrement( prev.realized_price, prev.supply_state.value, prev.price_sats, @@ -281,15 +277,22 @@ impl CohortState { } } - pub(crate) fn compute_unrealized_state(&mut self, height_price: Cents) -> UnrealizedState { - self.cost_basis_data.compute_unrealized_state(height_price) - } - pub(crate) fn write(&mut self, height: Height, cleanup: bool) -> Result<()> { - self.cost_basis_data.write(height, cleanup) - } - - pub(crate) fn cost_basis_map(&self) -> &BTreeMap { - self.cost_basis_data.map() + self.cost_basis.write(height, cleanup) + } +} + +/// Methods only available with full CostBasisData (map + unrealized). +impl CohortState { + pub(crate) fn compute_unrealized_state(&mut self, height_price: Cents) -> UnrealizedState { + self.cost_basis.compute_unrealized_state(height_price) + } + + pub(crate) fn for_each_cost_basis_pending(&self, f: impl FnMut(&CentsCompact, &PendingDelta)) { + self.cost_basis.for_each_pending(f); + } + + pub(crate) fn cost_basis_map(&self) -> &std::collections::BTreeMap { + self.cost_basis.map() } } diff --git a/crates/brk_computer/src/distribution/state/cohort/utxo.rs b/crates/brk_computer/src/distribution/state/cohort/utxo.rs index 6068159a6..297fab4ae 100644 --- a/crates/brk_computer/src/distribution/state/cohort/utxo.rs +++ b/crates/brk_computer/src/distribution/state/cohort/utxo.rs @@ -4,13 +4,13 @@ use brk_error::Result; use brk_types::{Sats, SupplyState}; use derive_more::{Deref, DerefMut}; -use super::super::cost_basis::RealizedOps; +use super::super::cost_basis::{CostBasisOps, RealizedOps}; use super::base::CohortState; #[derive(Deref, DerefMut)] -pub struct UTXOCohortState(pub(crate) CohortState); +pub struct UTXOCohortState(pub(crate) CohortState); -impl UTXOCohortState { +impl UTXOCohortState { pub(crate) fn new(path: &Path, name: &str) -> Self { Self(CohortState::new(path, name)) } diff --git a/crates/brk_computer/src/distribution/state/cost_basis/data.rs b/crates/brk_computer/src/distribution/state/cost_basis/data.rs index b839da37f..2444a463c 100644 --- a/crates/brk_computer/src/distribution/state/cost_basis/data.rs +++ b/crates/brk_computer/src/distribution/state/cost_basis/data.rs @@ -24,6 +24,15 @@ struct PendingRaw { investor_cap_dec: CentsSquaredSats, } +impl PendingRaw { + fn is_zero(&self) -> bool { + self.cap_inc == CentsSats::ZERO + && self.cap_dec == CentsSats::ZERO + && self.investor_cap_inc == CentsSquaredSats::ZERO + && self.investor_cap_dec == CentsSquaredSats::ZERO + } +} + /// Pending increments and decrements for a single price bucket. #[derive(Clone, Copy, Debug, Default)] pub struct PendingDelta { @@ -31,261 +40,79 @@ pub struct PendingDelta { pub dec: Sats, } -#[derive(Clone, Debug)] -pub struct CostBasisData { - pathbuf: PathBuf, - state: Option, - pending: FxHashMap, - pending_raw: PendingRaw, - cache: Option, - rounding_digits: Option, - /// Monotonically increasing counter, bumped on each apply_pending with actual changes. - generation: u64, -} - const STATE_TO_KEEP: usize = 10; -impl CostBasisData { - pub(crate) fn create(path: &Path, name: &str) -> Self { - Self { - pathbuf: path.join(format!("{name}_cost_basis")), - state: None, - pending: FxHashMap::default(), - pending_raw: PendingRaw::default(), - cache: None, - rounding_digits: None, - generation: 0, - } - } - - pub(crate) fn with_price_rounding(mut self, digits: i32) -> Self { - self.rounding_digits = Some(digits); - self - } - - #[inline] - fn round_price(&self, price: Cents) -> Cents { - match self.rounding_digits { - Some(digits) => price.round_to_dollar(digits), - None => price, - } - } - - pub(crate) fn import_at_or_before(&mut self, height: Height) -> Result { - let files = self.read_dir(None)?; - let (&height, path) = files.range(..=height).next_back().ok_or(Error::NotFound( - "No cost basis state found at or before height".into(), - ))?; - self.state = Some(State::deserialize(&fs::read(path)?)?); - self.pending.clear(); - self.pending_raw = PendingRaw::default(); - self.cache = None; - Ok(height) - } - - fn assert_pending_empty(&self) { - debug_assert!( - self.pending.is_empty() && self.pending_raw_is_zero(), - "CostBasisData: pending not empty, call apply_pending first" - ); - } - - fn pending_raw_is_zero(&self) -> bool { - self.pending_raw.cap_inc == CentsSats::ZERO - && self.pending_raw.cap_dec == CentsSats::ZERO - && self.pending_raw.investor_cap_inc == CentsSquaredSats::ZERO - && self.pending_raw.investor_cap_dec == CentsSquaredSats::ZERO - } - - pub(crate) fn map(&self) -> &CostBasisMap { - self.assert_pending_empty(); - &self.state.as_ref().unwrap().base.map - } - - pub(crate) fn is_empty(&self) -> bool { - self.pending.is_empty() && self.state.as_ref().unwrap().base.map.is_empty() - } - - /// Get the exact cap_raw value (not recomputed from map). - pub(crate) fn cap_raw(&self) -> CentsSats { - self.assert_pending_empty(); - self.state.as_ref().unwrap().cap_raw - } - - /// Get the exact investor_cap_raw value (not recomputed from map). - pub(crate) fn investor_cap_raw(&self) -> CentsSquaredSats { - self.assert_pending_empty(); - self.state.as_ref().unwrap().investor_cap_raw - } - - /// Increment with pre-computed typed values. - /// Handles rounding and cache update. - pub(crate) fn increment( +/// Common interface for cost basis tracking. +/// +/// Implemented by `CostBasisRaw` (scalars only) and `CostBasisData` (full map + scalars). +pub trait CostBasisOps: Send + Sync + 'static { + fn create(path: &Path, name: &str) -> Self; + fn with_price_rounding(self, digits: i32) -> Self; + fn import_at_or_before(&mut self, height: Height) -> Result; + fn cap_raw(&self) -> CentsSats; + fn investor_cap_raw(&self) -> CentsSquaredSats; + fn increment( &mut self, price: Cents, sats: Sats, price_sats: CentsSats, investor_cap: CentsSquaredSats, - ) { - let price = self.round_price(price); - self.pending.entry(price.into()).or_default().inc += sats; - self.pending_raw.cap_inc += price_sats; - if investor_cap != CentsSquaredSats::ZERO { - self.pending_raw.investor_cap_inc += investor_cap; - } - if let Some(cache) = self.cache.as_mut() { - cache.on_receive(price, sats); - } - } - - /// Decrement with pre-computed typed values. - /// Handles rounding and cache update. - pub(crate) fn decrement( + ); + fn decrement( &mut self, price: Cents, sats: Sats, price_sats: CentsSats, investor_cap: CentsSquaredSats, - ) { - let price = self.round_price(price); - self.pending.entry(price.into()).or_default().dec += sats; - self.pending_raw.cap_dec += price_sats; - if investor_cap != CentsSquaredSats::ZERO { - self.pending_raw.investor_cap_dec += investor_cap; - } - if let Some(cache) = self.cache.as_mut() { - cache.on_send(price, sats); - } + ); + fn apply_pending(&mut self); + fn init(&mut self); + fn clean(&mut self) -> Result<()>; + fn write(&mut self, height: Height, cleanup: bool) -> Result<()>; +} + +// ─── CostBasisRaw ─────────────────────────────────────────────────────────── + +#[derive(Clone, Default, Debug)] +struct RawState { + cap_raw: CentsSats, +} + +impl RawState { + fn serialize(&self) -> Vec { + self.cap_raw.to_bytes().to_vec() } - pub(crate) fn for_each_pending(&self, mut f: impl FnMut(&CentsCompact, &PendingDelta)) { - self.pending.iter().for_each(|(k, v)| f(k, v)); + fn deserialize(data: &[u8]) -> Result { + Ok(Self { + cap_raw: CentsSats::from_bytes(&data[0..16])?, + }) } +} - pub(crate) fn apply_pending(&mut self) { - if self.pending.is_empty() { - return; - } - self.generation = self.generation.wrapping_add(1); - let map = &mut self.state.as_mut().unwrap().base.map; - for (cents, PendingDelta { inc, dec }) in self.pending.drain() { - match map.entry(cents) { - Entry::Occupied(mut e) => { - *e.get_mut() += inc; - if unlikely(*e.get() < dec) { - panic!( - "CostBasisData::apply_pending underflow!\n\ - Path: {:?}\n\ - Price: {}\n\ - Current + increments: {}\n\ - Trying to decrement by: {}", - self.pathbuf, - cents.to_dollars(), - e.get(), - dec - ); - } - *e.get_mut() -= dec; - if *e.get() == Sats::ZERO { - e.remove(); - } - } - Entry::Vacant(e) => { - if unlikely(inc < dec) { - panic!( - "CostBasisData::apply_pending underflow (new entry)!\n\ - Path: {:?}\n\ - Price: {}\n\ - Increment: {}\n\ - Trying to decrement by: {}", - self.pathbuf, - cents.to_dollars(), - inc, - dec - ); - } - let val = inc - dec; - if val != Sats::ZERO { - e.insert(val); - } - } - } - } +/// Lightweight cost basis tracking: only cap_raw and investor_cap_raw scalars. +/// No BTreeMap, no unrealized computation, no pending map. +/// Used by cohorts that only need realized cap on restart (amount_range, address). +#[derive(Clone, Debug)] +pub struct CostBasisRaw { + pathbuf: PathBuf, + state: Option, + pending_raw: PendingRaw, +} - // Apply raw values - let state = self.state.as_mut().unwrap(); - state.cap_raw += self.pending_raw.cap_inc; - - // Check for underflow before subtracting - if unlikely(state.cap_raw.inner() < self.pending_raw.cap_dec.inner()) { - panic!( - "CostBasisData::apply_pending cap_raw underflow!\n\ - Path: {:?}\n\ - Current cap_raw (after increments): {}\n\ - Trying to decrement by: {}", - self.pathbuf, state.cap_raw, self.pending_raw.cap_dec - ); - } - state.cap_raw -= self.pending_raw.cap_dec; - - // Only process investor_cap if there are non-zero values - let has_investor_cap = self.pending_raw.investor_cap_inc != CentsSquaredSats::ZERO - || self.pending_raw.investor_cap_dec != CentsSquaredSats::ZERO; - - if has_investor_cap { - state.investor_cap_raw += self.pending_raw.investor_cap_inc; - - if unlikely(state.investor_cap_raw.inner() < self.pending_raw.investor_cap_dec.inner()) { - panic!( - "CostBasisData::apply_pending investor_cap_raw underflow!\n\ - Path: {:?}\n\ - Current investor_cap_raw (after increments): {}\n\ - Trying to decrement by: {}", - self.pathbuf, state.investor_cap_raw, self.pending_raw.investor_cap_dec - ); - } - state.investor_cap_raw -= self.pending_raw.investor_cap_dec; - } - - self.pending_raw = PendingRaw::default(); - } - - pub(crate) fn init(&mut self) { - self.state.replace(State::default()); - self.pending.clear(); - self.pending_raw = PendingRaw::default(); - self.cache = None; - } - - pub(crate) fn compute_unrealized_state(&mut self, height_price: Cents) -> UnrealizedState { - if self.is_empty() { - return UnrealizedState::ZERO; - } - - let map = &self.state.as_ref().unwrap().base.map; - - if let Some(cache) = self.cache.as_mut() { - cache.get_at_price(height_price, map) - } else { - let cache = CachedUnrealizedState::compute_fresh(height_price, map); - let state = cache.current_state(); - self.cache = Some(cache); - state - } - } - - pub(crate) fn clean(&mut self) -> Result<()> { - let _ = fs::remove_dir_all(&self.pathbuf); - fs::create_dir_all(self.path_by_height())?; - self.cache = None; - Ok(()) - } - - fn path_by_height(&self) -> PathBuf { +impl CostBasisRaw { + pub(super) fn path_by_height(&self) -> PathBuf { self.pathbuf.join("by_height") } - fn read_dir(&self, keep_only_before: Option) -> Result> { + pub(super) fn path_state(&self, height: Height) -> PathBuf { + self.path_by_height().join(height.to_string()) + } + + pub(super) fn read_dir( + &self, + keep_only_before: Option, + ) -> Result> { let by_height = self.path_by_height(); if !by_height.exists() { return Ok(BTreeMap::new()); @@ -305,15 +132,33 @@ impl CostBasisData { None } }) - .collect::>()) + .collect()) } - pub(crate) fn write(&mut self, height: Height, cleanup: bool) -> Result<()> { - self.apply_pending(); + fn apply_pending_raw(&mut self) { + if self.pending_raw.is_zero() { + return; + } + let state = self.state.as_mut().unwrap(); + state.cap_raw += self.pending_raw.cap_inc; + if unlikely(state.cap_raw.inner() < self.pending_raw.cap_dec.inner()) { + panic!( + "CostBasis cap_raw underflow!\n\ + Path: {:?}\n\ + Current cap_raw (after increments): {}\n\ + Trying to decrement by: {}", + self.pathbuf, state.cap_raw, self.pending_raw.cap_dec + ); + } + state.cap_raw -= self.pending_raw.cap_dec; + + self.pending_raw = PendingRaw::default(); + } + + fn write_and_cleanup(&mut self, height: Height, cleanup: bool) -> Result<()> { if cleanup { let files = self.read_dir(Some(height))?; - for (_, path) in files .iter() .take(files.len().saturating_sub(STATE_TO_KEEP - 1)) @@ -321,46 +166,309 @@ impl CostBasisData { fs::remove_file(path)?; } } + Ok(()) + } +} - fs::write( - self.path_state(height), - self.state.as_ref().unwrap().serialize()?, - )?; +impl CostBasisOps for CostBasisRaw { + fn create(path: &Path, name: &str) -> Self { + Self { + pathbuf: path.join(format!("{name}_cost_basis")), + state: None, + pending_raw: PendingRaw::default(), + } + } + fn with_price_rounding(self, _digits: i32) -> Self { + self + } + + fn import_at_or_before(&mut self, height: Height) -> Result { + let files = self.read_dir(None)?; + let (&height, path) = files.range(..=height).next_back().ok_or(Error::NotFound( + "No cost basis state found at or before height".into(), + ))?; + let data = fs::read(path)?; + // Handle both formats: full (map + raw at end) and raw-only (16 bytes). + self.state = Some(if data.len() == 16 { + RawState::deserialize(&data)? + } else { + let (_, rest) = CostBasisDistribution::deserialize_with_rest(&data)?; + RawState::deserialize(rest)? + }); + self.pending_raw = PendingRaw::default(); + Ok(height) + } + + fn cap_raw(&self) -> CentsSats { + debug_assert!(self.pending_raw.is_zero()); + self.state.as_ref().unwrap().cap_raw + } + + fn investor_cap_raw(&self) -> CentsSquaredSats { + CentsSquaredSats::ZERO + } + + #[inline] + fn increment( + &mut self, + _price: Cents, + _sats: Sats, + price_sats: CentsSats, + _investor_cap: CentsSquaredSats, + ) { + self.pending_raw.cap_inc += price_sats; + } + + #[inline] + fn decrement( + &mut self, + _price: Cents, + _sats: Sats, + price_sats: CentsSats, + _investor_cap: CentsSquaredSats, + ) { + self.pending_raw.cap_dec += price_sats; + } + + fn apply_pending(&mut self) { + self.apply_pending_raw(); + } + + fn init(&mut self) { + self.state.replace(RawState::default()); + self.pending_raw = PendingRaw::default(); + } + + fn clean(&mut self) -> Result<()> { + let _ = fs::remove_dir_all(&self.pathbuf); + fs::create_dir_all(self.path_by_height())?; Ok(()) } - fn path_state(&self, height: Height) -> PathBuf { - self.path_by_height().join(height.to_string()) + fn write(&mut self, height: Height, cleanup: bool) -> Result<()> { + self.apply_pending_raw(); + self.write_and_cleanup(height, cleanup)?; + fs::write( + self.path_state(height), + self.state.as_ref().unwrap().serialize(), + )?; + Ok(()) } } -#[derive(Clone, Default, Debug)] -struct State { - base: CostBasisDistribution, - /// Exact realized cap: Σ(price × sats) - cap_raw: CentsSats, - /// Exact investor cap: Σ(price² × sats) - investor_cap_raw: CentsSquaredSats, +// ─── CostBasisData ────────────────────────────────────────────────────────── + +/// Full cost basis tracking: BTreeMap distribution + raw scalars. +/// Composes `CostBasisRaw` for scalar tracking, adds map, pending, and cache. +/// Used by cohorts that need unrealized computation or Fenwick tree. +#[derive(Clone, Debug)] +pub struct CostBasisData { + raw: CostBasisRaw, + map: Option, + pending: FxHashMap, + cache: Option, + rounding_digits: Option, + /// Monotonically increasing counter, bumped on each apply_pending with actual changes. + generation: u64, } -impl State { - fn serialize(&self) -> Result> { - let mut buffer = self.base.serialize()?; - buffer.extend(self.cap_raw.to_bytes()); - buffer.extend(self.investor_cap_raw.to_bytes()); - Ok(buffer) +impl CostBasisData { + #[inline] + fn round_price(&self, price: Cents) -> Cents { + match self.rounding_digits { + Some(digits) => price.round_to_dollar(digits), + None => price, + } } - fn deserialize(data: &[u8]) -> Result { - let (base, rest) = CostBasisDistribution::deserialize_with_rest(data)?; - let cap_raw = CentsSats::from_bytes(&rest[0..16])?; - let investor_cap_raw = CentsSquaredSats::from_bytes(&rest[16..32])?; + pub(crate) fn map(&self) -> &CostBasisMap { + debug_assert!(self.pending.is_empty() && self.raw.pending_raw.is_zero()); + &self.map.as_ref().unwrap().map + } - Ok(Self { - base, - cap_raw, - investor_cap_raw, - }) + pub(crate) fn is_empty(&self) -> bool { + self.pending.is_empty() && self.map.as_ref().unwrap().map.is_empty() + } + + pub(crate) fn for_each_pending(&self, mut f: impl FnMut(&CentsCompact, &PendingDelta)) { + self.pending.iter().for_each(|(k, v)| f(k, v)); + } + + pub(crate) fn compute_unrealized_state(&mut self, height_price: Cents) -> UnrealizedState { + if self.is_empty() { + return UnrealizedState::ZERO; + } + + let map = &self.map.as_ref().unwrap().map; + + if let Some(cache) = self.cache.as_mut() { + cache.get_at_price(height_price, map) + } else { + let cache = CachedUnrealizedState::compute_fresh(height_price, map); + let state = cache.current_state(); + self.cache = Some(cache); + state + } + } + + fn apply_map_pending(&mut self) { + if self.pending.is_empty() { + return; + } + self.generation = self.generation.wrapping_add(1); + let map = &mut self.map.as_mut().unwrap().map; + for (cents, PendingDelta { inc, dec }) in self.pending.drain() { + match map.entry(cents) { + Entry::Occupied(mut e) => { + *e.get_mut() += inc; + if unlikely(*e.get() < dec) { + panic!( + "CostBasisData::apply_pending underflow!\n\ + Path: {:?}\n\ + Price: {}\n\ + Current + increments: {}\n\ + Trying to decrement by: {}", + self.raw.pathbuf, + cents.to_dollars(), + e.get(), + dec + ); + } + *e.get_mut() -= dec; + if *e.get() == Sats::ZERO { + e.remove(); + } + } + Entry::Vacant(e) => { + if unlikely(inc < dec) { + panic!( + "CostBasisData::apply_pending underflow (new entry)!\n\ + Path: {:?}\n\ + Price: {}\n\ + Increment: {}\n\ + Trying to decrement by: {}", + self.raw.pathbuf, + cents.to_dollars(), + inc, + dec + ); + } + let val = inc - dec; + if val != Sats::ZERO { + e.insert(val); + } + } + } + } + } +} + +impl CostBasisOps for CostBasisData { + fn create(path: &Path, name: &str) -> Self { + Self { + raw: CostBasisRaw::create(path, name), + map: None, + pending: FxHashMap::default(), + cache: None, + rounding_digits: None, + generation: 0, + } + } + + fn with_price_rounding(mut self, digits: i32) -> Self { + self.rounding_digits = Some(digits); + self + } + + fn import_at_or_before(&mut self, height: Height) -> Result { + let files = self.raw.read_dir(None)?; + let (&height, path) = files.range(..=height).next_back().ok_or(Error::NotFound( + "No cost basis state found at or before height".into(), + ))?; + let data = fs::read(path)?; + let (base, rest) = CostBasisDistribution::deserialize_with_rest(&data)?; + self.map = Some(base); + self.raw.state = Some(RawState::deserialize(rest)?); + self.pending.clear(); + self.raw.pending_raw = PendingRaw::default(); + self.cache = None; + Ok(height) + } + + fn cap_raw(&self) -> CentsSats { + self.raw.cap_raw() + } + + fn investor_cap_raw(&self) -> CentsSquaredSats { + self.raw.investor_cap_raw() + } + + #[inline] + fn increment( + &mut self, + price: Cents, + sats: Sats, + price_sats: CentsSats, + investor_cap: CentsSquaredSats, + ) { + let price = self.round_price(price); + self.pending.entry(price.into()).or_default().inc += sats; + self.raw.pending_raw.cap_inc += price_sats; + if investor_cap != CentsSquaredSats::ZERO { + self.raw.pending_raw.investor_cap_inc += investor_cap; + } + if let Some(cache) = self.cache.as_mut() { + cache.on_receive(price, sats); + } + } + + #[inline] + fn decrement( + &mut self, + price: Cents, + sats: Sats, + price_sats: CentsSats, + investor_cap: CentsSquaredSats, + ) { + let price = self.round_price(price); + self.pending.entry(price.into()).or_default().dec += sats; + self.raw.pending_raw.cap_dec += price_sats; + if investor_cap != CentsSquaredSats::ZERO { + self.raw.pending_raw.investor_cap_dec += investor_cap; + } + if let Some(cache) = self.cache.as_mut() { + cache.on_send(price, sats); + } + } + + fn apply_pending(&mut self) { + self.apply_map_pending(); + self.raw.apply_pending_raw(); + } + + fn init(&mut self) { + self.raw.init(); + self.map.replace(CostBasisDistribution::default()); + self.pending.clear(); + self.cache = None; + } + + fn clean(&mut self) -> Result<()> { + self.raw.clean()?; + self.cache = None; + Ok(()) + } + + fn write(&mut self, height: Height, cleanup: bool) -> Result<()> { + self.apply_pending(); + self.raw.write_and_cleanup(height, cleanup)?; + + let raw_state = self.raw.state.as_ref().unwrap(); + let mut buffer = self.map.as_ref().unwrap().serialize()?; + buffer.extend(raw_state.cap_raw.to_bytes()); + fs::write(self.raw.path_state(height), buffer)?; + + Ok(()) } } diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index 0e1dc8961..a70f832bf 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -2307,6 +2307,35 @@ function create_1m1w1y24hBpsPercentRatioPattern(client, acc) { }; } +/** + * @typedef {Object} CoindaysCoinyearsDormancySentVelocityPattern + * @property {CumulativeRawSumPattern} coindaysDestroyed + * @property {MetricPattern1} coindaysDestroyedSupplyAdjusted + * @property {MetricPattern1} coinyearsDestroyed + * @property {MetricPattern1} coinyearsDestroyedSupplyAdjusted + * @property {MetricPattern1} dormancy + * @property {RawSumPattern3} sent + * @property {MetricPattern1} velocity + */ + +/** + * Create a CoindaysCoinyearsDormancySentVelocityPattern pattern node + * @param {BrkClientBase} client + * @param {string} acc - Accumulated metric name + * @returns {CoindaysCoinyearsDormancySentVelocityPattern} + */ +function createCoindaysCoinyearsDormancySentVelocityPattern(client, acc) { + return { + coindaysDestroyed: createCumulativeRawSumPattern(client, _m(acc, 'coindays_destroyed')), + coindaysDestroyedSupplyAdjusted: createMetricPattern1(client, _m(acc, 'coindays_destroyed_supply_adjusted')), + coinyearsDestroyed: createMetricPattern1(client, _m(acc, 'coinyears_destroyed')), + coinyearsDestroyedSupplyAdjusted: createMetricPattern1(client, _m(acc, 'coinyears_destroyed_supply_adjusted')), + dormancy: createMetricPattern1(client, _m(acc, 'dormancy')), + sent: createRawSumPattern3(client, _m(acc, 'sent')), + velocity: createMetricPattern1(client, _m(acc, 'velocity')), + }; +} + /** * @typedef {Object} CumulativeDistributionRawRelSumValuePattern * @property {MetricPattern1} cumulative @@ -3030,29 +3059,6 @@ function createCentsRelUsdPattern2(client, acc) { }; } -/** - * @typedef {Object} CoindaysDormancySentVelocityPattern - * @property {CumulativeRawSumPattern} coindaysDestroyed - * @property {MetricPattern1} dormancy - * @property {RawSumPattern3} sent - * @property {MetricPattern1} velocity - */ - -/** - * Create a CoindaysDormancySentVelocityPattern pattern node - * @param {BrkClientBase} client - * @param {string} acc - Accumulated metric name - * @returns {CoindaysDormancySentVelocityPattern} - */ -function createCoindaysDormancySentVelocityPattern(client, acc) { - return { - coindaysDestroyed: createCumulativeRawSumPattern(client, _m(acc, 'coindays_destroyed')), - dormancy: createMetricPattern1(client, _m(acc, 'dormancy')), - sent: createRawSumPattern3(client, _m(acc, 'sent')), - velocity: createMetricPattern1(client, _m(acc, 'velocity')), - }; -} - /** * @typedef {Object} CumulativeNegativeRawSumPattern * @property {MetricPattern1} cumulative @@ -4750,6 +4756,7 @@ function createRawPattern(client, acc) { * @property {CentsUsdPattern} vaultedCap * @property {CentsUsdPattern} activeCap * @property {CentsUsdPattern} cointimeCap + * @property {BpsRatioPattern} aviv */ /** @@ -5859,7 +5866,7 @@ function createRawPattern(client, acc) { * @typedef {Object} MetricsTree_Distribution_UtxoCohorts_All * @property {MetricsTree_Distribution_UtxoCohorts_All_Supply} supply * @property {UtxoPattern3} outputs - * @property {CoindaysDormancySentVelocityPattern} activity + * @property {CoindaysCoinyearsDormancySentVelocityPattern} activity * @property {CapGrossInvestorLossMvrvNetNuplPeakPriceProfitSentSoprPattern} realized * @property {InvestedMaxMinPercentilesPattern} costBasis * @property {MetricsTree_Distribution_UtxoCohorts_All_Unrealized} unrealized @@ -5914,7 +5921,7 @@ function createRawPattern(client, acc) { * @property {CapGrossInvestorLossMvrvNetNuplPeakPriceProfitSentSoprPattern} realized * @property {DeltaHalvedRelTotalPattern2} supply * @property {UtxoPattern3} outputs - * @property {CoindaysDormancySentVelocityPattern} activity + * @property {CoindaysCoinyearsDormancySentVelocityPattern} activity * @property {InvestedMaxMinPercentilesPattern} costBasis * @property {GrossInvestedInvestorLossNetProfitSentimentPattern2} unrealized */ @@ -5923,7 +5930,7 @@ function createRawPattern(client, acc) { * @typedef {Object} MetricsTree_Distribution_UtxoCohorts_Lth * @property {DeltaHalvedRelTotalPattern2} supply * @property {UtxoPattern3} outputs - * @property {CoindaysDormancySentVelocityPattern} activity + * @property {CoindaysCoinyearsDormancySentVelocityPattern} activity * @property {MetricsTree_Distribution_UtxoCohorts_Lth_Realized} realized * @property {InvestedMaxMinPercentilesPattern} costBasis * @property {GrossInvestedInvestorLossNetProfitSentimentPattern2} unrealized @@ -7580,6 +7587,7 @@ class BrkClient extends BrkClientBase { vaultedCap: createCentsUsdPattern(this, 'vaulted_cap'), activeCap: createCentsUsdPattern(this, 'active_cap'), cointimeCap: createCentsUsdPattern(this, 'cointime_cap'), + aviv: createBpsRatioPattern(this, 'aviv_ratio'), }, pricing: { vaultedPrice: createCentsSatsUsdPattern(this, 'vaulted_price'), @@ -8349,7 +8357,7 @@ class BrkClient extends BrkClientBase { halved: createBtcCentsSatsUsdPattern(this, 'supply_halved'), }, outputs: createUtxoPattern3(this, 'utxo_count'), - activity: createCoindaysDormancySentVelocityPattern(this, ''), + activity: createCoindaysCoinyearsDormancySentVelocityPattern(this, ''), realized: createCapGrossInvestorLossMvrvNetNuplPeakPriceProfitSentSoprPattern(this, ''), costBasis: createInvestedMaxMinPercentilesPattern(this, ''), unrealized: { @@ -8383,14 +8391,14 @@ class BrkClient extends BrkClientBase { realized: createCapGrossInvestorLossMvrvNetNuplPeakPriceProfitSentSoprPattern(this, 'sth'), supply: createDeltaHalvedRelTotalPattern2(this, 'sth_supply'), outputs: createUtxoPattern3(this, 'sth_utxo_count'), - activity: createCoindaysDormancySentVelocityPattern(this, 'sth'), + activity: createCoindaysCoinyearsDormancySentVelocityPattern(this, 'sth'), costBasis: createInvestedMaxMinPercentilesPattern(this, 'sth'), unrealized: createGrossInvestedInvestorLossNetProfitSentimentPattern2(this, 'sth'), }, lth: { supply: createDeltaHalvedRelTotalPattern2(this, 'lth_supply'), outputs: createUtxoPattern3(this, 'lth_utxo_count'), - activity: createCoindaysDormancySentVelocityPattern(this, 'lth'), + activity: createCoindaysCoinyearsDormancySentVelocityPattern(this, 'lth'), realized: { profit: createCumulativeDistributionRawRelSumValuePattern(this, 'lth'), loss: createCapitulationCumulativeNegativeRawRelSumValuePattern(this, 'lth'), diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index 97409c315..688eafcaa 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -2423,6 +2423,19 @@ class _1m1w1y24hBpsPercentRatioPattern: self.percent: MetricPattern1[StoredF32] = MetricPattern1(client, acc) self.ratio: MetricPattern1[StoredF32] = MetricPattern1(client, _m(acc, 'ratio')) +class CoindaysCoinyearsDormancySentVelocityPattern: + """Pattern struct for repeated tree structure.""" + + def __init__(self, client: BrkClientBase, acc: str): + """Create pattern node with accumulated metric name.""" + self.coindays_destroyed: CumulativeRawSumPattern[StoredF64] = CumulativeRawSumPattern(client, _m(acc, 'coindays_destroyed')) + self.coindays_destroyed_supply_adjusted: MetricPattern1[StoredF32] = MetricPattern1(client, _m(acc, 'coindays_destroyed_supply_adjusted')) + self.coinyears_destroyed: MetricPattern1[StoredF64] = MetricPattern1(client, _m(acc, 'coinyears_destroyed')) + self.coinyears_destroyed_supply_adjusted: MetricPattern1[StoredF32] = MetricPattern1(client, _m(acc, 'coinyears_destroyed_supply_adjusted')) + self.dormancy: MetricPattern1[StoredF32] = MetricPattern1(client, _m(acc, 'dormancy')) + self.sent: RawSumPattern3[Sats] = RawSumPattern3(client, _m(acc, 'sent')) + self.velocity: MetricPattern1[StoredF32] = MetricPattern1(client, _m(acc, 'velocity')) + class CumulativeDistributionRawRelSumValuePattern: """Pattern struct for repeated tree structure.""" @@ -2740,16 +2753,6 @@ class CentsRelUsdPattern2: self.rel_to_own_market_cap: BpsPercentRatioPattern = BpsPercentRatioPattern(client, _m(acc, 'rel_to_own_market_cap')) self.usd: MetricPattern1[Dollars] = MetricPattern1(client, acc) -class CoindaysDormancySentVelocityPattern: - """Pattern struct for repeated tree structure.""" - - def __init__(self, client: BrkClientBase, acc: str): - """Create pattern node with accumulated metric name.""" - self.coindays_destroyed: CumulativeRawSumPattern[StoredF64] = CumulativeRawSumPattern(client, _m(acc, 'coindays_destroyed')) - self.dormancy: MetricPattern1[StoredF32] = MetricPattern1(client, _m(acc, 'dormancy')) - self.sent: RawSumPattern3[Sats] = RawSumPattern3(client, _m(acc, 'sent')) - self.velocity: MetricPattern1[StoredF32] = MetricPattern1(client, _m(acc, 'velocity')) - class CumulativeNegativeRawSumPattern: """Pattern struct for repeated tree structure.""" @@ -3704,6 +3707,7 @@ class MetricsTree_Cointime_Cap: self.vaulted_cap: CentsUsdPattern = CentsUsdPattern(client, 'vaulted_cap') self.active_cap: CentsUsdPattern = CentsUsdPattern(client, 'active_cap') self.cointime_cap: CentsUsdPattern = CentsUsdPattern(client, 'cointime_cap') + self.aviv: BpsRatioPattern = BpsRatioPattern(client, 'aviv_ratio') class MetricsTree_Cointime_Pricing: """Metrics tree node.""" @@ -4946,7 +4950,7 @@ class MetricsTree_Distribution_UtxoCohorts_All: def __init__(self, client: BrkClientBase, base_path: str = ''): self.supply: MetricsTree_Distribution_UtxoCohorts_All_Supply = MetricsTree_Distribution_UtxoCohorts_All_Supply(client) self.outputs: UtxoPattern3 = UtxoPattern3(client, 'utxo_count') - self.activity: CoindaysDormancySentVelocityPattern = CoindaysDormancySentVelocityPattern(client, '') + self.activity: CoindaysCoinyearsDormancySentVelocityPattern = CoindaysCoinyearsDormancySentVelocityPattern(client, '') self.realized: CapGrossInvestorLossMvrvNetNuplPeakPriceProfitSentSoprPattern = CapGrossInvestorLossMvrvNetNuplPeakPriceProfitSentSoprPattern(client, '') self.cost_basis: InvestedMaxMinPercentilesPattern = InvestedMaxMinPercentilesPattern(client, '') self.unrealized: MetricsTree_Distribution_UtxoCohorts_All_Unrealized = MetricsTree_Distribution_UtxoCohorts_All_Unrealized(client) @@ -4958,7 +4962,7 @@ class MetricsTree_Distribution_UtxoCohorts_Sth: self.realized: CapGrossInvestorLossMvrvNetNuplPeakPriceProfitSentSoprPattern = CapGrossInvestorLossMvrvNetNuplPeakPriceProfitSentSoprPattern(client, 'sth') self.supply: DeltaHalvedRelTotalPattern2 = DeltaHalvedRelTotalPattern2(client, 'sth_supply') self.outputs: UtxoPattern3 = UtxoPattern3(client, 'sth_utxo_count') - self.activity: CoindaysDormancySentVelocityPattern = CoindaysDormancySentVelocityPattern(client, 'sth') + self.activity: CoindaysCoinyearsDormancySentVelocityPattern = CoindaysCoinyearsDormancySentVelocityPattern(client, 'sth') self.cost_basis: InvestedMaxMinPercentilesPattern = InvestedMaxMinPercentilesPattern(client, 'sth') self.unrealized: GrossInvestedInvestorLossNetProfitSentimentPattern2 = GrossInvestedInvestorLossNetProfitSentimentPattern2(client, 'sth') @@ -5009,7 +5013,7 @@ class MetricsTree_Distribution_UtxoCohorts_Lth: def __init__(self, client: BrkClientBase, base_path: str = ''): self.supply: DeltaHalvedRelTotalPattern2 = DeltaHalvedRelTotalPattern2(client, 'lth_supply') self.outputs: UtxoPattern3 = UtxoPattern3(client, 'lth_utxo_count') - self.activity: CoindaysDormancySentVelocityPattern = CoindaysDormancySentVelocityPattern(client, 'lth') + self.activity: CoindaysCoinyearsDormancySentVelocityPattern = CoindaysCoinyearsDormancySentVelocityPattern(client, 'lth') self.realized: MetricsTree_Distribution_UtxoCohorts_Lth_Realized = MetricsTree_Distribution_UtxoCohorts_Lth_Realized(client) self.cost_basis: InvestedMaxMinPercentilesPattern = InvestedMaxMinPercentilesPattern(client, 'lth') self.unrealized: GrossInvestedInvestorLossNetProfitSentimentPattern2 = GrossInvestedInvestorLossNetProfitSentimentPattern2(client, 'lth')