mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-29 11:48:12 -07:00
global: snapshot
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
use std::ops::{Add, AddAssign, SubAssign};
|
||||
|
||||
use brk_types::{Dollars, SupplyState, Timestamp};
|
||||
use brk_types::{CentsUnsigned, SupplyState, Timestamp};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -8,7 +8,7 @@ pub struct BlockState {
|
||||
#[serde(flatten)]
|
||||
pub supply: SupplyState,
|
||||
#[serde(skip)]
|
||||
pub price: Option<Dollars>,
|
||||
pub price: Option<CentsUnsigned>,
|
||||
#[serde(skip)]
|
||||
pub timestamp: Timestamp,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use brk_error::Result;
|
||||
use brk_types::{Age, Dollars, Height, LoadedAddressData, Sats, SupplyState};
|
||||
use brk_types::{Age, CentsUnsigned, Height, LoadedAddressData, Sats, SupplyState};
|
||||
use vecdb::unlikely;
|
||||
|
||||
use super::{super::cost_basis::RealizedState, base::CohortState};
|
||||
@@ -28,12 +28,12 @@ impl AddressCohortState {
|
||||
self.inner.satblocks_destroyed = Sats::ZERO;
|
||||
self.inner.satdays_destroyed = Sats::ZERO;
|
||||
if let Some(realized) = self.inner.realized.as_mut() {
|
||||
*realized = RealizedState::NAN;
|
||||
*realized = RealizedState::default();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_price_to_amount_if_needed(&mut self) -> Result<()> {
|
||||
self.inner.reset_price_to_amount_if_needed()
|
||||
pub fn reset_cost_basis_data_if_needed(&mut self) -> Result<()> {
|
||||
self.inner.reset_cost_basis_data_if_needed()
|
||||
}
|
||||
|
||||
pub fn reset_single_iteration_values(&mut self) {
|
||||
@@ -44,35 +44,23 @@ impl AddressCohortState {
|
||||
&mut self,
|
||||
addressdata: &mut LoadedAddressData,
|
||||
value: Sats,
|
||||
current_price: Option<Dollars>,
|
||||
prev_price: Option<Dollars>,
|
||||
current_price: CentsUnsigned,
|
||||
prev_price: CentsUnsigned,
|
||||
ath: CentsUnsigned,
|
||||
age: Age,
|
||||
) -> Result<()> {
|
||||
let compute_price = current_price.is_some();
|
||||
let prev = addressdata.cost_basis_snapshot();
|
||||
addressdata.send(value, Some(prev_price))?;
|
||||
let current = addressdata.cost_basis_snapshot();
|
||||
|
||||
let prev_realized_price = compute_price.then(|| addressdata.realized_price());
|
||||
let prev_supply_state = SupplyState {
|
||||
utxo_count: addressdata.utxo_count() as u64,
|
||||
value: addressdata.balance(),
|
||||
};
|
||||
|
||||
addressdata.send(value, prev_price)?;
|
||||
|
||||
let supply_state = SupplyState {
|
||||
utxo_count: addressdata.utxo_count() as u64,
|
||||
value: addressdata.balance(),
|
||||
};
|
||||
|
||||
self.inner.send_(
|
||||
&SupplyState {
|
||||
utxo_count: 1,
|
||||
value,
|
||||
},
|
||||
self.inner.send_address(
|
||||
&SupplyState { utxo_count: 1, value },
|
||||
current_price,
|
||||
prev_price,
|
||||
ath,
|
||||
age,
|
||||
compute_price.then(|| (addressdata.realized_price(), &supply_state)),
|
||||
prev_realized_price.map(|prev_price| (prev_price, &prev_supply_state)),
|
||||
¤t,
|
||||
&prev,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -82,7 +70,7 @@ impl AddressCohortState {
|
||||
&mut self,
|
||||
address_data: &mut LoadedAddressData,
|
||||
value: Sats,
|
||||
price: Option<Dollars>,
|
||||
price: CentsUnsigned,
|
||||
) {
|
||||
self.receive_outputs(address_data, value, price, 1);
|
||||
}
|
||||
@@ -91,50 +79,31 @@ impl AddressCohortState {
|
||||
&mut self,
|
||||
address_data: &mut LoadedAddressData,
|
||||
value: Sats,
|
||||
price: Option<Dollars>,
|
||||
price: CentsUnsigned,
|
||||
output_count: u32,
|
||||
) {
|
||||
let compute_price = price.is_some();
|
||||
let prev = address_data.cost_basis_snapshot();
|
||||
address_data.receive_outputs(value, Some(price), output_count);
|
||||
let current = address_data.cost_basis_snapshot();
|
||||
|
||||
let prev_realized_price = compute_price.then(|| address_data.realized_price());
|
||||
let prev_supply_state = SupplyState {
|
||||
utxo_count: address_data.utxo_count() as u64,
|
||||
value: address_data.balance(),
|
||||
};
|
||||
|
||||
address_data.receive_outputs(value, price, output_count);
|
||||
|
||||
let supply_state = SupplyState {
|
||||
utxo_count: address_data.utxo_count() as u64,
|
||||
value: address_data.balance(),
|
||||
};
|
||||
|
||||
self.inner.receive_(
|
||||
&SupplyState {
|
||||
utxo_count: output_count as u64,
|
||||
value,
|
||||
},
|
||||
self.inner.receive_address(
|
||||
&SupplyState { utxo_count: output_count as u64, value },
|
||||
price,
|
||||
compute_price.then(|| (address_data.realized_price(), &supply_state)),
|
||||
prev_realized_price.map(|prev_price| (prev_price, &prev_supply_state)),
|
||||
¤t,
|
||||
&prev,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn add(&mut self, addressdata: &LoadedAddressData) {
|
||||
self.addr_count += 1;
|
||||
self.inner.increment_(
|
||||
&addressdata.into(),
|
||||
addressdata.realized_cap,
|
||||
addressdata.realized_price(),
|
||||
);
|
||||
self.inner.increment_snapshot(&addressdata.cost_basis_snapshot());
|
||||
}
|
||||
|
||||
pub fn subtract(&mut self, addressdata: &LoadedAddressData) {
|
||||
let addr_supply: SupplyState = addressdata.into();
|
||||
let realized_price = addressdata.realized_price();
|
||||
let snapshot = addressdata.cost_basis_snapshot();
|
||||
|
||||
// Check for potential underflow before it happens
|
||||
if unlikely(self.inner.supply.utxo_count < addr_supply.utxo_count) {
|
||||
if unlikely(self.inner.supply.utxo_count < snapshot.supply_state.utxo_count) {
|
||||
panic!(
|
||||
"AddressCohortState::subtract underflow!\n\
|
||||
Cohort state: addr_count={}, supply={}\n\
|
||||
@@ -142,10 +111,10 @@ impl AddressCohortState {
|
||||
Address supply: {}\n\
|
||||
Realized price: {}\n\
|
||||
This means the address is not properly tracked in this cohort.",
|
||||
self.addr_count, self.inner.supply, addressdata, addr_supply, realized_price
|
||||
self.addr_count, self.inner.supply, addressdata, snapshot.supply_state, snapshot.realized_price
|
||||
);
|
||||
}
|
||||
if unlikely(self.inner.supply.value < addr_supply.value) {
|
||||
if unlikely(self.inner.supply.value < snapshot.supply_state.value) {
|
||||
panic!(
|
||||
"AddressCohortState::subtract value underflow!\n\
|
||||
Cohort state: addr_count={}, supply={}\n\
|
||||
@@ -153,7 +122,7 @@ impl AddressCohortState {
|
||||
Address supply: {}\n\
|
||||
Realized price: {}\n\
|
||||
This means the address is not properly tracked in this cohort.",
|
||||
self.addr_count, self.inner.supply, addressdata, addr_supply, realized_price
|
||||
self.addr_count, self.inner.supply, addressdata, snapshot.supply_state, snapshot.realized_price
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,12 +131,11 @@ impl AddressCohortState {
|
||||
"AddressCohortState::subtract addr_count underflow! addr_count=0\n\
|
||||
Address being subtracted: {}\n\
|
||||
Realized price: {}",
|
||||
addressdata, realized_price
|
||||
addressdata, snapshot.realized_price
|
||||
)
|
||||
});
|
||||
|
||||
self.inner
|
||||
.decrement_(&addr_supply, addressdata.realized_cap, realized_price);
|
||||
self.inner.decrement_snapshot(&snapshot);
|
||||
}
|
||||
|
||||
pub fn write(&mut self, height: Height, cleanup: bool) -> Result<()> {
|
||||
|
||||
@@ -1,93 +1,78 @@
|
||||
use std::path::Path;
|
||||
|
||||
use brk_error::Result;
|
||||
use brk_types::{Age, Dollars, Height, Sats, SupplyState};
|
||||
|
||||
use crate::internal::PERCENTILES_LEN;
|
||||
use brk_types::{Age, CentsSats, CentsUnsigned, CostBasisSnapshot, Height, Sats, SupplyState};
|
||||
|
||||
use super::super::cost_basis::{
|
||||
CachedUnrealizedState, PriceToAmount, RealizedState, UnrealizedState,
|
||||
CachedUnrealizedState, Percentiles, CostBasisData, RealizedState, UnrealizedState,
|
||||
};
|
||||
|
||||
/// State tracked for each cohort during computation.
|
||||
#[derive(Clone)]
|
||||
pub struct CohortState {
|
||||
/// Current supply in this cohort
|
||||
pub supply: SupplyState,
|
||||
|
||||
/// Realized cap and profit/loss (requires price data)
|
||||
pub realized: Option<RealizedState>,
|
||||
|
||||
/// Amount sent in current block
|
||||
pub sent: Sats,
|
||||
|
||||
/// Satoshi-blocks destroyed (supply * blocks_old when spent)
|
||||
pub satblocks_destroyed: Sats,
|
||||
|
||||
/// Satoshi-days destroyed (supply * days_old when spent)
|
||||
pub satdays_destroyed: Sats,
|
||||
|
||||
/// Price distribution for percentile calculations (requires price data)
|
||||
price_to_amount: Option<PriceToAmount>,
|
||||
|
||||
/// Cached unrealized state for O(k) incremental updates.
|
||||
cost_basis_data: Option<CostBasisData>,
|
||||
cached_unrealized: Option<CachedUnrealizedState>,
|
||||
}
|
||||
|
||||
impl CohortState {
|
||||
/// Create new cohort state.
|
||||
pub fn new(path: &Path, name: &str, compute_dollars: bool) -> Self {
|
||||
Self {
|
||||
supply: SupplyState::default(),
|
||||
realized: compute_dollars.then_some(RealizedState::NAN),
|
||||
realized: compute_dollars.then_some(RealizedState::default()),
|
||||
sent: Sats::ZERO,
|
||||
satblocks_destroyed: Sats::ZERO,
|
||||
satdays_destroyed: Sats::ZERO,
|
||||
price_to_amount: compute_dollars.then_some(PriceToAmount::create(path, name)),
|
||||
cost_basis_data: compute_dollars.then_some(CostBasisData::create(path, name)),
|
||||
cached_unrealized: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Import state from checkpoint.
|
||||
pub fn import_at_or_before(&mut self, height: Height) -> Result<Height> {
|
||||
// Invalidate cache when importing new data
|
||||
self.cached_unrealized = None;
|
||||
|
||||
match self.price_to_amount.as_mut() {
|
||||
match self.cost_basis_data.as_mut() {
|
||||
Some(p) => p.import_at_or_before(height),
|
||||
None => Ok(height),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset price_to_amount if needed (for starting fresh).
|
||||
pub fn reset_price_to_amount_if_needed(&mut self) -> Result<()> {
|
||||
if let Some(p) = self.price_to_amount.as_mut() {
|
||||
/// Restore realized cap from cost_basis_data after import.
|
||||
/// Uses the exact persisted values instead of recomputing from the map.
|
||||
pub fn restore_realized_cap(&mut self) {
|
||||
if let Some(cost_basis_data) = self.cost_basis_data.as_ref()
|
||||
&& let Some(realized) = self.realized.as_mut()
|
||||
{
|
||||
realized.set_cap_raw(cost_basis_data.cap_raw());
|
||||
realized.set_investor_cap_raw(cost_basis_data.investor_cap_raw());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_cost_basis_data_if_needed(&mut self) -> Result<()> {
|
||||
if let Some(p) = self.cost_basis_data.as_mut() {
|
||||
p.clean()?;
|
||||
p.init();
|
||||
}
|
||||
// Invalidate cache when data is reset
|
||||
self.cached_unrealized = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply pending price_to_amount updates. Must be called before reads.
|
||||
pub fn apply_pending(&mut self) {
|
||||
if let Some(p) = self.price_to_amount.as_mut() {
|
||||
if let Some(p) = self.cost_basis_data.as_mut() {
|
||||
p.apply_pending();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get first (lowest) price entry in distribution.
|
||||
pub fn price_to_amount_first_key_value(&self) -> Option<(Dollars, &Sats)> {
|
||||
self.price_to_amount.as_ref()?.first_key_value()
|
||||
pub fn cost_basis_data_first_key_value(&self) -> Option<(CentsUnsigned, &Sats)> {
|
||||
self.cost_basis_data.as_ref()?.first_key_value().map(|(k, v)| (k.into(), v))
|
||||
}
|
||||
|
||||
/// Get last (highest) price entry in distribution.
|
||||
pub fn price_to_amount_last_key_value(&self) -> Option<(Dollars, &Sats)> {
|
||||
self.price_to_amount.as_ref()?.last_key_value()
|
||||
pub fn cost_basis_data_last_key_value(&self) -> Option<(CentsUnsigned, &Sats)> {
|
||||
self.cost_basis_data.as_ref()?.last_key_value().map(|(k, v)| (k.into(), v))
|
||||
}
|
||||
|
||||
/// Reset per-block values before processing next block.
|
||||
pub fn reset_single_iteration_values(&mut self) {
|
||||
self.sent = Sats::ZERO;
|
||||
self.satdays_destroyed = Sats::ZERO;
|
||||
@@ -97,177 +82,137 @@ impl CohortState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add supply to this cohort (e.g., when UTXO ages into cohort).
|
||||
pub fn increment(&mut self, supply: &SupplyState, price: Option<Dollars>) {
|
||||
pub fn increment(&mut self, supply: &SupplyState, price: Option<CentsUnsigned>) {
|
||||
match price {
|
||||
Some(p) => self.increment_snapshot(&CostBasisSnapshot::from_utxo(p, supply)),
|
||||
None => self.supply += supply,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment_snapshot(&mut self, s: &CostBasisSnapshot) {
|
||||
self.supply += &s.supply_state;
|
||||
|
||||
if s.supply_state.value > Sats::ZERO
|
||||
&& let Some(realized) = self.realized.as_mut()
|
||||
{
|
||||
realized.increment_snapshot(s.price_sats, s.investor_cap);
|
||||
self.cost_basis_data.as_mut().unwrap().increment(
|
||||
s.realized_price,
|
||||
s.supply_state.value,
|
||||
s.price_sats,
|
||||
s.investor_cap,
|
||||
);
|
||||
|
||||
if let Some(cache) = self.cached_unrealized.as_mut() {
|
||||
cache.on_receive(s.realized_price, s.supply_state.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decrement(&mut self, supply: &SupplyState, price: Option<CentsUnsigned>) {
|
||||
match price {
|
||||
Some(p) => self.decrement_snapshot(&CostBasisSnapshot::from_utxo(p, supply)),
|
||||
None => self.supply -= supply,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decrement_snapshot(&mut self, s: &CostBasisSnapshot) {
|
||||
self.supply -= &s.supply_state;
|
||||
|
||||
if s.supply_state.value > Sats::ZERO
|
||||
&& let Some(realized) = self.realized.as_mut()
|
||||
{
|
||||
realized.decrement_snapshot(s.price_sats, s.investor_cap);
|
||||
self.cost_basis_data.as_mut().unwrap().decrement(
|
||||
s.realized_price,
|
||||
s.supply_state.value,
|
||||
s.price_sats,
|
||||
s.investor_cap,
|
||||
);
|
||||
|
||||
if let Some(cache) = self.cached_unrealized.as_mut() {
|
||||
cache.on_send(s.realized_price, s.supply_state.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn receive_utxo(&mut self, supply: &SupplyState, price: Option<CentsUnsigned>) {
|
||||
self.supply += supply;
|
||||
|
||||
if supply.value > Sats::ZERO
|
||||
&& let Some(realized) = self.realized.as_mut()
|
||||
{
|
||||
let price = price.unwrap();
|
||||
realized.increment(supply, price);
|
||||
self.price_to_amount
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.increment(price, supply);
|
||||
let sats = supply.value;
|
||||
|
||||
// Compute once using typed values
|
||||
let price_sats = CentsSats::from_price_sats(price, sats);
|
||||
let investor_cap = price_sats.to_investor_cap(price);
|
||||
|
||||
realized.receive(price, sats);
|
||||
|
||||
self.cost_basis_data.as_mut().unwrap().increment(
|
||||
price,
|
||||
sats,
|
||||
price_sats,
|
||||
investor_cap,
|
||||
);
|
||||
|
||||
// Update cache for added supply
|
||||
if let Some(cache) = self.cached_unrealized.as_mut() {
|
||||
cache.on_receive(price, supply.value);
|
||||
cache.on_receive(price, sats);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add supply with pre-computed realized cap (for address cohorts).
|
||||
pub fn increment_(
|
||||
pub fn receive_address(
|
||||
&mut self,
|
||||
supply: &SupplyState,
|
||||
realized_cap: Dollars,
|
||||
realized_price: Dollars,
|
||||
price: CentsUnsigned,
|
||||
current: &CostBasisSnapshot,
|
||||
prev: &CostBasisSnapshot,
|
||||
) {
|
||||
self.supply += supply;
|
||||
|
||||
if supply.value > Sats::ZERO
|
||||
&& let Some(realized) = self.realized.as_mut()
|
||||
{
|
||||
realized.increment_(realized_cap);
|
||||
self.price_to_amount
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.increment(realized_price, supply);
|
||||
realized.receive(price, supply.value);
|
||||
|
||||
// Update cache for added supply
|
||||
if let Some(cache) = self.cached_unrealized.as_mut() {
|
||||
cache.on_receive(realized_price, supply.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if current.supply_state.value.is_not_zero() {
|
||||
self.cost_basis_data.as_mut().unwrap().increment(
|
||||
current.realized_price,
|
||||
current.supply_state.value,
|
||||
current.price_sats,
|
||||
current.investor_cap,
|
||||
);
|
||||
|
||||
/// Remove supply from this cohort (e.g., when UTXO ages out of cohort).
|
||||
pub fn decrement(&mut self, supply: &SupplyState, price: Option<Dollars>) {
|
||||
self.supply -= supply;
|
||||
|
||||
if supply.value > Sats::ZERO
|
||||
&& let Some(realized) = self.realized.as_mut()
|
||||
{
|
||||
let price = price.unwrap();
|
||||
realized.decrement(supply, price);
|
||||
self.price_to_amount
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.decrement(price, supply);
|
||||
|
||||
// Update cache for removed supply
|
||||
if let Some(cache) = self.cached_unrealized.as_mut() {
|
||||
cache.on_send(price, supply.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove supply with pre-computed realized cap (for address cohorts).
|
||||
pub fn decrement_(
|
||||
&mut self,
|
||||
supply: &SupplyState,
|
||||
realized_cap: Dollars,
|
||||
realized_price: Dollars,
|
||||
) {
|
||||
self.supply -= supply;
|
||||
|
||||
if supply.value > Sats::ZERO
|
||||
&& let Some(realized) = self.realized.as_mut()
|
||||
{
|
||||
realized.decrement_(realized_cap);
|
||||
self.price_to_amount
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.decrement(realized_price, supply);
|
||||
|
||||
// Update cache for removed supply
|
||||
if let Some(cache) = self.cached_unrealized.as_mut() {
|
||||
cache.on_send(realized_price, supply.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process received output (new UTXO in cohort).
|
||||
pub fn receive(&mut self, supply: &SupplyState, price: Option<Dollars>) {
|
||||
self.receive_(supply, price, price.map(|price| (price, supply)), None);
|
||||
}
|
||||
|
||||
/// Process received output with custom price_to_amount updates (for address cohorts).
|
||||
pub fn receive_(
|
||||
&mut self,
|
||||
supply: &SupplyState,
|
||||
price: Option<Dollars>,
|
||||
price_to_amount_increment: Option<(Dollars, &SupplyState)>,
|
||||
price_to_amount_decrement: Option<(Dollars, &SupplyState)>,
|
||||
) {
|
||||
self.supply += supply;
|
||||
|
||||
if supply.value > Sats::ZERO
|
||||
&& let Some(realized) = self.realized.as_mut()
|
||||
{
|
||||
let price = price.unwrap();
|
||||
realized.receive(supply, price);
|
||||
|
||||
if let Some((price, supply)) = price_to_amount_increment
|
||||
&& supply.value.is_not_zero()
|
||||
{
|
||||
self.price_to_amount
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.increment(price, supply);
|
||||
|
||||
// Update cache for added supply
|
||||
if let Some(cache) = self.cached_unrealized.as_mut() {
|
||||
cache.on_receive(price, supply.value);
|
||||
cache.on_receive(current.realized_price, current.supply_state.value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((price, supply)) = price_to_amount_decrement
|
||||
&& supply.value.is_not_zero()
|
||||
{
|
||||
self.price_to_amount
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.decrement(price, supply);
|
||||
if prev.supply_state.value.is_not_zero() {
|
||||
self.cost_basis_data.as_mut().unwrap().decrement(
|
||||
prev.realized_price,
|
||||
prev.supply_state.value,
|
||||
prev.price_sats,
|
||||
prev.investor_cap,
|
||||
);
|
||||
|
||||
// Update cache for removed supply
|
||||
if let Some(cache) = self.cached_unrealized.as_mut() {
|
||||
cache.on_send(price, supply.value);
|
||||
cache.on_send(prev.realized_price, prev.supply_state.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process spent input (UTXO leaving cohort).
|
||||
pub fn send(
|
||||
pub fn send_utxo(
|
||||
&mut self,
|
||||
supply: &SupplyState,
|
||||
current_price: Option<Dollars>,
|
||||
prev_price: Option<Dollars>,
|
||||
current_price: Option<CentsUnsigned>,
|
||||
prev_price: Option<CentsUnsigned>,
|
||||
ath: Option<CentsUnsigned>,
|
||||
age: Age,
|
||||
) {
|
||||
self.send_(
|
||||
supply,
|
||||
current_price,
|
||||
prev_price,
|
||||
age,
|
||||
None,
|
||||
prev_price.map(|prev_price| (prev_price, supply)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Process spent input with custom price_to_amount updates (for address cohorts).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn send_(
|
||||
&mut self,
|
||||
supply: &SupplyState,
|
||||
current_price: Option<Dollars>,
|
||||
prev_price: Option<Dollars>,
|
||||
age: Age,
|
||||
price_to_amount_increment: Option<(Dollars, &SupplyState)>,
|
||||
price_to_amount_decrement: Option<(Dollars, &SupplyState)>,
|
||||
) {
|
||||
if supply.utxo_count == 0 {
|
||||
return;
|
||||
@@ -281,77 +226,118 @@ impl CohortState {
|
||||
self.satdays_destroyed += age.satdays_destroyed(supply.value);
|
||||
|
||||
if let Some(realized) = self.realized.as_mut() {
|
||||
let current_price = current_price.unwrap();
|
||||
let prev_price = prev_price.unwrap();
|
||||
realized.send(supply, current_price, prev_price);
|
||||
let cp = current_price.unwrap();
|
||||
let pp = prev_price.unwrap();
|
||||
let ath_price = ath.unwrap();
|
||||
let sats = supply.value;
|
||||
|
||||
if let Some((price, supply)) = price_to_amount_increment
|
||||
&& supply.value.is_not_zero()
|
||||
{
|
||||
self.price_to_amount
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.increment(price, supply);
|
||||
// Compute ONCE using typed values
|
||||
let current_ps = CentsSats::from_price_sats(cp, sats);
|
||||
let prev_ps = CentsSats::from_price_sats(pp, sats);
|
||||
let ath_ps = CentsSats::from_price_sats(ath_price, sats);
|
||||
let prev_investor_cap = prev_ps.to_investor_cap(pp);
|
||||
|
||||
realized.send(current_ps, prev_ps, ath_ps, prev_investor_cap);
|
||||
|
||||
self.cost_basis_data.as_mut().unwrap().decrement(
|
||||
pp,
|
||||
sats,
|
||||
prev_ps,
|
||||
prev_investor_cap,
|
||||
);
|
||||
|
||||
if let Some(cache) = self.cached_unrealized.as_mut() {
|
||||
cache.on_send(pp, sats);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn send_address(
|
||||
&mut self,
|
||||
supply: &SupplyState,
|
||||
current_price: CentsUnsigned,
|
||||
prev_price: CentsUnsigned,
|
||||
ath: CentsUnsigned,
|
||||
age: Age,
|
||||
current: &CostBasisSnapshot,
|
||||
prev: &CostBasisSnapshot,
|
||||
) {
|
||||
if supply.utxo_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.supply -= supply;
|
||||
|
||||
if supply.value > Sats::ZERO {
|
||||
self.sent += supply.value;
|
||||
self.satblocks_destroyed += age.satblocks_destroyed(supply.value);
|
||||
self.satdays_destroyed += age.satdays_destroyed(supply.value);
|
||||
|
||||
if let Some(realized) = self.realized.as_mut() {
|
||||
let sats = supply.value;
|
||||
|
||||
// Compute once for realized.send using typed values
|
||||
let current_ps = CentsSats::from_price_sats(current_price, sats);
|
||||
let prev_ps = CentsSats::from_price_sats(prev_price, sats);
|
||||
let ath_ps = CentsSats::from_price_sats(ath, sats);
|
||||
let prev_investor_cap = prev_ps.to_investor_cap(prev_price);
|
||||
|
||||
realized.send(current_ps, prev_ps, ath_ps, prev_investor_cap);
|
||||
|
||||
if current.supply_state.value.is_not_zero() {
|
||||
self.cost_basis_data.as_mut().unwrap().increment(
|
||||
current.realized_price,
|
||||
current.supply_state.value,
|
||||
current.price_sats,
|
||||
current.investor_cap,
|
||||
);
|
||||
|
||||
// Update cache for added supply
|
||||
if let Some(cache) = self.cached_unrealized.as_mut() {
|
||||
cache.on_receive(price, supply.value);
|
||||
cache.on_receive(current.realized_price, current.supply_state.value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((price, supply)) = price_to_amount_decrement
|
||||
&& supply.value.is_not_zero()
|
||||
{
|
||||
self.price_to_amount
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.decrement(price, supply);
|
||||
if prev.supply_state.value.is_not_zero() {
|
||||
self.cost_basis_data.as_mut().unwrap().decrement(
|
||||
prev.realized_price,
|
||||
prev.supply_state.value,
|
||||
prev.price_sats,
|
||||
prev.investor_cap,
|
||||
);
|
||||
|
||||
// Update cache for removed supply
|
||||
if let Some(cache) = self.cached_unrealized.as_mut() {
|
||||
cache.on_send(price, supply.value);
|
||||
cache.on_send(prev.realized_price, prev.supply_state.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute prices at percentile thresholds.
|
||||
pub fn compute_percentile_prices(&self) -> [Dollars; PERCENTILES_LEN] {
|
||||
match self.price_to_amount.as_ref() {
|
||||
Some(p) if !p.is_empty() => p.compute_percentiles(),
|
||||
_ => [Dollars::NAN; PERCENTILES_LEN],
|
||||
}
|
||||
pub fn compute_percentiles(&self) -> Option<Percentiles> {
|
||||
self.cost_basis_data.as_ref()?.compute_percentiles()
|
||||
}
|
||||
|
||||
/// Compute unrealized profit/loss at current price.
|
||||
/// Uses O(k) incremental updates for height_price where k = flip range size.
|
||||
pub fn compute_unrealized_states(
|
||||
&mut self,
|
||||
height_price: Dollars,
|
||||
date_price: Option<Dollars>,
|
||||
height_price: CentsUnsigned,
|
||||
date_price: Option<CentsUnsigned>,
|
||||
) -> (UnrealizedState, Option<UnrealizedState>) {
|
||||
let price_to_amount = match self.price_to_amount.as_ref() {
|
||||
let cost_basis_data = match self.cost_basis_data.as_ref() {
|
||||
Some(p) if !p.is_empty() => p,
|
||||
_ => {
|
||||
return (
|
||||
UnrealizedState::NAN,
|
||||
date_price.map(|_| UnrealizedState::NAN),
|
||||
);
|
||||
}
|
||||
_ => return (UnrealizedState::ZERO, date_price.map(|_| UnrealizedState::ZERO)),
|
||||
};
|
||||
|
||||
// Date unrealized: compute from scratch (only at date boundaries, ~144x less frequent)
|
||||
let date_state = date_price.map(|date_price| {
|
||||
CachedUnrealizedState::compute_full_standalone(date_price, price_to_amount)
|
||||
CachedUnrealizedState::compute_full_standalone(date_price.into(), cost_basis_data)
|
||||
});
|
||||
|
||||
// Height unrealized: use incremental cache (O(k) where k = flip range)
|
||||
let height_state = if let Some(cache) = self.cached_unrealized.as_mut() {
|
||||
cache.get_at_price(height_price, price_to_amount).clone()
|
||||
cache.get_at_price(height_price, cost_basis_data)
|
||||
} else {
|
||||
let cache = CachedUnrealizedState::compute_fresh(height_price, price_to_amount);
|
||||
let state = cache.state.clone();
|
||||
let cache = CachedUnrealizedState::compute_fresh(height_price, cost_basis_data);
|
||||
let state = cache.current_state();
|
||||
self.cached_unrealized = Some(cache);
|
||||
state
|
||||
};
|
||||
@@ -359,33 +345,24 @@ impl CohortState {
|
||||
(height_state, date_state)
|
||||
}
|
||||
|
||||
/// Flush state to disk at checkpoint.
|
||||
pub fn write(&mut self, height: Height, cleanup: bool) -> Result<()> {
|
||||
if let Some(p) = self.price_to_amount.as_mut() {
|
||||
if let Some(p) = self.cost_basis_data.as_mut() {
|
||||
p.write(height, cleanup)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get first (lowest) price in distribution.
|
||||
pub fn min_price(&self) -> Option<Dollars> {
|
||||
self.price_to_amount
|
||||
.as_ref()?
|
||||
.first_key_value()
|
||||
.map(|(k, _)| k)
|
||||
pub fn min_price(&self) -> Option<CentsUnsigned> {
|
||||
self.cost_basis_data.as_ref()?.first_key_value().map(|(k, _)| k.into())
|
||||
}
|
||||
|
||||
/// Get last (highest) price in distribution.
|
||||
pub fn max_price(&self) -> Option<Dollars> {
|
||||
self.price_to_amount
|
||||
.as_ref()?
|
||||
.last_key_value()
|
||||
.map(|(k, _)| k)
|
||||
pub fn max_price(&self) -> Option<CentsUnsigned> {
|
||||
self.cost_basis_data.as_ref()?.last_key_value().map(|(k, _)| k.into())
|
||||
}
|
||||
|
||||
/// Get iterator over price_to_amount for merged percentile computation.
|
||||
/// Returns None if price data is not tracked for this cohort.
|
||||
pub fn price_to_amount_iter(&self) -> Option<impl Iterator<Item = (Dollars, &Sats)>> {
|
||||
self.price_to_amount.as_ref().map(|p| p.iter())
|
||||
pub fn cost_basis_data_iter(
|
||||
&self,
|
||||
) -> Option<impl Iterator<Item = (CentsUnsigned, &Sats)>> {
|
||||
self.cost_basis_data.as_ref().map(|p| p.iter().map(|(k, v)| (k.into(), v)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ impl UTXOCohortState {
|
||||
Self(CohortState::new(path, name, compute_dollars))
|
||||
}
|
||||
|
||||
pub fn reset_price_to_amount_if_needed(&mut self) -> Result<()> {
|
||||
self.0.reset_price_to_amount_if_needed()
|
||||
pub fn reset_cost_basis_data_if_needed(&mut self) -> Result<()> {
|
||||
self.0.reset_cost_basis_data_if_needed()
|
||||
}
|
||||
|
||||
/// Reset state for fresh start.
|
||||
@@ -25,7 +25,7 @@ impl UTXOCohortState {
|
||||
self.0.satblocks_destroyed = Sats::ZERO;
|
||||
self.0.satdays_destroyed = Sats::ZERO;
|
||||
if let Some(realized) = self.0.realized.as_mut() {
|
||||
*realized = RealizedState::NAN;
|
||||
*realized = RealizedState::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
ops::Bound,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use brk_error::{Error, Result};
|
||||
use brk_types::{CentsSats, CentsSquaredSats, CentsUnsigned, CentsUnsignedCompact, Height, Sats};
|
||||
use pco::{
|
||||
ChunkConfig,
|
||||
standalone::{simple_compress, simple_decompress},
|
||||
};
|
||||
use rustc_hash::FxHashMap;
|
||||
use vecdb::Bytes;
|
||||
|
||||
use crate::utils::OptionExt;
|
||||
|
||||
use super::Percentiles;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct PendingRaw {
|
||||
cap_inc: CentsSats,
|
||||
cap_dec: CentsSats,
|
||||
investor_cap_inc: CentsSquaredSats,
|
||||
investor_cap_dec: CentsSquaredSats,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CostBasisData {
|
||||
pathbuf: PathBuf,
|
||||
state: Option<State>,
|
||||
pending: FxHashMap<CentsUnsignedCompact, (Sats, Sats)>,
|
||||
pending_raw: PendingRaw,
|
||||
}
|
||||
|
||||
const STATE_TO_KEEP: usize = 10;
|
||||
|
||||
impl CostBasisData {
|
||||
pub fn create(path: &Path, name: &str) -> Self {
|
||||
Self {
|
||||
pathbuf: path.join(format!("{name}_cost_basis")),
|
||||
state: None,
|
||||
pending: FxHashMap::default(),
|
||||
pending_raw: PendingRaw::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn import_at_or_before(&mut self, height: Height) -> Result<Height> {
|
||||
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();
|
||||
Ok(height)
|
||||
}
|
||||
|
||||
fn assert_pending_empty(&self) {
|
||||
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 fn iter(&self) -> impl Iterator<Item = (CentsUnsignedCompact, &Sats)> {
|
||||
self.assert_pending_empty();
|
||||
self.state.u().map.iter().map(|(&k, v)| (k, v))
|
||||
}
|
||||
|
||||
pub fn range(
|
||||
&self,
|
||||
bounds: (Bound<CentsUnsignedCompact>, Bound<CentsUnsignedCompact>),
|
||||
) -> impl Iterator<Item = (CentsUnsignedCompact, &Sats)> {
|
||||
self.assert_pending_empty();
|
||||
self.state.u().map.range(bounds).map(|(&k, v)| (k, v))
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.pending.is_empty() && self.state.u().map.is_empty()
|
||||
}
|
||||
|
||||
pub fn first_key_value(&self) -> Option<(CentsUnsignedCompact, &Sats)> {
|
||||
self.assert_pending_empty();
|
||||
self.state.u().map.first_key_value().map(|(&k, v)| (k, v))
|
||||
}
|
||||
|
||||
pub fn last_key_value(&self) -> Option<(CentsUnsignedCompact, &Sats)> {
|
||||
self.assert_pending_empty();
|
||||
self.state.u().map.last_key_value().map(|(&k, v)| (k, v))
|
||||
}
|
||||
|
||||
/// Get the exact cap_raw value (not recomputed from map).
|
||||
pub fn cap_raw(&self) -> CentsSats {
|
||||
self.assert_pending_empty();
|
||||
self.state.u().cap_raw
|
||||
}
|
||||
|
||||
/// Get the exact investor_cap_raw value (not recomputed from map).
|
||||
pub fn investor_cap_raw(&self) -> CentsSquaredSats {
|
||||
self.assert_pending_empty();
|
||||
self.state.u().investor_cap_raw
|
||||
}
|
||||
|
||||
/// Increment with pre-computed typed values
|
||||
pub fn increment(
|
||||
&mut self,
|
||||
price: CentsUnsigned,
|
||||
sats: Sats,
|
||||
price_sats: CentsSats,
|
||||
investor_cap: CentsSquaredSats,
|
||||
) {
|
||||
self.pending.entry(price.into()).or_default().0 += sats;
|
||||
self.pending_raw.cap_inc += price_sats;
|
||||
if investor_cap != CentsSquaredSats::ZERO {
|
||||
self.pending_raw.investor_cap_inc += investor_cap;
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrement with pre-computed typed values
|
||||
pub fn decrement(
|
||||
&mut self,
|
||||
price: CentsUnsigned,
|
||||
sats: Sats,
|
||||
price_sats: CentsSats,
|
||||
investor_cap: CentsSquaredSats,
|
||||
) {
|
||||
self.pending.entry(price.into()).or_default().1 += sats;
|
||||
self.pending_raw.cap_dec += price_sats;
|
||||
if investor_cap != CentsSquaredSats::ZERO {
|
||||
self.pending_raw.investor_cap_dec += investor_cap;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_pending(&mut self) {
|
||||
for (cents, (inc, dec)) in self.pending.drain() {
|
||||
let entry = self.state.um().map.entry(cents).or_default();
|
||||
*entry += inc;
|
||||
if *entry < dec {
|
||||
panic!(
|
||||
"CostBasisData::apply_pending underflow!\n\
|
||||
Path: {:?}\n\
|
||||
Price: {}\n\
|
||||
Current + increments: {}\n\
|
||||
Trying to decrement by: {}",
|
||||
self.pathbuf,
|
||||
cents.to_dollars(),
|
||||
entry,
|
||||
dec
|
||||
);
|
||||
}
|
||||
*entry -= dec;
|
||||
if *entry == Sats::ZERO {
|
||||
self.state.um().map.remove(¢s);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply raw values
|
||||
let state = self.state.um();
|
||||
state.cap_raw += self.pending_raw.cap_inc;
|
||||
|
||||
// Check for underflow before subtracting
|
||||
if 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 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 fn init(&mut self) {
|
||||
self.state.replace(State::default());
|
||||
self.pending.clear();
|
||||
self.pending_raw = PendingRaw::default();
|
||||
}
|
||||
|
||||
pub fn compute_percentiles(&self) -> Option<Percentiles> {
|
||||
self.assert_pending_empty();
|
||||
Percentiles::compute(self.iter().map(|(k, &v)| (k, v)))
|
||||
}
|
||||
|
||||
pub fn clean(&mut self) -> Result<()> {
|
||||
let _ = fs::remove_dir_all(&self.pathbuf);
|
||||
fs::create_dir_all(&self.pathbuf)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_dir(&self, keep_only_before: Option<Height>) -> Result<BTreeMap<Height, PathBuf>> {
|
||||
Ok(fs::read_dir(&self.pathbuf)?
|
||||
.filter_map(|entry| {
|
||||
let path = entry.ok()?.path();
|
||||
let name = path.file_name()?.to_str()?;
|
||||
if let Ok(h) = name.parse::<u32>().map(Height::from) {
|
||||
if keep_only_before.is_none_or(|height| h < height) {
|
||||
Some((h, path))
|
||||
} else {
|
||||
let _ = fs::remove_file(path);
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<BTreeMap<Height, PathBuf>>())
|
||||
}
|
||||
|
||||
pub fn write(&mut self, height: Height, cleanup: bool) -> Result<()> {
|
||||
self.apply_pending();
|
||||
|
||||
if cleanup {
|
||||
let files = self.read_dir(Some(height))?;
|
||||
|
||||
for (_, path) in files
|
||||
.iter()
|
||||
.take(files.len().saturating_sub(STATE_TO_KEEP - 1))
|
||||
{
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(self.path_state(height), self.state.u().serialize()?)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn path_state(&self, height: Height) -> PathBuf {
|
||||
self.pathbuf.join(u32::from(height).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Debug)]
|
||||
struct State {
|
||||
map: BTreeMap<CentsUnsignedCompact, Sats>,
|
||||
/// Exact realized cap: Σ(price × sats)
|
||||
cap_raw: CentsSats,
|
||||
/// Exact investor cap: Σ(price² × sats)
|
||||
investor_cap_raw: CentsSquaredSats,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn serialize(&self) -> vecdb::Result<Vec<u8>> {
|
||||
let keys: Vec<u32> = self.map.keys().map(|k| k.inner()).collect();
|
||||
let values: Vec<u64> = self.map.values().map(|v| u64::from(*v)).collect();
|
||||
|
||||
let config = ChunkConfig::default();
|
||||
let compressed_keys = simple_compress(&keys, &config)?;
|
||||
let compressed_values = simple_compress(&values, &config)?;
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
buffer.extend(keys.len().to_bytes());
|
||||
buffer.extend(compressed_keys.len().to_bytes());
|
||||
buffer.extend(compressed_values.len().to_bytes());
|
||||
buffer.extend(compressed_keys);
|
||||
buffer.extend(compressed_values);
|
||||
buffer.extend(self.cap_raw.to_bytes());
|
||||
buffer.extend(self.investor_cap_raw.to_bytes());
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
fn deserialize(data: &[u8]) -> vecdb::Result<Self> {
|
||||
let entry_count = usize::from_bytes(&data[0..8])?;
|
||||
let keys_len = usize::from_bytes(&data[8..16])?;
|
||||
let values_len = usize::from_bytes(&data[16..24])?;
|
||||
|
||||
let keys_start = 24;
|
||||
let values_start = keys_start + keys_len;
|
||||
let raw_start = values_start + values_len;
|
||||
|
||||
let keys: Vec<u32> = simple_decompress(&data[keys_start..values_start])?;
|
||||
let values: Vec<u64> = simple_decompress(&data[values_start..raw_start])?;
|
||||
|
||||
let map: BTreeMap<CentsUnsignedCompact, Sats> = keys
|
||||
.into_iter()
|
||||
.zip(values)
|
||||
.map(|(k, v)| (CentsUnsignedCompact::new(k), Sats::from(v)))
|
||||
.collect();
|
||||
|
||||
assert_eq!(map.len(), entry_count);
|
||||
|
||||
let cap_raw = CentsSats::from_bytes(&data[raw_start..raw_start + 16])?;
|
||||
let investor_cap_raw = CentsSquaredSats::from_bytes(&data[raw_start + 16..raw_start + 32])?;
|
||||
|
||||
Ok(Self {
|
||||
map,
|
||||
cap_raw,
|
||||
investor_cap_raw,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
mod price_to_amount;
|
||||
mod cost_basis_data;
|
||||
mod percentiles;
|
||||
mod realized;
|
||||
mod unrealized;
|
||||
|
||||
pub use price_to_amount::*;
|
||||
pub use cost_basis_data::*;
|
||||
pub use percentiles::*;
|
||||
pub use realized::*;
|
||||
pub use unrealized::*;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
use brk_types::{CentsUnsigned, CentsUnsignedCompact, Sats};
|
||||
|
||||
use crate::internal::{PERCENTILES, PERCENTILES_LEN};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Percentiles {
|
||||
/// Sat-weighted: percentiles by coin count
|
||||
pub sat_weighted: [CentsUnsigned; PERCENTILES_LEN],
|
||||
/// USD-weighted: percentiles by invested capital (sats × price)
|
||||
pub usd_weighted: [CentsUnsigned; PERCENTILES_LEN],
|
||||
}
|
||||
|
||||
impl Percentiles {
|
||||
/// Compute both sat-weighted and USD-weighted percentiles in a single pass.
|
||||
/// Takes an iterator over (price, sats) pairs, assumed sorted by price ascending.
|
||||
pub fn compute(iter: impl Iterator<Item = (CentsUnsignedCompact, Sats)>) -> Option<Self> {
|
||||
// Collect to allow two passes: one for totals, one for percentiles
|
||||
let entries: Vec<_> = iter.collect();
|
||||
if entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Compute totals
|
||||
let mut total_sats: u64 = 0;
|
||||
let mut total_usd: u128 = 0;
|
||||
for &(cents, sats) in &entries {
|
||||
total_sats += u64::from(sats);
|
||||
total_usd += cents.as_u128() * sats.as_u128();
|
||||
}
|
||||
|
||||
if total_sats == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut sat_weighted = [CentsUnsigned::ZERO; PERCENTILES_LEN];
|
||||
let mut usd_weighted = [CentsUnsigned::ZERO; PERCENTILES_LEN];
|
||||
let mut cumsum_sats: u64 = 0;
|
||||
let mut cumsum_usd: u128 = 0;
|
||||
let mut sat_idx = 0;
|
||||
let mut usd_idx = 0;
|
||||
|
||||
for (cents, sats) in entries {
|
||||
cumsum_sats += u64::from(sats);
|
||||
cumsum_usd += cents.as_u128() * sats.as_u128();
|
||||
|
||||
while sat_idx < PERCENTILES_LEN
|
||||
&& cumsum_sats >= total_sats * u64::from(PERCENTILES[sat_idx]) / 100
|
||||
{
|
||||
sat_weighted[sat_idx] = cents.into();
|
||||
sat_idx += 1;
|
||||
}
|
||||
|
||||
while usd_idx < PERCENTILES_LEN
|
||||
&& cumsum_usd >= total_usd * u128::from(PERCENTILES[usd_idx]) / 100
|
||||
{
|
||||
usd_weighted[usd_idx] = cents.into();
|
||||
usd_idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
sat_weighted,
|
||||
usd_weighted,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
ops::Bound,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use brk_error::{Error, Result};
|
||||
use brk_types::{CentsCompact, Dollars, Height, Sats, SupplyState};
|
||||
use derive_more::{Deref, DerefMut};
|
||||
use pco::{standalone::{simple_compress, simple_decompress}, ChunkConfig};
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vecdb::Bytes;
|
||||
|
||||
use crate::{
|
||||
internal::{PERCENTILES, PERCENTILES_LEN},
|
||||
utils::OptionExt,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PriceToAmount {
|
||||
pathbuf: PathBuf,
|
||||
state: Option<State>,
|
||||
/// Pending deltas: (total_increment, total_decrement) per price.
|
||||
/// Flushed to BTreeMap before reads and at end of block.
|
||||
pending: FxHashMap<CentsCompact, (Sats, Sats)>,
|
||||
}
|
||||
|
||||
const STATE_AT_: &str = "state_at_";
|
||||
const STATE_TO_KEEP: usize = 10;
|
||||
|
||||
impl PriceToAmount {
|
||||
pub fn create(path: &Path, name: &str) -> Self {
|
||||
Self {
|
||||
pathbuf: path.join(format!("{name}_price_to_amount")),
|
||||
state: None,
|
||||
pending: FxHashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn import_at_or_before(&mut self, height: Height) -> Result<Height> {
|
||||
let files = self.read_dir(None)?;
|
||||
let (&height, path) = files.range(..=height).next_back().ok_or(Error::NotFound(
|
||||
"No price state found at or before height".into(),
|
||||
))?;
|
||||
self.state = Some(State::deserialize(&fs::read(path)?)?);
|
||||
self.pending.clear();
|
||||
Ok(height)
|
||||
}
|
||||
|
||||
fn assert_pending_empty(&self) {
|
||||
assert!(
|
||||
self.pending.is_empty(),
|
||||
"PriceToAmount: pending not empty, call apply_pending first"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (Dollars, &Sats)> {
|
||||
self.assert_pending_empty();
|
||||
self.state.u().iter().map(|(k, v)| (k.to_dollars(), v))
|
||||
}
|
||||
|
||||
/// Iterate over entries in a price range with explicit bounds.
|
||||
pub fn range(
|
||||
&self,
|
||||
bounds: (Bound<Dollars>, Bound<Dollars>),
|
||||
) -> impl Iterator<Item = (Dollars, &Sats)> {
|
||||
self.assert_pending_empty();
|
||||
|
||||
let start = match bounds.0 {
|
||||
Bound::Included(d) => Bound::Included(CentsCompact::from(d)),
|
||||
Bound::Excluded(d) => Bound::Excluded(CentsCompact::from(d)),
|
||||
Bound::Unbounded => Bound::Unbounded,
|
||||
};
|
||||
|
||||
let end = match bounds.1 {
|
||||
Bound::Included(d) => Bound::Included(CentsCompact::from(d)),
|
||||
Bound::Excluded(d) => Bound::Excluded(CentsCompact::from(d)),
|
||||
Bound::Unbounded => Bound::Unbounded,
|
||||
};
|
||||
|
||||
self.state
|
||||
.u()
|
||||
.range((start, end))
|
||||
.map(|(k, v)| (k.to_dollars(), v))
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.pending.is_empty() && self.state.u().is_empty()
|
||||
}
|
||||
|
||||
pub fn first_key_value(&self) -> Option<(Dollars, &Sats)> {
|
||||
self.assert_pending_empty();
|
||||
self.state
|
||||
.u()
|
||||
.first_key_value()
|
||||
.map(|(k, v)| (k.to_dollars(), v))
|
||||
}
|
||||
|
||||
pub fn last_key_value(&self) -> Option<(Dollars, &Sats)> {
|
||||
self.assert_pending_empty();
|
||||
self.state
|
||||
.u()
|
||||
.last_key_value()
|
||||
.map(|(k, v)| (k.to_dollars(), v))
|
||||
}
|
||||
|
||||
/// Accumulate increment in pending batch. O(1).
|
||||
pub fn increment(&mut self, price: Dollars, supply_state: &SupplyState) {
|
||||
self.pending.entry(CentsCompact::from(price)).or_default().0 += supply_state.value;
|
||||
}
|
||||
|
||||
/// Accumulate decrement in pending batch. O(1).
|
||||
pub fn decrement(&mut self, price: Dollars, supply_state: &SupplyState) {
|
||||
self.pending.entry(CentsCompact::from(price)).or_default().1 += supply_state.value;
|
||||
}
|
||||
|
||||
/// Apply pending deltas to BTreeMap. O(k log n) where k = unique prices in pending.
|
||||
/// Must be called before any read operations.
|
||||
pub fn apply_pending(&mut self) {
|
||||
for (cents, (inc, dec)) in self.pending.drain() {
|
||||
let entry = self.state.um().entry(cents).or_default();
|
||||
*entry += inc;
|
||||
if *entry < dec {
|
||||
panic!(
|
||||
"PriceToAmount::apply_pending underflow!\n\
|
||||
Path: {:?}\n\
|
||||
Price: {}\n\
|
||||
Current + increments: {}\n\
|
||||
Trying to decrement by: {}",
|
||||
self.pathbuf,
|
||||
cents.to_dollars(),
|
||||
entry,
|
||||
dec
|
||||
);
|
||||
}
|
||||
*entry -= dec;
|
||||
if *entry == Sats::ZERO {
|
||||
self.state.um().remove(¢s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(&mut self) {
|
||||
self.state.replace(State::default());
|
||||
self.pending.clear();
|
||||
}
|
||||
|
||||
/// Compute percentile prices by iterating the BTreeMap directly.
|
||||
/// O(n) where n = number of unique prices.
|
||||
pub fn compute_percentiles(&self) -> [Dollars; PERCENTILES_LEN] {
|
||||
self.assert_pending_empty();
|
||||
|
||||
let state = match self.state.as_ref() {
|
||||
Some(s) if !s.is_empty() => s,
|
||||
_ => return [Dollars::NAN; PERCENTILES_LEN],
|
||||
};
|
||||
|
||||
let total: u64 = state.values().map(|&s| u64::from(s)).sum();
|
||||
if total == 0 {
|
||||
return [Dollars::NAN; PERCENTILES_LEN];
|
||||
}
|
||||
|
||||
let mut result = [Dollars::NAN; PERCENTILES_LEN];
|
||||
let mut cumsum = 0u64;
|
||||
let mut idx = 0;
|
||||
|
||||
for (¢s, &amount) in state.iter() {
|
||||
cumsum += u64::from(amount);
|
||||
while idx < PERCENTILES_LEN && cumsum >= total * u64::from(PERCENTILES[idx]) / 100 {
|
||||
result[idx] = cents.to_dollars();
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn clean(&mut self) -> Result<()> {
|
||||
let _ = fs::remove_dir_all(&self.pathbuf);
|
||||
fs::create_dir_all(&self.pathbuf)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_dir(&self, keep_only_before: Option<Height>) -> Result<BTreeMap<Height, PathBuf>> {
|
||||
Ok(fs::read_dir(&self.pathbuf)?
|
||||
.filter_map(|entry| {
|
||||
let path = entry.ok()?.path();
|
||||
let name = path.file_name()?.to_str()?;
|
||||
let height_str = name.strip_prefix(STATE_AT_).unwrap_or(name);
|
||||
if let Ok(h) = height_str.parse::<u32>().map(Height::from) {
|
||||
if keep_only_before.is_none_or(|height| h < height) {
|
||||
Some((h, path))
|
||||
} else {
|
||||
let _ = fs::remove_file(path);
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<BTreeMap<Height, PathBuf>>())
|
||||
}
|
||||
|
||||
/// Flush state to disk, optionally cleaning up old state files.
|
||||
pub fn write(&mut self, height: Height, cleanup: bool) -> Result<()> {
|
||||
self.apply_pending();
|
||||
|
||||
if cleanup {
|
||||
let files = self.read_dir(Some(height))?;
|
||||
|
||||
for (_, path) in files
|
||||
.iter()
|
||||
.take(files.len().saturating_sub(STATE_TO_KEEP - 1))
|
||||
{
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(self.path_state(height), self.state.u().serialize()?)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn path_state(&self, height: Height) -> PathBuf {
|
||||
Self::path_state_(&self.pathbuf, height)
|
||||
}
|
||||
fn path_state_(path: &Path, height: Height) -> PathBuf {
|
||||
path.join(u32::from(height).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Debug, Deref, DerefMut, Serialize, Deserialize)]
|
||||
struct State(BTreeMap<CentsCompact, Sats>);
|
||||
|
||||
impl State {
|
||||
fn serialize(&self) -> vecdb::Result<Vec<u8>> {
|
||||
let keys: Vec<i32> = self.keys().map(|k| i32::from(*k)).collect();
|
||||
let values: Vec<u64> = self.values().map(|v| u64::from(*v)).collect();
|
||||
|
||||
let config = ChunkConfig::default();
|
||||
let compressed_keys = simple_compress(&keys, &config)?;
|
||||
let compressed_values = simple_compress(&values, &config)?;
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
buffer.extend(keys.len().to_bytes());
|
||||
buffer.extend(compressed_keys.len().to_bytes());
|
||||
buffer.extend(compressed_keys);
|
||||
buffer.extend(compressed_values);
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
fn deserialize(data: &[u8]) -> vecdb::Result<Self> {
|
||||
let entry_count = usize::from_bytes(&data[0..8])?;
|
||||
let keys_len = usize::from_bytes(&data[8..16])?;
|
||||
|
||||
let keys: Vec<i32> = simple_decompress(&data[16..16 + keys_len])?;
|
||||
let values: Vec<u64> = simple_decompress(&data[16 + keys_len..])?;
|
||||
|
||||
let map: BTreeMap<CentsCompact, Sats> = keys
|
||||
.into_iter()
|
||||
.zip(values)
|
||||
.map(|(k, v)| (CentsCompact::from(k), Sats::from(v)))
|
||||
.collect();
|
||||
|
||||
assert_eq!(map.len(), entry_count);
|
||||
|
||||
Ok(Self(map))
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,222 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use brk_types::{CheckedSub, Dollars, SupplyState};
|
||||
use brk_types::{CentsSats, CentsSquaredSats, CentsUnsigned, Sats};
|
||||
|
||||
/// Realized state using u128 for raw cent*sat values internally.
|
||||
/// This avoids overflow and defers division to output time for efficiency.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct RealizedState {
|
||||
pub cap: Dollars,
|
||||
pub profit: Dollars,
|
||||
pub loss: Dollars,
|
||||
pub value_created: Dollars,
|
||||
pub value_destroyed: Dollars,
|
||||
/// Raw realized cap: Σ(price × sats)
|
||||
cap_raw: u128,
|
||||
/// 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
|
||||
profit_value_destroyed_raw: u128,
|
||||
/// sell_price × sats for loss cases
|
||||
loss_value_created_raw: u128,
|
||||
/// cost_basis × sats for loss cases (= capitulation_flow)
|
||||
loss_value_destroyed_raw: u128,
|
||||
/// Raw realized ATH regret: Σ((ath - sell_price) × sats)
|
||||
ath_regret_raw: u128,
|
||||
}
|
||||
|
||||
impl RealizedState {
|
||||
pub const NAN: Self = Self {
|
||||
cap: Dollars::NAN,
|
||||
profit: Dollars::NAN,
|
||||
loss: Dollars::NAN,
|
||||
value_created: Dollars::NAN,
|
||||
value_destroyed: Dollars::NAN,
|
||||
};
|
||||
/// Get realized cap as CentsUnsigned (divides by ONE_BTC).
|
||||
#[inline]
|
||||
pub fn cap(&self) -> CentsUnsigned {
|
||||
CentsUnsigned::new((self.cap_raw / Sats::ONE_BTC_U128) as u64)
|
||||
}
|
||||
|
||||
/// Set cap_raw directly from persisted value.
|
||||
#[inline]
|
||||
pub fn set_cap_raw(&mut self, cap_raw: CentsSats) {
|
||||
self.cap_raw = cap_raw.inner();
|
||||
}
|
||||
|
||||
/// Set investor_cap_raw directly from persisted value.
|
||||
#[inline]
|
||||
pub fn set_investor_cap_raw(&mut self, investor_cap_raw: CentsSquaredSats) {
|
||||
self.investor_cap_raw = investor_cap_raw;
|
||||
}
|
||||
|
||||
/// Get investor price as CentsUnsigned.
|
||||
/// investor_price = Σ(price² × sats) / Σ(price × sats)
|
||||
/// This is the dollar-weighted average acquisition price.
|
||||
#[inline]
|
||||
pub fn investor_price(&self) -> CentsUnsigned {
|
||||
if self.cap_raw == 0 {
|
||||
return CentsUnsigned::ZERO;
|
||||
}
|
||||
CentsUnsigned::new((self.investor_cap_raw / self.cap_raw) as u64)
|
||||
}
|
||||
|
||||
/// Get raw realized cap for aggregation.
|
||||
#[inline]
|
||||
pub fn cap_raw(&self) -> CentsSats {
|
||||
CentsSats::new(self.cap_raw)
|
||||
}
|
||||
|
||||
/// Get raw investor cap for aggregation.
|
||||
#[inline]
|
||||
pub fn investor_cap_raw(&self) -> CentsSquaredSats {
|
||||
self.investor_cap_raw
|
||||
}
|
||||
|
||||
/// Get realized profit as CentsUnsigned.
|
||||
#[inline]
|
||||
pub fn profit(&self) -> CentsUnsigned {
|
||||
CentsUnsigned::new((self.profit_raw / Sats::ONE_BTC_U128) as u64)
|
||||
}
|
||||
|
||||
/// Get realized loss as CentsUnsigned.
|
||||
#[inline]
|
||||
pub fn loss(&self) -> CentsUnsigned {
|
||||
CentsUnsigned::new((self.loss_raw / Sats::ONE_BTC_U128) as u64)
|
||||
}
|
||||
|
||||
/// Get value created as CentsUnsigned (derived from profit + loss splits).
|
||||
#[inline]
|
||||
pub fn value_created(&self) -> CentsUnsigned {
|
||||
let raw = self.profit_value_created_raw + self.loss_value_created_raw;
|
||||
CentsUnsigned::new((raw / Sats::ONE_BTC_U128) as u64)
|
||||
}
|
||||
|
||||
/// Get value destroyed as CentsUnsigned (derived from profit + loss splits).
|
||||
#[inline]
|
||||
pub fn value_destroyed(&self) -> CentsUnsigned {
|
||||
let raw = self.profit_value_destroyed_raw + self.loss_value_destroyed_raw;
|
||||
CentsUnsigned::new((raw / Sats::ONE_BTC_U128) as u64)
|
||||
}
|
||||
|
||||
/// Get profit value created as CentsUnsigned (sell_price × sats for profit cases).
|
||||
#[inline]
|
||||
pub fn profit_value_created(&self) -> CentsUnsigned {
|
||||
CentsUnsigned::new((self.profit_value_created_raw / Sats::ONE_BTC_U128) as u64)
|
||||
}
|
||||
|
||||
/// Get profit value destroyed as CentsUnsigned (cost_basis × sats for profit cases).
|
||||
/// This is also known as profit_flow.
|
||||
#[inline]
|
||||
pub fn profit_value_destroyed(&self) -> CentsUnsigned {
|
||||
CentsUnsigned::new((self.profit_value_destroyed_raw / Sats::ONE_BTC_U128) as u64)
|
||||
}
|
||||
|
||||
/// Get loss value created as CentsUnsigned (sell_price × sats for loss cases).
|
||||
#[inline]
|
||||
pub fn loss_value_created(&self) -> CentsUnsigned {
|
||||
CentsUnsigned::new((self.loss_value_created_raw / Sats::ONE_BTC_U128) as u64)
|
||||
}
|
||||
|
||||
/// Get loss value destroyed as CentsUnsigned (cost_basis × sats for loss cases).
|
||||
/// This is also known as capitulation_flow.
|
||||
#[inline]
|
||||
pub fn loss_value_destroyed(&self) -> CentsUnsigned {
|
||||
CentsUnsigned::new((self.loss_value_destroyed_raw / Sats::ONE_BTC_U128) as u64)
|
||||
}
|
||||
|
||||
/// Get capitulation flow as CentsUnsigned.
|
||||
/// This is the invested capital (cost_basis × sats) sold at a loss.
|
||||
/// Alias for loss_value_destroyed.
|
||||
#[inline]
|
||||
pub fn capitulation_flow(&self) -> CentsUnsigned {
|
||||
self.loss_value_destroyed()
|
||||
}
|
||||
|
||||
/// Get profit flow as CentsUnsigned.
|
||||
/// This is the invested capital (cost_basis × sats) sold at a profit.
|
||||
/// Alias for profit_value_destroyed.
|
||||
#[inline]
|
||||
pub fn profit_flow(&self) -> CentsUnsigned {
|
||||
self.profit_value_destroyed()
|
||||
}
|
||||
|
||||
/// Get realized ATH regret as CentsUnsigned.
|
||||
/// This is Σ((ath - sell_price) × sats) - how much more could have been made
|
||||
/// by selling at ATH instead of when actually sold.
|
||||
#[inline]
|
||||
pub fn ath_regret(&self) -> CentsUnsigned {
|
||||
CentsUnsigned::new((self.ath_regret_raw / Sats::ONE_BTC_U128) as u64)
|
||||
}
|
||||
|
||||
pub fn reset_single_iteration_values(&mut self) {
|
||||
if self.cap != Dollars::NAN {
|
||||
self.profit = Dollars::ZERO;
|
||||
self.loss = Dollars::ZERO;
|
||||
self.value_created = Dollars::ZERO;
|
||||
self.value_destroyed = Dollars::ZERO;
|
||||
}
|
||||
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.ath_regret_raw = 0;
|
||||
}
|
||||
|
||||
pub fn increment(&mut self, supply_state: &SupplyState, price: Dollars) {
|
||||
if supply_state.value.is_zero() {
|
||||
/// Increment using pre-computed values (for UTXO path)
|
||||
#[inline]
|
||||
pub fn increment(&mut self, price: CentsUnsigned, sats: Sats) {
|
||||
if sats.is_zero() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.increment_(price * supply_state.value)
|
||||
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);
|
||||
}
|
||||
|
||||
pub fn increment_(&mut self, realized_cap: Dollars) {
|
||||
if self.cap == Dollars::NAN {
|
||||
self.cap = Dollars::ZERO;
|
||||
self.profit = Dollars::ZERO;
|
||||
self.loss = Dollars::ZERO;
|
||||
self.value_created = Dollars::ZERO;
|
||||
self.value_destroyed = Dollars::ZERO;
|
||||
}
|
||||
|
||||
self.cap += realized_cap;
|
||||
/// Increment using pre-computed snapshot values (for address path)
|
||||
#[inline]
|
||||
pub fn increment_snapshot(&mut self, price_sats: CentsSats, investor_cap: CentsSquaredSats) {
|
||||
self.cap_raw += price_sats.as_u128();
|
||||
self.investor_cap_raw += investor_cap;
|
||||
}
|
||||
|
||||
pub fn decrement(&mut self, supply_state: &SupplyState, price: Dollars) {
|
||||
self.decrement_(price * supply_state.value);
|
||||
/// Decrement using pre-computed snapshot values (for address path)
|
||||
#[inline]
|
||||
pub fn decrement_snapshot(&mut self, price_sats: CentsSats, investor_cap: CentsSquaredSats) {
|
||||
self.cap_raw -= price_sats.as_u128();
|
||||
self.investor_cap_raw -= investor_cap;
|
||||
}
|
||||
|
||||
pub fn decrement_(&mut self, realized_cap: Dollars) {
|
||||
self.cap = self.cap.checked_sub(realized_cap).unwrap();
|
||||
}
|
||||
|
||||
pub fn receive(&mut self, supply_state: &SupplyState, current_price: Dollars) {
|
||||
self.increment(supply_state, current_price);
|
||||
#[inline]
|
||||
pub fn receive(&mut self, price: CentsUnsigned, sats: Sats) {
|
||||
self.increment(price, sats);
|
||||
}
|
||||
|
||||
/// Send with pre-computed typed values. Inlines decrement to avoid recomputation.
|
||||
#[inline]
|
||||
pub fn send(
|
||||
&mut self,
|
||||
supply_state: &SupplyState,
|
||||
current_price: Dollars,
|
||||
prev_price: Dollars,
|
||||
current_ps: CentsSats,
|
||||
prev_ps: CentsSats,
|
||||
ath_ps: CentsSats,
|
||||
prev_investor_cap: CentsSquaredSats,
|
||||
) {
|
||||
let current_value = current_price * supply_state.value;
|
||||
let prev_value = prev_price * supply_state.value;
|
||||
|
||||
self.value_created += current_value;
|
||||
self.value_destroyed += prev_value;
|
||||
|
||||
match current_price.cmp(&prev_price) {
|
||||
match current_ps.cmp(&prev_ps) {
|
||||
Ordering::Greater => {
|
||||
self.profit += current_value.checked_sub(prev_value).unwrap();
|
||||
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();
|
||||
}
|
||||
Ordering::Less => {
|
||||
self.loss += prev_value.checked_sub(current_value).unwrap();
|
||||
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();
|
||||
}
|
||||
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();
|
||||
}
|
||||
Ordering::Equal => {}
|
||||
}
|
||||
|
||||
self.decrement(supply_state, prev_price);
|
||||
// Track ATH regret: (ath - sell_price) × sats
|
||||
self.ath_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,253 +1,328 @@
|
||||
use std::ops::Bound;
|
||||
|
||||
use brk_types::{CentsUnsigned, Dollars, Sats};
|
||||
use vecdb::CheckedSub;
|
||||
use brk_types::{CentsUnsigned, CentsUnsignedCompact, Sats};
|
||||
|
||||
use super::price_to_amount::PriceToAmount;
|
||||
use super::cost_basis_data::CostBasisData;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct UnrealizedState {
|
||||
pub supply_in_profit: Sats,
|
||||
pub supply_in_loss: Sats,
|
||||
pub unrealized_profit: Dollars,
|
||||
pub unrealized_loss: Dollars,
|
||||
/// Invested capital in profit: Σ(sats × price) where price <= spot
|
||||
pub invested_capital_in_profit: Dollars,
|
||||
/// Invested capital in loss: Σ(sats × price) where price > spot
|
||||
pub invested_capital_in_loss: Dollars,
|
||||
pub unrealized_profit: CentsUnsigned,
|
||||
pub unrealized_loss: CentsUnsigned,
|
||||
pub invested_capital_in_profit: CentsUnsigned,
|
||||
pub invested_capital_in_loss: CentsUnsigned,
|
||||
/// Raw Σ(price² × sats) for UTXOs in profit. Used for aggregation.
|
||||
pub investor_cap_in_profit_raw: u128,
|
||||
/// Raw Σ(price² × sats) for UTXOs in loss. Used for aggregation.
|
||||
pub investor_cap_in_loss_raw: u128,
|
||||
/// Raw Σ(price × sats) for UTXOs in profit. Used for aggregation.
|
||||
pub invested_capital_in_profit_raw: u128,
|
||||
/// Raw Σ(price × sats) for UTXOs in loss. Used for aggregation.
|
||||
pub invested_capital_in_loss_raw: u128,
|
||||
}
|
||||
|
||||
impl UnrealizedState {
|
||||
pub const NAN: Self = Self {
|
||||
supply_in_profit: Sats::ZERO,
|
||||
supply_in_loss: Sats::ZERO,
|
||||
unrealized_profit: Dollars::NAN,
|
||||
unrealized_loss: Dollars::NAN,
|
||||
invested_capital_in_profit: Dollars::NAN,
|
||||
invested_capital_in_loss: Dollars::NAN,
|
||||
};
|
||||
|
||||
pub const ZERO: Self = Self {
|
||||
supply_in_profit: Sats::ZERO,
|
||||
supply_in_loss: Sats::ZERO,
|
||||
unrealized_profit: Dollars::ZERO,
|
||||
unrealized_loss: Dollars::ZERO,
|
||||
invested_capital_in_profit: Dollars::ZERO,
|
||||
invested_capital_in_loss: Dollars::ZERO,
|
||||
unrealized_profit: CentsUnsigned::ZERO,
|
||||
unrealized_loss: CentsUnsigned::ZERO,
|
||||
invested_capital_in_profit: CentsUnsigned::ZERO,
|
||||
invested_capital_in_loss: CentsUnsigned::ZERO,
|
||||
investor_cap_in_profit_raw: 0,
|
||||
investor_cap_in_loss_raw: 0,
|
||||
invested_capital_in_profit_raw: 0,
|
||||
invested_capital_in_loss_raw: 0,
|
||||
};
|
||||
|
||||
/// Compute pain_index from raw values.
|
||||
/// pain_index = investor_price_of_losers - spot
|
||||
#[inline]
|
||||
pub fn pain_index(&self, spot: CentsUnsigned) -> CentsUnsigned {
|
||||
if self.invested_capital_in_loss_raw == 0 {
|
||||
return CentsUnsigned::ZERO;
|
||||
}
|
||||
let investor_price_losers =
|
||||
self.investor_cap_in_loss_raw / self.invested_capital_in_loss_raw;
|
||||
CentsUnsigned::new((investor_price_losers - spot.as_u128()) as u64)
|
||||
}
|
||||
|
||||
/// Compute greed_index from raw values.
|
||||
/// greed_index = spot - investor_price_of_winners
|
||||
#[inline]
|
||||
pub fn greed_index(&self, spot: CentsUnsigned) -> CentsUnsigned {
|
||||
if self.invested_capital_in_profit_raw == 0 {
|
||||
return CentsUnsigned::ZERO;
|
||||
}
|
||||
let investor_price_winners =
|
||||
self.investor_cap_in_profit_raw / self.invested_capital_in_profit_raw;
|
||||
CentsUnsigned::new((spot.as_u128() - investor_price_winners) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal cache state using u128 for raw cent*sat values.
|
||||
/// This avoids rounding errors from premature division by ONE_BTC.
|
||||
/// Division happens only when converting to UnrealizedState output.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct CachedStateRaw {
|
||||
supply_in_profit: Sats,
|
||||
supply_in_loss: Sats,
|
||||
/// Raw value: sum of (price_cents * sats) for UTXOs in profit
|
||||
unrealized_profit: u128,
|
||||
/// Raw value: sum of (price_cents * sats) for UTXOs in loss
|
||||
unrealized_loss: u128,
|
||||
/// Raw value: sum of (price_cents * sats) for UTXOs in profit
|
||||
invested_capital_in_profit: u128,
|
||||
/// Raw value: sum of (price_cents * sats) for UTXOs in loss
|
||||
invested_capital_in_loss: u128,
|
||||
/// Raw value: sum of (price_cents² * sats) for UTXOs in profit
|
||||
investor_cap_in_profit: u128,
|
||||
/// Raw value: sum of (price_cents² * sats) for UTXOs in loss
|
||||
investor_cap_in_loss: u128,
|
||||
}
|
||||
|
||||
impl CachedStateRaw {
|
||||
/// Convert raw values to final output by dividing by ONE_BTC.
|
||||
fn to_output(&self) -> UnrealizedState {
|
||||
UnrealizedState {
|
||||
supply_in_profit: self.supply_in_profit,
|
||||
supply_in_loss: self.supply_in_loss,
|
||||
unrealized_profit: CentsUnsigned::new(
|
||||
(self.unrealized_profit / Sats::ONE_BTC_U128) as u64,
|
||||
),
|
||||
unrealized_loss: CentsUnsigned::new(
|
||||
(self.unrealized_loss / Sats::ONE_BTC_U128) as u64,
|
||||
),
|
||||
invested_capital_in_profit: CentsUnsigned::new(
|
||||
(self.invested_capital_in_profit / Sats::ONE_BTC_U128) as u64,
|
||||
),
|
||||
invested_capital_in_loss: CentsUnsigned::new(
|
||||
(self.invested_capital_in_loss / Sats::ONE_BTC_U128) as u64,
|
||||
),
|
||||
investor_cap_in_profit_raw: self.investor_cap_in_profit,
|
||||
investor_cap_in_loss_raw: self.investor_cap_in_loss,
|
||||
invested_capital_in_profit_raw: self.invested_capital_in_profit,
|
||||
invested_capital_in_loss_raw: self.invested_capital_in_loss,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached unrealized state for O(k) incremental updates.
|
||||
/// k = number of entries in price flip range (typically tiny).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CachedUnrealizedState {
|
||||
pub state: UnrealizedState,
|
||||
at_price: Dollars,
|
||||
state: CachedStateRaw,
|
||||
at_price: CentsUnsignedCompact,
|
||||
}
|
||||
|
||||
impl CachedUnrealizedState {
|
||||
/// Create new cache by computing from scratch. O(n).
|
||||
pub fn compute_fresh(price: Dollars, price_to_amount: &PriceToAmount) -> Self {
|
||||
let state = Self::compute_full_standalone(price, price_to_amount);
|
||||
Self {
|
||||
state,
|
||||
at_price: price,
|
||||
}
|
||||
pub fn compute_fresh(price: CentsUnsigned, cost_basis_data: &CostBasisData) -> Self {
|
||||
let price: CentsUnsignedCompact = price.into();
|
||||
let state = Self::compute_raw(price, cost_basis_data);
|
||||
Self { state, at_price: price }
|
||||
}
|
||||
|
||||
/// Get the current cached state as output (without price update).
|
||||
pub fn current_state(&self) -> UnrealizedState {
|
||||
self.state.to_output()
|
||||
}
|
||||
|
||||
/// Get unrealized state at new_price. O(k) where k = flip range size.
|
||||
pub fn get_at_price(
|
||||
&mut self,
|
||||
new_price: Dollars,
|
||||
price_to_amount: &PriceToAmount,
|
||||
) -> &UnrealizedState {
|
||||
new_price: CentsUnsigned,
|
||||
cost_basis_data: &CostBasisData,
|
||||
) -> UnrealizedState {
|
||||
let new_price: CentsUnsignedCompact = new_price.into();
|
||||
if new_price != self.at_price {
|
||||
self.update_for_price_change(new_price, price_to_amount);
|
||||
self.update_for_price_change(new_price, cost_basis_data);
|
||||
}
|
||||
&self.state
|
||||
self.state.to_output()
|
||||
}
|
||||
|
||||
/// Update cached state when a receive happens.
|
||||
/// Determines profit/loss classification relative to cached price.
|
||||
pub fn on_receive(&mut self, purchase_price: Dollars, sats: Sats) {
|
||||
let invested_capital = purchase_price * sats;
|
||||
if purchase_price <= self.at_price {
|
||||
pub fn on_receive(&mut self, price: CentsUnsigned, sats: Sats) {
|
||||
let price: CentsUnsignedCompact = price.into();
|
||||
let sats_u128 = sats.as_u128();
|
||||
let price_u128 = price.as_u128();
|
||||
let invested_capital = price_u128 * sats_u128;
|
||||
let investor_cap = price_u128 * invested_capital;
|
||||
|
||||
if price <= self.at_price {
|
||||
self.state.supply_in_profit += sats;
|
||||
self.state.invested_capital_in_profit += invested_capital;
|
||||
if purchase_price < self.at_price {
|
||||
let diff = self.at_price.checked_sub(purchase_price).unwrap();
|
||||
self.state.unrealized_profit += diff * sats;
|
||||
self.state.investor_cap_in_profit += investor_cap;
|
||||
if price < self.at_price {
|
||||
let diff = (self.at_price - price).as_u128();
|
||||
self.state.unrealized_profit += diff * sats_u128;
|
||||
}
|
||||
} else {
|
||||
self.state.supply_in_loss += sats;
|
||||
self.state.invested_capital_in_loss += invested_capital;
|
||||
let diff = purchase_price.checked_sub(self.at_price).unwrap();
|
||||
self.state.unrealized_loss += diff * sats;
|
||||
self.state.investor_cap_in_loss += investor_cap;
|
||||
let diff = (price - self.at_price).as_u128();
|
||||
self.state.unrealized_loss += diff * sats_u128;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update cached state when a send happens from historical price.
|
||||
pub fn on_send(&mut self, historical_price: Dollars, sats: Sats) {
|
||||
let invested_capital = historical_price * sats;
|
||||
if historical_price <= self.at_price {
|
||||
// Was in profit
|
||||
pub fn on_send(&mut self, price: CentsUnsigned, sats: Sats) {
|
||||
let price: CentsUnsignedCompact = price.into();
|
||||
let sats_u128 = sats.as_u128();
|
||||
let price_u128 = price.as_u128();
|
||||
let invested_capital = price_u128 * sats_u128;
|
||||
let investor_cap = price_u128 * invested_capital;
|
||||
|
||||
if price <= self.at_price {
|
||||
self.state.supply_in_profit -= sats;
|
||||
self.state.invested_capital_in_profit = self
|
||||
.state
|
||||
.invested_capital_in_profit
|
||||
.checked_sub(invested_capital)
|
||||
.unwrap();
|
||||
if historical_price < self.at_price {
|
||||
let diff = self.at_price.checked_sub(historical_price).unwrap();
|
||||
let profit_removed = diff * sats;
|
||||
self.state.unrealized_profit = self
|
||||
.state
|
||||
.unrealized_profit
|
||||
.checked_sub(profit_removed)
|
||||
.unwrap_or(Dollars::ZERO);
|
||||
self.state.invested_capital_in_profit -= invested_capital;
|
||||
self.state.investor_cap_in_profit -= investor_cap;
|
||||
if price < self.at_price {
|
||||
let diff = (self.at_price - price).as_u128();
|
||||
self.state.unrealized_profit -= diff * sats_u128;
|
||||
}
|
||||
} else {
|
||||
// Was in loss
|
||||
self.state.supply_in_loss -= sats;
|
||||
self.state.invested_capital_in_loss = self
|
||||
.state
|
||||
.invested_capital_in_loss
|
||||
.checked_sub(invested_capital)
|
||||
.unwrap();
|
||||
let diff = historical_price.checked_sub(self.at_price).unwrap();
|
||||
let loss_removed = diff * sats;
|
||||
self.state.unrealized_loss = self
|
||||
.state
|
||||
.unrealized_loss
|
||||
.checked_sub(loss_removed)
|
||||
.unwrap_or(Dollars::ZERO);
|
||||
self.state.invested_capital_in_loss -= invested_capital;
|
||||
self.state.investor_cap_in_loss -= investor_cap;
|
||||
let diff = (price - self.at_price).as_u128();
|
||||
self.state.unrealized_loss -= diff * sats_u128;
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental update for price change. O(k) where k = entries in flip range.
|
||||
fn update_for_price_change(&mut self, new_price: Dollars, price_to_amount: &PriceToAmount) {
|
||||
fn update_for_price_change(
|
||||
&mut self,
|
||||
new_price: CentsUnsignedCompact,
|
||||
cost_basis_data: &CostBasisData,
|
||||
) {
|
||||
let old_price = self.at_price;
|
||||
let delta_f64 = f64::from(new_price) - f64::from(old_price);
|
||||
|
||||
// Update profit/loss for entries that DON'T flip
|
||||
// Profit changes by delta * supply_in_profit
|
||||
// Loss changes by -delta * supply_in_loss
|
||||
if delta_f64 > 0.0 {
|
||||
// Price went up: profits increase, losses decrease
|
||||
self.state.unrealized_profit += Dollars::from(delta_f64) * self.state.supply_in_profit;
|
||||
let loss_decrease = Dollars::from(delta_f64) * self.state.supply_in_loss;
|
||||
self.state.unrealized_loss = self
|
||||
.state
|
||||
.unrealized_loss
|
||||
.checked_sub(loss_decrease)
|
||||
.unwrap_or(Dollars::ZERO);
|
||||
} else if delta_f64 < 0.0 {
|
||||
// Price went down: profits decrease, losses increase
|
||||
let profit_decrease = Dollars::from(-delta_f64) * self.state.supply_in_profit;
|
||||
self.state.unrealized_profit = self
|
||||
.state
|
||||
.unrealized_profit
|
||||
.checked_sub(profit_decrease)
|
||||
.unwrap_or(Dollars::ZERO);
|
||||
self.state.unrealized_loss += Dollars::from(-delta_f64) * self.state.supply_in_loss;
|
||||
}
|
||||
|
||||
// Handle flipped entries (only iterate the small range between prices)
|
||||
if new_price > old_price {
|
||||
// Price went up: entries where old < price <= new flip from loss to profit
|
||||
let delta = (new_price - old_price).as_u128();
|
||||
|
||||
// Save original supply for delta calculation (before crossing UTXOs move)
|
||||
let original_supply_in_profit = self.state.supply_in_profit.as_u128();
|
||||
|
||||
// First, process UTXOs crossing from loss to profit
|
||||
// Range (old_price, new_price] means: old_price < price <= new_price
|
||||
for (price, &sats) in
|
||||
price_to_amount.range((Bound::Excluded(old_price), Bound::Included(new_price)))
|
||||
cost_basis_data.range((Bound::Excluded(old_price), Bound::Included(new_price)))
|
||||
{
|
||||
// Move from loss to profit
|
||||
let sats_u128 = sats.as_u128();
|
||||
let price_u128 = price.as_u128();
|
||||
let invested_capital = price_u128 * sats_u128;
|
||||
let investor_cap = price_u128 * invested_capital;
|
||||
|
||||
// Move between buckets
|
||||
self.state.supply_in_loss -= sats;
|
||||
self.state.supply_in_profit += sats;
|
||||
self.state.invested_capital_in_loss -= invested_capital;
|
||||
self.state.invested_capital_in_profit += invested_capital;
|
||||
self.state.investor_cap_in_loss -= investor_cap;
|
||||
self.state.investor_cap_in_profit += investor_cap;
|
||||
|
||||
// Undo the loss adjustment applied above for this entry
|
||||
// We decreased loss by delta * sats, but this entry should be removed entirely
|
||||
// Original loss: (price - old_price) * sats
|
||||
// After global adjustment: original - delta * sats (negative, wrong)
|
||||
// Correct: 0 (removed from loss)
|
||||
// Correction: add back delta * sats, then add original loss
|
||||
let delta_adj = Dollars::from(delta_f64) * sats;
|
||||
self.state.unrealized_loss += delta_adj;
|
||||
if price > old_price {
|
||||
let original_loss = price.checked_sub(old_price).unwrap() * sats;
|
||||
self.state.unrealized_loss += original_loss;
|
||||
}
|
||||
// Remove their original contribution to unrealized_loss
|
||||
// (price > old_price is always true due to Bound::Excluded)
|
||||
let original_loss = (price - old_price).as_u128();
|
||||
self.state.unrealized_loss -= original_loss * sats_u128;
|
||||
|
||||
// Undo the profit adjustment applied above for this entry
|
||||
// We increased profit by delta * sats, but this entry was not in profit before
|
||||
// Correct profit: (new_price - price) * sats
|
||||
// Correction: subtract delta * sats, add correct profit
|
||||
let profit_adj = Dollars::from(delta_f64) * sats;
|
||||
self.state.unrealized_profit = self
|
||||
.state
|
||||
.unrealized_profit
|
||||
.checked_sub(profit_adj)
|
||||
.unwrap_or(Dollars::ZERO);
|
||||
if new_price > price {
|
||||
let correct_profit = new_price.checked_sub(price).unwrap() * sats;
|
||||
self.state.unrealized_profit += correct_profit;
|
||||
// Add their new contribution to unrealized_profit (if not at boundary)
|
||||
if price < new_price {
|
||||
let new_profit = (new_price - price).as_u128();
|
||||
self.state.unrealized_profit += new_profit * sats_u128;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply delta to non-crossing UTXOs only
|
||||
// Non-crossing profit UTXOs: their profit increases by delta
|
||||
self.state.unrealized_profit += delta * original_supply_in_profit;
|
||||
// Non-crossing loss UTXOs: their loss decreases by delta
|
||||
let non_crossing_loss_sats =
|
||||
self.state.supply_in_loss.as_u128(); // Already excludes crossing
|
||||
self.state.unrealized_loss -= delta * non_crossing_loss_sats;
|
||||
} else if new_price < old_price {
|
||||
// Price went down: entries where new < price <= old flip from profit to loss
|
||||
let delta = (old_price - new_price).as_u128();
|
||||
|
||||
// Save original supply for delta calculation (before crossing UTXOs move)
|
||||
let original_supply_in_loss = self.state.supply_in_loss.as_u128();
|
||||
|
||||
// First, process UTXOs crossing from profit to loss
|
||||
// Range (new_price, old_price] means: new_price < price <= old_price
|
||||
for (price, &sats) in
|
||||
price_to_amount.range((Bound::Excluded(new_price), Bound::Included(old_price)))
|
||||
cost_basis_data.range((Bound::Excluded(new_price), Bound::Included(old_price)))
|
||||
{
|
||||
// Move from profit to loss
|
||||
let sats_u128 = sats.as_u128();
|
||||
let price_u128 = price.as_u128();
|
||||
let invested_capital = price_u128 * sats_u128;
|
||||
let investor_cap = price_u128 * invested_capital;
|
||||
|
||||
// Move between buckets
|
||||
self.state.supply_in_profit -= sats;
|
||||
self.state.supply_in_loss += sats;
|
||||
self.state.invested_capital_in_profit -= invested_capital;
|
||||
self.state.invested_capital_in_loss += invested_capital;
|
||||
self.state.investor_cap_in_profit -= investor_cap;
|
||||
self.state.investor_cap_in_loss += investor_cap;
|
||||
|
||||
// Undo the profit adjustment applied above for this entry
|
||||
let delta_adj = Dollars::from(-delta_f64) * sats;
|
||||
self.state.unrealized_profit += delta_adj;
|
||||
if old_price > price {
|
||||
let original_profit = old_price.checked_sub(price).unwrap() * sats;
|
||||
self.state.unrealized_profit += original_profit;
|
||||
// Remove their original contribution to unrealized_profit (if not at boundary)
|
||||
if price < old_price {
|
||||
let original_profit = (old_price - price).as_u128();
|
||||
self.state.unrealized_profit -= original_profit * sats_u128;
|
||||
}
|
||||
|
||||
// Undo the loss adjustment applied above for this entry
|
||||
let loss_adj = Dollars::from(-delta_f64) * sats;
|
||||
self.state.unrealized_loss = self
|
||||
.state
|
||||
.unrealized_loss
|
||||
.checked_sub(loss_adj)
|
||||
.unwrap_or(Dollars::ZERO);
|
||||
if price > new_price {
|
||||
let correct_loss = price.checked_sub(new_price).unwrap() * sats;
|
||||
self.state.unrealized_loss += correct_loss;
|
||||
}
|
||||
// Add their new contribution to unrealized_loss
|
||||
// (price > new_price is always true due to Bound::Excluded)
|
||||
let new_loss = (price - new_price).as_u128();
|
||||
self.state.unrealized_loss += new_loss * sats_u128;
|
||||
}
|
||||
|
||||
// Apply delta to non-crossing UTXOs only
|
||||
// Non-crossing loss UTXOs: their loss increases by delta
|
||||
self.state.unrealized_loss += delta * original_supply_in_loss;
|
||||
// Non-crossing profit UTXOs: their profit decreases by delta
|
||||
let non_crossing_profit_sats =
|
||||
self.state.supply_in_profit.as_u128(); // Already excludes crossing
|
||||
self.state.unrealized_profit -= delta * non_crossing_profit_sats;
|
||||
}
|
||||
|
||||
self.at_price = new_price;
|
||||
}
|
||||
|
||||
/// Full computation from scratch (no cache). O(n).
|
||||
pub fn compute_full_standalone(
|
||||
current_price: Dollars,
|
||||
price_to_amount: &PriceToAmount,
|
||||
) -> UnrealizedState {
|
||||
let mut state = UnrealizedState::ZERO;
|
||||
/// Compute raw cached state from cost_basis_data.
|
||||
fn compute_raw(
|
||||
current_price: CentsUnsignedCompact,
|
||||
cost_basis_data: &CostBasisData,
|
||||
) -> CachedStateRaw {
|
||||
let mut state = CachedStateRaw::default();
|
||||
|
||||
for (price, &sats) in cost_basis_data.iter() {
|
||||
let sats_u128 = sats.as_u128();
|
||||
let price_u128 = price.as_u128();
|
||||
let invested_capital = price_u128 * sats_u128;
|
||||
let investor_cap = price_u128 * invested_capital;
|
||||
|
||||
for (price, &sats) in price_to_amount.iter() {
|
||||
let invested_capital = price * sats;
|
||||
if price <= current_price {
|
||||
state.supply_in_profit += sats;
|
||||
state.invested_capital_in_profit += invested_capital;
|
||||
state.investor_cap_in_profit += investor_cap;
|
||||
if price < current_price {
|
||||
let diff = current_price.checked_sub(price).unwrap();
|
||||
state.unrealized_profit += diff * sats;
|
||||
let diff = (current_price - price).as_u128();
|
||||
state.unrealized_profit += diff * sats_u128;
|
||||
}
|
||||
} else {
|
||||
state.supply_in_loss += sats;
|
||||
state.invested_capital_in_loss += invested_capital;
|
||||
let diff = price.checked_sub(current_price).unwrap();
|
||||
state.unrealized_loss += diff * sats;
|
||||
state.investor_cap_in_loss += investor_cap;
|
||||
let diff = (price - current_price).as_u128();
|
||||
state.unrealized_loss += diff * sats_u128;
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
/// Compute final UnrealizedState directly (not cached).
|
||||
/// Used for date_state which doesn't use the cache.
|
||||
pub fn compute_full_standalone(
|
||||
current_price: CentsUnsignedCompact,
|
||||
cost_basis_data: &CostBasisData,
|
||||
) -> UnrealizedState {
|
||||
Self::compute_raw(current_price, cost_basis_data).to_output()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user