mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-08 14:11:56 -07:00
global: added support for oracle histograms
This commit is contained in:
@@ -9870,35 +9870,35 @@ impl BrkClient {
|
||||
/// Smoothed round-dollar payment histogram at the live tip: the committed EMA with the forming mempool block blended in. A flat array of log-scale bins.
|
||||
///
|
||||
/// Endpoint: `GET /api/oracle/histogram/ema/live`
|
||||
pub fn get_oracle_histogram_ema_live(&self) -> Result<Histogram_uint16> {
|
||||
pub fn get_oracle_histogram_ema_live(&self) -> Result<Vec<i64>> {
|
||||
self.base.get_json(&format!("/api/oracle/histogram/ema/live"))
|
||||
}
|
||||
|
||||
/// EMA histogram at height
|
||||
/// EMA histogram at height or day
|
||||
///
|
||||
/// Smoothed round-dollar payment histogram for a confirmed height. A flat array of log-scale bins.
|
||||
/// Smoothed round-dollar payment histogram for a confirmed point: a block height (`840000`) gives that block's EMA, a calendar date (`YYYY-MM-DD`) gives the average of that day's per-block EMAs. A flat array of log-scale bins.
|
||||
///
|
||||
/// Endpoint: `GET /api/oracle/histogram/ema/{height}`
|
||||
pub fn get_oracle_histogram_ema(&self, height: Height) -> Result<Histogram_uint16> {
|
||||
self.base.get_json(&format!("/api/oracle/histogram/ema/{height}"))
|
||||
/// Endpoint: `GET /api/oracle/histogram/ema/{point}`
|
||||
pub fn get_oracle_histogram_ema(&self, point: &str) -> Result<Vec<i64>> {
|
||||
self.base.get_json(&format!("/api/oracle/histogram/ema/{point}"))
|
||||
}
|
||||
|
||||
/// Live raw histogram
|
||||
///
|
||||
/// Un-smoothed per-block round-dollar counts for the forming mempool block. A flat array of log-scale bins, all zero when no mempool is configured.
|
||||
/// Unfiltered output histogram for the forming mempool block: every live output binned by value, with none of the round-dollar payment filters applied. A flat array of log-scale bins, all zero when no mempool is configured.
|
||||
///
|
||||
/// Endpoint: `GET /api/oracle/histogram/raw/live`
|
||||
pub fn get_oracle_histogram_raw_live(&self) -> Result<Histogram_uint32> {
|
||||
pub fn get_oracle_histogram_raw_live(&self) -> Result<Vec<i64>> {
|
||||
self.base.get_json(&format!("/api/oracle/histogram/raw/live"))
|
||||
}
|
||||
|
||||
/// Raw histogram at height
|
||||
/// Raw histogram at height or day
|
||||
///
|
||||
/// Un-smoothed round-dollar counts for a single confirmed block. A flat array of log-scale bins.
|
||||
/// Unfiltered output histogram for a confirmed point: a block height (`840000`) gives that block's outputs, coinbase included, binned by value with no payment filtering; a calendar date (`YYYY-MM-DD`) sums every block that day. A flat array of log-scale bins.
|
||||
///
|
||||
/// Endpoint: `GET /api/oracle/histogram/raw/{height}`
|
||||
pub fn get_oracle_histogram_raw(&self, height: Height) -> Result<Histogram_uint32> {
|
||||
self.base.get_json(&format!("/api/oracle/histogram/raw/{height}"))
|
||||
/// Endpoint: `GET /api/oracle/histogram/raw/{point}`
|
||||
pub fn get_oracle_histogram_raw(&self, point: &str) -> Result<Vec<i64>> {
|
||||
self.base.get_json(&format!("/api/oracle/histogram/raw/{point}"))
|
||||
}
|
||||
|
||||
/// Txid by index
|
||||
|
||||
@@ -24,51 +24,60 @@ pub struct RecoveredState {
|
||||
/// Returns Height::ZERO if any validation fails (triggers fresh start).
|
||||
pub(crate) fn recover_state(
|
||||
height: Height,
|
||||
chain_state_rollback: vecdb::Result<Stamp>,
|
||||
chain_state_rollback: Option<vecdb::Result<Stamp>>,
|
||||
any_addr_indexes: &mut AnyAddrIndexesVecs,
|
||||
addrs_data: &mut AddrsDataVecs,
|
||||
utxo_cohorts: &mut UTXOCohorts,
|
||||
addr_cohorts: &mut AddrCohorts,
|
||||
) -> Result<RecoveredState> {
|
||||
let stamp = Stamp::from(height);
|
||||
// `None`: clean resume, already at the checkpoint, nothing to undo.
|
||||
// `Some`: reorg, undo state past the resume point.
|
||||
let consistent_height = match chain_state_rollback {
|
||||
None => height,
|
||||
Some(chain_state_rollback) => {
|
||||
let stamp = Stamp::from(height);
|
||||
|
||||
// Rollback address state vectors
|
||||
let addr_indexes_rollback = any_addr_indexes.rollback_before(stamp);
|
||||
let addr_data_rollback = addrs_data.rollback_before(stamp);
|
||||
// Rollback address state vectors
|
||||
let addr_indexes_rollback = any_addr_indexes.rollback_before(stamp);
|
||||
let addr_data_rollback = addrs_data.rollback_before(stamp);
|
||||
|
||||
// Verify rollback consistency - all must agree on the same height
|
||||
let consistent_height = rollback_states(
|
||||
chain_state_rollback,
|
||||
addr_indexes_rollback,
|
||||
addr_data_rollback,
|
||||
);
|
||||
// Verify rollback consistency - all must agree on the same height
|
||||
let consistent_height = rollback_states(
|
||||
chain_state_rollback,
|
||||
addr_indexes_rollback,
|
||||
addr_data_rollback,
|
||||
);
|
||||
|
||||
// If rollbacks are inconsistent, start fresh
|
||||
if consistent_height.is_zero() {
|
||||
warn!("Rollback consistency check failed: inconsistent heights");
|
||||
return Ok(RecoveredState {
|
||||
starting_height: Height::ZERO,
|
||||
});
|
||||
}
|
||||
// If rollbacks are inconsistent, start fresh
|
||||
if consistent_height.is_zero() {
|
||||
warn!("Rollback consistency check failed: inconsistent heights");
|
||||
return Ok(RecoveredState {
|
||||
starting_height: Height::ZERO,
|
||||
});
|
||||
}
|
||||
|
||||
// Rollback can land at an earlier height (multi-block change file), which is fine.
|
||||
// But if it lands AHEAD of target, that means rollback failed (missing change files).
|
||||
if consistent_height > height {
|
||||
warn!(
|
||||
"Rollback failed: still at {} but target was {}, falling back to fresh start",
|
||||
consistent_height, height
|
||||
);
|
||||
return Ok(RecoveredState {
|
||||
starting_height: Height::ZERO,
|
||||
});
|
||||
}
|
||||
// Rollback can land at an earlier height (multi-block change file), which is fine.
|
||||
// But if it lands AHEAD of target, that means rollback failed (missing change files).
|
||||
if consistent_height > height {
|
||||
warn!(
|
||||
"Rollback failed: still at {} but target was {}, falling back to fresh start",
|
||||
consistent_height, height
|
||||
);
|
||||
return Ok(RecoveredState {
|
||||
starting_height: Height::ZERO,
|
||||
});
|
||||
}
|
||||
|
||||
if consistent_height != height {
|
||||
debug!(
|
||||
"Rollback landed at {} instead of {}, will resume from there",
|
||||
consistent_height, height
|
||||
);
|
||||
}
|
||||
if consistent_height != height {
|
||||
debug!(
|
||||
"Rollback landed at {} instead of {}, will resume from there",
|
||||
consistent_height, height
|
||||
);
|
||||
}
|
||||
|
||||
consistent_height
|
||||
}
|
||||
};
|
||||
|
||||
// Import UTXO cohort states - all must succeed
|
||||
debug!(
|
||||
|
||||
@@ -341,12 +341,13 @@ impl Vecs {
|
||||
// Try to resume from checkpoint, fall back to fresh start if needed
|
||||
let recovered_height = match start_mode {
|
||||
StartMode::Resume(height) => {
|
||||
let stamp = Stamp::from(height);
|
||||
// Roll back only on a reorg. A clean resume has nothing to undo, and an
|
||||
// interrupted run wrote no rollback metadata (periodic flushes use
|
||||
// with_changes=false; only the final write creates the `changes/` dir),
|
||||
// so `rollback_before` would fail with `NotFound`.
|
||||
let chain_state_rollback = (height < current_height)
|
||||
.then(|| self.supply_state.rollback_before(Stamp::from(height)));
|
||||
|
||||
// Rollback BytesVec state and capture results for validation
|
||||
let chain_state_rollback = self.supply_state.rollback_before(stamp);
|
||||
|
||||
// Validate all rollbacks and imports are consistent
|
||||
let recovered = recover_state(
|
||||
height,
|
||||
chain_state_rollback,
|
||||
|
||||
@@ -11,13 +11,21 @@ impl Mempool {
|
||||
self.read().info.clone()
|
||||
}
|
||||
|
||||
/// Snapshot of pre-bucketed oracle bins across all live mempool tx
|
||||
/// outputs. The total is maintained incrementally by `TxStore` on
|
||||
/// every insert/remove, so this hot path is `O(NUM_BINS)` regardless
|
||||
/// of pool size. Used by `live_price` to blend the mempool into the
|
||||
/// committed oracle without re-parsing scripts per request.
|
||||
/// Snapshot of pre-bucketed round-dollar-eligible bins across all live
|
||||
/// mempool tx outputs. Maintained incrementally by `TxStore` on every
|
||||
/// insert/remove, so this hot path is `O(NUM_BINS)` regardless of pool
|
||||
/// size. Used by `live_price` to blend the mempool into the committed
|
||||
/// oracle without re-parsing scripts per request.
|
||||
#[must_use]
|
||||
pub fn live_histogram(&self) -> HistogramRaw {
|
||||
self.read().txs.live_histogram()
|
||||
pub fn live_eligible_histogram(&self) -> HistogramRaw {
|
||||
self.read().txs.live_eligible_histogram()
|
||||
}
|
||||
|
||||
/// Snapshot of the raw histogram: every live mempool output binned by
|
||||
/// value with no payment filtering. Backs the `histogram/raw/live`
|
||||
/// endpoint.
|
||||
#[must_use]
|
||||
pub fn live_raw_histogram(&self) -> HistogramRaw {
|
||||
self.read().txs.live_raw_histogram()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
use brk_oracle::{HistogramRaw, for_each_round_dollar_bin, sats_to_bin};
|
||||
use brk_types::Transaction;
|
||||
|
||||
use crate::stores::tx_store::TxRecord;
|
||||
|
||||
/// The two live per-bin histograms the pool maintains incrementally as txs
|
||||
/// enter and leave: `eligible` applies the round-dollar payment filter (it
|
||||
/// feeds the oracle blend), `raw` bins every output by value with no filtering.
|
||||
/// Add and remove run through the same code so the two stay symmetric.
|
||||
#[derive(Default)]
|
||||
pub struct LiveHistograms {
|
||||
eligible: HistogramRaw,
|
||||
raw: HistogramRaw,
|
||||
}
|
||||
|
||||
impl LiveHistograms {
|
||||
/// Fold a record's outputs into both histograms.
|
||||
pub fn add(&mut self, record: &TxRecord) {
|
||||
Self::eligible_bins(&record.tx, |bin| self.eligible[bin as usize] += 1);
|
||||
for bin in Self::raw_bins(&record.tx) {
|
||||
self.raw[bin] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverse a previous `add` for the same record.
|
||||
pub fn remove(&mut self, record: &TxRecord) {
|
||||
Self::eligible_bins(&record.tx, |bin| self.eligible[bin as usize] -= 1);
|
||||
for bin in Self::raw_bins(&record.tx) {
|
||||
self.raw[bin] -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Round-dollar-eligible bins, blended into the oracle by `live_price`.
|
||||
pub fn eligible(&self) -> HistogramRaw {
|
||||
self.eligible.clone()
|
||||
}
|
||||
|
||||
/// Every live output binned by value, no payment filtering.
|
||||
pub fn raw(&self) -> HistogramRaw {
|
||||
self.raw.clone()
|
||||
}
|
||||
|
||||
/// Round-dollar-eligible bins, applying the oracle payment filter. Calls
|
||||
/// `emit(bin)` per eligible output. Deterministic over a tx's outputs,
|
||||
/// which are never mutated after insert, so add and remove recompute it
|
||||
/// identically rather than caching. Live mempool txs are post-tip, always
|
||||
/// above the historical max-outputs cap window, so the cap never applies.
|
||||
fn eligible_bins(tx: &Transaction, emit: impl FnMut(u16)) {
|
||||
for_each_round_dollar_bin(
|
||||
usize::MAX,
|
||||
tx.output.iter().map(|o| (o.value, o.type_())),
|
||||
emit,
|
||||
);
|
||||
}
|
||||
|
||||
/// Raw bin index per output, dropping only values outside the bin domain
|
||||
/// (zero / out-of-range).
|
||||
fn raw_bins(tx: &Transaction) -> impl Iterator<Item = usize> + '_ {
|
||||
tx.output.iter().filter_map(|o| sats_to_bin(o.value))
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
//! one lock-order discipline.
|
||||
|
||||
mod addr_tracker;
|
||||
mod live_histograms;
|
||||
mod outpoint_spends;
|
||||
mod output_bins;
|
||||
mod tx_graveyard;
|
||||
mod tx_store;
|
||||
|
||||
pub use addr_tracker::AddrTracker;
|
||||
pub use live_histograms::LiveHistograms;
|
||||
pub use outpoint_spends::OutpointSpends;
|
||||
pub use output_bins::OutputBins;
|
||||
pub use tx_graveyard::{TxGraveyard, TxTombstone};
|
||||
pub use tx_store::TxStore;
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
use brk_oracle::for_each_round_dollar_bin;
|
||||
use brk_types::Transaction;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
/// Pre-bucketed oracle bins for a tx's eligible outputs. Computed once on
|
||||
/// insert so `Mempool::live_histogram` can bin all live outputs without
|
||||
/// re-parsing scripts or recomputing eligibility per request.
|
||||
pub struct OutputBins(SmallVec<[u16; 4]>);
|
||||
|
||||
impl OutputBins {
|
||||
pub fn from_tx(tx: &Transaction) -> Self {
|
||||
let mut bins = SmallVec::new();
|
||||
// Live mempool txs are post-tip, always above the historical max-outputs
|
||||
// cap window, so the cap never applies here.
|
||||
for_each_round_dollar_bin(
|
||||
usize::MAX,
|
||||
tx.output.iter().map(|o| (o.value, o.type_())),
|
||||
|bin| bins.push(bin),
|
||||
);
|
||||
Self(bins)
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = u16> + '_ {
|
||||
self.0.iter().copied()
|
||||
}
|
||||
}
|
||||
@@ -2,27 +2,20 @@ use brk_oracle::HistogramRaw;
|
||||
use brk_types::{MempoolRecentTx, Transaction, TxOut, Txid, TxidPrefix, Vin};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
|
||||
use crate::{state::TxEntry, stores::OutputBins};
|
||||
use crate::{state::TxEntry, stores::LiveHistograms};
|
||||
|
||||
const RECENT_CAP: usize = 10;
|
||||
|
||||
/// Per-tx record: live tx body, its mempool entry, and the pre-bucketed
|
||||
/// oracle bins for its outputs. Kept under one key so a single map probe
|
||||
/// returns everything readers need.
|
||||
/// Per-tx record: live tx body and its mempool entry, kept under one key
|
||||
/// so a single map probe returns everything readers need.
|
||||
pub struct TxRecord {
|
||||
pub tx: Transaction,
|
||||
pub entry: TxEntry,
|
||||
pub output_bins: OutputBins,
|
||||
}
|
||||
|
||||
impl TxRecord {
|
||||
pub fn new(tx: Transaction, entry: TxEntry) -> Self {
|
||||
let output_bins = OutputBins::from_tx(&tx);
|
||||
Self {
|
||||
tx,
|
||||
entry,
|
||||
output_bins,
|
||||
}
|
||||
Self { tx, entry }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,15 +25,15 @@ impl TxRecord {
|
||||
/// set of prefixes whose tx still has at least one `prevout: None`,
|
||||
/// maintained on every `insert` / `remove_by_prefix` / `apply_fills`
|
||||
/// so the post-update prevout filler can early-exit when empty.
|
||||
/// `live_histogram` mirrors the union of each record's `OutputBins`,
|
||||
/// kept in sync on `insert` / `remove_by_prefix` so the oracle-blend
|
||||
/// read path is a single array clone, not a full pool walk.
|
||||
/// `histograms` holds the eligible (oracle-blend) and raw per-bin output
|
||||
/// histograms, kept in sync on `insert` / `remove_by_prefix` so each read
|
||||
/// path is a single array clone, not a full pool walk.
|
||||
#[derive(Default)]
|
||||
pub struct TxStore {
|
||||
records: FxHashMap<TxidPrefix, TxRecord>,
|
||||
recent: Vec<MempoolRecentTx>,
|
||||
unresolved: FxHashSet<TxidPrefix>,
|
||||
live_histogram: HistogramRaw,
|
||||
histograms: LiveHistograms,
|
||||
}
|
||||
|
||||
impl TxStore {
|
||||
@@ -92,9 +85,7 @@ impl TxStore {
|
||||
self.unresolved.insert(prefix);
|
||||
}
|
||||
let record = TxRecord::new(tx, entry);
|
||||
for bin in record.output_bins.iter() {
|
||||
self.live_histogram[bin as usize] += 1;
|
||||
}
|
||||
self.histograms.add(&record);
|
||||
self.records.insert(prefix, record);
|
||||
}
|
||||
|
||||
@@ -112,16 +103,21 @@ impl TxStore {
|
||||
pub fn remove_by_prefix(&mut self, prefix: &TxidPrefix) -> Option<TxRecord> {
|
||||
let record = self.records.remove(prefix)?;
|
||||
self.unresolved.remove(prefix);
|
||||
for bin in record.output_bins.iter() {
|
||||
self.live_histogram[bin as usize] -= 1;
|
||||
}
|
||||
self.histograms.remove(&record);
|
||||
Some(record)
|
||||
}
|
||||
|
||||
/// Snapshot the live oracle-bin histogram. Maintained incrementally
|
||||
/// on insert/remove, so this is `O(NUM_BINS)`, not `O(live_outputs)`.
|
||||
pub fn live_histogram(&self) -> HistogramRaw {
|
||||
self.live_histogram.clone()
|
||||
/// Snapshot the round-dollar-eligible histogram that feeds the oracle
|
||||
/// blend. Maintained incrementally, so this is `O(NUM_BINS)`, not
|
||||
/// `O(live_outputs)`.
|
||||
pub fn live_eligible_histogram(&self) -> HistogramRaw {
|
||||
self.histograms.eligible()
|
||||
}
|
||||
|
||||
/// Snapshot the raw histogram: every live output binned by value with no
|
||||
/// payment filtering. Maintained incrementally alongside the eligible one.
|
||||
pub fn live_raw_histogram(&self) -> HistogramRaw {
|
||||
self.histograms.raw()
|
||||
}
|
||||
|
||||
/// Set of prefixes with at least one unfilled prevout. Used by the
|
||||
@@ -338,11 +334,41 @@ mod tests {
|
||||
store.insert(tx_a, entry_a);
|
||||
store.insert(tx_b, entry_b);
|
||||
|
||||
let total_after_both: u32 = store.live_histogram().iter().sum();
|
||||
let total_after_both: u32 = store.live_eligible_histogram().iter().sum();
|
||||
assert_eq!(total_after_both, 3, "two outputs + one output");
|
||||
|
||||
store.remove_by_prefix(&prefix_a);
|
||||
let total_after_remove: u32 = store.live_histogram().iter().sum();
|
||||
let total_after_remove: u32 = store.live_eligible_histogram().iter().sum();
|
||||
assert_eq!(total_after_remove, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_histogram_bins_outputs_the_eligible_filter_drops() {
|
||||
let mut store = TxStore::default();
|
||||
// 2_345 sats is a round-dollar-eligible payment; 100_000_000 sats (1 BTC)
|
||||
// is a round-BTC value the eligible filter drops but raw still bins.
|
||||
let tx = fake_tx(
|
||||
30,
|
||||
&[Some(TxOut::from((p2wpkh_script(1), Sats::from(50_000u64))))],
|
||||
&[(p2wpkh_script(2), 2_345), (p2wpkh_script(3), 100_000_000)],
|
||||
);
|
||||
let entry = entry_for(&tx, 100, 100);
|
||||
let prefix = entry.txid_prefix();
|
||||
store.insert(tx, entry);
|
||||
|
||||
assert_eq!(
|
||||
store.live_eligible_histogram().iter().sum::<u32>(),
|
||||
1,
|
||||
"round-BTC output filtered out of the eligible histogram"
|
||||
);
|
||||
assert_eq!(
|
||||
store.live_raw_histogram().iter().sum::<u32>(),
|
||||
2,
|
||||
"raw histogram bins every output"
|
||||
);
|
||||
|
||||
store.remove_by_prefix(&prefix);
|
||||
assert_eq!(store.live_eligible_histogram().iter().sum::<u32>(), 0);
|
||||
assert_eq!(store.live_raw_histogram().iter().sum::<u32>(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,7 @@ fn arm_pattern(ema: &HistogramEma, center: i64, tau: f64) -> String {
|
||||
/// 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.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn guarded_best_bin(
|
||||
ema: &HistogramEma,
|
||||
prev_bin: f64,
|
||||
@@ -340,10 +341,11 @@ fn guarded_best_bin(
|
||||
best = Some((n, qn, raw_n));
|
||||
}
|
||||
}
|
||||
if let Some((n, qn, _)) = best {
|
||||
if qn >= qb + guard.q_margin && qn >= guard.q_min {
|
||||
target = n;
|
||||
}
|
||||
if let Some((n, qn, _)) = best
|
||||
&& qn >= qb + guard.q_margin
|
||||
&& qn >= guard.q_min
|
||||
{
|
||||
target = n;
|
||||
}
|
||||
} else {
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
@@ -587,12 +589,18 @@ fn main() {
|
||||
.and_then(|l| l.parse().ok())
|
||||
.unwrap_or_else(|| {
|
||||
let o = height_ohlc.get(start - 1).copied().unwrap_or([0.0; 4]);
|
||||
if o[3] > 0.0 { o[3] } else { (o[1] + o[2]) / 2.0 }
|
||||
if o[3] > 0.0 {
|
||||
o[3]
|
||||
} else {
|
||||
(o[1] + o[2]) / 2.0
|
||||
}
|
||||
});
|
||||
// Exact seed override (reproduce the committed prices.txt seed at a start the
|
||||
// truncated working-tree prices.txt no longer covers).
|
||||
let start_price =
|
||||
std::env::var("SEED").ok().and_then(|s| s.parse().ok()).unwrap_or(start_price);
|
||||
let start_price = std::env::var("SEED")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(start_price);
|
||||
|
||||
let mut config = Config::default();
|
||||
if let Some(w) = std::env::var("EMA_WINDOW")
|
||||
@@ -664,8 +672,7 @@ fn main() {
|
||||
// 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"));
|
||||
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.
|
||||
@@ -708,7 +715,9 @@ fn main() {
|
||||
for &i in &ALIAS_ARMS {
|
||||
arm_weights[i] = alias_weight;
|
||||
}
|
||||
eprintln!(" disc_weight={disc_weight} on {DISC_ARMS:?}; alias_weight={alias_weight} on {ALIAS_ARMS:?}; corr_weight={corr_weight}");
|
||||
eprintln!(
|
||||
" disc_weight={disc_weight} on {DISC_ARMS:?}; alias_weight={alias_weight} on {ALIAS_ARMS:?}; corr_weight={corr_weight}"
|
||||
);
|
||||
let anom_thresh: f64 = std::env::var("ANOM_THRESH")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
@@ -765,7 +774,9 @@ fn main() {
|
||||
guard.global_radius,
|
||||
);
|
||||
if switch_at != 0 {
|
||||
eprintln!(" switch: at height {switch_at} -> window={switch_window} alpha={switch_alpha:.5}");
|
||||
eprintln!(
|
||||
" switch: at height {switch_at} -> window={switch_window} alpha={switch_alpha:.5}"
|
||||
);
|
||||
}
|
||||
let (sb, sa) = (config.search_below, config.search_above);
|
||||
let mut window_size = config.window_size;
|
||||
@@ -821,9 +832,7 @@ fn main() {
|
||||
let mut sharp_cursor = 0usize;
|
||||
let mut sharp_filled = 0usize;
|
||||
let mut sharp_ema = HistogramEma::zeros();
|
||||
eprintln!(
|
||||
" sharp: span={sharp_span:.0} window={sharp_window} alpha={sharp_alpha:.5}"
|
||||
);
|
||||
eprintln!(" sharp: span={sharp_span:.0} window={sharp_window} alpha={sharp_alpha:.5}");
|
||||
|
||||
let total_txs = indexer.vecs.transactions.txid.len();
|
||||
let total_outputs = indexer.vecs.outputs.value.len();
|
||||
@@ -922,14 +931,14 @@ fn main() {
|
||||
filled += 1;
|
||||
}
|
||||
ema.fill(0.0);
|
||||
for age in 0..filled {
|
||||
(0..filled).for_each(|age| {
|
||||
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];
|
||||
}
|
||||
}
|
||||
});
|
||||
// Sharp detection EMA (diagnostic only - does not drive the price).
|
||||
{
|
||||
let slot = &mut sharp_ring[sharp_cursor];
|
||||
@@ -942,17 +951,27 @@ fn main() {
|
||||
sharp_filled += 1;
|
||||
}
|
||||
sharp_ema.fill(0.0);
|
||||
for age in 0..sharp_filled {
|
||||
(0..sharp_filled).for_each(|age| {
|
||||
let idx = (sharp_cursor + sharp_window - 1 - age) % sharp_window;
|
||||
let w = sharp_weights[age];
|
||||
let block = &sharp_ring[idx];
|
||||
for b in 0..NUM_BINS {
|
||||
sharp_ema[b] += w * block[b];
|
||||
}
|
||||
}
|
||||
});
|
||||
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);
|
||||
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 {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
use std::{ops::Range, sync::Arc};
|
||||
|
||||
use brk_computer::prices::Vecs as PricesVecs;
|
||||
use brk_error::{Error, Result};
|
||||
use brk_indexer::Lengths;
|
||||
use brk_oracle::{
|
||||
Config, HistogramEmaCompact, HistogramRaw, Oracle, START_HEIGHT_SLOW, cents_to_bin,
|
||||
for_each_round_dollar_bin,
|
||||
Config, HistogramEma, HistogramEmaCompact, HistogramRaw, Oracle, START_HEIGHT_SLOW,
|
||||
cents_to_bin, sats_to_bin,
|
||||
};
|
||||
use brk_types::{Dollars, OutputType, Sats, TxIndex, TxOutIndex};
|
||||
use brk_types::{Day1, Dollars, Sats, TxOutIndex};
|
||||
use vecdb::{AnyVec, ReadableVec, VecIndex};
|
||||
|
||||
use crate::Query;
|
||||
@@ -27,31 +27,67 @@ impl Query {
|
||||
/// seed-independent, so the result is exact.
|
||||
pub fn confirmed_histogram_ema(&self, height: usize) -> Result<HistogramEmaCompact> {
|
||||
let safe = self.check_histogram_height(height)?;
|
||||
let ref_bin = self.seed_bin_at(height)?;
|
||||
Ok(self.warm_oracle(ref_bin, height + 1, &safe).ema().to_compact())
|
||||
Ok(self.ema_oracle_at(height, &safe)?.ema().to_compact())
|
||||
}
|
||||
|
||||
/// Un-smoothed per-block round-dollar counts at the live tip: the mempool's
|
||||
/// forming-block histogram, or zeros when no mempool is configured.
|
||||
/// Smoothed EMA histogram for a calendar `day`: the bin-by-bin average of
|
||||
/// every confirmed block's per-block EMA. Each block's EMA is reconstructed
|
||||
/// independently (seed-independent, so exact); averaging keeps the result an
|
||||
/// intensive per-block rate rather than letting a busy day dominate.
|
||||
pub fn confirmed_histogram_ema_day(&self, day: Day1) -> Result<HistogramEmaCompact> {
|
||||
let safe = self.safe_lengths();
|
||||
let range = self.day_block_range(day, &safe)?;
|
||||
let count = range.len() as f64;
|
||||
let mut acc = HistogramEma::zeros();
|
||||
for height in range {
|
||||
let oracle = self.ema_oracle_at(height, &safe)?;
|
||||
acc.iter_mut()
|
||||
.zip(oracle.ema().iter())
|
||||
.for_each(|(a, &e)| *a += e);
|
||||
}
|
||||
acc.iter_mut().for_each(|a| *a /= count);
|
||||
Ok(acc.to_compact())
|
||||
}
|
||||
|
||||
/// Unfiltered per-bin output counts at the live tip: every forming-block
|
||||
/// mempool output binned by value, with none of the round-dollar payment
|
||||
/// filters applied. Zeros when no mempool is configured.
|
||||
pub fn live_histogram_raw(&self) -> Result<HistogramRaw> {
|
||||
Ok(match self.mempool() {
|
||||
Some(mempool) => mempool.live_histogram(),
|
||||
Some(mempool) => mempool.live_raw_histogram(),
|
||||
None => HistogramRaw::zeros(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Un-smoothed per-block round-dollar counts for a confirmed `height`.
|
||||
/// Unfiltered per-bin output counts for a confirmed `height`: every output
|
||||
/// in the block binned by value, with no payment filtering.
|
||||
pub fn confirmed_histogram_raw(&self, height: usize) -> Result<HistogramRaw> {
|
||||
let safe = self.check_histogram_height(height)?;
|
||||
Ok(self.block_raw_histogram(height, &safe))
|
||||
}
|
||||
|
||||
/// Unfiltered per-bin output counts for a calendar `day`: every block's raw
|
||||
/// histogram summed bin-by-bin. Raw counts are additive, so the day total is
|
||||
/// just the sum across its confirmed blocks.
|
||||
pub fn confirmed_histogram_raw_day(&self, day: Day1) -> Result<HistogramRaw> {
|
||||
let safe = self.safe_lengths();
|
||||
let range = self.day_block_range(day, &safe)?;
|
||||
let mut acc = HistogramRaw::zeros();
|
||||
for height in range {
|
||||
let block = self.block_raw_histogram(height, &safe);
|
||||
acc.iter_mut()
|
||||
.zip(block.iter())
|
||||
.for_each(|(a, &v)| *a += v);
|
||||
}
|
||||
Ok(acc)
|
||||
}
|
||||
|
||||
/// The live tip oracle: the cached committed base, with the forming block's
|
||||
/// mempool outputs blended in as a final slot when a mempool is configured.
|
||||
fn live_oracle(&self) -> Result<Oracle> {
|
||||
let mut oracle = (*self.cached_oracle()?).clone();
|
||||
if let Some(mempool) = self.mempool() {
|
||||
oracle.process_histogram(&mempool.live_histogram());
|
||||
oracle.process_histogram(&mempool.live_eligible_histogram());
|
||||
}
|
||||
Ok(oracle)
|
||||
}
|
||||
@@ -86,6 +122,14 @@ impl Query {
|
||||
Ok(oracle)
|
||||
}
|
||||
|
||||
/// Oracle warmed to just after `height`, ready for its per-block EMA. Seeds
|
||||
/// from the stored spot price at `height`, though the EMA is seed-independent
|
||||
/// so the seed only sets the price read-out, not the window contents.
|
||||
fn ema_oracle_at(&self, height: usize, safe: &Lengths) -> Result<Oracle> {
|
||||
let seed_bin = self.seed_bin_at(height)?;
|
||||
Ok(self.warm_oracle(seed_bin, height + 1, safe))
|
||||
}
|
||||
|
||||
/// An oracle seeded at `seed_bin` and warmed by replaying the `window_size`
|
||||
/// committed blocks ending just before `end`. Reads are capped at `safe` so
|
||||
/// concurrent indexer writes past the cap stay invisible.
|
||||
@@ -133,72 +177,62 @@ impl Query {
|
||||
Ok(safe)
|
||||
}
|
||||
|
||||
/// One confirmed block's round-dollar histogram, built from batched columnar
|
||||
/// reads and the shared `for_each_round_dollar_bin` filter. Kept separate from
|
||||
/// the hot-path `feed_blocks` (cursor + reusable buffers over a block range).
|
||||
/// The confirmed block heights `[first, end)` of calendar `day`, clamped to
|
||||
/// the same histogram-available bound as `check_histogram_height`. 404 when
|
||||
/// the day has no committed blocks in range.
|
||||
fn day_block_range(&self, day: Day1, safe: &Lengths) -> Result<Range<usize>> {
|
||||
let first_height = &self.computer().indexes.day1.first_height;
|
||||
let bound = self
|
||||
.computer()
|
||||
.prices
|
||||
.spot
|
||||
.cents
|
||||
.height
|
||||
.len()
|
||||
.min(safe.height.to_usize());
|
||||
let start = first_height
|
||||
.collect_one(day)
|
||||
.map_or(usize::MAX, |h| h.to_usize())
|
||||
.max(START_HEIGHT_SLOW);
|
||||
let end = first_height
|
||||
.collect_one(day + 1)
|
||||
.map_or(bound, |h| h.to_usize())
|
||||
.min(bound);
|
||||
if start >= end {
|
||||
return Err(Error::NotFound(format!(
|
||||
"oracle histogram unavailable for day {day}"
|
||||
)));
|
||||
}
|
||||
Ok(start..end)
|
||||
}
|
||||
|
||||
/// One confirmed block's unfiltered histogram: every output in the block,
|
||||
/// coinbase included, binned by value via `sats_to_bin` with no payment
|
||||
/// filtering. Built from a single batched columnar read of the block's
|
||||
/// output-value range.
|
||||
fn block_raw_histogram(&self, height: usize, safe: &Lengths) -> HistogramRaw {
|
||||
let indexer = self.indexer();
|
||||
let total_txs = safe.tx_index.to_usize();
|
||||
let total_outputs = safe.txout_index.to_usize();
|
||||
let next_height = (height + 2).min(safe.height.to_usize());
|
||||
|
||||
let first_tx_indexes: Vec<TxIndex> = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_tx_index
|
||||
.collect_range_at(height, next_height);
|
||||
let out_firsts: Vec<TxOutIndex> = indexer
|
||||
.vecs
|
||||
.outputs
|
||||
.first_txout_index
|
||||
.collect_range_at(height, next_height);
|
||||
|
||||
let block_first_tx = first_tx_indexes[0].to_usize() + 1;
|
||||
let next_first_tx = first_tx_indexes
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or(TxIndex::from(total_txs))
|
||||
.to_usize();
|
||||
let tx_count = next_first_tx - block_first_tx;
|
||||
|
||||
let mut hist = HistogramRaw::zeros();
|
||||
if tx_count == 0 {
|
||||
return hist;
|
||||
}
|
||||
|
||||
let out_start = out_firsts[0].to_usize();
|
||||
let out_end = out_firsts
|
||||
.get(1)
|
||||
.copied()
|
||||
.unwrap_or(TxOutIndex::from(total_outputs))
|
||||
.to_usize();
|
||||
let tx_starts: Vec<usize> = indexer
|
||||
.vecs
|
||||
.transactions
|
||||
.first_txout_index
|
||||
.collect_range_at(block_first_tx, next_first_tx)
|
||||
.into_iter()
|
||||
.map(|t| t.to_usize())
|
||||
.collect();
|
||||
let out_start = tx_starts.first().copied().unwrap_or(out_end);
|
||||
|
||||
let mut hist = HistogramRaw::zeros();
|
||||
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);
|
||||
|
||||
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);
|
||||
let outputs = values[lo..hi]
|
||||
.iter()
|
||||
.copied()
|
||||
.zip(output_types[lo..hi].iter().copied());
|
||||
for_each_round_dollar_bin(height, outputs, |bin| hist.increment(bin as usize));
|
||||
for sats in values {
|
||||
if let Some(bin) = sats_to_bin(sats) {
|
||||
hist.increment(bin);
|
||||
}
|
||||
}
|
||||
hist
|
||||
}
|
||||
|
||||
@@ -184,9 +184,10 @@ All errors return structured JSON with a consistent format:
|
||||
transaction outputs, with no external price feed. Payment activity is binned on a \
|
||||
log scale, and a smoothed EMA over recent blocks locates the price.\n\n\
|
||||
Histograms come in two flavors, each available at the live tip (mempool-blended) \
|
||||
or at any confirmed height: `raw` (per-block counts) and `ema` (the smoothed \
|
||||
window). The live price is also at `/api/mempool/price`. Confirmed per-height \
|
||||
price history is at `/api/vecs/height-to-price`."
|
||||
or at any confirmed height: `raw` bins every output by value with no filtering, \
|
||||
while `ema` is the smoothed round-dollar window the price is read from. The live \
|
||||
price is also at `/api/mempool/price`. Confirmed per-height price history is at \
|
||||
`/api/vecs/height-to-price`."
|
||||
.to_string(),
|
||||
),
|
||||
..Default::default()
|
||||
|
||||
@@ -2,14 +2,15 @@ use aide::axum::{ApiRouter, routing::get_with};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, Uri},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use brk_oracle::{HistogramEmaCompact, HistogramRaw};
|
||||
use brk_types::{Dollars, Version};
|
||||
use brk_types::{Day1, Dollars, Version};
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
extended::TransformResponseExtended,
|
||||
params::{Empty, HeightParam},
|
||||
params::{Empty, HeightOrDate, HeightOrDateParam},
|
||||
};
|
||||
|
||||
pub trait OracleRoutes {
|
||||
@@ -67,26 +68,42 @@ impl OracleRoutes for ApiRouter<AppState> {
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/oracle/histogram/ema/{height}",
|
||||
"/api/oracle/histogram/ema/{point}",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<HeightParam>,
|
||||
Path(path): Path<HeightOrDateParam>,
|
||||
_: Empty,
|
||||
State(state): State<AppState>| {
|
||||
let strategy = state.height_strategy(Version::new(brk_oracle::VERSION), path.height);
|
||||
state
|
||||
.respond_json(&headers, strategy, &uri, move |q| {
|
||||
q.confirmed_histogram_ema(usize::from(path.height))
|
||||
})
|
||||
.await
|
||||
let version = Version::new(brk_oracle::VERSION);
|
||||
match path.resolve() {
|
||||
Ok(HeightOrDate::Date(date)) => {
|
||||
let strategy = state.date_strategy(version, date);
|
||||
state
|
||||
.respond_json(&headers, strategy, &uri, move |q| {
|
||||
q.confirmed_histogram_ema_day(Day1::try_from(date)?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
Ok(HeightOrDate::Height(height)) => {
|
||||
let strategy = state.height_strategy(version, height);
|
||||
state
|
||||
.respond_json(&headers, strategy, &uri, move |q| {
|
||||
q.confirmed_histogram_ema(usize::from(height))
|
||||
})
|
||||
.await
|
||||
}
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
},
|
||||
|op| {
|
||||
op.id("get_oracle_histogram_ema")
|
||||
.oracle_tag()
|
||||
.summary("EMA histogram at height")
|
||||
.summary("EMA histogram at height or day")
|
||||
.description(
|
||||
"Smoothed round-dollar payment histogram for a confirmed height. \
|
||||
"Smoothed round-dollar payment histogram for a confirmed point: a \
|
||||
block height (`840000`) gives that block's EMA, a calendar date \
|
||||
(`YYYY-MM-DD`) gives the average of that day's per-block EMAs. \
|
||||
A flat array of log-scale bins.",
|
||||
)
|
||||
.json_response::<HistogramEmaCompact>()
|
||||
@@ -112,9 +129,10 @@ impl OracleRoutes for ApiRouter<AppState> {
|
||||
.oracle_tag()
|
||||
.summary("Live raw histogram")
|
||||
.description(
|
||||
"Un-smoothed per-block round-dollar counts for the forming mempool \
|
||||
block. A flat array of log-scale bins, all zero when no mempool is \
|
||||
configured.",
|
||||
"Unfiltered output histogram for the forming mempool block: every \
|
||||
live output binned by value, with none of the round-dollar payment \
|
||||
filters applied. A flat array of log-scale bins, all zero when no \
|
||||
mempool is configured.",
|
||||
)
|
||||
.json_response::<HistogramRaw>()
|
||||
.not_modified()
|
||||
@@ -123,27 +141,44 @@ impl OracleRoutes for ApiRouter<AppState> {
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/oracle/histogram/raw/{height}",
|
||||
"/api/oracle/histogram/raw/{point}",
|
||||
get_with(
|
||||
async |uri: Uri,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<HeightParam>,
|
||||
Path(path): Path<HeightOrDateParam>,
|
||||
_: Empty,
|
||||
State(state): State<AppState>| {
|
||||
let strategy = state.height_strategy(Version::new(brk_oracle::VERSION), path.height);
|
||||
state
|
||||
.respond_json(&headers, strategy, &uri, move |q| {
|
||||
q.confirmed_histogram_raw(usize::from(path.height))
|
||||
})
|
||||
.await
|
||||
let version = Version::new(brk_oracle::VERSION);
|
||||
|
||||
match path.resolve() {
|
||||
Ok(HeightOrDate::Date(date)) => {
|
||||
let strategy = state.date_strategy(version, date);
|
||||
state
|
||||
.respond_json(&headers, strategy, &uri, move |q| {
|
||||
q.confirmed_histogram_raw_day(Day1::try_from(date)?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
Ok(HeightOrDate::Height(height)) => {
|
||||
let strategy = state.height_strategy(version, height);
|
||||
state
|
||||
.respond_json(&headers, strategy, &uri, move |q| {
|
||||
q.confirmed_histogram_raw(usize::from(height))
|
||||
})
|
||||
.await
|
||||
}
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
},
|
||||
|op| {
|
||||
op.id("get_oracle_histogram_raw")
|
||||
.oracle_tag()
|
||||
.summary("Raw histogram at height")
|
||||
.summary("Raw histogram at height or day")
|
||||
.description(
|
||||
"Un-smoothed round-dollar counts for a single confirmed block. A \
|
||||
flat array of log-scale bins.",
|
||||
"Unfiltered output histogram for a confirmed point: a block height \
|
||||
(`840000`) gives that block's outputs, coinbase included, binned by \
|
||||
value with no payment filtering; a calendar date (`YYYY-MM-DD`) sums \
|
||||
every block that day. A flat array of log-scale bins.",
|
||||
)
|
||||
.json_response::<HistogramRaw>()
|
||||
.not_modified()
|
||||
|
||||
@@ -16,7 +16,8 @@ use brk_query::{Query as BrkQuery, ResolvedQuery};
|
||||
use brk_traversable::TreeNode;
|
||||
use brk_types::{
|
||||
DataRangeFormat, Format, IndexInfo, Output, PaginatedSeries, Pagination, SearchQuery,
|
||||
SeriesCount, SeriesData, SeriesInfo, SeriesNameWithIndex, SeriesSelection,
|
||||
SeriesCount, SeriesData, SeriesInfo, SeriesNameWithIndex, SeriesOutput, SeriesSelection,
|
||||
Version,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -67,7 +68,7 @@ pub(super) async fn serve(
|
||||
.await)
|
||||
}
|
||||
|
||||
fn output_to_bytes(out: brk_types::SeriesOutput) -> BrkResult<Bytes> {
|
||||
fn output_to_bytes(out: SeriesOutput) -> BrkResult<Bytes> {
|
||||
Ok(match out.output {
|
||||
Output::CSV(s) => Bytes::from(s),
|
||||
Output::Json(v) => Bytes::from(v),
|
||||
@@ -365,7 +366,7 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
|
||||
.series_tag()
|
||||
.summary("Get series version")
|
||||
.description("Returns the current version of a series. Changes when the series data is updated.")
|
||||
.json_response::<brk_types::Version>()
|
||||
.json_response::<Version>()
|
||||
.not_modified()
|
||||
.not_found(),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
use brk_types::{Date, Height};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::Error;
|
||||
|
||||
/// Path parameter accepting either a block height (`840000`) or a calendar date
|
||||
/// (`YYYY-MM-DD`). The handler resolves it and dispatches to the per-height or
|
||||
/// per-day variant, choosing the matching cache strategy.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
pub struct HeightOrDateParam {
|
||||
#[schemars(example = &"840000")]
|
||||
pub point: String,
|
||||
}
|
||||
|
||||
/// A resolved [`HeightOrDateParam`]: a confirmed block height or a calendar day.
|
||||
pub enum HeightOrDate {
|
||||
Height(Height),
|
||||
Date(Date),
|
||||
}
|
||||
|
||||
impl HeightOrDateParam {
|
||||
/// Parses the raw `point`: a `YYYY-MM-DD` string is a [`Date`], an all-digit
|
||||
/// string is a [`Height`], anything else is a 400. Dates are tried first
|
||||
/// because their dashes keep them from parsing as a height.
|
||||
pub fn resolve(&self) -> Result<HeightOrDate, Error> {
|
||||
if let Ok(date) = self.point.parse::<Date>() {
|
||||
Ok(HeightOrDate::Date(date))
|
||||
} else if let Ok(height) = self.point.parse::<usize>() {
|
||||
Ok(HeightOrDate::Height(Height::from(height)))
|
||||
} else {
|
||||
Err(Error::bad_request(format!(
|
||||
"expected a block height or YYYY-MM-DD date, got `{}`",
|
||||
self.point
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod blockhash_param;
|
||||
mod blockhash_start_index;
|
||||
mod blockhash_tx_index;
|
||||
mod empty;
|
||||
mod height_or_date_param;
|
||||
mod height_param;
|
||||
mod next_block_hash_param;
|
||||
mod pool_slug_param;
|
||||
@@ -25,6 +26,7 @@ pub use blockhash_param::*;
|
||||
pub use blockhash_start_index::*;
|
||||
pub use blockhash_tx_index::*;
|
||||
pub use empty::*;
|
||||
pub use height_or_date_param::*;
|
||||
pub use height_param::*;
|
||||
pub use next_block_hash_param::*;
|
||||
pub use pool_slug_param::*;
|
||||
|
||||
@@ -112,4 +112,11 @@ impl<T: JsonSchema, const N: usize> JsonSchema for Histogram<T, N> {
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> schemars::Schema {
|
||||
Vec::<T>::json_schema(generator)
|
||||
}
|
||||
|
||||
/// Inline as a plain array rather than registering a named `Histogram_uintN`
|
||||
/// component: the wire shape is just a flat array of counts, and the synthetic
|
||||
/// generic-mangled name has no real type for the Rust client to resolve to.
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user