Files
brk/crates/brk_oracle/examples/report_from.rs
T
2026-05-23 00:45:37 +02:00

859 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Generate detailed oracle accuracy report for README / documentation.
//!
//! Run with: cargo run -p brk_oracle --example report --release
use std::path::PathBuf;
use brk_indexer::Indexer;
use brk_oracle::{
Config, HistogramEma, HistogramRaw, NUM_BINS, PRICES, START_HEIGHT, bin_to_cents, cents_to_bin,
default_eligible_bin,
};
use brk_types::{OutputType, Sats, TxIndex, TxOutIndex};
use vecdb::{AnyVec, ReadableVec, VecIndex};
/// Day1 1 = Jan 9, 2009 (block 1). For dates after genesis week:
/// day1 = floor(timestamp / 86400) - 14252.
const GENESIS_DAY: u32 = 14252;
const BINS_5PCT: f64 = 4.24;
const BINS_10PCT: f64 = 8.28;
const BINS_20PCT: f64 = 15.84;
/// Local copy of the oracle's 19 round-USD stencil offsets (private in lib.rs),
/// used here only for per-block alias diagnostics.
const STENCIL_OFFSETS: [i32; 19] = [
-400, -340, -305, -260, -200, -165, -140, -120, -105, -60, 0, 35, 60, 95, 140, 200, 260, 340,
400,
];
/// Raw sum of EMA mass landing on the 19 stencil arms when centered at `center`.
fn ema_stencil_sum(ema: &HistogramEma, center: i64) -> f64 {
STENCIL_OFFSETS
.iter()
.map(|&off| {
let idx = center + off as i64;
if idx >= 0 && (idx as usize) < brk_oracle::NUM_BINS {
ema[idx as usize]
} else {
0.0
}
})
.sum()
}
/// log10(2) * 200 = one price octave (½× / 2×) in bins.
const OCTAVE_BINS: i64 = 60;
/// Tunable octave-guard thresholds (env-overridable for sweeping).
struct GuardCfg {
enabled: bool,
tau: f64, // arm "lit" if >= tau * peak arm
raw_margin: f64, // octave neighbor raw mass must be >= raw_margin * current
q_margin: usize, // neighbor must have >= q_margin MORE lit arms than current
q_min: usize, // neighbor must have at least this many lit arms (looks full)
}
impl GuardCfg {
fn from_env() -> Self {
let g = |k: &str, d: f64| -> f64 {
std::env::var(k)
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(d)
};
Self {
enabled: std::env::var("OCTAVE_GUARD")
.ok()
.map(|v| v != "0")
.unwrap_or(true),
tau: g("GUARD_TAU", 0.15),
raw_margin: g("GUARD_RAW", 1.0),
q_margin: g("GUARD_QMARGIN", 4.0) as usize,
q_min: g("GUARD_QMIN", 14.0) as usize,
}
}
}
/// Number of stencil arms carrying real mass at `center`. The true price lights
/// up ~all 19; a ½×/2× alias leaves ~8 structural holes (amounts with no ladder
/// partner one octave away), so this count separates truth from alias even when
/// the normalized score-sum cannot.
fn arm_count(ema: &HistogramEma, center: i64, tau: f64) -> usize {
let mut arms = [0.0f64; 19];
let mut peak = 0.0f64;
for (i, &off) in STENCIL_OFFSETS.iter().enumerate() {
let idx = center + off as i64;
let v = if idx >= 0 && (idx as usize) < brk_oracle::NUM_BINS {
ema[idx as usize]
} else {
0.0
};
arms[i] = v;
if v > peak {
peak = v;
}
}
if peak <= 0.0 {
return 0;
}
arms.iter().filter(|&&v| v >= tau * peak).count()
}
/// 19-char lit/dark pattern of the stencil arms at `center` (arm i lit if its
/// EMA mass >= tau * peak arm). Order: $1 $2 $3 $5 $10 $15 $20 $25 $30 $50 $100
/// $150 $200 $300 $500 $1k $2k $5k $10k. Reveals WHICH amounts are present.
fn arm_pattern(ema: &HistogramEma, center: i64, tau: f64) -> String {
let mut arms = [0.0f64; 19];
let mut peak = 0.0f64;
for (i, &off) in STENCIL_OFFSETS.iter().enumerate() {
let idx = center + off as i64;
let v = if idx >= 0 && (idx as usize) < brk_oracle::NUM_BINS {
ema[idx as usize]
} else {
0.0
};
arms[i] = v;
if v > peak {
peak = v;
}
}
arms.iter()
.map(|&v| {
if peak > 0.0 && v >= tau * peak {
'L'
} else {
'.'
}
})
.collect()
}
/// In-window stencil search (mirrors `Oracle::find_best_bin`) plus an octave
/// guard: if the half- or double-price bin lights up strictly more stencil arms
/// and carries comparable mass, snap to it. This escapes a ½×/2× alias lock that
/// the ±window can never climb the 60 bins out of on its own.
fn guarded_best_bin(
ema: &HistogramEma,
prev_bin: f64,
search_below: usize,
search_above: usize,
guard: &GuardCfg,
) -> f64 {
let center = prev_bin.round() as usize;
let search_start = center.saturating_sub(search_below);
let search_end = (center + search_above + 1).min(brk_oracle::NUM_BINS);
if search_start >= search_end {
return prev_bin;
}
let mut track_norm = [0.0f64; 19];
for (i, &off) in STENCIL_OFFSETS.iter().enumerate() {
for bin in search_start..search_end {
let idx = bin as i32 + off;
if idx >= 0 && (idx as usize) < brk_oracle::NUM_BINS {
track_norm[i] = track_norm[i].max(ema[idx as usize]);
}
}
}
let score = |bin: usize| -> f64 {
let mut total = 0.0;
for (i, &off) in STENCIL_OFFSETS.iter().enumerate() {
let idx = bin as i32 + off;
if idx >= 0 && (idx as usize) < brk_oracle::NUM_BINS && track_norm[i] > 0.0 {
total += ema[idx as usize] / track_norm[i];
}
}
total
};
let mut best_bin = search_start;
let mut best_score = score(search_start);
for bin in (search_start + 1)..search_end {
let c = score(bin);
if c > best_score {
best_score = c;
best_bin = bin;
}
}
if guard.enabled {
let b = best_bin as i64;
let qb = arm_count(ema, b, guard.tau);
let raw_b = ema_stencil_sum(ema, b);
let mut target = b;
let mut best: Option<(usize, f64)> = None;
for &delta in &[-OCTAVE_BINS, OCTAVE_BINS] {
let n = b + delta;
if n < 0 || n as usize >= brk_oracle::NUM_BINS {
continue;
}
let qn = arm_count(ema, n, guard.tau);
let raw_n = ema_stencil_sum(ema, n);
if qn >= qb + guard.q_margin && qn >= guard.q_min && raw_n >= guard.raw_margin * raw_b {
let better = best.is_none_or(|(sq, sr)| qn > sq || (qn == sq && raw_n > sr));
if better {
best = Some((qn, raw_n));
target = n;
}
}
}
if target != b {
return target as f64;
}
}
let score_center = best_score;
let score_left = if best_bin > search_start {
score(best_bin - 1)
} else {
score_center
};
let score_right = if best_bin + 1 < search_end {
score(best_bin + 1)
} else {
score_center
};
let denom = score_left - 2.0 * score_center + score_right;
let sub_bin = if denom.abs() > 1e-10 {
(0.5 * (score_left - score_right) / denom).clamp(-0.5, 0.5)
} else {
0.0
};
best_bin as f64 + sub_bin
}
fn bins_to_pct(bins: f64) -> f64 {
(10.0_f64.powf(bins / 200.0) - 1.0) * 100.0
}
/// Per-block EMA contribution weighting. `Off` keeps the raw count sum (a flood
/// block dominates the window); `Unit` rescales every block to the same total
/// mass (one block = one vote); `Cap` only scales down blocks above a ceiling.
#[derive(Clone, Copy, PartialEq)]
enum NormMode {
Off,
Unit,
Cap,
}
/// Scale factor applied to a block's bin counts before folding into the EMA.
fn norm_scale(total: u64, mode: NormMode, cap: f64, target: f64) -> f64 {
if total == 0 {
return 0.0;
}
match mode {
NormMode::Off => 1.0,
NormMode::Unit => target / total as f64,
NormMode::Cap => (cap / total as f64).min(1.0),
}
}
fn timestamp_to_year(ts: u32) -> u16 {
let years_since_1970 = ts as f64 / 31557600.0;
(1970.0 + years_since_1970) as u16
}
struct YearStats {
year: u16,
total_sq_err: f64,
max_err: f64,
total_blocks: u64,
gt_5pct: u64,
gt_10pct: u64,
gt_20pct: u64,
min_price: f64,
max_price: f64,
errors: Vec<f64>,
}
impl YearStats {
fn new(year: u16) -> Self {
Self {
year,
total_sq_err: 0.0,
max_err: 0.0,
total_blocks: 0,
gt_5pct: 0,
gt_10pct: 0,
gt_20pct: 0,
min_price: f64::MAX,
max_price: 0.0,
errors: Vec::new(),
}
}
fn update(&mut self, err: f64, exchange_high: f64, exchange_low: f64) {
let abs_err = err.abs();
self.total_sq_err += err * err;
self.total_blocks += 1;
self.errors.push(bins_to_pct(abs_err));
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;
}
if exchange_high > self.max_price {
self.max_price = exchange_high;
}
if exchange_low > 0.0 && exchange_low < self.min_price {
self.min_price = exchange_low;
}
}
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 median_pct(&mut self) -> f64 {
self.errors.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = self.errors.len();
if n == 0 { 0.0 } else { self.errors[n / 2] }
}
fn percentile(&self, p: f64) -> f64 {
let n = self.errors.len();
if n == 0 {
return 0.0;
}
let idx = ((p / 100.0) * (n - 1) as f64).round() as usize;
self.errors[idx.min(n - 1)]
}
}
/// Oracle OHLC for a single day, built from per-block prices.
struct DayCandle {
day1: usize,
open: f64,
high: f64,
low: f64,
close: f64,
}
struct BlockError {
height: usize,
oracle_price: f64,
exchange_low: f64,
exchange_high: f64,
error_pct: f64,
}
fn main() {
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 start = std::env::var("ORACLE_START")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(START_HEIGHT);
let end_override = std::env::var("ORACLE_END")
.ok()
.and_then(|s| s.parse::<usize>().ok());
let trace_every: usize = std::env::var("TRACE_EVERY")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(5000);
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 daily_ohlc: Vec<[f64; 4]> = serde_json::from_str(
&std::fs::read_to_string(format!("{manifest_dir}/examples/date_price_ohlc.json"))
.expect("Failed to read date_price_ohlc.json"),
)
.expect("Failed to parse daily 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();
// Read block timestamps for year + day1 mapping.
let timestamps: Vec<brk_types::Timestamp> = indexer.vecs.blocks.timestamp.collect();
let height_years: Vec<u16> = timestamps
.iter()
.map(|ts| timestamp_to_year(**ts))
.collect();
let height_day1s: Vec<usize> = timestamps
.iter()
.map(|ts| (**ts / 86400).saturating_sub(GENESIS_DAY) as usize)
.collect();
let start_price: f64 = PRICES
.lines()
.nth(start - 1)
.expect("prices.txt too short")
.parse()
.expect("Failed to parse seed price");
let mut config = Config::default();
if let Some(w) = std::env::var("EMA_WINDOW")
.ok()
.and_then(|s| s.parse().ok())
{
config.window_size = w;
}
if let Some(a) = std::env::var("EMA_ALPHA").ok().and_then(|s| s.parse().ok()) {
config.alpha = a;
}
// Investigation default: widened up-reach (9 -> 12) to survive fast rallies
// like the 2018-04-12 candle. Kept here only; config.rs is untouched.
config.search_below = std::env::var("SEARCH_BELOW")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(12);
if let Some(sa) = std::env::var("SEARCH_ABOVE")
.ok()
.and_then(|s| s.parse().ok())
{
config.search_above = sa;
}
let guard = GuardCfg::from_env();
let anom_thresh: f64 = std::env::var("ANOM_THRESH")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0.0);
let norm_mode = match std::env::var("NORM_MODE").as_deref() {
Ok("unit") => NormMode::Unit,
Ok("cap") => NormMode::Cap,
_ => NormMode::Off,
};
let norm_cap: f64 = std::env::var("NORM_CAP")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(8000.0);
let norm_target: f64 = std::env::var("NORM_TARGET")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(4000.0);
// Drop batch-payout txs (UTXOracle uses exactly-2-output; we cap instead).
// 0 = disabled. A flood block's 591-output txs are dropped at 100.
let max_outputs: usize = std::env::var("MAX_OUTPUTS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(100);
// Apply the output-count filter only below this height (it helps the thin
// 2018-2020 era, mildly hurts high-volume years). Default = always on.
let max_outputs_until: usize = std::env::var("MAX_OUTPUTS_UNTIL")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(usize::MAX);
eprintln!(
" norm: mode={} cap={} target={} max_outputs={}",
match norm_mode {
NormMode::Off => "off",
NormMode::Unit => "unit",
NormMode::Cap => "cap",
},
norm_cap,
norm_target,
max_outputs,
);
eprintln!(
" cfg: window_size={} alpha={:.5} (~{:.0}-block span) search -{}/+{} guard={} (tau={} raw={} qm={} qmin={})",
config.window_size,
config.alpha,
2.0 / config.alpha - 1.0,
config.search_below,
config.search_above,
guard.enabled,
guard.tau,
guard.raw_margin,
guard.q_margin,
guard.q_min,
);
let (sb, sa) = (config.search_below, config.search_above);
let window_size = config.window_size;
let alpha = config.alpha;
let weights: Vec<f64> = (0..window_size)
.map(|i| alpha * (1.0 - alpha).powi(i as i32))
.collect();
let mut ring: Vec<Vec<f64>> = vec![vec![0.0; NUM_BINS]; window_size];
let mut ring_cursor = 0usize;
let mut filled = 0usize;
let mut ema = HistogramEma::zeros();
let mut ref_bin = cents_to_bin(start_price * 100.0);
let total_txs = indexer.vecs.transactions.txid.len();
let total_outputs = indexer.vecs.outputs.value.len();
// Pre-collect height-indexed vecs (small). Transaction-indexed vecs are too
// large, so the tx-indexed first_txout_index is read through a forward cursor.
let first_tx_index: Vec<TxIndex> = indexer.vecs.transactions.first_tx_index.collect();
let out_first: Vec<TxOutIndex> = indexer.vecs.outputs.first_txout_index.collect();
let mut txout_cursor = indexer.vecs.transactions.first_txout_index.cursor();
let mut tx_starts: Vec<usize> = Vec::new();
let mut year_stats: Vec<YearStats> = Vec::new();
let mut overall = YearStats::new(0);
let mut worst_blocks: Vec<BlockError> = Vec::new();
let mut total_bias = 0.0f64;
// Track oracle daily candles.
let mut oracle_candles: Vec<DayCandle> = Vec::new();
let mut current_di: Option<usize> = None;
let loop_end = end_override.unwrap_or(total_heights).min(total_heights);
for h in start..loop_end {
let ft = first_tx_index[h];
let next_ft = first_tx_index
.get(h + 1)
.copied()
.unwrap_or(TxIndex::from(total_txs));
let block_first_tx = ft.to_usize() + 1;
let tx_count = next_ft.to_usize() - block_first_tx;
let out_end = out_first
.get(h + 1)
.copied()
.unwrap_or(TxOutIndex::from(total_outputs))
.to_usize();
// First txout index of each non-coinbase tx, for per-tx grouping.
txout_cursor.advance(block_first_tx - txout_cursor.position());
tx_starts.clear();
for _ in 0..tx_count {
tx_starts.push(txout_cursor.next().unwrap().to_usize());
}
let out_start = tx_starts.first().copied().unwrap_or(out_end);
let values: Vec<Sats> = indexer
.vecs
.outputs
.value
.collect_range_at(out_start, out_end);
let output_types: Vec<OutputType> = indexer
.vecs
.outputs
.output_type
.collect_range_at(out_start, out_end);
// Drop every output of a tx carrying an OP_RETURN (protocol machinery).
let mut hist = HistogramRaw::zeros();
for tx in 0..tx_count {
let lo = tx_starts[tx] - out_start;
let hi = tx_starts
.get(tx + 1)
.map(|s| s - out_start)
.unwrap_or(out_end - out_start);
if output_types[lo..hi].contains(&OutputType::OpReturn) {
continue;
}
if max_outputs > 0 && h < max_outputs_until && (hi - lo) > max_outputs {
continue;
}
for i in lo..hi {
if let Some(bin) = default_eligible_bin(values[i], output_types[i]) {
hist.increment(bin as usize);
}
}
}
let total: u64 = (0..NUM_BINS).map(|b| hist[b] as u64).sum();
let scale = norm_scale(total, norm_mode, norm_cap, norm_target);
{
let slot = &mut ring[ring_cursor];
for b in 0..NUM_BINS {
slot[b] = hist[b] as f64 * scale;
}
}
ring_cursor = (ring_cursor + 1) % window_size;
if filled < window_size {
filled += 1;
}
ema.fill(0.0);
for age in 0..filled {
let idx = (ring_cursor + window_size - 1 - age) % window_size;
let w = weights[age];
let block = &ring[idx];
for b in 0..NUM_BINS {
ema[b] += w * block[b];
}
}
ref_bin = guarded_best_bin(&ema, ref_bin, sb, sa, &guard);
let oracle_price = bin_to_cents(ref_bin) as f64 / 100.0;
let o = height_ohlc.get(h).copied().unwrap_or([0.0; 4]);
let (ex_high, ex_low, ex_close) = (o[1], o[2], o[3]);
let band_err = if ex_high > 0.0 && ex_low > 0.0 {
if oracle_price > ex_high {
(oracle_price - ex_high) / ex_high * 100.0
} else if oracle_price < ex_low {
(oracle_price - ex_low) / ex_low * 100.0
} else {
0.0
}
} else {
0.0
};
let do_print = h % trace_every == 0 || (anom_thresh > 0.0 && band_err.abs() >= anom_thresh);
if do_print {
let eligible: u32 = (0..brk_oracle::NUM_BINS).map(|b| hist[b]).sum();
// true_bin centered on exchange close; +60 bins = half price, -60 = double.
let true_bin = if ex_close > 0.0 {
cents_to_bin(ex_close * 100.0).round() as i64
} else {
ref_bin.round() as i64
};
let s_true = ema_stencil_sum(&ema, true_bin);
let s_half = ema_stencil_sum(&ema, true_bin + 60);
let s_dbl = ema_stencil_sum(&ema, true_bin - 60);
let qt = arm_count(&ema, true_bin, guard.tau);
let qh = arm_count(&ema, true_bin + 60, guard.tau);
let qd = arm_count(&ema, true_bin - 60, guard.tau);
let pat = arm_pattern(&ema, true_bin, guard.tau);
let ts_secs: u32 = *timestamps[h];
eprintln!(
"{h}\t{ts_secs}\t{oracle_price:.0}\t{ex_close:.0}\t{band_err:+.2}\t{eligible}\tT={s_true:.1}\tH={s_half:.1}\tD={s_dbl:.1}\tQt={qt}\tQh={qh}\tQd={qd}\t{pat}"
);
}
// Build oracle daily candle.
let di = height_day1s[h];
if current_di != Some(di) {
current_di = Some(di);
oracle_candles.push(DayCandle {
day1: di,
open: oracle_price,
high: oracle_price,
low: oracle_price,
close: oracle_price,
});
} else {
let candle = oracle_candles.last_mut().unwrap();
if oracle_price > candle.high {
candle.high = oracle_price;
}
if oracle_price < candle.low {
candle.low = oracle_price;
}
candle.close = oracle_price;
}
// Per-block error stats.
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
};
let exchange_high = height_ohlc[h][1];
let exchange_low = height_ohlc[h][2];
overall.update(err, exchange_high, exchange_low);
total_bias += err;
let year = height_years[h];
if year_stats.is_empty() || year_stats.last().unwrap().year != year {
year_stats.push(YearStats::new(year));
}
year_stats
.last_mut()
.unwrap()
.update(err, exchange_high, exchange_low);
if err.abs() > BINS_5PCT {
worst_blocks.push(BlockError {
height: h,
oracle_price,
exchange_low,
exchange_high,
error_pct: if err < 0.0 {
-bins_to_pct(err.abs())
} else {
bins_to_pct(err.abs())
},
});
}
}
}
}
worst_blocks.sort_by(|a, b| b.error_pct.abs().partial_cmp(&a.error_pct.abs()).unwrap());
overall.errors.sort_by(|a, b| a.partial_cmp(b).unwrap());
// Daily candle comparison: oracle OHLC vs exchange OHLC.
let mut daily_open_errors: Vec<f64> = Vec::new();
let mut daily_high_errors: Vec<f64> = Vec::new();
let mut daily_low_errors: Vec<f64> = Vec::new();
let mut daily_close_errors: Vec<f64> = Vec::new();
let mut daily_days = 0u64;
for candle in &oracle_candles {
let di = candle.day1;
if di >= daily_ohlc.len() {
continue;
}
let ex = &daily_ohlc[di];
if ex[0] <= 0.0 || ex[3] <= 0.0 {
continue;
}
let ex_open = ex[0];
let ex_high = ex[1];
let ex_low = ex[2];
let ex_close = ex[3];
// Error as percentage: (oracle - exchange) / exchange * 100
daily_open_errors.push((candle.open - ex_open) / ex_open * 100.0);
daily_high_errors.push((candle.high - ex_high) / ex_high * 100.0);
daily_low_errors.push((candle.low - ex_low) / ex_low * 100.0);
daily_close_errors.push((candle.close - ex_close) / ex_close * 100.0);
daily_days += 1;
}
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());
let max = errors.last().map(|e| e.abs()).unwrap_or(0.0);
let median = errors[errors.len() / 2].abs();
(median, rmse, max)
}
let (open_med, open_rmse, open_max) = daily_stats(&mut daily_open_errors);
let (high_med, high_rmse, high_max) = daily_stats(&mut daily_high_errors);
let (low_med, low_rmse, low_max) = daily_stats(&mut daily_low_errors);
let (close_med, close_rmse, close_max) = daily_stats(&mut daily_close_errors);
// Print report.
println!();
println!(" brk_oracle accuracy report");
println!(" ══════════════════════════");
println!();
println!(" Config: w12, alpha=2/7, search -9/+11, noisy/dust/round-btc filtered");
println!(
" Test range: height {} .. {} ({} blocks), seed ${:.2}",
start,
loop_end - 1,
overall.total_blocks,
start_price,
);
println!(
" Price range: ${:.0} .. ${:.0}",
overall.min_price, overall.max_price
);
println!();
println!(" Per-block accuracy (vs per-height exchange OHLC):");
println!(" Median: {:.3}%", overall.percentile(50.0));
println!(" 95th pct: {:.3}%", overall.percentile(95.0));
println!(" 99th pct: {:.3}%", overall.percentile(99.0));
println!(" 99.9th pct: {:.3}%", overall.percentile(99.9));
println!(" RMSE: {:.3}%", overall.rmse_pct());
println!(" Max: {:.1}%", overall.max_pct());
println!(
" Bias: {:+.2} bins",
total_bias / overall.total_blocks as f64
);
println!(
" > 5%: {} blocks ({:.3}%)",
overall.gt_5pct,
overall.gt_5pct as f64 / overall.total_blocks as f64 * 100.0
);
println!(" > 10%: {} blocks", overall.gt_10pct);
println!(" > 20%: {} blocks", overall.gt_20pct);
println!();
println!(
" Daily candle accuracy ({} days, vs exchange daily OHLC):",
daily_days
);
println!(
" {:>8} {:>10} {:>10} {:>10}",
"", "Median", "RMSE", "Max"
);
println!(
" {:>8} {:>9.2}% {:>9.2}% {:>9.1}%",
"Open", open_med, open_rmse, open_max
);
println!(
" {:>8} {:>9.2}% {:>9.2}% {:>9.1}%",
"High", high_med, high_rmse, high_max
);
println!(
" {:>8} {:>9.2}% {:>9.2}% {:>9.1}%",
"Low", low_med, low_rmse, low_max
);
println!(
" {:>8} {:>9.2}% {:>9.2}% {:>9.1}%",
"Close", close_med, close_rmse, close_max
);
println!();
println!(" By year:");
println!(
" {:<6} {:>7} {:>9} {:>9} {:>9} {:>6} {:>5} {:>5} {:>14}",
"Year", "Blocks", "Median", "RMSE", "Max", ">5%", ">10%", ">20%", "Price range"
);
println!(" {}", "-".repeat(80));
for ys in &mut year_stats {
let median = ys.median_pct();
println!(
" {:<6} {:>7} {:>8.3}% {:>8.3}% {:>8.1}% {:>6} {:>5} {:>5} ${:.0}..${:.0}",
ys.year,
ys.total_blocks,
median,
ys.rmse_pct(),
ys.max_pct(),
ys.gt_5pct,
ys.gt_10pct,
ys.gt_20pct,
ys.min_price,
ys.max_price,
);
}
if !worst_blocks.is_empty() {
println!();
println!(" Worst blocks:");
let show = worst_blocks.len().min(10);
for wb in &worst_blocks[..show] {
let dir = if wb.error_pct < 0.0 { "above" } else { "below" };
println!(
" height {:>7}: oracle ${:>9.0}, exchange ${:.0}..${:.0} ({:+.1}%, {})",
wb.height, wb.oracle_price, wb.exchange_low, wb.exchange_high, wb.error_pct, dir
);
}
if worst_blocks.len() > show {
println!(" ... and {} more", worst_blocks.len() - show);
}
}
println!();
}