mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-08 14:11:56 -07:00
oracle: start at 340k
This commit is contained in:
@@ -73,6 +73,40 @@ fn arm_profile_corr(ema: &HistogramEma, center: i64, profile: &[f64; N_ARMS]) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Shape-match via negative L1 distance between the candidate's L1-normalized arm
|
||||
/// vector and the L1-normalized `profile`. 1.0 = identical shape, lower as the
|
||||
/// shapes diverge. A covariance-free alternative to arm_profile_corr.
|
||||
fn arm_profile_l1(ema: &HistogramEma, center: i64, profile: &[f64; N_ARMS]) -> f64 {
|
||||
let arms = arms_at(ema, center);
|
||||
let s: f64 = arms.iter().sum();
|
||||
if s <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut dist = 0.0;
|
||||
for i in 0..N_ARMS {
|
||||
dist += (arms[i] / s - profile[i]).abs();
|
||||
}
|
||||
1.0 - dist
|
||||
}
|
||||
|
||||
/// Shape-match via the dot product of the candidate's L1-normalized arm vector
|
||||
/// with the L1-normalized `profile`. The minimal matched-filter form: the same
|
||||
/// multiply-accumulate the stencil sum already does, but profile-weighted instead
|
||||
/// of uniform. No covariance, no abs. Rewards mass on profile-heavy arms but
|
||||
/// (unlike L1/Pearson) does NOT penalize missing mass elsewhere.
|
||||
fn arm_profile_dot(ema: &HistogramEma, center: i64, profile: &[f64; N_ARMS]) -> f64 {
|
||||
let arms = arms_at(ema, center);
|
||||
let s: f64 = arms.iter().sum();
|
||||
if s <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut dot = 0.0;
|
||||
for i in 0..N_ARMS {
|
||||
dot += (arms[i] / s) * profile[i];
|
||||
}
|
||||
dot
|
||||
}
|
||||
|
||||
/// Stencil-arm indices whose value v has 2v NOT on the round-USD ladder
|
||||
/// ($2 $3 $20 $30 $200 $300 $2000 $10000). A half-price hypothesis shifts the
|
||||
/// center +60 bins; an arm is lit there only if 2v is itself a round-USD amount
|
||||
@@ -228,6 +262,8 @@ fn guarded_best_bin(
|
||||
arm_weights: &[f64; N_ARMS],
|
||||
corr_weight: f64,
|
||||
profile: &[f64; N_ARMS],
|
||||
metric: u8,
|
||||
stencil_weight: f64,
|
||||
) -> f64 {
|
||||
let center = prev_bin.round() as usize;
|
||||
let search_start = center.saturating_sub(search_below);
|
||||
@@ -247,14 +283,21 @@ fn guarded_best_bin(
|
||||
}
|
||||
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 += arm_weights[i] * ema[idx as usize] / track_norm[i];
|
||||
if stencil_weight != 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 += stencil_weight * arm_weights[i] * ema[idx as usize] / track_norm[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
if corr_weight != 0.0 {
|
||||
total += corr_weight * arm_profile_corr(ema, bin as i64, profile);
|
||||
let shape = match metric {
|
||||
1 => arm_profile_l1(ema, bin as i64, profile),
|
||||
2 => arm_profile_dot(ema, bin as i64, profile),
|
||||
_ => arm_profile_corr(ema, bin as i64, profile),
|
||||
};
|
||||
total += corr_weight * shape;
|
||||
}
|
||||
total
|
||||
};
|
||||
@@ -601,6 +644,47 @@ fn main() {
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0.002);
|
||||
// Apply the corr term only below this height. Lets the pre-X (slow) leg use
|
||||
// corr while the post-X (fast) leg stays bit-identical to the no-corr baseline.
|
||||
// Default = always on (global corr).
|
||||
let corr_until: usize = std::env::var("CORR_UNTIL")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(usize::MAX);
|
||||
// Shape-match metric: "l1" = negative L1 distance, "dot" = matched-filter dot
|
||||
// product (both covariance-free), else Pearson.
|
||||
let metric: u8 = match std::env::var("PROFILE_METRIC").as_deref() {
|
||||
Ok("l1") => 1,
|
||||
Ok("dot") => 2,
|
||||
_ => 0,
|
||||
};
|
||||
let metric_name = ["pearson", "l1", "dot"][metric as usize];
|
||||
// Profile seed: "bootstrap" = seed from the first warm-up pick's shape (no magic
|
||||
// constant), "uniform"/"flat" = every arm equal (1/N_ARMS), else the static
|
||||
// ARM_PROFILE.
|
||||
let profile_seed = std::env::var("PROFILE_SEED").ok();
|
||||
let bootstrap_profile = profile_seed.as_deref() == Some("bootstrap");
|
||||
let uniform_profile =
|
||||
matches!(profile_seed.as_deref(), Some("uniform") | Some("flat"));
|
||||
// Stencil-sum weight (default 1). Set 0 for SHAPE-ONLY scoring: the shape match
|
||||
// does both within-octave localization and octave discrimination, no stencil
|
||||
// term and no cw balance to tune.
|
||||
let stencil_weight: f64 = std::env::var("STENCIL_WEIGHT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1.0);
|
||||
eprintln!(
|
||||
" shape: metric={} seed={} stencil_weight={}",
|
||||
metric_name,
|
||||
if bootstrap_profile {
|
||||
"bootstrap"
|
||||
} else if uniform_profile {
|
||||
"uniform"
|
||||
} else {
|
||||
"static"
|
||||
},
|
||||
stencil_weight,
|
||||
);
|
||||
// Mid-run regime switch, mirrors production Oracle::reconfigure at START_HEIGHT:
|
||||
// at SWITCH_AT rebuild the EMA to SWITCH_WINDOW/SWITCH_ALPHA and warm-start fresh
|
||||
// (ring reset, ref_bin kept) - the same state as a fresh warm-up. Search window
|
||||
@@ -694,9 +778,27 @@ fn main() {
|
||||
let mut filled = 0usize;
|
||||
let mut ema = HistogramEma::zeros();
|
||||
let mut ref_bin = cents_to_bin(start_price * 100.0);
|
||||
// Adaptive shape template, seeded with the static profile and re-estimated
|
||||
// each block from the L1-normalized arm vector at the pick.
|
||||
let mut profile = ARM_PROFILE;
|
||||
// Adaptive shape template, re-estimated each block from the L1-normalized arm
|
||||
// vector at the pick. Static seed = ARM_PROFILE; bootstrap = filled from the
|
||||
// first warm-up pick (zeros until then, so corr contributes nothing yet).
|
||||
let mut profile = if bootstrap_profile {
|
||||
[0.0f64; N_ARMS]
|
||||
} else if uniform_profile {
|
||||
[1.0 / N_ARMS as f64; N_ARMS]
|
||||
} else {
|
||||
ARM_PROFILE
|
||||
};
|
||||
let mut profile_seeded = !bootstrap_profile;
|
||||
|
||||
// Parity check (VERIFY_PROD=1): drive the PRODUCTION Oracle (lib.rs) over the
|
||||
// same per-block histograms and confirm its ref_bin matches this harness pick
|
||||
// bit-for-bit. Only meaningful under the shipped slow config (EMA_ALPHA=0.10
|
||||
// EMA_WINDOW=40 search 12/11, metric=l1, cw=8, norm off, ORACLE_END<=508000 so
|
||||
// corr stays on the whole run).
|
||||
let verify_prod = std::env::var("VERIFY_PROD").as_deref() == Ok("1");
|
||||
let mut prod_oracle = brk_oracle::Oracle::new(ref_bin, brk_oracle::Config::slow());
|
||||
let mut prod_max_diff = 0.0f64;
|
||||
let mut prod_diff_blocks = 0usize;
|
||||
|
||||
// Lever 4: a parallel "sharp" detection EMA (fast span, short window) folded
|
||||
// from the same per-block hists. The slow EMA above still sets the price; this
|
||||
@@ -848,17 +950,35 @@ fn main() {
|
||||
sharp_ema[b] += w * block[b];
|
||||
}
|
||||
}
|
||||
ref_bin = guarded_best_bin(&ema, ref_bin, sb, sa, &guard, &arm_weights, corr_weight, &profile);
|
||||
let cw = if h < corr_until { corr_weight } else { 0.0 };
|
||||
ref_bin =
|
||||
guarded_best_bin(&ema, ref_bin, sb, sa, &guard, &arm_weights, cw, &profile, metric, stencil_weight);
|
||||
let oracle_price = bin_to_cents(ref_bin) as f64 / 100.0;
|
||||
|
||||
if verify_prod {
|
||||
let prod_bin = prod_oracle.process_histogram(&hist);
|
||||
let d = (prod_bin - ref_bin).abs();
|
||||
prod_max_diff = prod_max_diff.max(d);
|
||||
if prod_bin != ref_bin {
|
||||
prod_diff_blocks += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-estimate the shape template from the L1-normalized arm vector at the
|
||||
// new pick, blended in slowly so a transient octave slide cannot corrupt it.
|
||||
if corr_weight != 0.0 {
|
||||
if cw != 0.0 {
|
||||
let arms = arms_at(&ema, ref_bin.round() as i64);
|
||||
let s: f64 = arms.iter().sum();
|
||||
if s > 0.0 {
|
||||
for i in 0..N_ARMS {
|
||||
profile[i] = (1.0 - corr_beta) * profile[i] + corr_beta * (arms[i] / s);
|
||||
if !profile_seeded {
|
||||
for i in 0..N_ARMS {
|
||||
profile[i] = arms[i] / s;
|
||||
}
|
||||
profile_seeded = true;
|
||||
} else {
|
||||
for i in 0..N_ARMS {
|
||||
profile[i] = (1.0 - corr_beta) * profile[i] + corr_beta * (arms[i] / s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -972,6 +1092,12 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
if verify_prod {
|
||||
eprintln!(
|
||||
" VERIFY_PROD: production Oracle vs harness - max ref_bin diff {prod_max_diff:.6}, {prod_diff_blocks} blocks differ"
|
||||
);
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ pub struct Config {
|
||||
/// Search window bins below/above previous estimate. Asymmetric for log-scale.
|
||||
pub search_below: usize,
|
||||
pub search_above: usize,
|
||||
/// Weight of the adaptive shape-correlation restoring force added to the
|
||||
/// stencil score. `0.0` disables it (mature regime, where the fast EMA
|
||||
/// tracks real moves the shape term would resist); the slow cold-start uses
|
||||
/// a positive weight to resist round-USD octave aliasing in the thin early
|
||||
/// output mix.
|
||||
pub corr_weight: f64,
|
||||
/// Minimum output value in sats (dust filter).
|
||||
pub min_sats: u64,
|
||||
/// Exclude round BTC amounts that create false stencil matches.
|
||||
@@ -31,6 +37,7 @@ impl Default for Config {
|
||||
window_size: 12,
|
||||
search_below: 12,
|
||||
search_above: 11,
|
||||
corr_weight: 0.0,
|
||||
min_sats: DEFAULT_MIN_SATS,
|
||||
exclude_common_round_values: true,
|
||||
excluded_output_types: DEFAULT_EXCLUDED_OUTPUT_TYPES.to_vec(),
|
||||
@@ -42,11 +49,13 @@ impl Config {
|
||||
/// Cold-start config below [`START_HEIGHT`](crate::START_HEIGHT): a slow EMA
|
||||
/// (span ~19) that resists the round-USD half-price drift the fast default
|
||||
/// octave-locks onto in the thin pre-2018 output mix. Window grows to 40 to
|
||||
/// hold the decay.
|
||||
/// hold the decay, and a shape-correlation restoring force (`corr_weight`)
|
||||
/// pulls the pick toward the octave whose arm-shape looks like real payments.
|
||||
pub fn slow() -> Self {
|
||||
Self {
|
||||
alpha: 0.10,
|
||||
window_size: 40,
|
||||
corr_weight: 8.0,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ use config::{DEFAULT_EXCLUDED_OUTPUT_TYPES, DEFAULT_MIN_SATS};
|
||||
/// so downstream consumers can invalidate cached results.
|
||||
pub const VERSION: u32 = 3;
|
||||
|
||||
/// Pre-oracle dollar prices, one per line, heights 0..470_000. The last entry
|
||||
/// 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");
|
||||
|
||||
/// First height the oracle computes on-chain, with the slow cold-start EMA
|
||||
/// ([`Config::slow`]). Below it, prices come from [`PRICES`].
|
||||
pub const START_HEIGHT_SLOW: usize = 470_000;
|
||||
pub const START_HEIGHT_SLOW: usize = 340_000;
|
||||
|
||||
/// Height where the oracle switches slow -> fast EMA ([`Config::default`]).
|
||||
/// The regimes are complementary: slow resists the round-USD half-price drift
|
||||
@@ -78,6 +78,14 @@ const STENCIL_OFFSETS: [i32; 19] = [
|
||||
400, // $10000
|
||||
];
|
||||
|
||||
/// Number of round-USD stencil arms.
|
||||
const N_ARMS: usize = STENCIL_OFFSETS.len();
|
||||
|
||||
/// EMA rate for the adaptive shape template (~250-block time constant), slow
|
||||
/// enough that a transient octave slide can't corrupt the profile before the
|
||||
/// pick recovers.
|
||||
const CORR_BETA: f64 = 0.004;
|
||||
|
||||
/// Maps a satoshi value to its log-scale bin index.
|
||||
/// bin = round(log10(sats) * BINS_PER_DECADE).
|
||||
#[inline(always)]
|
||||
@@ -165,13 +173,57 @@ pub fn cents_to_bin(cents: f64) -> f64 {
|
||||
(10.0 - (cents / 100.0).log10()) * BINS_PER_DECADE as f64
|
||||
}
|
||||
|
||||
/// Raw EMA mass on each of the 19 stencil arms at `center`.
|
||||
fn arms_at(ema: &HistogramEma, center: i64) -> [f64; N_ARMS] {
|
||||
let mut arms = [0.0; N_ARMS];
|
||||
for (i, &offset) in STENCIL_OFFSETS.iter().enumerate() {
|
||||
let idx = center + offset as i64;
|
||||
if idx >= 0 && (idx as usize) < NUM_BINS {
|
||||
arms[i] = ema[idx as usize];
|
||||
}
|
||||
}
|
||||
arms
|
||||
}
|
||||
|
||||
/// [`arms_at`] L1-normalized to sum 1, or `None` when the center carries no mass.
|
||||
fn normalized_arms_at(ema: &HistogramEma, center: i64) -> Option<[f64; N_ARMS]> {
|
||||
let mut arms = arms_at(ema, center);
|
||||
let sum: f64 = arms.iter().sum();
|
||||
if sum <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
for arm in &mut arms {
|
||||
*arm /= sum;
|
||||
}
|
||||
Some(arms)
|
||||
}
|
||||
|
||||
/// Shape match `1 - L1distance` between the candidate's L1-normalized arm vector
|
||||
/// and the L1-normalized `profile`. 1.0 is an identical shape and it falls as
|
||||
/// mass shifts off the round-USD ladder, so it pulls the pick toward the octave
|
||||
/// whose payment shape looks real. Returns 0 for an empty (no-mass) center.
|
||||
fn arm_profile_match(ema: &HistogramEma, center: i64, profile: &[f64; N_ARMS]) -> f64 {
|
||||
match normalized_arms_at(ema, center) {
|
||||
Some(arms) => {
|
||||
1.0 - (0..N_ARMS)
|
||||
.map(|i| (arms[i] - profile[i]).abs())
|
||||
.sum::<f64>()
|
||||
}
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scores each candidate bin in the search window by summing normalized stencil
|
||||
/// matches across the EMA histogram, then refines with parabolic interpolation.
|
||||
/// When `corr_weight` is non-zero the [`arm_profile_match`] shape term is added
|
||||
/// to each candidate's score as an octave-discriminating restoring force.
|
||||
fn find_best_bin(
|
||||
ema: &HistogramEma,
|
||||
prev_bin: f64,
|
||||
search_below: usize,
|
||||
search_above: usize,
|
||||
corr_weight: f64,
|
||||
profile: &[f64; N_ARMS],
|
||||
) -> f64 {
|
||||
let center = prev_bin.round() as usize;
|
||||
let search_start = center.saturating_sub(search_below);
|
||||
@@ -200,6 +252,9 @@ fn find_best_bin(
|
||||
total += ema[idx as usize] / track_norm[i];
|
||||
}
|
||||
}
|
||||
if corr_weight != 0.0 {
|
||||
total += corr_weight * arm_profile_match(ema, bin as i64, profile);
|
||||
}
|
||||
total
|
||||
};
|
||||
|
||||
@@ -246,6 +301,12 @@ pub struct Oracle {
|
||||
weights: Vec<f64>,
|
||||
excluded_mask: u16,
|
||||
warmup: bool,
|
||||
/// Adaptive round-USD shape template, re-estimated each non-warmup block from
|
||||
/// the arm vector at the pick. Seeded flat (every arm equal) and only
|
||||
/// read/updated when `config.corr_weight` is non-zero (the slow cold-start
|
||||
/// regime), so the EMA learns the real payment shape within a few hundred
|
||||
/// blocks without a hand-tuned starting guess biasing acquisition.
|
||||
profile: [f64; N_ARMS],
|
||||
}
|
||||
|
||||
impl Oracle {
|
||||
@@ -269,6 +330,7 @@ impl Oracle {
|
||||
excluded_mask,
|
||||
warmup: false,
|
||||
config,
|
||||
profile: [1.0 / N_ARMS as f64; N_ARMS],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,11 +362,28 @@ impl Oracle {
|
||||
self.ref_bin,
|
||||
self.config.search_below,
|
||||
self.config.search_above,
|
||||
self.config.corr_weight,
|
||||
&self.profile,
|
||||
);
|
||||
if self.config.corr_weight != 0.0 {
|
||||
self.update_profile();
|
||||
}
|
||||
}
|
||||
self.ref_bin
|
||||
}
|
||||
|
||||
/// Blend the L1-normalized arm shape at the current pick into the adaptive
|
||||
/// `profile` (slow EMA, [`CORR_BETA`]). The slow rate lets the template ride
|
||||
/// through a transient octave dip without locking onto it. No-op when the
|
||||
/// pick carries no mass.
|
||||
fn update_profile(&mut self) {
|
||||
if let Some(arms) = normalized_arms_at(&self.ema, self.ref_bin.round() as i64) {
|
||||
(0..N_ARMS).for_each(|i| {
|
||||
self.profile[i] = (1.0 - CORR_BETA) * self.profile[i] + CORR_BETA * arms[i];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch EMA regime mid-stream (slow -> fast at [`START_HEIGHT`]) 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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user