1use brk_types::{Block, CentsUnsigned, Dollars, OutputType, Sats};
7
8pub const PRICES: &str = include_str!("prices.txt");
10
11pub 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
19const STENCIL_OFFSETS: [i32; 19] = [
22 -400, -340, -305, -260, -200, -165, -140, -120, -105, -60, 0, 35, 60, 95, 140, 200, 260, 340, 400, ];
42
43#[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#[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#[inline]
69pub fn cents_to_bin(cents: f64) -> f64 {
70 (10.0 - (cents / 100.0).log10()) * BINS_PER_DECADE as f64
71}
72
73fn 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 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 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 pub alpha: f64,
139 pub window_size: usize,
141 pub search_below: usize,
143 pub search_above: usize,
144 pub min_sats: u64,
146 pub exclude_common_round_values: bool,
148 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) .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 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}