mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-10 15:03:32 -07:00
173 lines
5.7 KiB
Rust
173 lines
5.7 KiB
Rust
//! Pure on-chain BTC/USD price oracle.
|
|
//!
|
|
//! Detects round-dollar transaction patterns ($1, $5, $10, ... $10,000) in Bitcoin
|
|
//! block outputs to derive the current price without any exchange data.
|
|
//!
|
|
//! Behavior changes by height along two independent axes, each in its own module:
|
|
//!
|
|
//! - EMA regime (`config`): below [`START_HEIGHT_SLOW`] prices come from the baked
|
|
//! [`PRICES`]. From there to [`START_HEIGHT_FAST`] a slow cold-start EMA runs with
|
|
//! a shape-anchoring restoring force. At [`START_HEIGHT_FAST`] it switches to a
|
|
//! fast EMA that tracks mature-market volatility.
|
|
//! - Output filter (`filter`): below [`MAX_OUTPUTS_UNTIL_HEIGHT`] batch-payout
|
|
//! transactions are dropped from the histogram. Above it the cap is lifted.
|
|
//!
|
|
//! The two boundaries differ on purpose. The EMA must hand off to fast before the
|
|
//! 2020 crash, while the output cap helps the thin pre-2020 mix for longer.
|
|
|
|
use brk_types::{Cents, Dollars};
|
|
|
|
mod config;
|
|
mod filter;
|
|
mod scale;
|
|
mod shape;
|
|
mod stencil;
|
|
mod window;
|
|
|
|
pub use config::{Config, START_HEIGHT_FAST, START_HEIGHT_SLOW};
|
|
pub use filter::{MAX_OUTPUTS, MAX_OUTPUTS_UNTIL_HEIGHT, eligible_bin, for_each_round_dollar_bin};
|
|
pub use scale::{
|
|
BINS_PER_DECADE, HistogramEma, HistogramEmaCompact, HistogramRaw, NUM_BINS, bin_to_cents,
|
|
cents_to_bin, sats_to_bin,
|
|
};
|
|
|
|
use shape::ShapeAnchor;
|
|
use stencil::find_best_bin;
|
|
use window::EmaWindow;
|
|
|
|
/// Oracle algorithm version. Bump on any change that alters computed prices
|
|
/// so downstream consumers can invalidate cached results.
|
|
pub const VERSION: u32 = 3;
|
|
|
|
/// Pre-oracle dollar prices, one per line, heights 0..340_000. The last entry
|
|
/// seeds the oracle's first on-chain computation at [`START_HEIGHT_SLOW`].
|
|
pub const PRICES: &str = include_str!("prices.txt");
|
|
|
|
#[derive(Clone)]
|
|
pub struct Oracle {
|
|
window: EmaWindow,
|
|
ref_bin: f64,
|
|
config: Config,
|
|
warmup: bool,
|
|
/// Shape-anchoring restoring force, inert outside the slow cold-start
|
|
/// regime (zero weight). See [`ShapeAnchor`](shape::ShapeAnchor).
|
|
shape: ShapeAnchor,
|
|
}
|
|
|
|
impl Oracle {
|
|
pub fn new(start_bin: f64, config: Config) -> Self {
|
|
Self {
|
|
window: EmaWindow::new(config.window_size, config.alpha),
|
|
ref_bin: start_bin,
|
|
warmup: false,
|
|
shape: ShapeAnchor::new(config.shape_weight),
|
|
config,
|
|
}
|
|
}
|
|
|
|
/// Create an oracle restored from a known price. `fill` should call
|
|
/// `process_histogram` for the warmup blocks. During warmup the ring
|
|
/// fills without recomputing EMA or searching, then we recompute once
|
|
/// at the end so the first non-warmup call has a primed EMA.
|
|
pub fn from_checkpoint(ref_bin: f64, config: Config, fill: impl FnOnce(&mut Self)) -> Self {
|
|
let mut oracle = Self::new(ref_bin, config);
|
|
oracle.warmup = true;
|
|
fill(&mut oracle);
|
|
oracle.warmup = false;
|
|
oracle.window.recompute();
|
|
oracle
|
|
}
|
|
|
|
pub fn process_histogram(&mut self, hist: &HistogramRaw) -> f64 {
|
|
self.window.push(hist);
|
|
|
|
if !self.warmup {
|
|
self.window.recompute();
|
|
|
|
self.ref_bin = find_best_bin(
|
|
self.window.ema(),
|
|
self.ref_bin,
|
|
self.config.search_below,
|
|
self.config.search_above,
|
|
&self.shape,
|
|
);
|
|
self.shape.update(self.window.ema(), self.ref_bin.round() as i64);
|
|
}
|
|
self.ref_bin
|
|
}
|
|
|
|
/// Switch EMA regime mid-stream (slow -> fast at [`START_HEIGHT_FAST`]) by
|
|
/// re-warming under `config` over the most recent `config.window_size` raw
|
|
/// histograms, so a continuous build and an incremental warm-up reach the
|
|
/// same state. `ref_bin` carries over.
|
|
pub fn reconfigure(&mut self, config: Config) {
|
|
let kept = self.window.recent(config.window_size);
|
|
*self = Self::from_checkpoint(self.ref_bin, config, |o| {
|
|
kept.iter().for_each(|h| {
|
|
o.process_histogram(h);
|
|
});
|
|
});
|
|
}
|
|
|
|
pub fn ref_bin(&self) -> f64 {
|
|
self.ref_bin
|
|
}
|
|
|
|
/// The current weighted EMA over the window, one value per log-scale bin.
|
|
/// `ema()[i]` is bin `i` (see `sats_to_bin`).
|
|
pub fn ema(&self) -> &HistogramEma {
|
|
self.window.ema()
|
|
}
|
|
|
|
pub fn price_cents(&self) -> Cents {
|
|
bin_to_cents(self.ref_bin).into()
|
|
}
|
|
|
|
pub fn price_dollars(&self) -> Dollars {
|
|
self.price_cents().into()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn oracle_basic() {
|
|
let oracle = Oracle::new(1600.0, Config::default());
|
|
assert_eq!(oracle.ref_bin(), 1600.0);
|
|
assert_eq!(oracle.price_cents(), bin_to_cents(1600.0).into());
|
|
}
|
|
|
|
// reconfigure must leave the oracle in the same state as a fresh warm-up
|
|
// over the most recent window of raw histograms. The continuous build and
|
|
// the incremental resume rely on this agreeing at the slow -> fast seam.
|
|
#[test]
|
|
fn reconfigure_matches_fresh_warmup() {
|
|
let hists: Vec<HistogramRaw> = (0..60)
|
|
.map(|i| {
|
|
let mut h = HistogramRaw::zeros();
|
|
h.increment(1200 + i % 7);
|
|
h.increment(1600 + i % 5);
|
|
h
|
|
})
|
|
.collect();
|
|
|
|
let fast = Config::default();
|
|
let mut switched = Oracle::new(1600.0, Config::slow());
|
|
hists.iter().for_each(|h| {
|
|
switched.process_histogram(h);
|
|
});
|
|
switched.reconfigure(fast.clone());
|
|
|
|
let keep = fast.window_size;
|
|
let fresh = Oracle::from_checkpoint(switched.ref_bin(), fast, |o| {
|
|
hists[hists.len() - keep..].iter().for_each(|h| {
|
|
o.process_histogram(h);
|
|
});
|
|
});
|
|
|
|
assert!(switched.ema().iter().eq(fresh.ema().iter()));
|
|
}
|
|
}
|