use std::collections::VecDeque;
use brk_error::Result;
use brk_indexer::Indexer;
use brk_traversable::Traversable;
use brk_types::{Bitcoin, Dollars, Height, PartsPerMillion32, StoredF32, StoredU8, Version};
use schemars::JsonSchema;
use vecdb::{
AnyStoredVec, AnyVec, Database, Exit, ReadableVec, Rw, StorageMode, VecIndex, WritableVec,
};
use crate::{
indexes,
internal::{
NumericValue, PerBlock, PercentPerBlock,
algo::{ExactOrderStats, FenwickTree},
db_utils::validate_any_computed_version_or_reset,
},
};
const VERSION: Version = Version::new(6);
const MIN_HISTORY_BLOCKS: usize = 210_000;
const WRITE_INTERVAL: usize = 10_000;
const BULK_BACKFILL_THRESHOLD: usize = 10_000;
const BANDS: [(f64, u8); 3] = [(0.00025, 3), (0.0005, 2), (0.001, 1)];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum WindowKind {
Full,
Rolling(usize),
}
#[derive(Clone, Copy)]
struct Config {
upper_tail: bool,
window: WindowKind,
positive_only: bool,
}
const REALIZED: Config = Config {
upper_tail: true,
window: WindowKind::Full,
positive_only: false,
};
const COINS_IN_LOSS: Config = Config {
upper_tail: true,
window: WindowKind::Full,
positive_only: true,
};
const SELLER_EXHAUSTION: Config = Config {
upper_tail: false,
window: WindowKind::Rolling(MIN_HISTORY_BLOCKS),
positive_only: true,
};
#[derive(Traversable)]
pub struct Extreme<T, M: StorageMode = Rw>
where
T: NumericValue + JsonSchema,
{
pub threshold_pct0_1: PerBlock<T, M>,
pub threshold_pct0_05: PerBlock<T, M>,
pub threshold_pct0_025: PerBlock<T, M>,
pub tail: PercentPerBlock<PartsPerMillion32, M>,
pub rank: PerBlock<StoredU8, M>,
#[traversable(skip)]
history: LiveHistory,
}
impl<T> Extreme<T>
where
T: NumericValue + JsonSchema,
{
fn forced_import(
db: &Database,
name: &str,
version: Version,
indexes: &indexes::Vecs,
) -> Result<Self> {
Ok(Self {
threshold_pct0_1: PerBlock::forced_import(
db,
&format!("{name}_threshold_pct0_1"),
version,
indexes,
)?,
threshold_pct0_05: PerBlock::forced_import(
db,
&format!("{name}_threshold_pct0_05"),
version,
indexes,
)?,
threshold_pct0_025: PerBlock::forced_import(
db,
&format!("{name}_threshold"),
version,
indexes,
)?,
tail: PercentPerBlock::forced_import(db, &format!("{name}_tail"), version, indexes)?,
rank: PerBlock::forced_import(db, &format!("{name}_rank"), version, indexes)?,
history: LiveHistory::new(),
})
}
fn compute(
&mut self,
indexer: &Indexer,
source: &impl ReadableVec<Height, T>,
config: Config,
exit: &Exit,
) -> Result<()> {
let dependency_version = source.version();
for output in [
&mut self.threshold_pct0_1.height as &mut dyn AnyStoredVec,
&mut self.threshold_pct0_05.height,
&mut self.threshold_pct0_025.height,
&mut self.tail.ppm.height,
&mut self.rank.height,
] {
validate_any_computed_version_or_reset(output, dependency_version)?;
}
let source_end = source.len();
let start = [
self.threshold_pct0_1.height.len(),
self.threshold_pct0_05.height.len(),
self.threshold_pct0_025.height.len(),
self.tail.ppm.height.len(),
self.rank.height.len(),
indexer.safe_lengths().height.to_usize(),
source_end,
]
.into_iter()
.min()
.unwrap_or_default();
self.threshold_pct0_1
.height
.any_truncate_if_needed_at(start)?;
self.threshold_pct0_05
.height
.any_truncate_if_needed_at(start)?;
self.threshold_pct0_025
.height
.any_truncate_if_needed_at(start)?;
self.tail.ppm.height.any_truncate_if_needed_at(start)?;
self.rank.height.any_truncate_if_needed_at(start)?;
if source_end.saturating_sub(start) > BULK_BACKFILL_THRESHOLD {
self.compute_bulk(source, start, source_end, config, exit)
} else {
self.compute_incremental(source, start, source_end, config, exit)
}
}
fn compute_incremental(
&mut self,
source: &impl ReadableVec<Height, T>,
start: usize,
source_end: usize,
config: Config,
exit: &Exit,
) -> Result<()> {
let pending = if self.history.is_current(start, config.window) {
source
.collect_range_at(start, source_end)
.into_iter()
.map(Into::into)
.collect()
} else {
let mut values: Vec<f64> = source
.collect_range_at(0, source_end)
.into_iter()
.map(Into::into)
.collect();
let pending = values.split_off(start);
self.history.rebuild(values, config);
pending
};
for (offset, value) in pending.into_iter().enumerate() {
let height_index = start + offset;
let state = if is_valid(value, config) && self.history.len() >= MIN_HISTORY_BLOCKS {
event_state(value, &self.history, config)
} else {
EventState::missing()
};
self.push_state(state);
self.history.observe(value, config);
self.write_if_needed(height_index, source_end, exit)?;
}
Ok(())
}
fn compute_bulk(
&mut self,
source: &impl ReadableVec<Height, T>,
start: usize,
source_end: usize,
config: Config,
exit: &Exit,
) -> Result<()> {
let values: Vec<f64> = source
.collect_range_at(0, source_end)
.into_iter()
.map(Into::into)
.collect();
let mut sorted_values: Vec<f64> = values
.iter()
.copied()
.filter(|&value| is_valid(value, config))
.collect();
sorted_values.sort_unstable_by(f64::total_cmp);
let mut coordinates = sorted_values.clone();
coordinates.dedup_by(|a, b| a.total_cmp(b).is_eq());
let mut history = CoordinateHistory::new(&coordinates, config.window);
for &value in &values[..start] {
if is_valid(value, config) {
history.add(value);
}
}
for (height_index, &value) in values.iter().enumerate().skip(start) {
let state = if is_valid(value, config) && history.len() >= MIN_HISTORY_BLOCKS {
event_state(value, &history, config)
} else {
EventState::missing()
};
self.push_state(state);
if is_valid(value, config) {
history.add(value);
}
self.write_if_needed(height_index, source_end, exit)?;
}
let (sorted_values, window) = match config.window {
WindowKind::Full => (sorted_values, HistoryWindow::Full),
WindowKind::Rolling(_) => history.rolling_snapshot(),
};
self.history
.replace_from_sorted(source_end, sorted_values, window);
Ok(())
}
fn push_state(&mut self, state: EventState) {
self.threshold_pct0_1
.height
.push(T::from(state.thresholds.pct0_1));
self.threshold_pct0_05
.height
.push(T::from(state.thresholds.pct0_05));
self.threshold_pct0_025
.height
.push(T::from(state.thresholds.pct0_025));
self.tail
.ppm
.height
.push(PartsPerMillion32::from(state.tail));
self.rank.height.push(StoredU8::new(state.rank));
}
fn write_if_needed(
&mut self,
height_index: usize,
source_end: usize,
exit: &Exit,
) -> Result<()> {
if (height_index + 1).is_multiple_of(WRITE_INTERVAL) || height_index + 1 == source_end {
let _lock = exit.lock();
self.threshold_pct0_1.height.write()?;
self.threshold_pct0_05.height.write()?;
self.threshold_pct0_025.height.write()?;
self.tail.ppm.height.write()?;
self.rank.height.write()?;
}
Ok(())
}
}
#[derive(Traversable)]
pub struct Extremes<M: StorageMode = Rw> {
pub coins_in_loss: Extreme<Bitcoin, M>,
pub profit_taking: Extreme<Dollars, M>,
pub capitulation: Extreme<Dollars, M>,
pub peak_regret: Extreme<Dollars, M>,
pub seller_exhaustion: Extreme<StoredF32, M>,
}
impl Extremes {
pub(super) fn forced_import(
db: &Database,
parent_version: Version,
indexes: &indexes::Vecs,
) -> Result<Self> {
let version = parent_version + VERSION;
Ok(Self {
coins_in_loss: Extreme::forced_import(
db,
"rarity_meter_coins_in_loss",
version,
indexes,
)?,
profit_taking: Extreme::forced_import(
db,
"rarity_meter_profit_taking",
version,
indexes,
)?,
capitulation: Extreme::forced_import(
db,
"rarity_meter_capitulation",
version,
indexes,
)?,
peak_regret: Extreme::forced_import(db, "rarity_meter_peak_regret", version, indexes)?,
seller_exhaustion: Extreme::forced_import(
db,
"rarity_meter_seller_exhaustion",
version,
indexes,
)?,
})
}
#[allow(clippy::too_many_arguments)]
pub(super) fn compute(
&mut self,
indexer: &Indexer,
coins_in_loss: &impl ReadableVec<Height, Bitcoin>,
realized_profit: &impl ReadableVec<Height, Dollars>,
realized_loss: &impl ReadableVec<Height, Dollars>,
peak_regret: &impl ReadableVec<Height, Dollars>,
seller_exhaustion: &impl ReadableVec<Height, StoredF32>,
exit: &Exit,
) -> Result<()> {
self.coins_in_loss
.compute(indexer, coins_in_loss, COINS_IN_LOSS, exit)?;
self.profit_taking
.compute(indexer, realized_profit, REALIZED, exit)?;
self.capitulation
.compute(indexer, realized_loss, REALIZED, exit)?;
self.peak_regret
.compute(indexer, peak_regret, REALIZED, exit)?;
self.seller_exhaustion
.compute(indexer, seller_exhaustion, SELLER_EXHAUSTION, exit)?;
Ok(())
}
}
#[derive(Clone)]
enum HistoryWindow<T> {
Full,
Rolling { values: VecDeque<T>, limit: usize },
}
impl<T> HistoryWindow<T> {
fn new(kind: WindowKind) -> Self {
match kind {
WindowKind::Full => Self::Full,
WindowKind::Rolling(limit) => Self::Rolling {
values: VecDeque::with_capacity(limit),
limit,
},
}
}
fn kind(&self) -> WindowKind {
match self {
Self::Full => WindowKind::Full,
Self::Rolling { limit, .. } => WindowKind::Rolling(*limit),
}
}
fn push(&mut self, value: T) -> Option<T> {
let Self::Rolling { values, limit } = self else {
return None;
};
values.push_back(value);
(values.len() > *limit).then(|| values.pop_front().unwrap())
}
}
struct CoordinateHistory<'a> {
coordinates: &'a [f64],
tree: FenwickTree<f64>,
len: usize,
window: HistoryWindow<usize>,
}
impl<'a> CoordinateHistory<'a> {
fn new(coordinates: &'a [f64], window: WindowKind) -> Self {
Self {
coordinates,
tree: FenwickTree::new(coordinates.len().max(1)),
len: 0,
window: HistoryWindow::new(window),
}
}
fn add(&mut self, value: f64) {
let bucket = bucket(self.coordinates, value);
self.tree.add(bucket, &1.0);
self.len += 1;
if let Some(expired) = self.window.push(bucket) {
self.tree.add(expired, &-1.0);
self.len -= 1;
}
}
fn rolling_snapshot(&self) -> (Vec<f64>, HistoryWindow<f64>) {
let HistoryWindow::Rolling { values, limit } = &self.window else {
unreachable!("rolling snapshot requested for full history")
};
let mut counts = vec![0_usize; self.coordinates.len()];
let chronological: VecDeque<_> = values
.iter()
.map(|&bucket| {
counts[bucket] += 1;
self.coordinates[bucket]
})
.collect();
let sorted = self
.coordinates
.iter()
.zip(counts)
.flat_map(|(&value, count)| std::iter::repeat_n(value, count))
.collect();
(
sorted,
HistoryWindow::Rolling {
values: chronological,
limit: *limit,
},
)
}
}
#[derive(Clone)]
struct LiveHistory {
stats: ExactOrderStats,
processed: usize,
window: HistoryWindow<f64>,
}
impl LiveHistory {
fn new() -> Self {
Self {
stats: ExactOrderStats::new(0),
processed: 0,
window: HistoryWindow::Full,
}
}
fn is_current(&self, processed: usize, window: WindowKind) -> bool {
self.processed == processed && self.window.kind() == window
}
fn rebuild(&mut self, mut historical: Vec<f64>, config: Config) {
self.processed = historical.len();
historical.retain(|&value| is_valid(value, config));
self.window = HistoryWindow::new(config.window);
if let HistoryWindow::Rolling { values, limit } = &mut self.window {
let keep_from = historical.len().saturating_sub(*limit);
historical.drain(..keep_from);
values.extend(historical.iter().copied());
}
self.stats = ExactOrderStats::from_unsorted(historical);
}
fn replace_from_sorted(
&mut self,
processed: usize,
sorted_values: Vec<f64>,
window: HistoryWindow<f64>,
) {
if let HistoryWindow::Rolling { values, .. } = &window {
debug_assert_eq!(sorted_values.len(), values.len());
}
self.processed = processed;
self.stats = ExactOrderStats::from_sorted(sorted_values);
self.window = window;
}
fn observe(&mut self, value: f64, config: Config) {
self.processed += 1;
if !is_valid(value, config) {
return;
}
self.stats.insert(value);
if let Some(expired) = self.window.push(value) {
assert!(self.stats.remove(expired));
}
}
}
trait HistoryStats {
fn len(&self) -> usize;
fn quantile(&self, percentile: f64) -> f64;
fn tail(&self, value: f64, upper: bool) -> f64;
}
impl HistoryStats for CoordinateHistory<'_> {
fn len(&self) -> usize {
self.len
}
fn quantile(&self, percentile: f64) -> f64 {
let target = ((self.len - 1) as f64 * percentile).floor();
let mut index = [0];
self.tree.kth(&[target], &|count: &f64| *count, &mut index);
self.coordinates[index[0]]
}
fn tail(&self, value: f64, upper: bool) -> f64 {
let bucket = bucket(self.coordinates, value);
let less = if bucket == 0 {
0.0
} else {
self.tree.prefix_sum(bucket - 1)
};
let less_or_equal = self.tree.prefix_sum(bucket);
let count = if upper {
self.len as f64 - less
} else {
less_or_equal
};
(count + 1.0) / (self.len as f64 + 1.0)
}
}
impl HistoryStats for LiveHistory {
fn len(&self) -> usize {
self.stats.len()
}
fn quantile(&self, percentile: f64) -> f64 {
let index = ((self.stats.len() - 1) as f64 * percentile).floor() as usize;
self.stats.kth(index)
}
fn tail(&self, value: f64, upper: bool) -> f64 {
let count = if upper {
self.stats.len() - self.stats.count_lt(value)
} else {
self.stats.count_le(value)
};
(count as f64 + 1.0) / (self.stats.len() as f64 + 1.0)
}
}
struct EventState {
thresholds: EventThresholds,
tail: f64,
rank: u8,
}
struct EventThresholds {
pct0_1: f64,
pct0_05: f64,
pct0_025: f64,
}
impl EventState {
fn missing() -> Self {
Self {
thresholds: EventThresholds {
pct0_1: f64::NAN,
pct0_05: f64::NAN,
pct0_025: f64::NAN,
},
tail: f64::NAN,
rank: 0,
}
}
}
fn event_state(value: f64, history: &impl HistoryStats, config: Config) -> EventState {
let percentile = |tail: f64| {
if config.upper_tail { 1.0 - tail } else { tail }
};
let boundary = |tail: f64| history.quantile(percentile(tail));
let thresholds = EventThresholds {
pct0_1: boundary(BANDS[2].0),
pct0_05: boundary(BANDS[1].0),
pct0_025: boundary(BANDS[0].0),
};
let rank = [
(thresholds.pct0_025, BANDS[0].1),
(thresholds.pct0_05, BANDS[1].1),
(thresholds.pct0_1, BANDS[2].1),
]
.into_iter()
.find_map(|(boundary, rank)| {
let reached = if config.upper_tail {
value >= boundary
} else {
value <= boundary
};
reached.then_some(rank)
})
.unwrap_or_default();
EventState {
thresholds,
tail: history.tail(value, config.upper_tail),
rank,
}
}
fn is_valid(value: f64, config: Config) -> bool {
value.is_finite() && (!config.positive_only || value > 0.0)
}
fn bucket(coordinates: &[f64], value: f64) -> usize {
coordinates
.binary_search_by(|candidate| candidate.total_cmp(&value))
.expect("event value must exist in coordinate set")
}
#[cfg(test)]
mod tests {
use super::*;
fn history(values: &[f64], config: Config) -> LiveHistory {
let mut history = LiveHistory::new();
history.rebuild(values.to_vec(), config);
history
}
fn assert_same_state(left: EventState, right: EventState) {
assert_eq!(left.thresholds.pct0_1, right.thresholds.pct0_1);
assert_eq!(left.thresholds.pct0_05, right.thresholds.pct0_05);
assert_eq!(left.thresholds.pct0_025, right.thresholds.pct0_025);
assert_eq!(left.tail, right.tail);
assert_eq!(left.rank, right.rank);
}
#[test]
fn upper_tail_includes_current_observation() {
let history = history(&(1..=100).map(f64::from).collect::<Vec<_>>(), REALIZED);
let state = event_state(101.0, &history, REALIZED);
assert!((state.tail - 1.0 / 101.0).abs() < f64::EPSILON);
assert_eq!(state.rank, 3);
assert_eq!(state.thresholds.pct0_025, 99.0);
assert!(state.thresholds.pct0_1 <= state.thresholds.pct0_05);
assert!(state.thresholds.pct0_05 <= state.thresholds.pct0_025);
}
#[test]
fn lower_tail_includes_current_observation() {
let history = history(
&(1..=100).map(f64::from).collect::<Vec<_>>(),
SELLER_EXHAUSTION,
);
let state = event_state(0.0, &history, SELLER_EXHAUSTION);
assert!((state.tail - 1.0 / 101.0).abs() < f64::EPSILON);
assert_eq!(state.rank, 3);
assert_eq!(state.thresholds.pct0_025, 1.0);
assert!(state.thresholds.pct0_1 >= state.thresholds.pct0_05);
assert!(state.thresholds.pct0_05 >= state.thresholds.pct0_025);
}
#[test]
fn incremental_history_matches_coordinate_history() {
let values = [3.0, 1.0, 2.0, 2.0, -1.0, 5.0, 4.0, 5.0];
let mut coordinates = values.to_vec();
coordinates.sort_unstable_by(f64::total_cmp);
coordinates.dedup_by(|a, b| a.total_cmp(b).is_eq());
let mut coordinate = CoordinateHistory::new(&coordinates, WindowKind::Full);
let mut incremental = LiveHistory::new();
for value in values {
if coordinate.len() > 0 {
assert_same_state(
event_state(value, &coordinate, REALIZED),
event_state(value, &incremental, REALIZED),
);
}
coordinate.add(value);
incremental.observe(value, REALIZED);
}
}
#[test]
fn rolling_history_evicts_the_oldest_observation() {
let values: Vec<_> = (1..=MIN_HISTORY_BLOCKS + 1)
.map(|value| value as f64)
.collect();
let mut history = history(&values, SELLER_EXHAUSTION);
assert_eq!(history.len(), MIN_HISTORY_BLOCKS);
assert_eq!(history.stats.kth(0), 2.0);
history.observe((MIN_HISTORY_BLOCKS + 2) as f64, SELLER_EXHAUSTION);
assert_eq!(history.len(), MIN_HISTORY_BLOCKS);
assert_eq!(history.stats.kth(0), 3.0);
}
#[test]
fn rollback_rebuild_replaces_incremental_state() {
let mut history = history(&[1.0, 2.0, 3.0], REALIZED);
history.observe(4.0, REALIZED);
assert!(history.is_current(4, WindowKind::Full));
history.rebuild(vec![1.0, 2.0], REALIZED);
assert!(history.is_current(2, WindowKind::Full));
assert_eq!(history.len(), 2);
assert_eq!(history.stats.kth(0), 1.0);
assert_eq!(history.stats.kth(1), 2.0);
}
}