//! 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 = (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())); } }