global: snapshot

This commit is contained in:
nym21
2026-02-13 13:54:09 +01:00
parent b779edc0d6
commit 80b2c636b0
53 changed files with 1819 additions and 1184 deletions
+8 -4
View File
@@ -5145,7 +5145,9 @@ impl MetricsTree_Price_Usd {
pub struct MetricsTree_Price_Oracle {
pub price_cents: MetricPattern11<CentsUnsigned>,
pub ohlc_cents: MetricPattern6<OHLCCentsUnsigned>,
pub ohlc_dollars: MetricPattern6<OHLCDollars>,
pub split: CloseHighLowOpenPattern2<CentsUnsigned>,
pub ohlc: MetricPattern1<OHLCCentsUnsigned>,
pub ohlc_dollars: MetricPattern1<OHLCDollars>,
}
impl MetricsTree_Price_Oracle {
@@ -5153,7 +5155,9 @@ impl MetricsTree_Price_Oracle {
Self {
price_cents: MetricPattern11::new(client.clone(), "oracle_price_cents".to_string()),
ohlc_cents: MetricPattern6::new(client.clone(), "oracle_ohlc_cents".to_string()),
ohlc_dollars: MetricPattern6::new(client.clone(), "oracle_ohlc_dollars".to_string()),
split: CloseHighLowOpenPattern2::new(client.clone(), "oracle_price".to_string()),
ohlc: MetricPattern1::new(client.clone(), "oracle_price_ohlc".to_string()),
ohlc_dollars: MetricPattern1::new(client.clone(), "oracle_ohlc_dollars".to_string()),
}
}
}
@@ -6318,10 +6322,10 @@ impl BrkClient {
/// Live BTC/USD price
///
/// Returns the current BTC/USD price in cents, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool.
/// Returns the current BTC/USD price in dollars, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool.
///
/// Endpoint: `GET /api/mempool/price`
pub fn get_live_price(&self) -> Result<f64> {
pub fn get_live_price(&self) -> Result<Dollars> {
self.base.get_json(&format!("/api/mempool/price"))
}
@@ -249,8 +249,10 @@ impl UTXOCohorts {
.try_for_each(|v| v.compute_rest_part1(indexes, price, starting_indexes, exit))?;
// 2. Compute net_sentiment.height for separate cohorts (greed - pain)
self.par_iter_separate_mut()
.try_for_each(|v| v.metrics.compute_net_sentiment_height(starting_indexes, exit))?;
self.par_iter_separate_mut().try_for_each(|v| {
v.metrics
.compute_net_sentiment_height(starting_indexes, exit)
})?;
// 3. Compute net_sentiment.height for aggregate cohorts (weighted average)
self.for_each_aggregate(|vecs, sources| {
@@ -260,8 +262,10 @@ impl UTXOCohorts {
})?;
// 4. Compute net_sentiment dateindex for ALL cohorts
self.par_iter_mut()
.try_for_each(|v| v.metrics.compute_net_sentiment_rest(indexes, starting_indexes, exit))
self.par_iter_mut().try_for_each(|v| {
v.metrics
.compute_net_sentiment_rest(indexes, starting_indexes, exit)
})
}
/// Second phase of post-processing: compute relative metrics.
@@ -468,7 +472,8 @@ impl UTXOCohorts {
// Collect merged entries during the merge (already in sorted order)
// Pre-allocate with max possible unique prices (actual count likely lower due to dedup)
let max_unique_prices = relevant.iter().map(|e| e.len()).max().unwrap_or(0);
let mut merged: Vec<(CentsUnsignedCompact, Sats)> = Vec::with_capacity(max_unique_prices);
let mut merged: Vec<(CentsUnsignedCompact, Sats)> =
Vec::with_capacity(max_unique_prices);
// Finalize a price point: compute percentiles and accumulate for merged vec
let mut finalize_price = |price: CentsUnsigned, sats: u64, usd: u128| {
@@ -489,7 +494,8 @@ impl UTXOCohorts {
}
// Round to nearest dollar with N significant digits for storage
let rounded: CentsUnsignedCompact = price.round_to_dollar(COST_BASIS_PRICE_DIGITS).into();
let rounded: CentsUnsignedCompact =
price.round_to_dollar(COST_BASIS_PRICE_DIGITS).into();
// Merge entries with same rounded price using last_mut
if let Some((last_price, last_sats)) = merged.last_mut()
@@ -562,7 +568,10 @@ impl UTXOCohorts {
let dir = states_path.join(format!("utxo_{cohort_name}_cost_basis/by_date"));
fs::create_dir_all(&dir)?;
let path = dir.join(date.to_string());
fs::write(path, CostBasisDistribution::serialize_iter(merged.into_iter())?)?;
fs::write(
path,
CostBasisDistribution::serialize_iter(merged.into_iter())?,
)?;
Ok(())
})
+1 -1
View File
@@ -67,7 +67,7 @@ impl Vecs {
let cents = CentsVecs::forced_import(db, version)?;
let usd = UsdVecs::forced_import(db, version, indexes)?;
let sats = SatsVecs::forced_import(db, version, indexes)?;
let oracle = OracleVecs::forced_import(db, version)?;
let oracle = OracleVecs::forced_import(db, version, indexes)?;
Ok(Self {
db: db.clone(),
+220 -2
View File
@@ -4,8 +4,8 @@ use brk_error::Result;
use brk_indexer::Indexer;
use brk_oracle::{Config, Oracle, START_HEIGHT, bin_to_cents, cents_to_bin};
use brk_types::{
CentsUnsigned, Close, DateIndex, Height, High, Low, OHLCCentsUnsigned, Open, OutputType, Sats,
TxIndex, TxOutIndex,
CentsUnsigned, Close, DateIndex, Height, High, Low, OHLCCentsUnsigned, OHLCDollars, Open,
OutputType, Sats, TxIndex, TxOutIndex,
};
use tracing::info;
use vecdb::{
@@ -26,6 +26,224 @@ impl Vecs {
) -> Result<()> {
self.compute_prices(indexer, starting_indexes, exit)?;
self.compute_daily_ohlc(indexes, starting_indexes, exit)?;
self.compute_split_and_ohlc(starting_indexes, exit)?;
Ok(())
}
fn compute_split_and_ohlc(
&mut self,
starting_indexes: &ComputeIndexes,
exit: &Exit,
) -> Result<()> {
// Destructure to allow simultaneous borrows of different fields
let Self {
price_cents,
ohlc_cents,
split,
ohlc,
ohlc_dollars,
} = self;
// Open: first-value aggregation
split.open.height.compute_transform(
starting_indexes.height,
&*price_cents,
|(h, price, ..)| (h, Open::new(price)),
exit,
)?;
split.open.compute_rest(starting_indexes, exit, |v| {
v.compute_transform(
starting_indexes.dateindex,
&*ohlc_cents,
|(di, ohlc_val, ..)| (di, ohlc_val.open),
exit,
)?;
Ok(())
})?;
// High: max-value aggregation
split.high.height.compute_transform(
starting_indexes.height,
&*price_cents,
|(h, price, ..)| (h, High::new(price)),
exit,
)?;
split.high.compute_rest(starting_indexes, exit, |v| {
v.compute_transform(
starting_indexes.dateindex,
&*ohlc_cents,
|(di, ohlc_val, ..)| (di, ohlc_val.high),
exit,
)?;
Ok(())
})?;
// Low: min-value aggregation
split.low.height.compute_transform(
starting_indexes.height,
&*price_cents,
|(h, price, ..)| (h, Low::new(price)),
exit,
)?;
split.low.compute_rest(starting_indexes, exit, |v| {
v.compute_transform(
starting_indexes.dateindex,
&*ohlc_cents,
|(di, ohlc_val, ..)| (di, ohlc_val.low),
exit,
)?;
Ok(())
})?;
// Close: last-value aggregation
split.close.height.compute_transform(
starting_indexes.height,
&*price_cents,
|(h, price, ..)| (h, Close::new(price)),
exit,
)?;
split.close.compute_rest(starting_indexes, exit, |v| {
v.compute_transform(
starting_indexes.dateindex,
&*ohlc_cents,
|(di, ohlc_val, ..)| (di, ohlc_val.close),
exit,
)?;
Ok(())
})?;
// Period OHLC aggregates - time based
ohlc.dateindex.compute_transform4(
starting_indexes.dateindex,
&split.open.dateindex,
&split.high.dateindex,
&split.low.dateindex,
&split.close.dateindex,
|(i, open, high, low, close, _)| {
(i, OHLCCentsUnsigned { open, high, low, close })
},
exit,
)?;
ohlc.week.compute_transform4(
starting_indexes.weekindex,
&*split.open.weekindex,
&*split.high.weekindex,
&*split.low.weekindex,
&*split.close.weekindex,
|(i, open, high, low, close, _)| {
(i, OHLCCentsUnsigned { open, high, low, close })
},
exit,
)?;
ohlc.month.compute_transform4(
starting_indexes.monthindex,
&*split.open.monthindex,
&*split.high.monthindex,
&*split.low.monthindex,
&*split.close.monthindex,
|(i, open, high, low, close, _)| {
(i, OHLCCentsUnsigned { open, high, low, close })
},
exit,
)?;
ohlc.quarter.compute_transform4(
starting_indexes.quarterindex,
&*split.open.quarterindex,
&*split.high.quarterindex,
&*split.low.quarterindex,
&*split.close.quarterindex,
|(i, open, high, low, close, _)| {
(i, OHLCCentsUnsigned { open, high, low, close })
},
exit,
)?;
ohlc.semester.compute_transform4(
starting_indexes.semesterindex,
&*split.open.semesterindex,
&*split.high.semesterindex,
&*split.low.semesterindex,
&*split.close.semesterindex,
|(i, open, high, low, close, _)| {
(i, OHLCCentsUnsigned { open, high, low, close })
},
exit,
)?;
ohlc.year.compute_transform4(
starting_indexes.yearindex,
&*split.open.yearindex,
&*split.high.yearindex,
&*split.low.yearindex,
&*split.close.yearindex,
|(i, open, high, low, close, _)| {
(i, OHLCCentsUnsigned { open, high, low, close })
},
exit,
)?;
ohlc.decade.compute_transform4(
starting_indexes.decadeindex,
&*split.open.decadeindex,
&*split.high.decadeindex,
&*split.low.decadeindex,
&*split.close.decadeindex,
|(i, open, high, low, close, _)| {
(i, OHLCCentsUnsigned { open, high, low, close })
},
exit,
)?;
// Period OHLC aggregates - chain based
ohlc.height.compute_transform4(
starting_indexes.height,
&split.open.height,
&split.high.height,
&split.low.height,
&split.close.height,
|(i, open, high, low, close, _)| {
(i, OHLCCentsUnsigned { open, high, low, close })
},
exit,
)?;
ohlc.difficultyepoch.compute_transform4(
starting_indexes.difficultyepoch,
&*split.open.difficultyepoch,
&*split.high.difficultyepoch,
&*split.low.difficultyepoch,
&*split.close.difficultyepoch,
|(i, open, high, low, close, _)| {
(i, OHLCCentsUnsigned { open, high, low, close })
},
exit,
)?;
// OHLC dollars - transform cents to dollars at every period level
macro_rules! cents_to_dollars {
($field:ident, $idx:expr) => {
ohlc_dollars.$field.compute_transform(
$idx,
&ohlc.$field,
|(i, c, ..)| (i, OHLCDollars::from(c)),
exit,
)?;
};
}
cents_to_dollars!(dateindex, starting_indexes.dateindex);
cents_to_dollars!(week, starting_indexes.weekindex);
cents_to_dollars!(month, starting_indexes.monthindex);
cents_to_dollars!(quarter, starting_indexes.quarterindex);
cents_to_dollars!(semester, starting_indexes.semesterindex);
cents_to_dollars!(year, starting_indexes.yearindex);
cents_to_dollars!(decade, starting_indexes.decadeindex);
cents_to_dollars!(height, starting_indexes.height);
cents_to_dollars!(difficultyepoch, starting_indexes.difficultyepoch);
Ok(())
}
+37 -13
View File
@@ -1,29 +1,53 @@
use brk_error::Result;
use brk_types::{DateIndex, OHLCCentsUnsigned, OHLCDollars, Version};
use vecdb::{BytesVec, Database, ImportableVec, IterableCloneableVec, LazyVecFrom1, PcoVec};
use brk_types::Version;
use vecdb::{BytesVec, Database, EagerVec, ImportableVec, PcoVec};
use super::Vecs;
use crate::indexes;
use crate::internal::{ComputedOHLC, LazyFromHeightAndDateOHLC};
impl Vecs {
pub fn forced_import(db: &Database, parent_version: Version) -> Result<Self> {
let version = parent_version + Version::new(10);
pub fn forced_import(
db: &Database,
parent_version: Version,
indexes: &indexes::Vecs,
) -> Result<Self> {
let version = parent_version + Version::new(11);
let price_cents = PcoVec::forced_import(db, "oracle_price_cents", version)?;
let ohlc_cents = BytesVec::forced_import(db, "oracle_ohlc_cents", version)?;
let ohlc_dollars = LazyVecFrom1::init(
"oracle_ohlc_dollars",
version,
ohlc_cents.boxed_clone(),
|di: DateIndex, iter| {
iter.get(di)
.map(|o: OHLCCentsUnsigned| OHLCDollars::from(o))
},
);
let split = ComputedOHLC::forced_import(db, "oracle_price", version, indexes)?;
let ohlc = LazyFromHeightAndDateOHLC {
dateindex: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
week: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
month: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
quarter: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
semester: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
year: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
decade: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
height: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
difficultyepoch: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
};
let ohlc_dollars = LazyFromHeightAndDateOHLC {
dateindex: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
week: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
month: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
quarter: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
semester: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
year: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
decade: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
height: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
difficultyepoch: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
};
Ok(Self {
price_cents,
ohlc_cents,
split,
ohlc,
ohlc_dollars,
})
}
+6 -2
View File
@@ -1,10 +1,14 @@
use brk_traversable::Traversable;
use brk_types::{CentsUnsigned, DateIndex, Height, OHLCCentsUnsigned, OHLCDollars};
use vecdb::{BytesVec, LazyVecFrom1, PcoVec};
use vecdb::{BytesVec, PcoVec};
use crate::internal::{ComputedOHLC, LazyFromHeightAndDateOHLC};
#[derive(Clone, Traversable)]
pub struct Vecs {
pub price_cents: PcoVec<Height, CentsUnsigned>,
pub ohlc_cents: BytesVec<DateIndex, OHLCCentsUnsigned>,
pub ohlc_dollars: LazyVecFrom1<DateIndex, OHLCDollars, DateIndex, OHLCCentsUnsigned>,
pub split: ComputedOHLC<CentsUnsigned>,
pub ohlc: LazyFromHeightAndDateOHLC<OHLCCentsUnsigned>,
pub ohlc_dollars: LazyFromHeightAndDateOHLC<OHLCDollars>,
}
+2 -2
View File
@@ -52,7 +52,7 @@ impl Vecs {
vec.compute_percentage_change(
starting_indexes.dateindex,
mcap_dateindex,
30,
365,
exit,
)?;
Ok(())
@@ -66,7 +66,7 @@ impl Vecs {
vec.compute_percentage_change(
starting_indexes.dateindex,
rcap_dateindex,
30,
365,
exit,
)?;
Ok(())
+14 -5
View File
@@ -7,11 +7,12 @@ use vecdb::{Database, IterableCloneableVec, LazyVecFrom2, PAGE_SIZE};
use super::Vecs;
use crate::{
distribution, indexes, price,
distribution, indexes,
internal::{
ComputedFromDateAverage, ComputedFromDateLast, DifferenceF32, DollarsIdentity,
LazyFromHeightLast, LazyValueFromHeightLast, SatsIdentity,
},
price,
};
const VERSION: Version = Version::ONE;
@@ -61,10 +62,18 @@ impl Vecs {
});
// Growth rates
let market_cap_growth_rate =
ComputedFromDateLast::forced_import(&db, "market_cap_growth_rate", version, indexes)?;
let realized_cap_growth_rate =
ComputedFromDateLast::forced_import(&db, "realized_cap_growth_rate", version, indexes)?;
let market_cap_growth_rate = ComputedFromDateLast::forced_import(
&db,
"market_cap_growth_rate",
version + Version::ONE,
indexes,
)?;
let realized_cap_growth_rate = ComputedFromDateLast::forced_import(
&db,
"realized_cap_growth_rate",
version + Version::ONE,
indexes,
)?;
let cap_growth_rate_diff = LazyVecFrom2::transformed::<DifferenceF32>(
"cap_growth_rate_diff",
version,
@@ -1,3 +1,5 @@
use std::hash::{DefaultHasher, Hash, Hasher};
use brk_types::RecommendedFees;
use super::{fees, stats::{self, BlockStats}};
@@ -36,4 +38,14 @@ impl Snapshot {
fees,
}
}
/// Hash of the first projected block (the one about to be mined).
pub fn next_block_hash(&self) -> u64 {
let Some(block) = self.blocks.first() else {
return 0;
};
let mut hasher = DefaultHasher::new();
block.hash(&mut hasher);
hasher.finish()
}
}
+16 -1
View File
@@ -1,4 +1,5 @@
use std::{
hash::{DefaultHasher, Hash, Hasher},
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
@@ -9,7 +10,7 @@ use std::{
use brk_error::Result;
use brk_rpc::Client;
use brk_types::{MempoolEntryInfo, MempoolInfo, TxWithHex, Txid, TxidPrefix};
use brk_types::{AddressBytes, MempoolEntryInfo, MempoolInfo, TxWithHex, Txid, TxidPrefix};
use derive_more::Deref;
use parking_lot::{RwLock, RwLockReadGuard};
use rustc_hash::FxHashMap;
@@ -87,6 +88,20 @@ impl MempoolInner {
self.snapshot.read().block_stats.clone()
}
pub fn next_block_hash(&self) -> u64 {
self.snapshot.read().next_block_hash()
}
pub fn address_hash(&self, address: &AddressBytes) -> u64 {
let addresses = self.addresses.read();
let Some((stats, _)) = addresses.get(address) else {
return 0;
};
let mut hasher = DefaultHasher::new();
stats.hash(&mut hasher);
hasher.finish()
}
pub fn get_txs(&self) -> RwLockReadGuard<'_, TxStore> {
self.txs.read()
}
+5 -5
View File
@@ -36,7 +36,7 @@ All parameters are exposed via Config with sensible defaults:
- **window_size** (12): number of block histograms in the ring buffer
- **search_below / search_above** (9 / 11): how far to search around the previous estimate, in bins
- **min_sats** (1,000): minimum output value, filters dust
- **exclude_round_btc** (true): exclude round BTC amounts that create false stencil matches
- **exclude_common_round_values** (true): exclude common round values (d × 10^n, d ∈ {1,2,3,5,6}) that create false stencil matches
- **excluded_output_types** (P2TR, P2WSH): script types dominated by protocol activity, not round-dollar purchases
## Inspiration
@@ -69,11 +69,11 @@ Tested over 361,245 blocks (heights 575,000 to 936,244) against exchange OHLC da
| 95th percentile | 0.55% |
| 99th percentile | 1.4% |
| 99.9th percentile | 4.4% |
| RMSE | 0.39% |
| Max error | 18.2% |
| RMSE | 0.38% |
| Max error | 18.1% |
| Bias | +0.04 bins (essentially zero) |
| Blocks > 5% error | 261 (0.07%) |
| Blocks > 10% error | 40 (0.01%) |
| Blocks > 5% error | 237 (0.07%) |
| Blocks > 10% error | 22 (0.006%) |
| Blocks > 20% error | 0 |
### Daily candles
@@ -0,0 +1,286 @@
//! Compare specific digit filter configurations across multiple start heights.
//!
//! Run with: cargo run -p brk_oracle --example compare_digits --release
use std::path::PathBuf;
use std::time::Instant;
use brk_indexer::Indexer;
use brk_oracle::{Config, NUM_BINS, Oracle, PRICES, START_HEIGHT, cents_to_bin, sats_to_bin};
use brk_types::{OutputType, Sats, TxIndex, TxOutIndex};
use vecdb::{AnyVec, VecIndex, VecIterator};
const BINS_5PCT: f64 = 4.24;
const BINS_10PCT: f64 = 8.28;
const BINS_20PCT: f64 = 15.84;
fn bins_to_pct(bins: f64) -> f64 {
(10.0_f64.powf(bins / 200.0) - 1.0) * 100.0
}
fn seed_bin(start_height: usize) -> f64 {
let price: f64 = PRICES
.lines()
.nth(start_height - 1)
.expect("prices.txt too short")
.parse()
.expect("Failed to parse seed price");
cents_to_bin(price * 100.0)
}
fn leading_digit(sats: u64) -> u8 {
let log = (sats as f64).log10();
let magnitude = 10.0_f64.powf(log.floor());
let d = (sats as f64 / magnitude).round() as u8;
if d >= 10 { 1 } else { d }
}
fn is_round(sats: u64) -> bool {
let log = (sats as f64).log10();
let magnitude = 10.0_f64.powf(log.floor());
let leading = (sats as f64 / magnitude).round();
let round_val = leading * magnitude;
(sats as f64 - round_val).abs() <= round_val * 0.001
}
struct Stats {
total_sq_err: f64,
total_bias: f64,
max_err: f64,
total_blocks: u64,
gt_5pct: u64,
gt_10pct: u64,
gt_20pct: u64,
}
impl Stats {
fn new() -> Self {
Self {
total_sq_err: 0.0,
total_bias: 0.0,
max_err: 0.0,
total_blocks: 0,
gt_5pct: 0,
gt_10pct: 0,
gt_20pct: 0,
}
}
fn update(&mut self, err: f64) {
self.total_sq_err += err * err;
self.total_bias += err;
self.total_blocks += 1;
let abs_err = err.abs();
if abs_err > self.max_err {
self.max_err = abs_err;
}
if abs_err > BINS_5PCT {
self.gt_5pct += 1;
}
if abs_err > BINS_10PCT {
self.gt_10pct += 1;
}
if abs_err > BINS_20PCT {
self.gt_20pct += 1;
}
}
fn rmse_pct(&self) -> f64 {
bins_to_pct((self.total_sq_err / self.total_blocks as f64).sqrt())
}
fn max_pct(&self) -> f64 {
bins_to_pct(self.max_err)
}
fn bias(&self) -> f64 {
self.total_bias / self.total_blocks as f64
}
}
fn main() {
let t0 = Instant::now();
let data_dir = std::env::var("BRK_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap();
PathBuf::from(home).join(".brk")
});
let indexer = Indexer::forced_import(&data_dir).expect("Failed to load indexer");
let total_heights = indexer.vecs.blocks.timestamp.len();
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let height_ohlc: Vec<[f64; 4]> = serde_json::from_str(
&std::fs::read_to_string(format!("{manifest_dir}/examples/height_price_ohlc.json"))
.expect("Failed to read height_price_ohlc.json"),
)
.expect("Failed to parse height OHLC");
let height_bands: Vec<(f64, f64)> = height_ohlc
.iter()
.map(|ohlc| {
let high = ohlc[1];
let low = ohlc[2];
if high > 0.0 && low > 0.0 {
(cents_to_bin(high * 100.0), cents_to_bin(low * 100.0))
} else {
(0.0, 0.0)
}
})
.collect();
// Configs to compare.
// 987654321
let masks: &[(u16, &str)] = &[
(0b0_0111_0111, "{1,2,3,5,6,7}"),
(0b0_0011_0111, "{1,2,3,5,6}"),
(0b0_0001_1111, "{1,2,3,4,5}"),
(0b0_0001_0111, "{1,2,3,5}"),
];
let start_heights: &[usize] = &[575_000, 600_000, 630_000];
// (mask_idx, start_idx) -> (Oracle, Stats)
let n = masks.len() * start_heights.len();
let mut oracles: Vec<Option<Oracle>> = (0..n).map(|_| None).collect();
let mut stats: Vec<Stats> = (0..n).map(|_| Stats::new()).collect();
let idx = |m: usize, s: usize| -> usize { m * start_heights.len() + s };
let total_txs = indexer.vecs.transactions.height.len();
let total_outputs = indexer.vecs.outputs.value.len();
let mut first_txindex_iter = indexer.vecs.transactions.first_txindex.into_iter();
let mut first_txoutindex_iter = indexer.vecs.transactions.first_txoutindex.into_iter();
let mut out_first_iter = indexer.vecs.outputs.first_txoutindex.into_iter();
let mut value_iter = indexer.vecs.outputs.value.into_iter();
let mut outputtype_iter = indexer.vecs.outputs.outputtype.into_iter();
let ref_config = Config::default();
let earliest_start = *start_heights.iter().min().unwrap();
for h in START_HEIGHT..total_heights {
let first_txindex: TxIndex = first_txindex_iter.get_at_unwrap(h);
let next_first_txindex = first_txindex_iter
.get_at(h + 1)
.unwrap_or(TxIndex::from(total_txs));
let out_start = if first_txindex.to_usize() + 1 < next_first_txindex.to_usize() {
first_txoutindex_iter
.get_at_unwrap(first_txindex.to_usize() + 1)
.to_usize()
} else {
out_first_iter
.get_at(h + 1)
.unwrap_or(TxOutIndex::from(total_outputs))
.to_usize()
};
let out_end = out_first_iter
.get_at(h + 1)
.unwrap_or(TxOutIndex::from(total_outputs))
.to_usize();
if h < earliest_start {
continue;
}
// Build full histogram and per-digit histograms.
let mut full_hist = [0u32; NUM_BINS];
let mut digit_hist = [[0u32; NUM_BINS]; 9];
for i in out_start..out_end {
let sats: Sats = value_iter.get_at_unwrap(i);
let output_type: OutputType = outputtype_iter.get_at_unwrap(i);
if ref_config.excluded_output_types.contains(&output_type) {
continue;
}
if *sats < ref_config.min_sats {
continue;
}
if let Some(bin) = sats_to_bin(sats) {
full_hist[bin] += 1;
if is_round(*sats) {
let d = leading_digit(*sats);
if (1..=9).contains(&d) {
digit_hist[(d - 1) as usize][bin] += 1;
}
}
}
}
// Feed each (mask, start_height) combo.
for (mi, &(mask, _)) in masks.iter().enumerate() {
// Build filtered histogram for this mask.
let mut hist = full_hist;
(0..9usize).for_each(|d| {
if mask & (1 << d) != 0 {
for bin in 0..NUM_BINS {
hist[bin] -= digit_hist[d][bin];
}
}
});
for (si, &sh) in start_heights.iter().enumerate() {
if h < sh {
continue;
}
let i = idx(mi, si);
if oracles[i].is_none() {
oracles[i] = Some(Oracle::new(
seed_bin(sh),
Config {
exclude_common_round_values: false,
..Default::default()
},
));
}
let ref_bin = oracles[i].as_mut().unwrap().process_histogram(&hist);
if h < height_bands.len() {
let (high_bin, low_bin) = height_bands[h];
if high_bin > 0.0 && low_bin > 0.0 {
let err = if ref_bin < high_bin {
ref_bin - high_bin
} else if ref_bin > low_bin {
ref_bin - low_bin
} else {
0.0
};
stats[i].update(err);
}
}
}
}
}
// Print results grouped by start height.
for (si, &sh) in start_heights.iter().enumerate() {
println!();
println!("@ {}k:", sh / 1000);
println!(
" {:<16} {:>8} {:>10} {:>10} {:>6} {:>6} {:>6} {:>8}",
"Digits", "Blocks", "RMSE%", "Max%", ">5%", ">10%", ">20%", "Bias"
);
println!(" {}", "-".repeat(72));
for (mi, &(_, label)) in masks.iter().enumerate() {
let s = &stats[idx(mi, si)];
println!(
" {:<16} {:>8} {:>7.3}% {:>7.1}% {:>6} {:>6} {:>6} {:>+8.2}",
label,
s.total_blocks,
s.rmse_pct(),
s.max_pct(),
s.gt_5pct,
s.gt_10pct,
s.gt_20pct,
s.bias()
);
}
}
println!("\nDone in {:.1}s", t0.elapsed().as_secs_f64());
}
+3 -2
View File
@@ -229,7 +229,8 @@ fn main() {
if ref_config.excluded_output_types.contains(&output_type) {
continue;
}
if *sats < ref_config.min_sats || (ref_config.exclude_round_btc && sats.is_round_btc())
if *sats < ref_config.min_sats
|| (ref_config.exclude_common_round_values && sats.is_common_round_value())
{
continue;
}
@@ -339,7 +340,7 @@ fn main() {
daily_days += 1;
}
fn daily_stats(errors: &mut Vec<f64>) -> (f64, f64, f64) {
fn daily_stats(errors: &mut [f64]) -> (f64, f64, f64) {
let n = errors.len() as f64;
let rmse = (errors.iter().map(|e| e * e).sum::<f64>() / n).sqrt();
errors.sort_by(|a, b| a.abs().partial_cmp(&b.abs()).unwrap());
+407
View File
@@ -0,0 +1,407 @@
//! Sweep round-value digit filter to find optimal configuration.
//!
//! Tests all 512 subsets of leading digits {1,...,9} to find which
//! digits to filter out for best oracle accuracy.
//!
//! Phase 1: single pass over indexer, precompute per-block histograms.
//! Phase 2: run 512 configs in parallel across CPU cores.
//!
//! Run with: cargo run -p brk_oracle --example sweep_digits --release
use std::path::PathBuf;
use std::time::Instant;
use brk_indexer::Indexer;
use brk_oracle::{Config, NUM_BINS, Oracle, PRICES, START_HEIGHT, cents_to_bin, sats_to_bin};
use brk_types::{OutputType, Sats, TxIndex, TxOutIndex};
use vecdb::{AnyVec, VecIndex, VecIterator};
const BINS_5PCT: f64 = 4.24;
const BINS_10PCT: f64 = 8.28;
const BINS_20PCT: f64 = 15.84;
fn bins_to_pct(bins: f64) -> f64 {
(10.0_f64.powf(bins / 200.0) - 1.0) * 100.0
}
fn seed_bin(start_height: usize) -> f64 {
let price: f64 = PRICES
.lines()
.nth(start_height - 1)
.expect("prices.txt too short")
.parse()
.expect("Failed to parse seed price");
cents_to_bin(price * 100.0)
}
fn leading_digit(sats: u64) -> u8 {
let log = (sats as f64).log10();
let magnitude = 10.0_f64.powf(log.floor());
let d = (sats as f64 / magnitude).round() as u8;
if d >= 10 { 1 } else { d }
}
fn is_round(sats: u64) -> bool {
let log = (sats as f64).log10();
let magnitude = 10.0_f64.powf(log.floor());
let leading = (sats as f64 / magnitude).round();
let round_val = leading * magnitude;
(sats as f64 - round_val).abs() <= round_val * 0.001
}
fn mask_label(mask: u16) -> String {
let digits: String = (1..=9u8)
.filter(|&d| mask & (1 << (d - 1)) != 0)
.map(|d| char::from_digit(d as u32, 10).unwrap())
.collect();
if digits.is_empty() {
"none".to_string()
} else {
digits
}
}
struct Stats {
total_sq_err: f64,
total_bias: f64,
max_err: f64,
total_blocks: u64,
gt_5pct: u64,
gt_10pct: u64,
gt_20pct: u64,
}
impl Stats {
fn new() -> Self {
Self {
total_sq_err: 0.0,
total_bias: 0.0,
max_err: 0.0,
total_blocks: 0,
gt_5pct: 0,
gt_10pct: 0,
gt_20pct: 0,
}
}
fn update(&mut self, err: f64) {
self.total_sq_err += err * err;
self.total_bias += err;
self.total_blocks += 1;
let abs_err = err.abs();
if abs_err > self.max_err {
self.max_err = abs_err;
}
if abs_err > BINS_5PCT {
self.gt_5pct += 1;
}
if abs_err > BINS_10PCT {
self.gt_10pct += 1;
}
if abs_err > BINS_20PCT {
self.gt_20pct += 1;
}
}
fn rmse_pct(&self) -> f64 {
bins_to_pct((self.total_sq_err / self.total_blocks as f64).sqrt())
}
fn max_pct(&self) -> f64 {
bins_to_pct(self.max_err)
}
fn bias(&self) -> f64 {
self.total_bias / self.total_blocks as f64
}
}
struct BlockData {
full_hist: Box<[u32; NUM_BINS]>,
/// (bin_index, leading_digit) for outputs that are round values.
round_outputs: Vec<(u16, u8)>,
high_bin: f64,
low_bin: f64,
}
fn main() {
let t0 = Instant::now();
let data_dir = std::env::var("BRK_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap();
PathBuf::from(home).join(".brk")
});
let indexer = Indexer::forced_import(&data_dir).expect("Failed to load indexer");
let total_heights = indexer.vecs.blocks.timestamp.len();
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let height_ohlc: Vec<[f64; 4]> = serde_json::from_str(
&std::fs::read_to_string(format!("{manifest_dir}/examples/height_price_ohlc.json"))
.expect("Failed to read height_price_ohlc.json"),
)
.expect("Failed to parse height OHLC");
let height_bands: Vec<(f64, f64)> = height_ohlc
.iter()
.map(|ohlc| {
let high = ohlc[1];
let low = ohlc[2];
if high > 0.0 && low > 0.0 {
(cents_to_bin(high * 100.0), cents_to_bin(low * 100.0))
} else {
(0.0, 0.0)
}
})
.collect();
let sweep_start: usize = 575_000;
// Phase 1: precompute per-block data in a single pass over the indexer.
eprintln!("Phase 1: precomputing block data...");
let total_txs = indexer.vecs.transactions.height.len();
let total_outputs = indexer.vecs.outputs.value.len();
let mut first_txindex_iter = indexer.vecs.transactions.first_txindex.into_iter();
let mut first_txoutindex_iter = indexer.vecs.transactions.first_txoutindex.into_iter();
let mut out_first_iter = indexer.vecs.outputs.first_txoutindex.into_iter();
let mut value_iter = indexer.vecs.outputs.value.into_iter();
let mut outputtype_iter = indexer.vecs.outputs.outputtype.into_iter();
let ref_config = Config::default();
let total_blocks = total_heights - sweep_start;
let mut blocks: Vec<BlockData> = Vec::with_capacity(total_blocks);
for h in START_HEIGHT..total_heights {
let first_txindex: TxIndex = first_txindex_iter.get_at_unwrap(h);
let next_first_txindex = first_txindex_iter
.get_at(h + 1)
.unwrap_or(TxIndex::from(total_txs));
let out_start = if first_txindex.to_usize() + 1 < next_first_txindex.to_usize() {
first_txoutindex_iter
.get_at_unwrap(first_txindex.to_usize() + 1)
.to_usize()
} else {
out_first_iter
.get_at(h + 1)
.unwrap_or(TxOutIndex::from(total_outputs))
.to_usize()
};
let out_end = out_first_iter
.get_at(h + 1)
.unwrap_or(TxOutIndex::from(total_outputs))
.to_usize();
if h < sweep_start {
continue;
}
let mut full_hist = Box::new([0u32; NUM_BINS]);
let mut round_outputs = Vec::new();
for i in out_start..out_end {
let sats: Sats = value_iter.get_at_unwrap(i);
let output_type: OutputType = outputtype_iter.get_at_unwrap(i);
if ref_config.excluded_output_types.contains(&output_type) {
continue;
}
if *sats < ref_config.min_sats {
continue;
}
if let Some(bin) = sats_to_bin(sats) {
full_hist[bin] += 1;
if is_round(*sats) {
let d = leading_digit(*sats);
if (1..=9).contains(&d) {
round_outputs.push((bin as u16, d));
}
}
}
}
let (high_bin, low_bin) = if h < height_bands.len() {
height_bands[h]
} else {
(0.0, 0.0)
};
blocks.push(BlockData {
full_hist,
round_outputs,
high_bin,
low_bin,
});
if (h - sweep_start).is_multiple_of(50_000) {
eprint!(
"\r {}/{} ({:.0}%)",
h - sweep_start,
total_blocks,
(h - sweep_start) as f64 / total_blocks as f64 * 100.0
);
}
}
let mem_hists = blocks.len() * std::mem::size_of::<[u32; NUM_BINS]>();
let mem_rounds: usize = blocks.iter().map(|b| b.round_outputs.len() * 3).sum();
eprintln!(
"\r {} blocks precomputed ({:.1} GB hists + {:.0} MB rounds) in {:.1}s",
blocks.len(),
mem_hists as f64 / 1e9,
mem_rounds as f64 / 1e6,
t0.elapsed().as_secs_f64()
);
// Phase 2: sweep digit masks in parallel.
// Always filter digit 1 (powers of 10), sweep digits 2-9.
let base_mask: u16 = 1 << 0; // digit 1 always on
let num_masks: usize = 256; // 2^8 subsets of {2,...,9}
let num_threads = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(8);
eprintln!(
"Phase 2: sweeping {} masks across {} threads...",
num_masks, num_threads
);
let t1 = Instant::now();
let blocks = &blocks; // shared reference for threads
let all_results: Vec<(u16, Stats)> = std::thread::scope(|s| {
let masks_per_thread = num_masks.div_ceil(num_threads);
let handles: Vec<_> = (0..num_threads)
.map(|t| {
s.spawn(move || {
let mask_start = t * masks_per_thread;
let mask_end = ((t + 1) * masks_per_thread).min(num_masks);
let mut results = Vec::with_capacity(mask_end - mask_start);
for idx in mask_start..mask_end {
// Shift idx bits into positions 1-8 (digits 2-9) and add base_mask (digit 1).
let mask = base_mask | ((idx as u16) << 1);
let mut oracle = Oracle::new(
seed_bin(sweep_start),
Config {
exclude_common_round_values: false,
..Default::default()
},
);
let mut stats = Stats::new();
for bd in blocks.iter() {
let mut hist = *bd.full_hist;
for &(bin, digit) in &bd.round_outputs {
if mask & (1 << (digit - 1)) != 0 {
hist[bin as usize] -= 1;
}
}
let ref_bin = oracle.process_histogram(&hist);
if bd.high_bin > 0.0 && bd.low_bin > 0.0 {
let err = if ref_bin < bd.high_bin {
ref_bin - bd.high_bin
} else if ref_bin > bd.low_bin {
ref_bin - bd.low_bin
} else {
0.0
};
stats.update(err);
}
}
results.push((mask, stats));
}
results
})
})
.collect();
handles
.into_iter()
.flat_map(|h| h.join().unwrap())
.collect()
});
eprintln!(" Done in {:.1}s.", t1.elapsed().as_secs_f64());
// Sort by RMSE.
let mut results: Vec<&(u16, Stats)> = all_results.iter().collect();
results.sort_by(|a, b| a.1.rmse_pct().partial_cmp(&b.1.rmse_pct()).unwrap());
// Print top 20.
println!();
println!("Top 20 (by RMSE):");
println!(
"{:>4} {:>12} {:>10} {:>10} {:>6} {:>6} {:>6} {:>8}",
"#", "Digits", "RMSE%", "Max%", ">5%", ">10%", ">20%", "Bias"
);
println!("{}", "-".repeat(70));
for (rank, (mask, s)) in results.iter().take(20).enumerate() {
println!(
"{:>4} {:>12} {:>8.3}% {:>8.1}% {:>6} {:>6} {:>6} {:>+8.2}",
rank + 1,
mask_label(*mask),
s.rmse_pct(),
s.max_pct(),
s.gt_5pct,
s.gt_10pct,
s.gt_20pct,
s.bias()
);
}
// Print bottom 5.
println!();
println!("Bottom 5 (worst):");
println!(
"{:>4} {:>12} {:>10} {:>10} {:>6} {:>6} {:>6} {:>8}",
"#", "Digits", "RMSE%", "Max%", ">5%", ">10%", ">20%", "Bias"
);
println!("{}", "-".repeat(70));
for (mask, s) in results.iter().rev().take(5) {
println!(
"{:>4} {:>12} {:>8.3}% {:>8.1}% {:>6} {:>6} {:>6} {:>+8.2}",
"",
mask_label(*mask),
s.rmse_pct(),
s.max_pct(),
s.gt_5pct,
s.gt_10pct,
s.gt_20pct,
s.bias()
);
}
// Print current config {1,2,3,5} for reference.
let current_mask: u16 = (1 << 0) | (1 << 1) | (1 << 2) | (1 << 4); // digits 1,2,3,5
let current_stats = all_results
.iter()
.find(|(m, _)| *m == current_mask)
.map(|(_, s)| s)
.unwrap();
let current_rank = results
.iter()
.position(|(m, _)| *m == current_mask)
.unwrap();
println!();
println!(
"Current {{1,2,3,5}} = rank {}/{}: RMSE {:.3}%, Max {:.1}%, >5%: {}, >10%: {}, >20%: {}",
current_rank + 1,
num_masks,
current_stats.rmse_pct(),
current_stats.max_pct(),
current_stats.gt_5pct,
current_stats.gt_10pct,
current_stats.gt_20pct,
);
println!("\nTotal time: {:.1}s", t0.elapsed().as_secs_f64());
}
+3 -3
View File
@@ -174,7 +174,7 @@ fn main() {
continue;
}
if *sats < ref_config.min_sats
|| (ref_config.exclude_round_btc && sats.is_round_btc())
|| (ref_config.exclude_common_round_values && sats.is_common_round_value())
{
continue;
}
@@ -237,8 +237,8 @@ fn main() {
// trunc w12 @ 600k: 174 >5%, 31 >10%, 0 >20%
// trunc w12 @ 630k: 84 >5%, 9 >10%, 0 >20%
let expected: &[(&str, u64, u64, u64)] = &[
("w12 @ 575k", 261, 40, 0),
("w12 @ 600k", 174, 31, 0),
("w12 @ 575k", 237, 22, 0),
("w12 @ 600k", 152, 15, 0),
("w12 @ 630k", 84, 9, 0),
];
+3 -3
View File
@@ -143,7 +143,7 @@ pub struct Config {
/// Minimum output value in sats (dust filter).
pub min_sats: u64,
/// Exclude round BTC amounts that create false stencil matches.
pub exclude_round_btc: bool,
pub exclude_common_round_values: bool,
/// Output types to ignore (e.g. P2TR, P2WSH are noisy).
pub excluded_output_types: Vec<OutputType>,
}
@@ -156,7 +156,7 @@ impl Default for Config {
search_below: 9,
search_above: 11,
min_sats: 1000,
exclude_round_btc: true,
exclude_common_round_values: true,
excluded_output_types: vec![OutputType::P2TR, OutputType::P2WSH],
}
}
@@ -241,7 +241,7 @@ impl Oracle {
if self.config.excluded_output_types.contains(&output_type) {
return None;
}
if *sats < self.config.min_sats || (self.config.exclude_round_btc && sats.is_round_btc()) {
if *sats < self.config.min_sats || (self.config.exclude_common_round_values && sats.is_common_round_value()) {
return None;
}
sats_to_bin(sats)
+10
View File
@@ -201,6 +201,16 @@ impl Query {
Ok(utxos)
}
pub fn address_mempool_hash(&self, address: &Address) -> u64 {
let Some(mempool) = self.mempool() else {
return 0;
};
let Ok(bytes) = AddressBytes::from_str(address) else {
return 0;
};
mempool.address_hash(&bytes)
}
pub fn address_mempool_txids(&self, address: Address) -> Result<Vec<Txid>> {
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
+2 -2
View File
@@ -91,8 +91,8 @@ impl AddressRoutes for ApiRouter<AppState> {
Path(path): Path<AddressParam>,
State(state): State<AppState>
| {
// Mempool txs for an address - use MaxAge since it's volatile
state.cached_json(&headers, CacheStrategy::MaxAge(5), move |q| q.address_mempool_txids(path.address)).await
let hash = state.sync(|q| q.address_mempool_hash(&path.address));
state.cached_json(&headers, CacheStrategy::MempoolHash(hash), move |q| q.address_mempool_txids(path.address)).await
}, |op| op
.id("get_address_mempool_txs")
.addresses_tag()
+9 -9
View File
@@ -1,8 +1,8 @@
use aide::axum::{ApiRouter, routing::get_with};
use axum::{extract::State, http::HeaderMap, response::Redirect, routing::get};
use brk_types::{MempoolBlock, MempoolInfo, RecommendedFees, Txid};
use brk_types::{Dollars, MempoolBlock, MempoolInfo, RecommendedFees, Txid};
use crate::{CacheStrategy, extended::TransformResponseExtended};
use crate::extended::TransformResponseExtended;
use super::AppState;
@@ -18,7 +18,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
"/api/mempool/info",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.mempool_info()).await
state.cached_json(&headers, state.mempool_cache(), |q| q.mempool_info()).await
},
|op| {
op.id("get_mempool")
@@ -34,7 +34,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
"/api/mempool/txids",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.mempool_txids()).await
state.cached_json(&headers, state.mempool_cache(), |q| q.mempool_txids()).await
},
|op| {
op.id("get_mempool_txids")
@@ -50,7 +50,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
"/api/v1/fees/recommended",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::MaxAge(3), |q| q.recommended_fees()).await
state.cached_json(&headers, state.mempool_cache(), |q| q.recommended_fees()).await
},
|op| {
op.id("get_recommended_fees")
@@ -67,7 +67,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.live_price())
.cached_json(&headers, state.mempool_cache(), |q| q.live_price())
.await
},
|op| {
@@ -75,11 +75,11 @@ impl MempoolRoutes for ApiRouter<AppState> {
.mempool_tag()
.summary("Live BTC/USD price")
.description(
"Returns the current BTC/USD price in cents, derived from \
"Returns the current BTC/USD price in dollars, derived from \
on-chain round-dollar output patterns in the last 12 blocks \
plus mempool.",
)
.ok_response::<u64>()
.ok_response::<Dollars>()
.server_error()
},
),
@@ -88,7 +88,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
"/api/v1/fees/mempool-blocks",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.mempool_blocks()).await
state.cached_json(&headers, state.mempool_cache(), |q| q.mempool_blocks()).await
},
|op| {
op.id("get_mempool_blocks")
+6 -6
View File
@@ -12,9 +12,9 @@ pub enum CacheStrategy {
/// Etag = VERSION only, Cache-Control: must-revalidate
Static,
/// Volatile data (mempool) - no etag, just max-age
/// Cache-Control: max-age={seconds}
MaxAge(u64),
/// Mempool data - etag from next projected block hash + short max-age
/// Etag = VERSION-m{hash:x}, Cache-Control: max-age=1, must-revalidate
MempoolHash(u64),
}
/// Resolved cache parameters
@@ -50,9 +50,9 @@ impl CacheParams {
etag: Some(VERSION.to_string()),
cache_control: "public, max-age=1, must-revalidate".into(),
},
MaxAge(secs) => Self {
etag: None,
cache_control: format!("public, max-age={secs}"),
MempoolHash(hash) => Self {
etag: Some(format!("{VERSION}-m{hash:x}")),
cache_control: "public, max-age=1, must-revalidate".into(),
},
}
}
+5
View File
@@ -30,6 +30,11 @@ pub struct AppState {
}
impl AppState {
pub fn mempool_cache(&self) -> CacheStrategy {
let hash = self.sync(|q| q.mempool().map(|m| m.next_block_hash()).unwrap_or(0));
CacheStrategy::MempoolHash(hash)
}
/// JSON response with caching
pub async fn cached_json<T, F>(
&self,
+1 -1
View File
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
///
/// Based on mempool.space's format.
///
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
#[derive(Debug, Default, Clone, Hash, Serialize, Deserialize, JsonSchema)]
pub struct AddressMempoolStats {
/// Number of unconfirmed transaction outputs funding this address
#[schemars(example = 0)]
+44 -31
View File
@@ -26,6 +26,7 @@ use super::{Bitcoin, CentsUnsigned, Dollars, Height};
Default,
Serialize,
Deserialize,
Hash,
Pco,
JsonSchema,
)]
@@ -76,39 +77,51 @@ impl Sats {
*self == Self::MAX
}
/// Check if value is a "round" BTC amount (±0.1% of common round values).
/// Check if value is a "round" BTC amount (±0.1% of d × 10^n, d ∈ {1,2,3,5,6}).
/// Used to filter out non-price-related transactions.
/// Round amounts: 1k, 10k, 20k, 30k, 50k, 100k, 200k, 300k, 500k sats,
/// 0.01, 0.02, 0.03, 0.05, 0.1, 0.2, 0.3, 0.5, 1, 10 BTC
pub fn is_round_btc(&self) -> bool {
const ROUND_SATS: [u64; 19] = [
1_000, // 1k sats
10_000, // 10k sats
20_000, // 20k sats
30_000, // 30k sats
50_000, // 50k sats
100_000, // 100k sats (0.001 BTC)
200_000, // 200k sats
300_000, // 300k sats
500_000, // 500k sats
1_000_000, // 0.01 BTC
2_000_000, // 0.02 BTC
3_000_000, // 0.03 BTC
5_000_000, // 0.05 BTC
10_000_000, // 0.1 BTC
20_000_000, // 0.2 BTC
30_000_000, // 0.3 BTC
50_000_000, // 0.5 BTC
100_000_000, // 1 BTC
1_000_000_000, // 10 BTC
];
const TOLERANCE: f64 = 0.001; // 0.1%
let v = self.0 as f64;
ROUND_SATS
.iter()
.any(|&r| (v - r as f64).abs() <= r as f64 * TOLERANCE)
pub fn is_common_round_value(&self) -> bool {
if self.0 == 0 {
return false;
}
let log = (self.0 as f64).log10();
let magnitude = 10.0_f64.powf(log.floor());
let leading = (self.0 as f64 / magnitude).round() as u64;
if !matches!(leading, 1 | 2 | 3 | 5 | 6 | 10) {
return false;
}
let round_val = leading as f64 * magnitude;
(self.0 as f64 - round_val).abs() <= round_val * 0.001
}
// pub fn is_common_round_value(&self) -> bool {
// const ROUND_SATS: [u64; 19] = [
// 1_000, // 1k sats
// 10_000, // 10k sats
// 20_000, // 20k sats
// 30_000, // 30k sats
// 50_000, // 50k sats
// 100_000, // 100k sats (0.001 BTC)
// 200_000, // 200k sats
// 300_000, // 300k sats
// 500_000, // 500k sats
// 1_000_000, // 0.01 BTC
// 2_000_000, // 0.02 BTC
// 3_000_000, // 0.03 BTC
// 5_000_000, // 0.05 BTC
// 10_000_000, // 0.1 BTC
// 20_000_000, // 0.2 BTC
// 30_000_000, // 0.3 BTC
// 50_000_000, // 0.5 BTC
// 100_000_000, // 1 BTC
// 1_000_000_000, // 10 BTC
// ];
// const TOLERANCE: f64 = 0.001; // 0.1%
//
// let v = self.0 as f64;
// ROUND_SATS
// .iter()
// .any(|&r| (v - r as f64).abs() <= r as f64 * TOLERANCE)
// }
}
impl Add for Sats {