Skip to main content

brk_oracle/
lib.rs

1//! Pure on-chain BTC/USD price oracle.
2//!
3//! Detects round-dollar transaction patterns ($1, $5, $10, ... $10,000) in Bitcoin
4//! block outputs to derive the current price without any exchange data.
5
6use brk_types::{Block, CentsUnsigned, Dollars, OutputType, Sats};
7
8/// Pre-oracle dollar prices, one per line, heights 0..630_000.
9pub const PRICES: &str = include_str!("prices.txt");
10
11/// First height where the oracle computes from on-chain data.
12pub const START_HEIGHT: usize = 575_000;
13
14pub const BINS_PER_DECADE: usize = 200;
15const MIN_LOG_BTC: i32 = -8;
16const MAX_LOG_BTC: i32 = 4;
17pub const NUM_BINS: usize = BINS_PER_DECADE * (MAX_LOG_BTC - MIN_LOG_BTC) as usize;
18
19/// Bin offsets for 19 round-USD amounts relative to the $100 reference (offset 0).
20/// Each offset = log10(amount / 100) * BINS_PER_DECADE.
21const STENCIL_OFFSETS: [i32; 19] = [
22    -400, // $1
23    -340, // $2
24    -305, // $3
25    -260, // $5
26    -200, // $10
27    -165, // $15
28    -140, // $20
29    -120, // $25
30    -105, // $30
31    -60,  // $50
32    0,    // $100
33    35,   // $150
34    60,   // $200
35    95,   // $300
36    140,  // $500
37    200,  // $1000
38    260,  // $2000
39    340,  // $5000
40    400,  // $10000
41];
42
43/// Maps a satoshi value to its log-scale bin index.
44/// bin = round(log10(sats) * BINS_PER_DECADE).
45#[inline(always)]
46pub fn sats_to_bin(sats: Sats) -> Option<usize> {
47    if sats.is_zero() {
48        return None;
49    }
50    let bin = ((*sats as f64).log10() * BINS_PER_DECADE as f64).round() as i64;
51    if bin >= 0 && (bin as usize) < NUM_BINS {
52        Some(bin as usize)
53    } else {
54        None
55    }
56}
57
58/// Converts a fractional bin to a USD price in cents.
59/// For a $D output at price P: sats = D * 1e8 / P, so P = 10^(10 - bin/200) dollars,
60/// where 10 = log10($100 reference * 1e8 sats/BTC).
61#[inline]
62pub fn bin_to_cents(bin: f64) -> u64 {
63    let dollars = 10.0_f64.powf(10.0 - bin / BINS_PER_DECADE as f64);
64    (dollars * 100.0).round() as u64
65}
66
67/// Converts a USD price in cents to a fractional bin (inverse of bin_to_cents).
68#[inline]
69pub fn cents_to_bin(cents: f64) -> f64 {
70    (10.0 - (cents / 100.0).log10()) * BINS_PER_DECADE as f64
71}
72
73/// Scores each candidate bin in the search window by summing normalized stencil
74/// matches across the EMA histogram, then refines with parabolic interpolation.
75fn find_best_bin(
76    ema: &[f64; NUM_BINS],
77    prev_bin: f64,
78    search_below: usize,
79    search_above: usize,
80) -> f64 {
81    let center = prev_bin.round() as usize;
82    let search_start = center.saturating_sub(search_below);
83    let search_end = (center + search_above + 1).min(NUM_BINS);
84
85    if search_start >= search_end {
86        return prev_bin;
87    }
88
89    // Per-offset peak within the search window (for normalization).
90    let mut track_norm = [0.0f64; 19];
91    for (i, &offset) in STENCIL_OFFSETS.iter().enumerate() {
92        for bin in search_start..search_end {
93            let idx = bin as i32 + offset;
94            if idx >= 0 && (idx as usize) < NUM_BINS {
95                track_norm[i] = track_norm[i].max(ema[idx as usize]);
96            }
97        }
98    }
99
100    let score = |bin: usize| -> f64 {
101        let mut total = 0.0;
102        for (i, &offset) in STENCIL_OFFSETS.iter().enumerate() {
103            let idx = bin as i32 + offset;
104            if idx >= 0 && (idx as usize) < NUM_BINS && track_norm[i] > 0.0 {
105                total += ema[idx as usize] / track_norm[i];
106            }
107        }
108        total
109    };
110
111    let mut best_bin = search_start;
112    let mut best_score = score(search_start);
113    for bin in (search_start + 1)..search_end {
114        let candidate = score(bin);
115        if candidate > best_score {
116            best_score = candidate;
117            best_bin = bin;
118        }
119    }
120
121    // Parabolic sub-bin interpolation for fractional precision.
122    let score_center = best_score;
123    let score_left = if best_bin > search_start { score(best_bin - 1) } else { score_center };
124    let score_right = if best_bin + 1 < search_end { score(best_bin + 1) } else { score_center };
125    let denom = score_left - 2.0 * score_center + score_right;
126    let sub_bin = if denom.abs() > 1e-10 {
127        (0.5 * (score_left - score_right) / denom).clamp(-0.5, 0.5)
128    } else {
129        0.0
130    };
131
132    best_bin as f64 + sub_bin
133}
134
135#[derive(Clone)]
136pub struct Config {
137    /// EMA decay: 2/(N+1) where N is span in blocks. 2/7 = 6-block span.
138    pub alpha: f64,
139    /// Ring buffer depth. 12 blocks for deterministic convergence at any start height.
140    pub window_size: usize,
141    /// Search window bins below/above previous estimate. Asymmetric for log-scale.
142    pub search_below: usize,
143    pub search_above: usize,
144    /// Minimum output value in sats (dust filter).
145    pub min_sats: u64,
146    /// Exclude round BTC amounts that create false stencil matches.
147    pub exclude_common_round_values: bool,
148    /// Output types to ignore (e.g. P2TR, P2WSH are noisy).
149    pub excluded_output_types: Vec<OutputType>,
150}
151
152impl Default for Config {
153    fn default() -> Self {
154        Self {
155            alpha: 2.0 / 7.0,
156            window_size: 12,
157            search_below: 9,
158            search_above: 11,
159            min_sats: 1000,
160            exclude_common_round_values: true,
161            excluded_output_types: vec![OutputType::P2TR, OutputType::P2WSH],
162        }
163    }
164}
165
166#[derive(Clone)]
167pub struct Oracle {
168    histograms: Vec<[u32; NUM_BINS]>,
169    ema: Box<[f64; NUM_BINS]>,
170    cursor: usize,
171    filled: usize,
172    ref_bin: f64,
173    config: Config,
174    weights: Vec<f64>,
175    excluded_mask: u16,
176    warmup: bool,
177}
178
179impl Oracle {
180    pub fn new(start_bin: f64, config: Config) -> Self {
181        let window_size = config.window_size;
182        let decay = 1.0 - config.alpha;
183        let weights: Vec<f64> = (0..window_size)
184            .map(|i| config.alpha * decay.powi(i as i32))
185            .collect();
186        let excluded_mask = config
187            .excluded_output_types
188            .iter()
189            .fold(0u16, |mask, ot| mask | (1 << *ot as u8));
190        Self {
191            histograms: vec![[0u32; NUM_BINS]; window_size],
192            ema: Box::new([0.0; NUM_BINS]),
193            cursor: 0,
194            filled: 0,
195            ref_bin: start_bin,
196            weights,
197            excluded_mask,
198            warmup: false,
199            config,
200        }
201    }
202
203    pub fn process_block(&mut self, block: &Block) -> f64 {
204        self.process_outputs(
205            block
206                .txdata
207                .iter()
208                .skip(1) // skip coinbase
209                .flat_map(|tx| &tx.output)
210                .map(|txout| (Sats::from(txout.value), OutputType::from(&txout.script_pubkey))),
211        )
212    }
213
214    pub fn process_outputs(&mut self, outputs: impl Iterator<Item = (Sats, OutputType)>) -> f64 {
215        let mut hist = [0u32; NUM_BINS];
216        for (sats, output_type) in outputs {
217            if let Some(bin) = self.eligible_bin(sats, output_type) {
218                hist[bin] += 1;
219            }
220        }
221        self.ingest(&hist)
222    }
223
224    /// Create an oracle restored from a known price.
225    /// `fill` should feed warmup blocks to populate the ring buffer.
226    /// ref_bin is anchored to the checkpoint regardless of warmup drift.
227    pub fn from_checkpoint(ref_bin: f64, config: Config, fill: impl FnOnce(&mut Self)) -> Self {
228        let mut oracle = Self::new(ref_bin, config);
229        oracle.warmup = true;
230        fill(&mut oracle);
231        oracle.warmup = false;
232        oracle.recompute_ema();
233        oracle.ref_bin = ref_bin;
234        oracle
235    }
236
237    pub fn process_histogram(&mut self, hist: &[u32; NUM_BINS]) -> f64 {
238        self.ingest(hist)
239    }
240
241    pub fn ref_bin(&self) -> f64 {
242        self.ref_bin
243    }
244
245    pub fn price_cents(&self) -> CentsUnsigned {
246        bin_to_cents(self.ref_bin).into()
247    }
248
249    pub fn price_dollars(&self) -> Dollars {
250        self.price_cents().into()
251    }
252
253    #[inline(always)]
254    pub fn output_to_bin(&self, sats: Sats, output_type: OutputType) -> Option<usize> {
255        self.eligible_bin(sats, output_type)
256    }
257
258    #[inline(always)]
259    fn eligible_bin(&self, sats: Sats, output_type: OutputType) -> Option<usize> {
260        if self.excluded_mask & (1 << output_type as u8) != 0 {
261            return None;
262        }
263        if *sats < self.config.min_sats
264            || (self.config.exclude_common_round_values && sats.is_common_round_value())
265        {
266            return None;
267        }
268        sats_to_bin(sats)
269    }
270
271    fn ingest(&mut self, hist: &[u32; NUM_BINS]) -> f64 {
272        self.histograms[self.cursor] = *hist;
273        self.cursor = (self.cursor + 1) % self.config.window_size;
274        if self.filled < self.config.window_size {
275            self.filled += 1;
276        }
277
278        if !self.warmup {
279            self.recompute_ema();
280
281            self.ref_bin = find_best_bin(
282                &self.ema,
283                self.ref_bin,
284                self.config.search_below,
285                self.config.search_above,
286            );
287        }
288        self.ref_bin
289    }
290
291    fn recompute_ema(&mut self) {
292        self.ema.fill(0.0);
293        for age in 0..self.filled {
294            let idx =
295                (self.cursor + self.config.window_size - 1 - age) % self.config.window_size;
296            let weight = self.weights[age];
297            let h = &self.histograms[idx];
298            for bin in 0..NUM_BINS {
299                self.ema[bin] += weight * h[bin] as f64;
300            }
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn sats_to_bin_round_trip() {
311        assert_eq!(sats_to_bin(Sats::new(100_000_000)), Some(1600));
312        assert_eq!(sats_to_bin(Sats::new(1)), Some(0));
313        assert_eq!(sats_to_bin(Sats::ZERO), None);
314    }
315
316    #[test]
317    fn bin_to_cents_known_values() {
318        assert_eq!(bin_to_cents(1600.0), 10000);
319        assert_eq!(bin_to_cents(1800.0), 1000);
320    }
321
322    #[test]
323    fn sats_to_bin_boundary() {
324        assert_eq!(sats_to_bin(Sats::new(1_000_000_000_000)), None);
325        let sats = 10.0_f64.powf(11.995) as u64;
326        assert!(sats_to_bin(Sats::new(sats)).is_some());
327    }
328
329    #[test]
330    fn oracle_basic() {
331        let oracle = Oracle::new(1600.0, Config::default());
332        assert_eq!(oracle.ref_bin(), 1600.0);
333        assert_eq!(oracle.price_cents(), bin_to_cents(1600.0).into());
334    }
335}