mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-09 22:43:33 -07:00
global: snapshot
This commit is contained in:
Generated
+32
-20
@@ -457,6 +457,7 @@ dependencies = [
|
||||
"brk_indexer",
|
||||
"brk_iterator",
|
||||
"brk_logger",
|
||||
"brk_oracle",
|
||||
"brk_reader",
|
||||
"brk_rpc",
|
||||
"brk_store",
|
||||
@@ -464,6 +465,7 @@ dependencies = [
|
||||
"brk_types",
|
||||
"color-eyre",
|
||||
"derive_more",
|
||||
"pco",
|
||||
"rayon",
|
||||
"rustc-hash",
|
||||
"schemars",
|
||||
@@ -562,6 +564,16 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brk_oracle"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"brk_indexer",
|
||||
"brk_types",
|
||||
"serde_json",
|
||||
"vecdb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brk_query"
|
||||
version = "0.1.7"
|
||||
@@ -1030,9 +1042,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.5.1"
|
||||
version = "3.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790"
|
||||
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"nix",
|
||||
@@ -1845,9 +1857,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.19"
|
||||
version = "0.2.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89a5b5e10d5a9ad6e5d1f4bd58225f655d6fe9767575a5e8ac5a6fe64e04495"
|
||||
checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
@@ -1859,9 +1871,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.19"
|
||||
version = "0.2.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff7a39c8862fc1369215ccf0a8f12dd4598c7f6484704359f0351bd617034dbf"
|
||||
checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1926,9 +1938,9 @@ checksum = "9fa0e2a1fcbe2f6be6c42e342259976206b383122fc152e872795338b5a3f3a7"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.181"
|
||||
version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@@ -2123,9 +2135,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
version = "0.31.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
@@ -2965,9 +2977,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.114"
|
||||
version = "2.0.115"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3083,9 +3095,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.11+spec-1.1.0"
|
||||
version = "1.0.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
|
||||
checksum = "bbe30f93627849fa362d4a602212d41bb237dc2bd0f8ba0b2ce785012e124220"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
@@ -3098,18 +3110,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
version = "1.0.0+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
version = "1.0.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
|
||||
checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
@@ -3947,9 +3959,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.20"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
|
||||
+2
-1
@@ -52,6 +52,7 @@ brk_fetcher = { version = "0.1.7", path = "crates/brk_fetcher" }
|
||||
brk_indexer = { version = "0.1.7", path = "crates/brk_indexer" }
|
||||
brk_iterator = { version = "0.1.7", path = "crates/brk_iterator" }
|
||||
brk_logger = { version = "0.1.7", path = "crates/brk_logger" }
|
||||
brk_oracle = { version = "0.1.7", path = "crates/brk_oracle" }
|
||||
brk_mempool = { version = "0.1.7", path = "crates/brk_mempool" }
|
||||
brk_query = { version = "0.1.7", path = "crates/brk_query", features = ["tokio"] }
|
||||
brk_reader = { version = "0.1.7", path = "crates/brk_reader" }
|
||||
@@ -67,7 +68,7 @@ color-eyre = "0.6.5"
|
||||
derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] }
|
||||
fjall = "3.0.1"
|
||||
indexmap = { version = "2.13.0", features = ["serde"] }
|
||||
jiff = { version = "0.2.19", features = ["perf-inline", "tz-system"], default-features = false }
|
||||
jiff = { version = "0.2.20", features = ["perf-inline", "tz-system"], default-features = false }
|
||||
minreq = { version = "2.14.1", features = ["https", "json-using-serde"] }
|
||||
owo-colors = "4.2.3"
|
||||
parking_lot = "0.12.5"
|
||||
|
||||
@@ -27,7 +27,7 @@ owo-colors = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
toml = "0.9.11"
|
||||
toml = "1.0.1"
|
||||
vecdb = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -5078,6 +5078,7 @@ pub struct MetricsTree_Price {
|
||||
pub cents: MetricsTree_Price_Cents,
|
||||
pub usd: MetricsTree_Price_Usd,
|
||||
pub sats: OhlcSplitPattern2<OHLCSats>,
|
||||
pub oracle: MetricsTree_Price_Oracle,
|
||||
}
|
||||
|
||||
impl MetricsTree_Price {
|
||||
@@ -5086,6 +5087,7 @@ impl MetricsTree_Price {
|
||||
cents: MetricsTree_Price_Cents::new(client.clone(), format!("{base_path}_cents")),
|
||||
usd: MetricsTree_Price_Usd::new(client.clone(), format!("{base_path}_usd")),
|
||||
sats: OhlcSplitPattern2::new(client.clone(), "price".to_string()),
|
||||
oracle: MetricsTree_Price_Oracle::new(client.clone(), format!("{base_path}_oracle")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5139,6 +5141,23 @@ impl MetricsTree_Price_Usd {
|
||||
}
|
||||
}
|
||||
|
||||
/// Metrics tree node.
|
||||
pub struct MetricsTree_Price_Oracle {
|
||||
pub price_cents: MetricPattern11<CentsUnsigned>,
|
||||
pub ohlc_cents: MetricPattern6<OHLCCentsUnsigned>,
|
||||
pub ohlc_dollars: MetricPattern6<OHLCDollars>,
|
||||
}
|
||||
|
||||
impl MetricsTree_Price_Oracle {
|
||||
pub fn new(client: Arc<BrkClientBase>, base_path: String) -> Self {
|
||||
Self {
|
||||
price_cents: MetricPattern11::new(client.clone(), "oracle_price_cents".to_string()),
|
||||
ohlc_cents: MetricPattern6::new(client.clone(), "oracle_ohlc_cents".to_string()),
|
||||
ohlc_dollars: MetricPattern6::new(client.clone(), "oracle_ohlc_dollars".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Metrics tree node.
|
||||
pub struct MetricsTree_Distribution {
|
||||
pub supply_state: MetricPattern11<SupplyState>,
|
||||
@@ -5998,6 +6017,9 @@ pub struct MetricsTree_Supply {
|
||||
pub inflation: MetricPattern4<StoredF32>,
|
||||
pub velocity: MetricsTree_Supply_Velocity,
|
||||
pub market_cap: MetricPattern1<Dollars>,
|
||||
pub market_cap_growth_rate: MetricPattern4<StoredF32>,
|
||||
pub realized_cap_growth_rate: MetricPattern4<StoredF32>,
|
||||
pub cap_growth_rate_diff: MetricPattern6<StoredF32>,
|
||||
}
|
||||
|
||||
impl MetricsTree_Supply {
|
||||
@@ -6008,6 +6030,9 @@ impl MetricsTree_Supply {
|
||||
inflation: MetricPattern4::new(client.clone(), "inflation_rate".to_string()),
|
||||
velocity: MetricsTree_Supply_Velocity::new(client.clone(), format!("{base_path}_velocity")),
|
||||
market_cap: MetricPattern1::new(client.clone(), "market_cap".to_string()),
|
||||
market_cap_growth_rate: MetricPattern4::new(client.clone(), "market_cap_growth_rate".to_string()),
|
||||
realized_cap_growth_rate: MetricPattern4::new(client.clone(), "realized_cap_growth_rate".to_string()),
|
||||
cap_growth_rate_diff: MetricPattern6::new(client.clone(), "cap_growth_rate_diff".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6291,6 +6316,15 @@ impl BrkClient {
|
||||
self.base.get_json(&format!("/api/mempool/info"))
|
||||
}
|
||||
|
||||
/// Live BTC/USD price
|
||||
///
|
||||
/// Returns the current BTC/USD price in cents, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool.
|
||||
///
|
||||
/// Endpoint: `GET /api/mempool/price`
|
||||
pub fn get_live_price(&self) -> Result<f64> {
|
||||
self.base.get_json(&format!("/api/mempool/price"))
|
||||
}
|
||||
|
||||
/// Mempool transaction IDs
|
||||
///
|
||||
/// Get all transaction IDs currently in the mempool.
|
||||
|
||||
@@ -13,6 +13,7 @@ brk_error = { workspace = true, features = ["vecdb"] }
|
||||
brk_fetcher = { workspace = true }
|
||||
brk_cohort = { workspace = true }
|
||||
brk_indexer = { workspace = true }
|
||||
brk_oracle = { workspace = true }
|
||||
brk_iterator = { workspace = true }
|
||||
brk_logger = { workspace = true }
|
||||
brk_reader = { workspace = true }
|
||||
@@ -21,6 +22,7 @@ brk_store = { workspace = true }
|
||||
brk_traversable = { workspace = true }
|
||||
brk_types = { workspace = true }
|
||||
derive_more = { workspace = true }
|
||||
pco = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
rustc-hash = { workspace = true }
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
oracle
|
||||
@@ -6,7 +6,6 @@ use super::Vecs;
|
||||
use crate::{indexes, ComputeIndexes};
|
||||
|
||||
impl Vecs {
|
||||
#[allow(unused_variables)]
|
||||
pub fn compute(
|
||||
&mut self,
|
||||
indexer: &Indexer,
|
||||
@@ -18,18 +17,8 @@ impl Vecs {
|
||||
|
||||
self.sats.compute(starting_indexes, &self.usd, exit)?;
|
||||
|
||||
// Oracle price computation is slow and still WIP, only run in dev builds
|
||||
// #[cfg(debug_assertions)]
|
||||
// {
|
||||
// use std::time::Instant;
|
||||
// use tracing::info;
|
||||
//
|
||||
// info!("Computing oracle prices...");
|
||||
// let i = Instant::now();
|
||||
// self.oracle
|
||||
// .compute(indexer, indexes, &self.cents, starting_indexes, exit)?;
|
||||
// info!("Computed oracle prices in {:?}", i.elapsed());
|
||||
// }
|
||||
self.oracle
|
||||
.compute(indexer, indexes, starting_indexes, exit)?;
|
||||
|
||||
let _lock = exit.lock();
|
||||
self.db().compact()?;
|
||||
|
||||
@@ -2,12 +2,12 @@ mod compute;
|
||||
mod fetch;
|
||||
|
||||
pub mod cents;
|
||||
// pub mod oracle;
|
||||
pub mod oracle;
|
||||
pub mod sats;
|
||||
pub mod usd;
|
||||
|
||||
pub use cents::Vecs as CentsVecs;
|
||||
// pub use oracle::Vecs as OracleVecs;
|
||||
pub use oracle::Vecs as OracleVecs;
|
||||
pub use sats::Vecs as SatsVecs;
|
||||
pub use usd::Vecs as UsdVecs;
|
||||
|
||||
@@ -33,7 +33,7 @@ pub struct Vecs {
|
||||
pub cents: CentsVecs,
|
||||
pub usd: UsdVecs,
|
||||
pub sats: SatsVecs,
|
||||
// pub oracle: OracleVecs,
|
||||
pub oracle: OracleVecs,
|
||||
}
|
||||
|
||||
impl Vecs {
|
||||
@@ -67,7 +67,7 @@ impl Vecs {
|
||||
let cents = CentsVecs::forced_import(db, version)?;
|
||||
let usd = UsdVecs::forced_import(db, version, indexes)?;
|
||||
let sats = SatsVecs::forced_import(db, version, indexes)?;
|
||||
// let oracle = OracleVecs::forced_import(db, version)?;
|
||||
let oracle = OracleVecs::forced_import(db, version)?;
|
||||
|
||||
Ok(Self {
|
||||
db: db.clone(),
|
||||
@@ -75,7 +75,7 @@ impl Vecs {
|
||||
cents,
|
||||
usd,
|
||||
sats,
|
||||
// oracle,
|
||||
oracle,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use brk_error::Result;
|
||||
use brk_indexer::Indexer;
|
||||
use brk_oracle::{Config, Oracle, START_HEIGHT, bin_to_cents, cents_to_bin};
|
||||
use brk_types::{
|
||||
CentsUnsigned, Close, DateIndex, Height, High, Low, OHLCCentsUnsigned, Open, OutputType, Sats,
|
||||
TxIndex, TxOutIndex,
|
||||
};
|
||||
use tracing::info;
|
||||
use vecdb::{
|
||||
AnyStoredVec, AnyVec, Exit, GenericStoredVec, IterableVec, TypedVecIterator, VecIndex,
|
||||
VecIterator,
|
||||
};
|
||||
|
||||
use super::Vecs;
|
||||
use crate::{ComputeIndexes, indexes};
|
||||
|
||||
impl Vecs {
|
||||
pub fn compute(
|
||||
&mut self,
|
||||
indexer: &Indexer,
|
||||
indexes: &indexes::Vecs,
|
||||
starting_indexes: &ComputeIndexes,
|
||||
exit: &Exit,
|
||||
) -> Result<()> {
|
||||
self.compute_prices(indexer, starting_indexes, exit)?;
|
||||
self.compute_daily_ohlc(indexes, starting_indexes, exit)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compute_prices(
|
||||
&mut self,
|
||||
indexer: &Indexer,
|
||||
starting_indexes: &ComputeIndexes,
|
||||
exit: &Exit,
|
||||
) -> Result<()> {
|
||||
let source_version =
|
||||
indexer.vecs.outputs.value.version() + indexer.vecs.outputs.outputtype.version();
|
||||
self.price_cents
|
||||
.validate_computed_version_or_reset(source_version)?;
|
||||
|
||||
let total_heights = indexer.vecs.blocks.timestamp.len();
|
||||
|
||||
if total_heights <= START_HEIGHT {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Reorg: truncate to starting_indexes
|
||||
let truncate_to = self
|
||||
.price_cents
|
||||
.len()
|
||||
.min(starting_indexes.height.to_usize());
|
||||
self.price_cents.truncate_if_needed_at(truncate_to)?;
|
||||
|
||||
if self.price_cents.len() < START_HEIGHT {
|
||||
for line in brk_oracle::PRICES.lines().skip(self.price_cents.len()) {
|
||||
if self.price_cents.len() >= START_HEIGHT {
|
||||
break;
|
||||
}
|
||||
let dollars: f64 = line.parse().unwrap_or(0.0);
|
||||
let cents = (dollars * 100.0).round() as u64;
|
||||
self.price_cents.push(CentsUnsigned::new(cents));
|
||||
}
|
||||
}
|
||||
|
||||
if self.price_cents.len() >= total_heights {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config = Config::default();
|
||||
let committed = self.price_cents.len();
|
||||
let prev_cents = self.price_cents
|
||||
.iter()?
|
||||
.get(Height::from(committed - 1))
|
||||
.unwrap();
|
||||
let seed_bin = cents_to_bin(prev_cents.inner() as f64);
|
||||
let warmup = config.window_size.min(committed - START_HEIGHT);
|
||||
let mut oracle = Oracle::from_checkpoint(seed_bin, config, |o| {
|
||||
Self::feed_blocks(o, indexer, (committed - warmup)..committed);
|
||||
});
|
||||
|
||||
let num_new = total_heights - committed;
|
||||
info!(
|
||||
"Computing oracle prices: {} to {} ({warmup} warmup)",
|
||||
committed, total_heights
|
||||
);
|
||||
|
||||
let ref_bins = Self::feed_blocks(&mut oracle, indexer, committed..total_heights);
|
||||
|
||||
for (i, ref_bin) in ref_bins.into_iter().enumerate() {
|
||||
self.price_cents.push(CentsUnsigned::new(bin_to_cents(ref_bin)));
|
||||
|
||||
let progress = ((i + 1) * 100 / num_new) as u8;
|
||||
if i > 0 && progress > ((i * 100 / num_new) as u8) {
|
||||
info!("Oracle price computation: {}%", progress);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let _lock = exit.lock();
|
||||
self.price_cents.write()?;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Oracle prices complete: {} committed",
|
||||
self.price_cents.len()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns an Oracle seeded from the last committed price, with the last
|
||||
/// window_size blocks already processed. Ready for additional blocks (e.g. mempool).
|
||||
pub fn live_oracle(&self, indexer: &Indexer) -> Result<Oracle> {
|
||||
let config = Config::default();
|
||||
let height = indexer.vecs.blocks.timestamp.len();
|
||||
let last_cents = self.price_cents
|
||||
.iter()?
|
||||
.get(Height::from(self.price_cents.len() - 1))
|
||||
.unwrap();
|
||||
let seed_bin = cents_to_bin(last_cents.inner() as f64);
|
||||
let window_size = config.window_size;
|
||||
let oracle = Oracle::from_checkpoint(seed_bin, config, |o| {
|
||||
Self::feed_blocks(o, indexer, height.saturating_sub(window_size)..height);
|
||||
});
|
||||
|
||||
Ok(oracle)
|
||||
}
|
||||
|
||||
/// Feed a range of blocks from the indexer into an Oracle (skipping coinbase),
|
||||
/// returning per-block ref_bin values.
|
||||
fn feed_blocks(oracle: &mut Oracle, indexer: &Indexer, range: Range<usize>) -> Vec<f64> {
|
||||
let total_txs = indexer.vecs.transactions.height.len();
|
||||
let total_outputs = indexer.vecs.outputs.value.len();
|
||||
|
||||
let mut first_txindex_iter = indexer.vecs.transactions.first_txindex.into_iter();
|
||||
let mut first_txoutindex_iter = indexer.vecs.transactions.first_txoutindex.into_iter();
|
||||
let mut out_first_iter = indexer.vecs.outputs.first_txoutindex.into_iter();
|
||||
let mut value_iter = indexer.vecs.outputs.value.into_iter();
|
||||
let mut outputtype_iter = indexer.vecs.outputs.outputtype.into_iter();
|
||||
|
||||
let mut block_outputs: Vec<(Sats, OutputType)> = Vec::new();
|
||||
let mut ref_bins = Vec::with_capacity(range.len());
|
||||
|
||||
for h in range {
|
||||
let first_txindex: TxIndex = first_txindex_iter.get_at_unwrap(h);
|
||||
let next_first_txindex = first_txindex_iter
|
||||
.get_at(h + 1)
|
||||
.unwrap_or(TxIndex::from(total_txs));
|
||||
|
||||
let out_start = if first_txindex.to_usize() + 1 < next_first_txindex.to_usize() {
|
||||
first_txoutindex_iter
|
||||
.get_at_unwrap(first_txindex.to_usize() + 1)
|
||||
.to_usize()
|
||||
} else {
|
||||
out_first_iter
|
||||
.get_at(h + 1)
|
||||
.unwrap_or(TxOutIndex::from(total_outputs))
|
||||
.to_usize()
|
||||
};
|
||||
let out_end = out_first_iter
|
||||
.get_at(h + 1)
|
||||
.unwrap_or(TxOutIndex::from(total_outputs))
|
||||
.to_usize();
|
||||
|
||||
block_outputs.clear();
|
||||
for i in out_start..out_end {
|
||||
block_outputs.push((
|
||||
value_iter.get_at_unwrap(i),
|
||||
outputtype_iter.get_at_unwrap(i),
|
||||
));
|
||||
}
|
||||
|
||||
ref_bins.push(oracle.process_outputs(block_outputs.iter().copied()));
|
||||
}
|
||||
|
||||
ref_bins
|
||||
}
|
||||
|
||||
fn compute_daily_ohlc(
|
||||
&mut self,
|
||||
indexes: &indexes::Vecs,
|
||||
starting_indexes: &ComputeIndexes,
|
||||
exit: &Exit,
|
||||
) -> Result<()> {
|
||||
let last_dateindex = DateIndex::from(indexes.dateindex.date.len());
|
||||
let start_dateindex = starting_indexes
|
||||
.dateindex
|
||||
.min(DateIndex::from(self.ohlc_cents.len()));
|
||||
|
||||
if start_dateindex >= last_dateindex {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let last_height = Height::from(self.price_cents.len());
|
||||
let mut height_to_price_iter = self.price_cents.iter()?;
|
||||
let mut dateindex_to_first_height_iter = indexes.dateindex.first_height.iter();
|
||||
let mut height_count_iter = indexes.dateindex.height_count.iter();
|
||||
|
||||
for dateindex_usize in start_dateindex.to_usize()..last_dateindex.to_usize() {
|
||||
let dateindex = DateIndex::from(dateindex_usize);
|
||||
let first_height = dateindex_to_first_height_iter.get_unwrap(dateindex);
|
||||
let count = height_count_iter.get_unwrap(dateindex);
|
||||
|
||||
if *count == 0 || first_height >= last_height {
|
||||
self.ohlc_cents
|
||||
.truncate_push(dateindex, self.previous_ohlc(dateindex)?)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let count = *count as usize;
|
||||
let mut open = None;
|
||||
let mut high = CentsUnsigned::ZERO;
|
||||
let mut low = CentsUnsigned::MAX;
|
||||
let mut close = CentsUnsigned::ZERO;
|
||||
|
||||
for i in 0..count {
|
||||
let height = first_height + Height::from(i);
|
||||
if height >= last_height {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(price) = height_to_price_iter.get(height) {
|
||||
if price == CentsUnsigned::ZERO {
|
||||
continue;
|
||||
}
|
||||
if open.is_none() {
|
||||
open = Some(price);
|
||||
}
|
||||
if price > high {
|
||||
high = price;
|
||||
}
|
||||
if price < low {
|
||||
low = price;
|
||||
}
|
||||
close = price;
|
||||
}
|
||||
}
|
||||
|
||||
let ohlc = if let Some(open_price) = open {
|
||||
OHLCCentsUnsigned {
|
||||
open: Open::new(open_price),
|
||||
high: High::new(high),
|
||||
low: Low::new(low),
|
||||
close: Close::new(close),
|
||||
}
|
||||
} else {
|
||||
self.previous_ohlc(dateindex)?
|
||||
};
|
||||
|
||||
self.ohlc_cents.truncate_push(dateindex, ohlc)?;
|
||||
}
|
||||
|
||||
{
|
||||
let _lock = exit.lock();
|
||||
self.ohlc_cents.write()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn previous_ohlc(&self, dateindex: DateIndex) -> Result<OHLCCentsUnsigned> {
|
||||
Ok(if dateindex > DateIndex::from(0usize) {
|
||||
self.ohlc_cents
|
||||
.iter()?
|
||||
.get(dateindex.decremented().unwrap())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
OHLCCentsUnsigned::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use brk_error::Result;
|
||||
use brk_types::{DateIndex, OHLCCentsUnsigned, OHLCDollars, Version};
|
||||
use vecdb::{BytesVec, Database, ImportableVec, IterableCloneableVec, LazyVecFrom1, PcoVec};
|
||||
|
||||
use super::Vecs;
|
||||
|
||||
impl Vecs {
|
||||
pub fn forced_import(db: &Database, parent_version: Version) -> Result<Self> {
|
||||
let version = parent_version + Version::new(10);
|
||||
|
||||
let price_cents = PcoVec::forced_import(db, "oracle_price_cents", version)?;
|
||||
let ohlc_cents = BytesVec::forced_import(db, "oracle_ohlc_cents", version)?;
|
||||
|
||||
let ohlc_dollars = LazyVecFrom1::init(
|
||||
"oracle_ohlc_dollars",
|
||||
version,
|
||||
ohlc_cents.boxed_clone(),
|
||||
|di: DateIndex, iter| {
|
||||
iter.get(di)
|
||||
.map(|o: OHLCCentsUnsigned| OHLCDollars::from(o))
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
price_cents,
|
||||
ohlc_cents,
|
||||
ohlc_dollars,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use brk_traversable::Traversable;
|
||||
use brk_types::{CentsUnsigned, DateIndex, Height, OHLCCentsUnsigned, OHLCDollars};
|
||||
use vecdb::{BytesVec, LazyVecFrom1, PcoVec};
|
||||
|
||||
#[derive(Clone, Traversable)]
|
||||
pub struct Vecs {
|
||||
pub price_cents: PcoVec<Height, CentsUnsigned>,
|
||||
pub ohlc_cents: BytesVec<DateIndex, OHLCCentsUnsigned>,
|
||||
pub ohlc_dollars: LazyVecFrom1<DateIndex, OHLCDollars, DateIndex, OHLCCentsUnsigned>,
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
use brk_types::Version;
|
||||
|
||||
use super::Vecs;
|
||||
use crate::{
|
||||
distribution,
|
||||
internal::{DollarsIdentity, LazyValueFromHeightLast, SatsIdentity},
|
||||
};
|
||||
|
||||
impl Vecs {
|
||||
pub fn import(version: Version, distribution: &distribution::Vecs) -> Self {
|
||||
let supply_metrics = &distribution.utxo_cohorts.all.metrics.supply;
|
||||
|
||||
Self(LazyValueFromHeightLast::from_block_source::<
|
||||
SatsIdentity,
|
||||
DollarsIdentity,
|
||||
>(
|
||||
"circulating_supply", &supply_metrics.total, version)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
mod import;
|
||||
mod vecs;
|
||||
|
||||
pub use vecs::Vecs;
|
||||
@@ -1,8 +0,0 @@
|
||||
use brk_traversable::Traversable;
|
||||
use derive_more::{Deref, DerefMut};
|
||||
|
||||
use crate::internal::LazyValueFromHeightLast;
|
||||
|
||||
/// Circulating supply - lazy references to distribution's actual supply (KISS)
|
||||
#[derive(Clone, Deref, DerefMut, Traversable)]
|
||||
pub struct Vecs(pub LazyValueFromHeightLast);
|
||||
@@ -2,7 +2,7 @@ use brk_error::Result;
|
||||
use vecdb::Exit;
|
||||
|
||||
use super::Vecs;
|
||||
use crate::{blocks, distribution, indexes, scripts, transactions, ComputeIndexes};
|
||||
use crate::{ComputeIndexes, blocks, distribution, indexes, scripts, transactions};
|
||||
|
||||
impl Vecs {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -20,15 +20,60 @@ impl Vecs {
|
||||
self.burned
|
||||
.compute(indexes, scripts, blocks, starting_indexes, exit)?;
|
||||
|
||||
// 2. Compute inflation rate
|
||||
self.inflation
|
||||
.compute(blocks, distribution, starting_indexes, exit)?;
|
||||
// 2. Compute inflation rate: daily_subsidy / circulating_supply * 365 * 100
|
||||
let circulating_supply = &distribution.utxo_cohorts.all.metrics.supply.total.sats;
|
||||
self.inflation.compute_all(starting_indexes, exit, |v| {
|
||||
v.compute_transform2(
|
||||
starting_indexes.dateindex,
|
||||
&blocks.rewards.subsidy.sats.dateindex.sum_cum.sum.0,
|
||||
&circulating_supply.dateindex.0,
|
||||
|(i, subsidy_1d_sum, supply, ..)| {
|
||||
let inflation = if *supply > 0 {
|
||||
365.0 * *subsidy_1d_sum as f64 / *supply as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(i, inflation.into())
|
||||
},
|
||||
exit,
|
||||
)?;
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
// 3. Compute velocity
|
||||
self.velocity
|
||||
.compute(transactions, distribution, starting_indexes, exit)?;
|
||||
|
||||
// Note: circulating and market_cap are lazy - no compute needed
|
||||
// 4. Compute cap growth rates
|
||||
if let Some(market_cap) = self.market_cap.as_ref() {
|
||||
let mcap_dateindex = &market_cap.dateindex.0;
|
||||
self.market_cap_growth_rate
|
||||
.compute_all(starting_indexes, exit, |vec| {
|
||||
vec.compute_percentage_change(
|
||||
starting_indexes.dateindex,
|
||||
mcap_dateindex,
|
||||
30,
|
||||
exit,
|
||||
)?;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Some(realized) = distribution.utxo_cohorts.all.metrics.realized.as_ref() {
|
||||
let rcap_dateindex = &realized.realized_cap.dateindex.0;
|
||||
self.realized_cap_growth_rate
|
||||
.compute_all(starting_indexes, exit, |vec| {
|
||||
vec.compute_percentage_change(
|
||||
starting_indexes.dateindex,
|
||||
rcap_dateindex,
|
||||
30,
|
||||
exit,
|
||||
)?;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
// Note: circulating, market_cap, cap_growth_rate_diff are lazy
|
||||
|
||||
let _lock = exit.lock();
|
||||
self.db.compact()?;
|
||||
|
||||
@@ -3,12 +3,18 @@ use std::path::Path;
|
||||
use brk_error::Result;
|
||||
use brk_traversable::Traversable;
|
||||
use brk_types::Version;
|
||||
use vecdb::{Database, PAGE_SIZE};
|
||||
use vecdb::{Database, IterableCloneableVec, LazyVecFrom2, PAGE_SIZE};
|
||||
|
||||
use super::Vecs;
|
||||
use crate::{distribution, indexes, price};
|
||||
use crate::{
|
||||
distribution, indexes, price,
|
||||
internal::{
|
||||
ComputedFromDateAverage, ComputedFromDateLast, DifferenceF32, DollarsIdentity,
|
||||
LazyFromHeightLast, LazyValueFromHeightLast, SatsIdentity,
|
||||
},
|
||||
};
|
||||
|
||||
const VERSION: Version = Version::ZERO;
|
||||
const VERSION: Version = Version::ONE;
|
||||
|
||||
impl Vecs {
|
||||
pub fn forced_import(
|
||||
@@ -24,21 +30,47 @@ impl Vecs {
|
||||
let version = parent_version + VERSION;
|
||||
let compute_dollars = price.is_some();
|
||||
|
||||
let supply_metrics = &distribution.utxo_cohorts.all.metrics.supply;
|
||||
|
||||
// Circulating supply - lazy refs to distribution
|
||||
let circulating = super::circulating::Vecs::import(version, distribution);
|
||||
let circulating = LazyValueFromHeightLast::from_block_source::<SatsIdentity, DollarsIdentity>(
|
||||
"circulating_supply",
|
||||
&supply_metrics.total,
|
||||
version,
|
||||
);
|
||||
|
||||
// Burned/unspendable supply - computed from scripts
|
||||
let burned = super::burned::Vecs::forced_import(&db, version, indexes, price)?;
|
||||
|
||||
// Inflation rate
|
||||
let inflation = super::inflation::Vecs::forced_import(&db, version, indexes)?;
|
||||
let inflation =
|
||||
ComputedFromDateAverage::forced_import(&db, "inflation_rate", version, indexes)?;
|
||||
|
||||
// Velocity
|
||||
let velocity =
|
||||
super::velocity::Vecs::forced_import(&db, version, indexes, compute_dollars)?;
|
||||
|
||||
// Market cap - lazy refs to supply in USD
|
||||
let market_cap = super::market_cap::Vecs::import(version, distribution);
|
||||
// Market cap - lazy identity from distribution supply in USD
|
||||
let market_cap = supply_metrics.total.dollars.as_ref().map(|d| {
|
||||
LazyFromHeightLast::from_lazy_binary_computed::<DollarsIdentity, _, _>(
|
||||
"market_cap",
|
||||
version,
|
||||
d.height.boxed_clone(),
|
||||
d,
|
||||
)
|
||||
});
|
||||
|
||||
// Growth rates
|
||||
let market_cap_growth_rate =
|
||||
ComputedFromDateLast::forced_import(&db, "market_cap_growth_rate", version, indexes)?;
|
||||
let realized_cap_growth_rate =
|
||||
ComputedFromDateLast::forced_import(&db, "realized_cap_growth_rate", version, indexes)?;
|
||||
let cap_growth_rate_diff = LazyVecFrom2::transformed::<DifferenceF32>(
|
||||
"cap_growth_rate_diff",
|
||||
version,
|
||||
market_cap_growth_rate.dateindex.boxed_clone(),
|
||||
realized_cap_growth_rate.dateindex.boxed_clone(),
|
||||
);
|
||||
|
||||
let this = Self {
|
||||
db,
|
||||
@@ -47,6 +79,9 @@ impl Vecs {
|
||||
inflation,
|
||||
velocity,
|
||||
market_cap,
|
||||
market_cap_growth_rate,
|
||||
realized_cap_growth_rate,
|
||||
cap_growth_rate_diff,
|
||||
};
|
||||
|
||||
this.db.retain_regions(
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
use brk_error::Result;
|
||||
use vecdb::Exit;
|
||||
|
||||
use super::Vecs;
|
||||
use crate::{ComputeIndexes, blocks, distribution};
|
||||
|
||||
impl Vecs {
|
||||
pub fn compute(
|
||||
&mut self,
|
||||
blocks: &blocks::Vecs,
|
||||
distribution: &distribution::Vecs,
|
||||
starting_indexes: &ComputeIndexes,
|
||||
exit: &Exit,
|
||||
) -> Result<()> {
|
||||
// inflation = daily_subsidy / circulating_supply * 365 * 100
|
||||
let circulating_supply = &distribution.utxo_cohorts.all.metrics.supply.total.sats;
|
||||
|
||||
self.compute_all(starting_indexes, exit, |v| {
|
||||
v.compute_transform2(
|
||||
starting_indexes.dateindex,
|
||||
&blocks.rewards.subsidy.sats.dateindex.sum_cum.sum.0,
|
||||
&circulating_supply.dateindex.0,
|
||||
|(i, subsidy_1d_sum, supply, ..)| {
|
||||
let inflation = if *supply > 0 {
|
||||
365.0 * *subsidy_1d_sum as f64 / *supply as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(i, inflation.into())
|
||||
},
|
||||
exit,
|
||||
)?;
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
use brk_error::Result;
|
||||
use brk_types::Version;
|
||||
use vecdb::Database;
|
||||
|
||||
use super::Vecs;
|
||||
use crate::{indexes, internal::ComputedFromDateAverage};
|
||||
|
||||
impl Vecs {
|
||||
pub fn forced_import(db: &Database, version: Version, indexes: &indexes::Vecs) -> Result<Self> {
|
||||
Ok(Self(ComputedFromDateAverage::forced_import(
|
||||
db,
|
||||
"inflation_rate",
|
||||
version,
|
||||
indexes,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
use brk_traversable::Traversable;
|
||||
use brk_types::StoredF32;
|
||||
use derive_more::{Deref, DerefMut};
|
||||
|
||||
use crate::internal::ComputedFromDateAverage;
|
||||
|
||||
/// Inflation rate metrics
|
||||
#[derive(Clone, Deref, DerefMut, Traversable)]
|
||||
#[traversable(transparent)]
|
||||
pub struct Vecs(pub ComputedFromDateAverage<StoredF32>);
|
||||
@@ -1,23 +0,0 @@
|
||||
use brk_types::Version;
|
||||
use vecdb::IterableCloneableVec;
|
||||
|
||||
use super::Vecs;
|
||||
use crate::{
|
||||
distribution,
|
||||
internal::{DollarsIdentity, LazyFromHeightLast},
|
||||
};
|
||||
|
||||
impl Vecs {
|
||||
pub fn import(version: Version, distribution: &distribution::Vecs) -> Option<Self> {
|
||||
let supply_metrics = &distribution.utxo_cohorts.all.metrics.supply;
|
||||
|
||||
supply_metrics.total.dollars.as_ref().map(|d| {
|
||||
Self(LazyFromHeightLast::from_lazy_binary_computed::<DollarsIdentity, _, _>(
|
||||
"market_cap",
|
||||
version,
|
||||
d.height.boxed_clone(),
|
||||
d,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
mod import;
|
||||
mod vecs;
|
||||
|
||||
pub use vecs::Vecs;
|
||||
@@ -1,8 +0,0 @@
|
||||
use brk_traversable::Traversable;
|
||||
use brk_types::Dollars;
|
||||
use derive_more::{Deref, DerefMut};
|
||||
|
||||
use crate::internal::LazyFromHeightLast;
|
||||
|
||||
#[derive(Clone, Deref, DerefMut, Traversable)]
|
||||
pub struct Vecs(pub LazyFromHeightLast<Dollars>);
|
||||
@@ -1,7 +1,4 @@
|
||||
pub mod burned;
|
||||
pub mod circulating;
|
||||
pub mod inflation;
|
||||
pub mod market_cap;
|
||||
pub mod velocity;
|
||||
|
||||
mod compute;
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
use brk_traversable::Traversable;
|
||||
use vecdb::Database;
|
||||
use brk_types::{DateIndex, Dollars, StoredF32};
|
||||
use vecdb::{Database, LazyVecFrom2};
|
||||
|
||||
use super::{burned, circulating, inflation, market_cap, velocity};
|
||||
use super::{burned, velocity};
|
||||
use crate::internal::{
|
||||
ComputedFromDateAverage, ComputedFromDateLast, LazyFromHeightLast, LazyValueFromHeightLast,
|
||||
};
|
||||
|
||||
/// Supply metrics module
|
||||
///
|
||||
/// This module owns all supply-related metrics:
|
||||
/// - circulating: Lazy references to distribution's actual circulating supply
|
||||
/// - burned: Cumulative opreturn and unspendable supply
|
||||
/// - inflation: Inflation rate derived from supply
|
||||
/// - velocity: BTC and USD velocity metrics
|
||||
/// - market_cap: Lazy references to supply in USD (circulating * price)
|
||||
#[derive(Clone, Traversable)]
|
||||
pub struct Vecs {
|
||||
#[traversable(skip)]
|
||||
pub(crate) db: Database,
|
||||
|
||||
pub circulating: circulating::Vecs,
|
||||
pub circulating: LazyValueFromHeightLast,
|
||||
pub burned: burned::Vecs,
|
||||
pub inflation: inflation::Vecs,
|
||||
pub inflation: ComputedFromDateAverage<StoredF32>,
|
||||
pub velocity: velocity::Vecs,
|
||||
pub market_cap: Option<market_cap::Vecs>,
|
||||
pub market_cap: Option<LazyFromHeightLast<Dollars>>,
|
||||
pub market_cap_growth_rate: ComputedFromDateLast<StoredF32>,
|
||||
pub realized_cap_growth_rate: ComputedFromDateLast<StoredF32>,
|
||||
pub cap_growth_rate_diff:
|
||||
LazyVecFrom2<DateIndex, StoredF32, DateIndex, StoredF32, DateIndex, StoredF32>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "brk_oracle"
|
||||
description = "Pure on-chain BTC/USD price oracle algorithm"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
brk_types = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
brk_indexer = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
vecdb = { workspace = true }
|
||||
@@ -0,0 +1,103 @@
|
||||
# brk_oracle
|
||||
|
||||
BTC/USD price oracle from on-chain Bitcoin data alone. No exchange feeds, no external APIs. Given an initial price estimate, tracks block by block from height 575,000 (May 2019) onward.
|
||||
|
||||
## The insight
|
||||
|
||||
When someone buys $100 of bitcoin at $50,000/BTC, the output is 200,000 sats. At $60,000 it would be 166,667 sats. Millions of round-dollar purchases happen every day at common amounts like $1, $5, $10, $20, $50, $100, $200, $500. Each amount creates its own spike in the histogram of transaction outputs, at a position that depends on the current price. As the price moves, all spikes shift together. The oracle finds those spikes and reads the price from their position.
|
||||
|
||||
## How it works
|
||||
|
||||
For each new block:
|
||||
|
||||
1. **Filter outputs.** Skip the coinbase transaction, then apply the configured filters: excluded script types, dust threshold, and round BTC exclusion.
|
||||
|
||||
2. **Map to bins.** Each output's satoshi value is placed into a log-scale histogram with 2,400 bins (200 per 10x): bin = round(log10(sats) * 200). Log-scale is key: if the price doubles, all spikes shift by 60 bins whether bitcoin goes from $1k to $2k or from $50k to $100k.
|
||||
|
||||
3. **Store in ring buffer.** The block histogram goes into a ring buffer of configurable depth. A single block is too sparse to get a clean signal, so the oracle accumulates several.
|
||||
|
||||
4. **Compute EMA.** The stored histograms are combined into a weighted average where recent blocks count more than older ones: weight = alpha * (1 - alpha)^age. Fully recomputed from the ring buffer each block.
|
||||
|
||||
5. **Score with stencil.** A 19-point stencil encodes where the spikes from round-dollar amounts ($1 through $10,000) should appear relative to each other. The oracle slides this stencil across the EMA histogram within a search window around the previous estimate. At each position, it reads the EMA value at each of the 19 expected spike locations, divides each by that offset's peak in the window, and sums them into a score. This gives every dollar amount, common or rare, an equal vote.
|
||||
|
||||
6. **Pick the best.** The position with the highest score is the new price estimate. Parabolic interpolation between neighbors refines it to fractional-bin precision.
|
||||
|
||||
The resulting bin converts to a dollar price: 10^(10 - bin/200). The search is bounded to prevent the stencil from matching at wrong price levels, so the oracle tracks incrementally block by block.
|
||||
|
||||
The oracle accepts three input formats: raw block data, an iterator of (sats, output type) pairs, or a pre-built histogram. Each call returns the current estimate as a fractional bin, convertible to cents or dollars. Daily candles can be built from the per-block prices.
|
||||
|
||||
The initial seed must be close to the real price at the starting height. The crate includes a PRICES constant with exchange prices for every height before 630,000 to derive a seed from.
|
||||
|
||||
## Config
|
||||
|
||||
All parameters are exposed via Config with sensible defaults:
|
||||
|
||||
- **alpha** (2/7): EMA decay rate, ~6-block span
|
||||
- **window_size** (12): number of block histograms in the ring buffer
|
||||
- **search_below / search_above** (9 / 11): how far to search around the previous estimate, in bins
|
||||
- **min_sats** (1,000): minimum output value, filters dust
|
||||
- **exclude_round_btc** (true): exclude round BTC amounts that create false stencil matches
|
||||
- **excluded_output_types** (P2TR, P2WSH): script types dominated by protocol activity, not round-dollar purchases
|
||||
|
||||
## Inspiration
|
||||
|
||||
Inspired by [UTXOracle](https://utxo.live/oracle/) by [@SteveSimple](https://x.com/SteveSimple), which showed that the BTC/USD price can be derived from on-chain data alone. brk_oracle takes the same core insight (round-dollar detection via log-scale histogram) and redesigns the algorithm for per-block resolution and rolling operation.
|
||||
|
||||
### Differences from UTXOracle
|
||||
|
||||
| | brk_oracle | UTXOracle |
|
||||
|---|---|---|
|
||||
| Resolution | Per-block (~10 min) and daily candles | Per-day |
|
||||
| Algorithm | Single-pass stencil scoring | Multi-step: rough stencil match, output-to-USD mapping, iterative median convergence |
|
||||
| Operation | Rolling EMA over configurable window | Stateless, processes a full day from scratch |
|
||||
| Stencil | 19 offsets with per-offset peak normalization | Gaussian smooth + empirically weighted spikes |
|
||||
| Round BTC handling | Excludes outputs entirely | Smooths histogram bins by averaging neighbors |
|
||||
| Output filtering | Script type, dust threshold, round BTC | 2-output txs only, input count limits, same-day exclusion, witness size limits |
|
||||
| Validated from | Height 575,000 (May 2019) | December 2023 |
|
||||
|
||||
Both use 200 bins per 10x on a log scale.
|
||||
|
||||
## Accuracy
|
||||
|
||||
Tested over 361,245 blocks (heights 575,000 to 936,244) against exchange OHLC data. Error is measured per block as the distance from the oracle's estimate to the exchange high-low range at that height. If the oracle falls within the range, the error is zero.
|
||||
|
||||
### Per-block
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Median error | 0.10% |
|
||||
| 95th percentile | 0.55% |
|
||||
| 99th percentile | 1.4% |
|
||||
| 99.9th percentile | 4.4% |
|
||||
| RMSE | 0.39% |
|
||||
| Max error | 18.2% |
|
||||
| Bias | +0.04 bins (essentially zero) |
|
||||
| Blocks > 5% error | 261 (0.07%) |
|
||||
| Blocks > 10% error | 40 (0.01%) |
|
||||
| Blocks > 20% error | 0 |
|
||||
|
||||
### Daily candles
|
||||
|
||||
Oracle daily OHLC built from per-block prices vs exchange daily OHLC:
|
||||
|
||||
| | Median | RMSE | Max |
|
||||
|-------|--------|------|-----|
|
||||
| Open | 0.20% | 0.49% | 5.9% |
|
||||
| High | 0.54% | 0.87% | 9.1% |
|
||||
| Low | 0.48% | 1.31% | 19.7% |
|
||||
| Close | 0.23% | 0.58% | 6.9% |
|
||||
|
||||
### By year
|
||||
|
||||
| Year | Blocks | Median | RMSE | Max | >5% | >10% | Price range |
|
||||
|------|--------|--------|------|-----|-----|------|-------------|
|
||||
| 2019 | 35,764 | 0.10% | 0.61% | 17.2% | 103 | 16 | $5,656–$13,868 |
|
||||
| 2020 | 53,102 | 0.10% | 0.48% | 18.2% | 85 | 15 | $3,858–$29,322 |
|
||||
| 2021 | 52,733 | 0.07% | 0.47% | 14.4% | 38 | 9 | $27,678–$69,000 |
|
||||
| 2022 | 53,230 | 0.07% | 0.32% | 6.8% | 10 | 0 | $15,460–$48,240 |
|
||||
| 2023 | 54,032 | 0.10% | 0.25% | 6.7% | 5 | 0 | $16,490–$44,700 |
|
||||
| 2024 | 53,367 | 0.11% | 0.31% | 9.7% | 16 | 0 | $38,555–$108,298 |
|
||||
| 2025 | 53,113 | 0.11% | 0.25% | 5.8% | 4 | 0 | $74,409–$126,198 |
|
||||
| 2026 | 5,904 | 0.11% | 0.27% | 3.3% | 0 | 0 | $60,000–$97,900 |
|
||||
|
||||
Accuracy improves over time as on-chain transaction volume grows. Since 2022, zero blocks exceed 10% error. All worst-case errors occur during the fastest intraday price moves in 2019–2021.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,460 @@
|
||||
//! 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, NUM_BINS, Oracle, PRICES, START_HEIGHT, bin_to_cents, cents_to_bin, sats_to_bin,
|
||||
};
|
||||
use brk_types::{OutputType, Sats, TxIndex, TxOutIndex};
|
||||
use vecdb::{AnyVec, VecIndex, VecIterator};
|
||||
|
||||
/// DateIndex 1 = Jan 9, 2009 (block 1). For dates after genesis week:
|
||||
/// dateindex = 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;
|
||||
|
||||
fn bins_to_pct(bins: f64) -> f64 {
|
||||
(10.0_f64.powf(bins / 200.0) - 1.0) * 100.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 {
|
||||
dateindex: 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 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 + dateindex mapping.
|
||||
let mut timestamp_iter = indexer.vecs.blocks.timestamp.into_iter();
|
||||
let mut height_years: Vec<u16> = Vec::with_capacity(total_heights);
|
||||
let mut height_dateindexes: Vec<usize> = Vec::with_capacity(total_heights);
|
||||
for h in 0..total_heights {
|
||||
let ts: brk_types::Timestamp = timestamp_iter.get_at_unwrap(h);
|
||||
let ts_u32 = *ts as u32;
|
||||
height_years.push(timestamp_to_year(ts_u32));
|
||||
height_dateindexes.push((ts_u32 / 86400).saturating_sub(GENESIS_DAY) as usize);
|
||||
}
|
||||
|
||||
let start_price: f64 = PRICES
|
||||
.lines()
|
||||
.nth(START_HEIGHT - 1)
|
||||
.expect("prices.txt too short")
|
||||
.parse()
|
||||
.expect("Failed to parse seed price");
|
||||
|
||||
let config = Config::default();
|
||||
let mut oracle = Oracle::new(cents_to_bin(start_price * 100.0), config);
|
||||
|
||||
let total_txs = indexer.vecs.transactions.height.len();
|
||||
let total_outputs = indexer.vecs.outputs.value.len();
|
||||
|
||||
let mut first_txindex_iter = indexer.vecs.transactions.first_txindex.into_iter();
|
||||
let mut first_txoutindex_iter = indexer.vecs.transactions.first_txoutindex.into_iter();
|
||||
let mut out_first_iter = indexer.vecs.outputs.first_txoutindex.into_iter();
|
||||
let mut value_iter = indexer.vecs.outputs.value.into_iter();
|
||||
let mut outputtype_iter = indexer.vecs.outputs.outputtype.into_iter();
|
||||
|
||||
let ref_config = Config::default();
|
||||
|
||||
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;
|
||||
|
||||
for h in START_HEIGHT..total_heights {
|
||||
let first_txindex: TxIndex = first_txindex_iter.get_at_unwrap(h);
|
||||
let next_first_txindex = first_txindex_iter
|
||||
.get_at(h + 1)
|
||||
.unwrap_or(TxIndex::from(total_txs));
|
||||
|
||||
let out_start = if first_txindex.to_usize() + 1 < next_first_txindex.to_usize() {
|
||||
first_txoutindex_iter
|
||||
.get_at_unwrap(first_txindex.to_usize() + 1)
|
||||
.to_usize()
|
||||
} else {
|
||||
out_first_iter
|
||||
.get_at(h + 1)
|
||||
.unwrap_or(TxOutIndex::from(total_outputs))
|
||||
.to_usize()
|
||||
};
|
||||
let out_end = out_first_iter
|
||||
.get_at(h + 1)
|
||||
.unwrap_or(TxOutIndex::from(total_outputs))
|
||||
.to_usize();
|
||||
|
||||
let mut hist = [0u32; NUM_BINS];
|
||||
for i in out_start..out_end {
|
||||
let sats: Sats = value_iter.get_at_unwrap(i);
|
||||
let output_type: OutputType = outputtype_iter.get_at_unwrap(i);
|
||||
if ref_config.excluded_output_types.contains(&output_type) {
|
||||
continue;
|
||||
}
|
||||
if *sats < ref_config.min_sats || (ref_config.exclude_round_btc && sats.is_round_btc())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(bin) = sats_to_bin(sats) {
|
||||
hist[bin] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let ref_bin = oracle.process_histogram(&hist);
|
||||
let oracle_price = bin_to_cents(ref_bin) as f64 / 100.0;
|
||||
|
||||
// Build oracle daily candle.
|
||||
let di = height_dateindexes[h];
|
||||
if current_di != Some(di) {
|
||||
current_di = Some(di);
|
||||
oracle_candles.push(DayCandle {
|
||||
dateindex: 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.dateindex;
|
||||
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 Vec<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)",
|
||||
START_HEIGHT,
|
||||
total_heights - 1,
|
||||
overall.total_blocks
|
||||
);
|
||||
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!();
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Validate oracle accuracy against exchange reference prices.
|
||||
//!
|
||||
//! Run with: cargo run -p brk_oracle --example validate --release
|
||||
//!
|
||||
//! Requires:
|
||||
//! - ~/.brk indexed blockchain data (brk_indexer)
|
||||
//! - examples/height_price_ohlc.json (per-height [open, high, low, close] in dollars)
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use brk_indexer::Indexer;
|
||||
use brk_oracle::{cents_to_bin, sats_to_bin, Config, Oracle, NUM_BINS, PRICES, START_HEIGHT};
|
||||
use brk_types::{OutputType, Sats, TxIndex, TxOutIndex};
|
||||
use vecdb::{AnyVec, VecIndex, VecIterator};
|
||||
|
||||
const BINS_5PCT: f64 = 4.24;
|
||||
const BINS_10PCT: f64 = 8.28;
|
||||
const BINS_20PCT: f64 = 15.84;
|
||||
|
||||
fn bins_to_pct(bins: f64) -> f64 {
|
||||
(10.0_f64.powf(bins / 200.0) - 1.0) * 100.0
|
||||
}
|
||||
|
||||
fn seed_bin(start_height: usize) -> f64 {
|
||||
let price: f64 = PRICES
|
||||
.lines()
|
||||
.nth(start_height - 1)
|
||||
.expect("prices.txt too short")
|
||||
.parse()
|
||||
.expect("Failed to parse seed price");
|
||||
cents_to_bin(price * 100.0)
|
||||
}
|
||||
|
||||
struct Stats {
|
||||
total_sq_err: f64,
|
||||
total_bias: f64,
|
||||
max_err: f64,
|
||||
total_blocks: u64,
|
||||
gt_5pct: u64,
|
||||
gt_10pct: u64,
|
||||
gt_20pct: u64,
|
||||
}
|
||||
|
||||
impl Stats {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
total_sq_err: 0.0,
|
||||
total_bias: 0.0,
|
||||
max_err: 0.0,
|
||||
total_blocks: 0,
|
||||
gt_5pct: 0,
|
||||
gt_10pct: 0,
|
||||
gt_20pct: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, err: f64) {
|
||||
self.total_sq_err += err * err;
|
||||
self.total_bias += err;
|
||||
self.total_blocks += 1;
|
||||
let abs_err = err.abs();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 bias(&self) -> f64 {
|
||||
self.total_bias / self.total_blocks as f64
|
||||
}
|
||||
}
|
||||
|
||||
struct Run {
|
||||
label: &'static str,
|
||||
start_height: usize,
|
||||
oracle: Option<Oracle>,
|
||||
stats: Stats,
|
||||
}
|
||||
|
||||
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 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");
|
||||
|
||||
// Pre-compute per-height (high_bin, low_bin) tolerance band.
|
||||
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();
|
||||
|
||||
let mut runs = vec![
|
||||
Run { label: "w12 @ 575k", start_height: 575_000, oracle: None, stats: Stats::new() },
|
||||
Run { label: "w12 @ 600k", start_height: 600_000, oracle: None, stats: Stats::new() },
|
||||
Run { label: "w12 @ 630k", start_height: 630_000, oracle: None, stats: Stats::new() },
|
||||
];
|
||||
|
||||
// Build per-block filtered histograms from the indexer, feeding all oracles in one pass.
|
||||
let total_txs = indexer.vecs.transactions.height.len();
|
||||
let total_outputs = indexer.vecs.outputs.value.len();
|
||||
|
||||
let mut first_txindex_iter = indexer.vecs.transactions.first_txindex.into_iter();
|
||||
let mut first_txoutindex_iter = indexer.vecs.transactions.first_txoutindex.into_iter();
|
||||
let mut out_first_iter = indexer.vecs.outputs.first_txoutindex.into_iter();
|
||||
let mut value_iter = indexer.vecs.outputs.value.into_iter();
|
||||
let mut outputtype_iter = indexer.vecs.outputs.outputtype.into_iter();
|
||||
|
||||
let ref_config = Config::default();
|
||||
|
||||
for h in START_HEIGHT..total_heights {
|
||||
let first_txindex: TxIndex = first_txindex_iter.get_at_unwrap(h);
|
||||
let next_first_txindex = first_txindex_iter
|
||||
.get_at(h + 1)
|
||||
.unwrap_or(TxIndex::from(total_txs));
|
||||
|
||||
let out_start = if first_txindex.to_usize() + 1 < next_first_txindex.to_usize() {
|
||||
first_txoutindex_iter
|
||||
.get_at_unwrap(first_txindex.to_usize() + 1)
|
||||
.to_usize()
|
||||
} else {
|
||||
out_first_iter
|
||||
.get_at(h + 1)
|
||||
.unwrap_or(TxOutIndex::from(total_outputs))
|
||||
.to_usize()
|
||||
};
|
||||
let out_end = out_first_iter
|
||||
.get_at(h + 1)
|
||||
.unwrap_or(TxOutIndex::from(total_outputs))
|
||||
.to_usize();
|
||||
|
||||
// Build filtered histogram once for all oracles.
|
||||
let mut hist = [0u32; NUM_BINS];
|
||||
for i in out_start..out_end {
|
||||
let sats: Sats = value_iter.get_at_unwrap(i);
|
||||
let output_type: OutputType = outputtype_iter.get_at_unwrap(i);
|
||||
if ref_config.excluded_output_types.contains(&output_type) {
|
||||
continue;
|
||||
}
|
||||
if *sats < ref_config.min_sats
|
||||
|| (ref_config.exclude_round_btc && sats.is_round_btc())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(bin) = sats_to_bin(sats) {
|
||||
hist[bin] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for run in &mut runs {
|
||||
if h < run.start_height {
|
||||
continue;
|
||||
}
|
||||
if run.oracle.is_none() {
|
||||
let config = Config::default();
|
||||
run.oracle = Some(Oracle::new(seed_bin(run.start_height), config));
|
||||
}
|
||||
let ref_bin = run.oracle.as_mut().unwrap().process_histogram(&hist);
|
||||
|
||||
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
|
||||
};
|
||||
run.stats.update(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print results.
|
||||
println!();
|
||||
println!(
|
||||
"{:<14} {:>8} {:>10} {:>10} {:>6} {:>6} {:>6} {:>8}",
|
||||
"Config", "Blocks", "RMSE%", "Max%", ">5%", ">10%", ">20%", "Bias"
|
||||
);
|
||||
println!("{}", "-".repeat(72));
|
||||
for run in &runs {
|
||||
let s = &run.stats;
|
||||
println!(
|
||||
"{:<14} {:>8} {:>7.2}% {:>7.1}% {:>6} {:>6} {:>6} {:>+8.2}",
|
||||
run.label,
|
||||
s.total_blocks,
|
||||
s.rmse_pct(),
|
||||
s.max_pct(),
|
||||
s.gt_5pct,
|
||||
s.gt_10pct,
|
||||
s.gt_20pct,
|
||||
s.bias()
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
// Verify exact counts against reference.
|
||||
// Reference: trunc w12 @ 575k: 261 >5%, 40 >10%, 0 >20%
|
||||
// trunc w12 @ 600k: 174 >5%, 31 >10%, 0 >20%
|
||||
// trunc w12 @ 630k: 84 >5%, 9 >10%, 0 >20%
|
||||
let expected: &[(&str, u64, u64, u64)] = &[
|
||||
("w12 @ 575k", 261, 40, 0),
|
||||
("w12 @ 600k", 174, 31, 0),
|
||||
("w12 @ 630k", 84, 9, 0),
|
||||
];
|
||||
|
||||
for (run, &(label, exp_5, exp_10, exp_20)) in runs.iter().zip(expected) {
|
||||
let s = &run.stats;
|
||||
assert_eq!(s.gt_20pct, exp_20, "{label}: expected {exp_20} blocks >20%, got {}", s.gt_20pct);
|
||||
assert_eq!(s.gt_10pct, exp_10, "{label}: expected {exp_10} blocks >10%, got {}", s.gt_10pct);
|
||||
assert_eq!(s.gt_5pct, exp_5, "{label}: expected {exp_5} blocks >5%, got {}", s.gt_5pct);
|
||||
}
|
||||
|
||||
println!("All assertions passed!");
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Pure on-chain BTC/USD price oracle.
|
||||
//!
|
||||
//! Detects round-dollar transaction patterns ($1, $5, $10, ... $10,000) in Bitcoin
|
||||
//! block outputs to derive the current price without any exchange data.
|
||||
|
||||
use brk_types::{Block, CentsUnsigned, Dollars, OutputType, Sats};
|
||||
|
||||
/// Pre-oracle dollar prices, one per line, heights 0..630_000.
|
||||
pub const PRICES: &str = include_str!("prices.txt");
|
||||
|
||||
/// First height where the oracle computes from on-chain data.
|
||||
pub const START_HEIGHT: usize = 575_000;
|
||||
|
||||
pub const BINS_PER_DECADE: usize = 200;
|
||||
const MIN_LOG_BTC: i32 = -8;
|
||||
const MAX_LOG_BTC: i32 = 4;
|
||||
pub const NUM_BINS: usize = BINS_PER_DECADE * (MAX_LOG_BTC - MIN_LOG_BTC) as usize;
|
||||
|
||||
/// Bin offsets for 19 round-USD amounts relative to the $100 reference (offset 0).
|
||||
/// Each offset = log10(amount / 100) * BINS_PER_DECADE.
|
||||
const STENCIL_OFFSETS: [i32; 19] = [
|
||||
-400, // $1
|
||||
-340, // $2
|
||||
-305, // $3
|
||||
-260, // $5
|
||||
-200, // $10
|
||||
-165, // $15
|
||||
-140, // $20
|
||||
-120, // $25
|
||||
-105, // $30
|
||||
-60, // $50
|
||||
0, // $100
|
||||
35, // $150
|
||||
60, // $200
|
||||
95, // $300
|
||||
140, // $500
|
||||
200, // $1000
|
||||
260, // $2000
|
||||
340, // $5000
|
||||
400, // $10000
|
||||
];
|
||||
|
||||
/// Maps a satoshi value to its log-scale bin index.
|
||||
/// bin = round(log10(sats) * BINS_PER_DECADE).
|
||||
#[inline(always)]
|
||||
pub fn sats_to_bin(sats: Sats) -> Option<usize> {
|
||||
if sats.is_zero() {
|
||||
return None;
|
||||
}
|
||||
let bin = ((*sats as f64).log10() * BINS_PER_DECADE as f64).round() as i64;
|
||||
if bin >= 0 && (bin as usize) < NUM_BINS {
|
||||
Some(bin as usize)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a fractional bin to a USD price in cents.
|
||||
/// For a $D output at price P: sats = D * 1e8 / P, so P = 10^(10 - bin/200) dollars,
|
||||
/// where 10 = log10($100 reference * 1e8 sats/BTC).
|
||||
#[inline]
|
||||
pub fn bin_to_cents(bin: f64) -> u64 {
|
||||
let dollars = 10.0_f64.powf(10.0 - bin / BINS_PER_DECADE as f64);
|
||||
(dollars * 100.0).round() as u64
|
||||
}
|
||||
|
||||
/// Converts a USD price in cents to a fractional bin (inverse of bin_to_cents).
|
||||
#[inline]
|
||||
pub fn cents_to_bin(cents: f64) -> f64 {
|
||||
(10.0 - (cents / 100.0).log10()) * BINS_PER_DECADE as f64
|
||||
}
|
||||
|
||||
/// Scores each candidate bin in the search window by summing normalized stencil
|
||||
/// matches across the EMA histogram, then refines with parabolic interpolation.
|
||||
fn find_best_bin(
|
||||
ema: &[f64; NUM_BINS],
|
||||
prev_bin: f64,
|
||||
search_below: usize,
|
||||
search_above: usize,
|
||||
) -> f64 {
|
||||
let center = prev_bin.round() as usize;
|
||||
let search_start = center.saturating_sub(search_below);
|
||||
let search_end = (center + search_above + 1).min(NUM_BINS);
|
||||
|
||||
if search_start >= search_end {
|
||||
return prev_bin;
|
||||
}
|
||||
|
||||
// Per-offset peak within the search window (for normalization).
|
||||
let mut track_norm = [0.0f64; 19];
|
||||
for (i, &offset) in STENCIL_OFFSETS.iter().enumerate() {
|
||||
for bin in search_start..search_end {
|
||||
let idx = bin as i32 + offset;
|
||||
if idx >= 0 && (idx as usize) < 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, &offset) in STENCIL_OFFSETS.iter().enumerate() {
|
||||
let idx = bin as i32 + offset;
|
||||
if idx >= 0 && (idx as usize) < 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 candidate = score(bin);
|
||||
if candidate > best_score {
|
||||
best_score = candidate;
|
||||
best_bin = bin;
|
||||
}
|
||||
}
|
||||
|
||||
// Parabolic sub-bin interpolation for fractional precision.
|
||||
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
|
||||
}
|
||||
|
||||
pub struct Config {
|
||||
/// EMA decay: 2/(N+1) where N is span in blocks. 2/7 = 6-block span.
|
||||
pub alpha: f64,
|
||||
/// Ring buffer depth. 12 blocks for deterministic convergence at any start height.
|
||||
pub window_size: usize,
|
||||
/// Search window bins below/above previous estimate. Asymmetric for log-scale.
|
||||
pub search_below: usize,
|
||||
pub search_above: usize,
|
||||
/// Minimum output value in sats (dust filter).
|
||||
pub min_sats: u64,
|
||||
/// Exclude round BTC amounts that create false stencil matches.
|
||||
pub exclude_round_btc: bool,
|
||||
/// Output types to ignore (e.g. P2TR, P2WSH are noisy).
|
||||
pub excluded_output_types: Vec<OutputType>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
alpha: 2.0 / 7.0,
|
||||
window_size: 12,
|
||||
search_below: 9,
|
||||
search_above: 11,
|
||||
min_sats: 1000,
|
||||
exclude_round_btc: true,
|
||||
excluded_output_types: vec![OutputType::P2TR, OutputType::P2WSH],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Oracle {
|
||||
histograms: Vec<[u32; NUM_BINS]>,
|
||||
ema: Box<[f64; NUM_BINS]>,
|
||||
weights: Vec<f64>,
|
||||
cursor: usize,
|
||||
filled: usize,
|
||||
ref_bin: f64,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl Oracle {
|
||||
pub fn new(start_bin: f64, config: Config) -> Self {
|
||||
let weights: Vec<f64> = (0..config.window_size)
|
||||
.map(|age| config.alpha * (1.0 - config.alpha).powi(age as i32))
|
||||
.collect();
|
||||
let window_size = config.window_size;
|
||||
Self {
|
||||
histograms: vec![[0u32; NUM_BINS]; window_size],
|
||||
ema: Box::new([0.0; NUM_BINS]),
|
||||
weights,
|
||||
cursor: 0,
|
||||
filled: 0,
|
||||
ref_bin: start_bin,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_block(&mut self, block: &Block) -> f64 {
|
||||
self.process_outputs(
|
||||
block
|
||||
.txdata
|
||||
.iter()
|
||||
.skip(1) // skip coinbase
|
||||
.flat_map(|tx| &tx.output)
|
||||
.map(|txout| (Sats::from(txout.value), OutputType::from(&txout.script_pubkey))),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn process_outputs(&mut self, outputs: impl Iterator<Item = (Sats, OutputType)>) -> f64 {
|
||||
let mut hist = [0u32; NUM_BINS];
|
||||
for (sats, output_type) in outputs {
|
||||
if let Some(bin) = self.eligible_bin(sats, output_type) {
|
||||
hist[bin] += 1;
|
||||
}
|
||||
}
|
||||
self.ingest(&hist)
|
||||
}
|
||||
|
||||
/// Create an oracle restored from a known price.
|
||||
/// `fill` should feed warmup blocks to populate the ring buffer.
|
||||
/// ref_bin is anchored to the checkpoint regardless of warmup drift.
|
||||
pub fn from_checkpoint(ref_bin: f64, config: Config, fill: impl FnOnce(&mut Self)) -> Self {
|
||||
let mut oracle = Self::new(ref_bin, config);
|
||||
fill(&mut oracle);
|
||||
oracle.ref_bin = ref_bin;
|
||||
oracle
|
||||
}
|
||||
|
||||
pub fn process_histogram(&mut self, hist: &[u32; NUM_BINS]) -> f64 {
|
||||
self.ingest(hist)
|
||||
}
|
||||
|
||||
pub fn ref_bin(&self) -> f64 {
|
||||
self.ref_bin
|
||||
}
|
||||
|
||||
pub fn price_cents(&self) -> CentsUnsigned {
|
||||
bin_to_cents(self.ref_bin).into()
|
||||
}
|
||||
|
||||
pub fn price_dollars(&self) -> Dollars {
|
||||
self.price_cents().into()
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn eligible_bin(&self, sats: Sats, output_type: OutputType) -> Option<usize> {
|
||||
if self.config.excluded_output_types.contains(&output_type) {
|
||||
return None;
|
||||
}
|
||||
if *sats < self.config.min_sats || (self.config.exclude_round_btc && sats.is_round_btc()) {
|
||||
return None;
|
||||
}
|
||||
sats_to_bin(sats)
|
||||
}
|
||||
|
||||
fn ingest(&mut self, hist: &[u32; NUM_BINS]) -> f64 {
|
||||
self.histograms[self.cursor] = *hist;
|
||||
self.cursor = (self.cursor + 1) % self.config.window_size;
|
||||
if self.filled < self.config.window_size {
|
||||
self.filled += 1;
|
||||
}
|
||||
|
||||
self.recompute_ema();
|
||||
|
||||
self.ref_bin = find_best_bin(
|
||||
&self.ema,
|
||||
self.ref_bin,
|
||||
self.config.search_below,
|
||||
self.config.search_above,
|
||||
);
|
||||
self.ref_bin
|
||||
}
|
||||
|
||||
fn recompute_ema(&mut self) {
|
||||
self.ema.fill(0.0);
|
||||
for age in 0..self.filled {
|
||||
let idx = (self.cursor + self.config.window_size - 1 - age) % self.config.window_size;
|
||||
let weight = self.weights[age];
|
||||
for bin in 0..NUM_BINS {
|
||||
self.ema[bin] += weight * self.histograms[idx][bin] as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sats_to_bin_round_trip() {
|
||||
assert_eq!(sats_to_bin(Sats::new(100_000_000)), Some(1600));
|
||||
assert_eq!(sats_to_bin(Sats::new(1)), Some(0));
|
||||
assert_eq!(sats_to_bin(Sats::ZERO), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bin_to_cents_known_values() {
|
||||
assert_eq!(bin_to_cents(1600.0), 10000);
|
||||
assert_eq!(bin_to_cents(1800.0), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sats_to_bin_boundary() {
|
||||
assert_eq!(sats_to_bin(Sats::new(1_000_000_000_000)), None);
|
||||
let sats = 10.0_f64.powf(11.995) as u64;
|
||||
assert!(sats_to_bin(Sats::new(sats)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_basic() {
|
||||
let oracle = Oracle::new(1600.0, Config::default());
|
||||
assert_eq!(oracle.ref_bin(), 1600.0);
|
||||
assert_eq!(oracle.price_cents(), bin_to_cents(1600.0).into());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod mempool;
|
||||
mod metrics;
|
||||
mod metrics_legacy;
|
||||
mod mining;
|
||||
mod price;
|
||||
mod transaction;
|
||||
|
||||
pub use block::BLOCK_TXS_PAGE_SIZE;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
use brk_error::{Error, Result};
|
||||
use brk_types::Dollars;
|
||||
|
||||
use crate::Query;
|
||||
|
||||
impl Query {
|
||||
pub fn live_price(&self) -> Result<Dollars> {
|
||||
let oracle_vecs = &self
|
||||
.computer()
|
||||
.price
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::OutOfRange("Oracle prices not computed yet".into()))?
|
||||
.oracle;
|
||||
|
||||
let mut oracle = oracle_vecs.live_oracle(self.indexer())?;
|
||||
|
||||
if let Some(mempool) = self.mempool() {
|
||||
let txs = mempool.get_txs();
|
||||
let mempool_outputs: Vec<_> = txs
|
||||
.values()
|
||||
.flat_map(|tx| &tx.tx().output)
|
||||
.map(|txout| (txout.value, txout.type_()))
|
||||
.collect();
|
||||
oracle.process_outputs(mempool_outputs.into_iter());
|
||||
}
|
||||
|
||||
Ok(oracle.price_dollars())
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,28 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/mempool/price",
|
||||
get_with(
|
||||
async |headers: HeaderMap, State(state): State<AppState>| {
|
||||
state
|
||||
.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.live_price())
|
||||
.await
|
||||
},
|
||||
|op| {
|
||||
op.id("get_live_price")
|
||||
.mempool_tag()
|
||||
.summary("Live BTC/USD price")
|
||||
.description(
|
||||
"Returns the current BTC/USD price in cents, derived from \
|
||||
on-chain round-dollar output patterns in the last 12 blocks \
|
||||
plus mempool.",
|
||||
)
|
||||
.ok_response::<u64>()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
)
|
||||
.api_route(
|
||||
"/api/v1/fees/mempool-blocks",
|
||||
get_with(
|
||||
|
||||
@@ -71,7 +71,18 @@ impl Server {
|
||||
mut request: Request<Body>,
|
||||
next: Next|
|
||||
-> Response<Body> {
|
||||
request.extensions_mut().insert(connect_info.0);
|
||||
let mut addr = connect_info.0;
|
||||
|
||||
// When behind a reverse proxy (e.g. cloudflared), the direct
|
||||
// connection comes from loopback but the request is external.
|
||||
// Mark it as non-loopback so it gets the stricter limit.
|
||||
if addr.ip().is_loopback()
|
||||
&& request.headers().contains_key("CF-Connecting-IP")
|
||||
{
|
||||
addr.set_ip(std::net::Ipv4Addr::UNSPECIFIED.into());
|
||||
}
|
||||
|
||||
request.extensions_mut().insert(addr);
|
||||
next.run(request).await
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4550,6 +4550,7 @@ function createRatioPattern2(client, acc) {
|
||||
* @property {MetricsTree_Price_Cents} cents
|
||||
* @property {MetricsTree_Price_Usd} usd
|
||||
* @property {OhlcSplitPattern2<OHLCSats>} sats
|
||||
* @property {MetricsTree_Price_Oracle} oracle
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -4572,6 +4573,13 @@ function createRatioPattern2(client, acc) {
|
||||
* @property {MetricPattern1<OHLCDollars>} ohlc
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} MetricsTree_Price_Oracle
|
||||
* @property {MetricPattern11<CentsUnsigned>} priceCents
|
||||
* @property {MetricPattern6<OHLCCentsUnsigned>} ohlcCents
|
||||
* @property {MetricPattern6<OHLCDollars>} ohlcDollars
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} MetricsTree_Distribution
|
||||
* @property {MetricPattern11<SupplyState>} supplyState
|
||||
@@ -4966,6 +4974,9 @@ function createRatioPattern2(client, acc) {
|
||||
* @property {MetricPattern4<StoredF32>} inflation
|
||||
* @property {MetricsTree_Supply_Velocity} velocity
|
||||
* @property {MetricPattern1<Dollars>} marketCap
|
||||
* @property {MetricPattern4<StoredF32>} marketCapGrowthRate
|
||||
* @property {MetricPattern4<StoredF32>} realizedCapGrowthRate
|
||||
* @property {MetricPattern6<StoredF32>} capGrowthRateDiff
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -6730,6 +6741,11 @@ class BrkClient extends BrkClientBase {
|
||||
ohlc: createMetricPattern1(this, 'price_ohlc'),
|
||||
},
|
||||
sats: createOhlcSplitPattern2(this, 'price'),
|
||||
oracle: {
|
||||
priceCents: createMetricPattern11(this, 'oracle_price_cents'),
|
||||
ohlcCents: createMetricPattern6(this, 'oracle_ohlc_cents'),
|
||||
ohlcDollars: createMetricPattern6(this, 'oracle_ohlc_dollars'),
|
||||
},
|
||||
},
|
||||
distribution: {
|
||||
supplyState: createMetricPattern11(this, 'supply_state'),
|
||||
@@ -7057,6 +7073,9 @@ class BrkClient extends BrkClientBase {
|
||||
usd: createMetricPattern4(this, 'usd_velocity'),
|
||||
},
|
||||
marketCap: createMetricPattern1(this, 'market_cap'),
|
||||
marketCapGrowthRate: createMetricPattern4(this, 'market_cap_growth_rate'),
|
||||
realizedCapGrowthRate: createMetricPattern4(this, 'realized_cap_growth_rate'),
|
||||
capGrowthRateDiff: createMetricPattern6(this, 'cap_growth_rate_diff'),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7339,6 +7358,18 @@ class BrkClient extends BrkClientBase {
|
||||
return this.getJson(`/api/mempool/info`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live BTC/USD price
|
||||
*
|
||||
* Returns the current BTC/USD price in cents, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool.
|
||||
*
|
||||
* Endpoint: `GET /api/mempool/price`
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async getLivePrice() {
|
||||
return this.getJson(`/api/mempool/price`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mempool transaction IDs
|
||||
*
|
||||
|
||||
@@ -3933,6 +3933,14 @@ class MetricsTree_Price_Usd:
|
||||
self.split: CloseHighLowOpenPattern2[Dollars] = CloseHighLowOpenPattern2(client, 'price')
|
||||
self.ohlc: MetricPattern1[OHLCDollars] = MetricPattern1(client, 'price_ohlc')
|
||||
|
||||
class MetricsTree_Price_Oracle:
|
||||
"""Metrics tree node."""
|
||||
|
||||
def __init__(self, client: BrkClientBase, base_path: str = ''):
|
||||
self.price_cents: MetricPattern11[CentsUnsigned] = MetricPattern11(client, 'oracle_price_cents')
|
||||
self.ohlc_cents: MetricPattern6[OHLCCentsUnsigned] = MetricPattern6(client, 'oracle_ohlc_cents')
|
||||
self.ohlc_dollars: MetricPattern6[OHLCDollars] = MetricPattern6(client, 'oracle_ohlc_dollars')
|
||||
|
||||
class MetricsTree_Price:
|
||||
"""Metrics tree node."""
|
||||
|
||||
@@ -3940,6 +3948,7 @@ class MetricsTree_Price:
|
||||
self.cents: MetricsTree_Price_Cents = MetricsTree_Price_Cents(client)
|
||||
self.usd: MetricsTree_Price_Usd = MetricsTree_Price_Usd(client)
|
||||
self.sats: OhlcSplitPattern2[OHLCSats] = OhlcSplitPattern2(client, 'price')
|
||||
self.oracle: MetricsTree_Price_Oracle = MetricsTree_Price_Oracle(client)
|
||||
|
||||
class MetricsTree_Distribution_AnyAddressIndexes:
|
||||
"""Metrics tree node."""
|
||||
@@ -4385,6 +4394,9 @@ class MetricsTree_Supply:
|
||||
self.inflation: MetricPattern4[StoredF32] = MetricPattern4(client, 'inflation_rate')
|
||||
self.velocity: MetricsTree_Supply_Velocity = MetricsTree_Supply_Velocity(client)
|
||||
self.market_cap: MetricPattern1[Dollars] = MetricPattern1(client, 'market_cap')
|
||||
self.market_cap_growth_rate: MetricPattern4[StoredF32] = MetricPattern4(client, 'market_cap_growth_rate')
|
||||
self.realized_cap_growth_rate: MetricPattern4[StoredF32] = MetricPattern4(client, 'realized_cap_growth_rate')
|
||||
self.cap_growth_rate_diff: MetricPattern6[StoredF32] = MetricPattern6(client, 'cap_growth_rate_diff')
|
||||
|
||||
class MetricsTree:
|
||||
"""Metrics tree node."""
|
||||
@@ -5491,6 +5503,14 @@ class BrkClient(BrkClientBase):
|
||||
Endpoint: `GET /api/mempool/info`"""
|
||||
return self.get_json('/api/mempool/info')
|
||||
|
||||
def get_live_price(self) -> float:
|
||||
"""Live BTC/USD price.
|
||||
|
||||
Returns the current BTC/USD price in cents, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool.
|
||||
|
||||
Endpoint: `GET /api/mempool/price`"""
|
||||
return self.get_json('/api/mempool/price')
|
||||
|
||||
def get_mempool_txids(self) -> List[Txid]:
|
||||
"""Mempool transaction IDs.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export const canCapture = !ios || canShare;
|
||||
* @param {HTMLCanvasElement} args.screenshot
|
||||
* @param {number} args.chartWidth
|
||||
* @param {HTMLElement} args.parent
|
||||
* @param {{ top: { element: HTMLElement }, bottom: { element: HTMLElement } }} args.legends
|
||||
* @param {{ element: HTMLElement }[]} args.legends
|
||||
*/
|
||||
export function capture({ screenshot, chartWidth, parent, legends }) {
|
||||
const dpr = screenshot.width / chartWidth;
|
||||
@@ -22,8 +22,8 @@ export function capture({ screenshot, chartWidth, parent, legends }) {
|
||||
|
||||
const title = (parent.querySelector("h1")?.textContent ?? "").toUpperCase();
|
||||
const hasTitle = title.length > 0;
|
||||
const hasTopLegend = legends.top.element.children.length > 0;
|
||||
const hasBottomLegend = legends.bottom.element.children.length > 0;
|
||||
const hasTopLegend = legends[0].element.children.length > 0;
|
||||
const hasBottomLegend = legends[1].element.children.length > 0;
|
||||
const titleOffset = hasTitle ? titleHeight : 0;
|
||||
const topLegendOffset = hasTopLegend ? legendHeight : 0;
|
||||
const bottomOffset = hasBottomLegend ? legendHeight : 0;
|
||||
@@ -80,7 +80,7 @@ export function capture({ screenshot, chartWidth, parent, legends }) {
|
||||
|
||||
// Top legend
|
||||
if (hasTopLegend) {
|
||||
drawLegend(legends.top.element, pad + titleOffset + topLegendOffset / 2);
|
||||
drawLegend(legends[0].element, pad + titleOffset + topLegendOffset / 2);
|
||||
}
|
||||
|
||||
// Chart
|
||||
@@ -89,7 +89,7 @@ export function capture({ screenshot, chartWidth, parent, legends }) {
|
||||
// Bottom legend
|
||||
if (hasBottomLegend) {
|
||||
drawLegend(
|
||||
legends.bottom.element,
|
||||
legends[1].element,
|
||||
pad +
|
||||
titleOffset +
|
||||
topLegendOffset +
|
||||
|
||||
+217
-572
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,13 @@ export function createLegend() {
|
||||
scroller.append(items);
|
||||
element.append(scroller);
|
||||
|
||||
scroller.addEventListener("wheel", (e) => e.stopPropagation());
|
||||
scroller.addEventListener("touchstart", (e) => e.stopPropagation());
|
||||
scroller.addEventListener("touchmove", (e) => e.stopPropagation());
|
||||
/** @param {HTMLElement} el */
|
||||
function captureScroll(el) {
|
||||
el.addEventListener("wheel", (e) => e.stopPropagation());
|
||||
el.addEventListener("touchstart", (e) => e.stopPropagation());
|
||||
el.addEventListener("touchmove", (e) => e.stopPropagation());
|
||||
}
|
||||
captureScroll(items);
|
||||
|
||||
/** @type {AnySeries | null} */
|
||||
let hoveredSeries = null;
|
||||
@@ -37,6 +41,7 @@ export function createLegend() {
|
||||
let prefix = null;
|
||||
const separator = window.document.createElement("span");
|
||||
separator.textContent = "|";
|
||||
captureScroll(separator);
|
||||
|
||||
return {
|
||||
element,
|
||||
@@ -47,6 +52,7 @@ export function createLegend() {
|
||||
if (prefix) prefix.replaceWith(el);
|
||||
else scroller.insertBefore(el, items);
|
||||
prefix = el;
|
||||
captureScroll(el);
|
||||
el.after(separator);
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BrkClient } from "./modules/brk-client/index.js";
|
||||
|
||||
const brk = new BrkClient("https://bitview.space");
|
||||
// const brk = new BrkClient("/");
|
||||
// const brk = new BrkClient("https://bitview.space");
|
||||
const brk = new BrkClient("/");
|
||||
|
||||
console.log(`VERSION = ${brk.VERSION}`);
|
||||
|
||||
|
||||
@@ -396,7 +396,7 @@ export function createCointimeSection() {
|
||||
line({
|
||||
metric: reserveRisk.vocdd365dMedian,
|
||||
name: "365d Median",
|
||||
color: colors.ma._1y,
|
||||
color: colors.time._1y,
|
||||
unit: Unit.usd,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -367,21 +367,21 @@ export function createActivitySection({
|
||||
line({
|
||||
metric: tree.activity.sent14dEma.sats,
|
||||
name: "14d EMA",
|
||||
color: colors.ma._14d,
|
||||
color: colors.indicator.main,
|
||||
unit: Unit.sats,
|
||||
defaultActive: false,
|
||||
}),
|
||||
line({
|
||||
metric: tree.activity.sent14dEma.bitcoin,
|
||||
name: "14d EMA",
|
||||
color: colors.ma._14d,
|
||||
color: colors.indicator.main,
|
||||
unit: Unit.btc,
|
||||
defaultActive: false,
|
||||
}),
|
||||
line({
|
||||
metric: tree.activity.sent14dEma.dollars,
|
||||
name: "14d EMA",
|
||||
color: colors.ma._14d,
|
||||
color: colors.indicator.main,
|
||||
unit: Unit.usd,
|
||||
defaultActive: false,
|
||||
}),
|
||||
@@ -814,13 +814,13 @@ function createSingleSellSideRiskSeries(tree) {
|
||||
line({
|
||||
metric: tree.realized.sellSideRiskRatio30dEma,
|
||||
name: "30d EMA",
|
||||
color: colors.ma._1m,
|
||||
color: colors.time._1m,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
line({
|
||||
metric: tree.realized.sellSideRiskRatio7dEma,
|
||||
name: "7d EMA",
|
||||
color: colors.ma._1w,
|
||||
color: colors.time._1w,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
dots({
|
||||
|
||||
@@ -11,118 +11,30 @@
|
||||
*/
|
||||
|
||||
import { colors } from "../../utils/colors.js";
|
||||
import { entries } from "../../utils/array.js";
|
||||
import { Unit } from "../../utils/units.js";
|
||||
import { priceLines } from "../constants.js";
|
||||
import { line, price } from "../series.js";
|
||||
import { mapCohortsWithAll } from "../shared.js";
|
||||
|
||||
const ACTIVE_PCTS = new Set(["pct75", "pct50", "pct25"]);
|
||||
|
||||
/**
|
||||
* @param {PercentilesPattern} p
|
||||
* @param {(name: string) => string} [n]
|
||||
* @returns {FetchedPriceSeriesBlueprint[]}
|
||||
*/
|
||||
function createCorePercentileSeries(p, n = (x) => x) {
|
||||
return [
|
||||
price({
|
||||
metric: p.pct95,
|
||||
name: n("p95"),
|
||||
color: colors.pct._95,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: p.pct90,
|
||||
name: n("p90"),
|
||||
color: colors.pct._90,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: p.pct85,
|
||||
name: n("p85"),
|
||||
color: colors.pct._85,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: p.pct80,
|
||||
name: n("p80"),
|
||||
color: colors.pct._80,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({ metric: p.pct75, name: n("p75"), color: colors.pct._75 }),
|
||||
price({
|
||||
metric: p.pct70,
|
||||
name: n("p70"),
|
||||
color: colors.pct._70,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: p.pct65,
|
||||
name: n("p65"),
|
||||
color: colors.pct._65,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: p.pct60,
|
||||
name: n("p60"),
|
||||
color: colors.pct._60,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: p.pct55,
|
||||
name: n("p55"),
|
||||
color: colors.pct._55,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({ metric: p.pct50, name: n("p50"), color: colors.pct._50 }),
|
||||
price({
|
||||
metric: p.pct45,
|
||||
name: n("p45"),
|
||||
color: colors.pct._45,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: p.pct40,
|
||||
name: n("p40"),
|
||||
color: colors.pct._40,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: p.pct35,
|
||||
name: n("p35"),
|
||||
color: colors.pct._35,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: p.pct30,
|
||||
name: n("p30"),
|
||||
color: colors.pct._30,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({ metric: p.pct25, name: n("p25"), color: colors.pct._25 }),
|
||||
price({
|
||||
metric: p.pct20,
|
||||
name: n("p20"),
|
||||
color: colors.pct._20,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: p.pct15,
|
||||
name: n("p15"),
|
||||
color: colors.pct._15,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: p.pct10,
|
||||
name: n("p10"),
|
||||
color: colors.pct._10,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: p.pct05,
|
||||
name: n("p05"),
|
||||
color: colors.pct._05,
|
||||
defaultActive: false,
|
||||
}),
|
||||
];
|
||||
return entries(p)
|
||||
.reverse()
|
||||
.map(([key, metric], i, arr) =>
|
||||
price({
|
||||
metric,
|
||||
name: n(key.replace("pct", "p")),
|
||||
color: colors.at(i, arr.length),
|
||||
...(ACTIVE_PCTS.has(key) ? {} : { defaultActive: false }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,13 +48,13 @@ function createSingleSummarySeriesBasic(cohort) {
|
||||
price({
|
||||
metric: tree.costBasis.max,
|
||||
name: "Max",
|
||||
color: colors.pct._100,
|
||||
color: colors.stat.max,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: tree.costBasis.min,
|
||||
name: "Min",
|
||||
color: colors.pct._0,
|
||||
color: colors.stat.min,
|
||||
defaultActive: false,
|
||||
}),
|
||||
];
|
||||
@@ -160,26 +72,26 @@ function createSingleSummarySeriesWithPercentiles(cohort) {
|
||||
price({
|
||||
metric: tree.costBasis.max,
|
||||
name: "Max (p100)",
|
||||
color: colors.pct._100,
|
||||
color: colors.stat.max,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: p.pct75,
|
||||
name: "Q3 (p75)",
|
||||
color: colors.pct._75,
|
||||
color: colors.stat.pct75,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({ metric: p.pct50, name: "Median (p50)", color: colors.pct._50 }),
|
||||
price({ metric: p.pct50, name: "Median (p50)", color: colors.stat.median }),
|
||||
price({
|
||||
metric: p.pct25,
|
||||
name: "Q1 (p25)",
|
||||
color: colors.pct._25,
|
||||
color: colors.stat.pct25,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: tree.costBasis.min,
|
||||
name: "Min (p0)",
|
||||
color: colors.pct._0,
|
||||
color: colors.stat.min,
|
||||
defaultActive: false,
|
||||
}),
|
||||
];
|
||||
@@ -208,14 +120,14 @@ function createSingleByCoinSeries(cohort) {
|
||||
price({
|
||||
metric: cb.max,
|
||||
name: "p100",
|
||||
color: colors.pct._100,
|
||||
color: colors.stat.max,
|
||||
defaultActive: false,
|
||||
}),
|
||||
...createCorePercentileSeries(cb.percentiles),
|
||||
price({
|
||||
metric: cb.min,
|
||||
name: "p0",
|
||||
color: colors.pct._0,
|
||||
color: colors.stat.min,
|
||||
defaultActive: false,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -67,68 +67,72 @@ export function buildCohortData() {
|
||||
};
|
||||
|
||||
// Max age cohorts (up to X time)
|
||||
const upToDate = entries(utxoCohorts.maxAge).map(([key, tree]) => {
|
||||
const upToDate = entries(utxoCohorts.maxAge).map(([key, tree], i, arr) => {
|
||||
const names = MAX_AGE_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: `UTXOs ${names.long}`,
|
||||
color: colors.age[key],
|
||||
color: colors.at(i, arr.length),
|
||||
tree,
|
||||
};
|
||||
});
|
||||
|
||||
// Min age cohorts (from X time)
|
||||
const fromDate = entries(utxoCohorts.minAge).map(([key, tree]) => {
|
||||
const fromDate = entries(utxoCohorts.minAge).map(([key, tree], i, arr) => {
|
||||
const names = MIN_AGE_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: `UTXOs ${names.long}`,
|
||||
color: colors.age[key],
|
||||
color: colors.at(i, arr.length),
|
||||
tree,
|
||||
};
|
||||
});
|
||||
|
||||
// Age range cohorts
|
||||
const dateRange = entries(utxoCohorts.ageRange).map(([key, tree]) => {
|
||||
const names = AGE_RANGE_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: `UTXOs ${names.long}`,
|
||||
color: colors.ageRange[key],
|
||||
tree,
|
||||
};
|
||||
});
|
||||
const dateRange = entries(utxoCohorts.ageRange).map(
|
||||
([key, tree], i, arr) => {
|
||||
const names = AGE_RANGE_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: `UTXOs ${names.long}`,
|
||||
color: colors.at(i, arr.length),
|
||||
tree,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Epoch cohorts
|
||||
const epoch = entries(utxoCohorts.epoch).map(([key, tree]) => {
|
||||
const epoch = entries(utxoCohorts.epoch).map(([key, tree], i, arr) => {
|
||||
const names = EPOCH_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: names.long,
|
||||
color: colors.epoch[key],
|
||||
color: colors.at(i, arr.length),
|
||||
tree,
|
||||
};
|
||||
});
|
||||
|
||||
// UTXOs above amount
|
||||
const utxosAboveAmount = entries(utxoCohorts.geAmount).map(([key, tree]) => {
|
||||
const names = GE_AMOUNT_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: `UTXOs ${names.long}`,
|
||||
color: colors.amount[key],
|
||||
tree,
|
||||
};
|
||||
});
|
||||
const utxosAboveAmount = entries(utxoCohorts.geAmount).map(
|
||||
([key, tree], i, arr) => {
|
||||
const names = GE_AMOUNT_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: `UTXOs ${names.long}`,
|
||||
color: colors.at(i, arr.length),
|
||||
tree,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Addresses above amount
|
||||
const addressesAboveAmount = entries(addressCohorts.geAmount).map(
|
||||
([key, cohort]) => {
|
||||
([key, cohort], i, arr) => {
|
||||
const names = GE_AMOUNT_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: `Addresses ${names.long}`,
|
||||
color: colors.amount[key],
|
||||
color: colors.at(i, arr.length),
|
||||
tree: cohort,
|
||||
addrCount: {
|
||||
count: cohort.addrCount,
|
||||
@@ -139,24 +143,26 @@ export function buildCohortData() {
|
||||
);
|
||||
|
||||
// UTXOs under amount
|
||||
const utxosUnderAmount = entries(utxoCohorts.ltAmount).map(([key, tree]) => {
|
||||
const names = LT_AMOUNT_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: `UTXOs ${names.long}`,
|
||||
color: colors.amount[key],
|
||||
tree,
|
||||
};
|
||||
});
|
||||
const utxosUnderAmount = entries(utxoCohorts.ltAmount).map(
|
||||
([key, tree], i, arr) => {
|
||||
const names = LT_AMOUNT_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: `UTXOs ${names.long}`,
|
||||
color: colors.at(i, arr.length),
|
||||
tree,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Addresses under amount
|
||||
const addressesUnderAmount = entries(addressCohorts.ltAmount).map(
|
||||
([key, cohort]) => {
|
||||
([key, cohort], i, arr) => {
|
||||
const names = LT_AMOUNT_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: `Addresses ${names.long}`,
|
||||
color: colors.amount[key],
|
||||
color: colors.at(i, arr.length),
|
||||
tree: cohort,
|
||||
addrCount: {
|
||||
count: cohort.addrCount,
|
||||
@@ -168,12 +174,12 @@ export function buildCohortData() {
|
||||
|
||||
// UTXOs amount ranges
|
||||
const utxosAmountRanges = entries(utxoCohorts.amountRange).map(
|
||||
([key, tree]) => {
|
||||
([key, tree], i, arr) => {
|
||||
const names = AMOUNT_RANGE_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: `UTXOs ${names.long}`,
|
||||
color: colors.amountRange[key],
|
||||
color: colors.at(i, arr.length),
|
||||
tree,
|
||||
};
|
||||
},
|
||||
@@ -181,12 +187,12 @@ export function buildCohortData() {
|
||||
|
||||
// Addresses amount ranges
|
||||
const addressesAmountRanges = entries(addressCohorts.amountRange).map(
|
||||
([key, cohort]) => {
|
||||
([key, cohort], i, arr) => {
|
||||
const names = AMOUNT_RANGE_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: `Addresses ${names.long}`,
|
||||
color: colors.amountRange[key],
|
||||
color: colors.at(i, arr.length),
|
||||
tree: cohort,
|
||||
addrCount: {
|
||||
count: cohort.addrCount,
|
||||
@@ -197,12 +203,12 @@ export function buildCohortData() {
|
||||
);
|
||||
|
||||
// Spendable type cohorts - split by addressability
|
||||
const typeAddressable = ADDRESSABLE_TYPES.map((key) => {
|
||||
const typeAddressable = ADDRESSABLE_TYPES.map((key, i, arr) => {
|
||||
const names = SPENDABLE_TYPE_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: names.short,
|
||||
color: colors.scriptType[key],
|
||||
color: colors.at(i, arr.length),
|
||||
tree: utxoCohorts.type[key],
|
||||
addrCount: addrCount[key],
|
||||
};
|
||||
@@ -210,26 +216,28 @@ export function buildCohortData() {
|
||||
|
||||
const typeOther = entries(utxoCohorts.type)
|
||||
.filter(([key]) => !isAddressable(key))
|
||||
.map(([key, tree]) => {
|
||||
.map(([key, tree], i, arr) => {
|
||||
const names = SPENDABLE_TYPE_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: names.short,
|
||||
color: colors.scriptType[key],
|
||||
color: colors.at(i, arr.length),
|
||||
tree,
|
||||
};
|
||||
});
|
||||
|
||||
// Year cohorts
|
||||
const year = entries(utxoCohorts.year).map(([key, tree]) => {
|
||||
const names = YEAR_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: names.long,
|
||||
color: colors.year[key],
|
||||
tree,
|
||||
};
|
||||
});
|
||||
const year = entries(utxoCohorts.year)
|
||||
.reverse()
|
||||
.map(([key, tree], i, arr) => {
|
||||
const names = YEAR_NAMES[key];
|
||||
return {
|
||||
name: names.short,
|
||||
title: names.long,
|
||||
color: colors.at(i, arr.length),
|
||||
tree,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
cohortAll,
|
||||
|
||||
@@ -91,7 +91,7 @@ export function createValuationSectionFull({ cohort, title }) {
|
||||
* @returns {PartialOptionsGroup}
|
||||
*/
|
||||
export function createValuationSection({ cohort, title }) {
|
||||
const { tree, color } = cohort;
|
||||
const { tree } = cohort;
|
||||
return {
|
||||
name: "Valuation",
|
||||
tree: [
|
||||
|
||||
@@ -66,17 +66,20 @@ const periodName = (key) => periodIdToName(key.slice(1), true);
|
||||
* @typedef {BaseEntryItem & { cagr: AnyMetricPattern }} LongEntryItem
|
||||
*/
|
||||
|
||||
const ALL_YEARS = /** @type {const} */ ([...YEARS_2020S, ...YEARS_2010S]);
|
||||
|
||||
/**
|
||||
* Build DCA class entry from year
|
||||
* @param {MarketDca} dca
|
||||
* @param {DcaYear} year
|
||||
* @param {number} i
|
||||
* @returns {BaseEntryItem}
|
||||
*/
|
||||
function buildYearEntry(dca, year) {
|
||||
function buildYearEntry(dca, year, i) {
|
||||
const key = /** @type {DcaYearKey} */ (`_${year}`);
|
||||
return {
|
||||
name: `${year}`,
|
||||
color: colors.year[key],
|
||||
color: colors.at(i, ALL_YEARS.length),
|
||||
costBasis: dca.classAveragePrice[key],
|
||||
returns: dca.classReturns[key],
|
||||
minReturn: dca.classMinReturn[key],
|
||||
@@ -536,10 +539,12 @@ function createPeriodSection({ dca, lookback, returns }) {
|
||||
const isLumpSum = !!lookback;
|
||||
const suffix = isLumpSum ? "Lump Sum" : "DCA";
|
||||
|
||||
/** @param {AllPeriodKey} key @returns {BaseEntryItem} */
|
||||
const buildBaseEntry = (key) => ({
|
||||
const allPeriods = /** @type {const} */ ([...SHORT_PERIODS, ...LONG_PERIODS]);
|
||||
|
||||
/** @param {AllPeriodKey} key @param {number} i @returns {BaseEntryItem} */
|
||||
const buildBaseEntry = (key, i) => ({
|
||||
name: periodName(key),
|
||||
color: colors.dca[key],
|
||||
color: colors.at(i, allPeriods.length),
|
||||
costBasis: isLumpSum ? lookback[key] : dca.periodAveragePrice[key],
|
||||
returns: isLumpSum ? dca.periodLumpSumReturns[key] : dca.periodReturns[key],
|
||||
minReturn: isLumpSum
|
||||
@@ -557,10 +562,10 @@ function createPeriodSection({ dca, lookback, returns }) {
|
||||
stack: isLumpSum ? dca.periodLumpSumStack[key] : dca.periodStack[key],
|
||||
});
|
||||
|
||||
/** @param {LongPeriodKey} key @returns {LongEntryItem} */
|
||||
const buildLongEntry = (key) =>
|
||||
/** @param {LongPeriodKey} key @param {number} i @returns {LongEntryItem} */
|
||||
const buildLongEntry = (key, i) =>
|
||||
withCagr(
|
||||
buildBaseEntry(key),
|
||||
buildBaseEntry(key, i),
|
||||
isLumpSum ? returns.cagr[key] : dca.periodCagr[key],
|
||||
);
|
||||
|
||||
@@ -578,8 +583,10 @@ function createPeriodSection({ dca, lookback, returns }) {
|
||||
titlePrefix: `${entry.name} ${suffix}`,
|
||||
});
|
||||
|
||||
const shortEntries = SHORT_PERIODS.map(buildBaseEntry);
|
||||
const longEntries = LONG_PERIODS.map(buildLongEntry);
|
||||
const shortEntries = SHORT_PERIODS.map((key, i) => buildBaseEntry(key, i));
|
||||
const longEntries = LONG_PERIODS.map((key, i) =>
|
||||
buildLongEntry(key, SHORT_PERIODS.length + i),
|
||||
);
|
||||
|
||||
return {
|
||||
name: `${suffix} by Period`,
|
||||
@@ -651,8 +658,12 @@ export function createDcaByStartYearSection({ dca }) {
|
||||
],
|
||||
});
|
||||
|
||||
const entries2020s = YEARS_2020S.map((year) => buildYearEntry(dca, year));
|
||||
const entries2010s = YEARS_2010S.map((year) => buildYearEntry(dca, year));
|
||||
const entries2020s = YEARS_2020S.map((year, i) =>
|
||||
buildYearEntry(dca, year, i),
|
||||
);
|
||||
const entries2010s = YEARS_2010S.map((year, i) =>
|
||||
buildYearEntry(dca, year, YEARS_2020S.length + i),
|
||||
);
|
||||
|
||||
return {
|
||||
name: "DCA by Start Year",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { brk } from "../client.js";
|
||||
import { includes } from "../utils/array.js";
|
||||
import { Unit } from "../utils/units.js";
|
||||
import { priceLine, priceLines } from "./constants.js";
|
||||
import { baseline, histogram, line, price } from "./series.js";
|
||||
import { baseline, candlestick, histogram, line, price } from "./series.js";
|
||||
import { createPriceRatioCharts } from "./shared.js";
|
||||
import { periodIdToName } from "./utils.js";
|
||||
|
||||
@@ -184,7 +184,7 @@ function historicalSubSection(name, periods) {
|
||||
* @returns {PartialOptionsGroup}
|
||||
*/
|
||||
export function createMarketSection() {
|
||||
const { market, supply, price: priceMetrics } = brk.metrics;
|
||||
const { market, supply, distribution, price: priceMetrics } = brk.metrics;
|
||||
const {
|
||||
movingAverage: ma,
|
||||
ath,
|
||||
@@ -195,53 +195,28 @@ export function createMarketSection() {
|
||||
lookback,
|
||||
} = market;
|
||||
|
||||
/** @type {Period[]} */
|
||||
const shortPeriods = [
|
||||
{
|
||||
id: "1d",
|
||||
color: colors.returns._1d,
|
||||
returns: returns.priceReturns._1d,
|
||||
lookback: lookback._1d,
|
||||
},
|
||||
{
|
||||
id: "1w",
|
||||
color: colors.returns._1w,
|
||||
returns: returns.priceReturns._1w,
|
||||
lookback: lookback._1w,
|
||||
},
|
||||
{
|
||||
id: "1m",
|
||||
color: colors.returns._1m,
|
||||
returns: returns.priceReturns._1m,
|
||||
lookback: lookback._1m,
|
||||
},
|
||||
const shortPeriodsBase = [
|
||||
{ id: "1d", returns: returns.priceReturns._1d, lookback: lookback._1d },
|
||||
{ id: "1w", returns: returns.priceReturns._1w, lookback: lookback._1w },
|
||||
{ id: "1m", returns: returns.priceReturns._1m, lookback: lookback._1m },
|
||||
{
|
||||
id: "3m",
|
||||
color: colors.returns._3m,
|
||||
returns: returns.priceReturns._3m,
|
||||
lookback: lookback._3m,
|
||||
defaultActive: false,
|
||||
},
|
||||
{
|
||||
id: "6m",
|
||||
color: colors.returns._6m,
|
||||
returns: returns.priceReturns._6m,
|
||||
lookback: lookback._6m,
|
||||
defaultActive: false,
|
||||
},
|
||||
{
|
||||
id: "1y",
|
||||
color: colors.returns._1y,
|
||||
returns: returns.priceReturns._1y,
|
||||
lookback: lookback._1y,
|
||||
},
|
||||
{ id: "1y", returns: returns.priceReturns._1y, lookback: lookback._1y },
|
||||
];
|
||||
|
||||
/** @type {PeriodWithCagr[]} */
|
||||
const longPeriods = [
|
||||
const longPeriodsBase = [
|
||||
{
|
||||
id: "2y",
|
||||
color: colors.returns._2y,
|
||||
returns: returns.priceReturns._2y,
|
||||
cagr: returns.cagr._2y,
|
||||
lookback: lookback._2y,
|
||||
@@ -249,7 +224,6 @@ export function createMarketSection() {
|
||||
},
|
||||
{
|
||||
id: "3y",
|
||||
color: colors.returns._3y,
|
||||
returns: returns.priceReturns._3y,
|
||||
cagr: returns.cagr._3y,
|
||||
lookback: lookback._3y,
|
||||
@@ -257,14 +231,12 @@ export function createMarketSection() {
|
||||
},
|
||||
{
|
||||
id: "4y",
|
||||
color: colors.returns._4y,
|
||||
returns: returns.priceReturns._4y,
|
||||
cagr: returns.cagr._4y,
|
||||
lookback: lookback._4y,
|
||||
},
|
||||
{
|
||||
id: "5y",
|
||||
color: colors.returns._5y,
|
||||
returns: returns.priceReturns._5y,
|
||||
cagr: returns.cagr._5y,
|
||||
lookback: lookback._5y,
|
||||
@@ -272,7 +244,6 @@ export function createMarketSection() {
|
||||
},
|
||||
{
|
||||
id: "6y",
|
||||
color: colors.returns._6y,
|
||||
returns: returns.priceReturns._6y,
|
||||
cagr: returns.cagr._6y,
|
||||
lookback: lookback._6y,
|
||||
@@ -280,7 +251,6 @@ export function createMarketSection() {
|
||||
},
|
||||
{
|
||||
id: "8y",
|
||||
color: colors.returns._8y,
|
||||
returns: returns.priceReturns._8y,
|
||||
cagr: returns.cagr._8y,
|
||||
lookback: lookback._8y,
|
||||
@@ -288,7 +258,6 @@ export function createMarketSection() {
|
||||
},
|
||||
{
|
||||
id: "10y",
|
||||
color: colors.returns._10y,
|
||||
returns: returns.priceReturns._10y,
|
||||
cagr: returns.cagr._10y,
|
||||
lookback: lookback._10y,
|
||||
@@ -296,96 +265,117 @@ export function createMarketSection() {
|
||||
},
|
||||
];
|
||||
|
||||
const totalReturnPeriods =
|
||||
shortPeriodsBase.length + longPeriodsBase.length;
|
||||
|
||||
/** @type {Period[]} */
|
||||
const shortPeriods = shortPeriodsBase.map((p, i) => ({
|
||||
...p,
|
||||
color: colors.at(i, totalReturnPeriods),
|
||||
}));
|
||||
|
||||
/** @type {PeriodWithCagr[]} */
|
||||
const longPeriods = longPeriodsBase.map((p, i) => ({
|
||||
...p,
|
||||
color: colors.at(shortPeriodsBase.length + i, totalReturnPeriods),
|
||||
}));
|
||||
|
||||
/** @type {MaPeriod[]} */
|
||||
const sma = [
|
||||
{ id: "1w", color: colors.ma._1w, ratio: ma.price1wSma },
|
||||
{ id: "8d", color: colors.ma._8d, ratio: ma.price8dSma },
|
||||
{ id: "13d", color: colors.ma._13d, ratio: ma.price13dSma },
|
||||
{ id: "21d", color: colors.ma._21d, ratio: ma.price21dSma },
|
||||
{ id: "1m", color: colors.ma._1m, ratio: ma.price1mSma },
|
||||
{ id: "34d", color: colors.ma._34d, ratio: ma.price34dSma },
|
||||
{ id: "55d", color: colors.ma._55d, ratio: ma.price55dSma },
|
||||
{ id: "89d", color: colors.ma._89d, ratio: ma.price89dSma },
|
||||
{ id: "111d", color: colors.ma._111d, ratio: ma.price111dSma },
|
||||
{ id: "144d", color: colors.ma._144d, ratio: ma.price144dSma },
|
||||
{ id: "200d", color: colors.ma._200d, ratio: ma.price200dSma },
|
||||
{ id: "350d", color: colors.ma._350d, ratio: ma.price350dSma },
|
||||
{ id: "1y", color: colors.ma._1y, ratio: ma.price1ySma },
|
||||
{ id: "2y", color: colors.ma._2y, ratio: ma.price2ySma },
|
||||
{ id: "200w", color: colors.ma._200w, ratio: ma.price200wSma },
|
||||
{ id: "4y", color: colors.ma._4y, ratio: ma.price4ySma },
|
||||
];
|
||||
{ id: "1w", ratio: ma.price1wSma },
|
||||
{ id: "8d", ratio: ma.price8dSma },
|
||||
{ id: "13d", ratio: ma.price13dSma },
|
||||
{ id: "21d", ratio: ma.price21dSma },
|
||||
{ id: "1m", ratio: ma.price1mSma },
|
||||
{ id: "34d", ratio: ma.price34dSma },
|
||||
{ id: "55d", ratio: ma.price55dSma },
|
||||
{ id: "89d", ratio: ma.price89dSma },
|
||||
{ id: "111d", ratio: ma.price111dSma },
|
||||
{ id: "144d", ratio: ma.price144dSma },
|
||||
{ id: "200d", ratio: ma.price200dSma },
|
||||
{ id: "350d", ratio: ma.price350dSma },
|
||||
{ id: "1y", ratio: ma.price1ySma },
|
||||
{ id: "2y", ratio: ma.price2ySma },
|
||||
{ id: "200w", ratio: ma.price200wSma },
|
||||
{ id: "4y", ratio: ma.price4ySma },
|
||||
].map((p, i, arr) => ({ ...p, color: colors.at(i, arr.length) }));
|
||||
|
||||
/** @type {MaPeriod[]} */
|
||||
const ema = [
|
||||
{ id: "1w", color: colors.ma._1w, ratio: ma.price1wEma },
|
||||
{ id: "8d", color: colors.ma._8d, ratio: ma.price8dEma },
|
||||
{ id: "12d", color: colors.ma._12d, ratio: ma.price12dEma },
|
||||
{ id: "13d", color: colors.ma._13d, ratio: ma.price13dEma },
|
||||
{ id: "21d", color: colors.ma._21d, ratio: ma.price21dEma },
|
||||
{ id: "26d", color: colors.ma._26d, ratio: ma.price26dEma },
|
||||
{ id: "1m", color: colors.ma._1m, ratio: ma.price1mEma },
|
||||
{ id: "34d", color: colors.ma._34d, ratio: ma.price34dEma },
|
||||
{ id: "55d", color: colors.ma._55d, ratio: ma.price55dEma },
|
||||
{ id: "89d", color: colors.ma._89d, ratio: ma.price89dEma },
|
||||
{ id: "144d", color: colors.ma._144d, ratio: ma.price144dEma },
|
||||
{ id: "200d", color: colors.ma._200d, ratio: ma.price200dEma },
|
||||
{ id: "1y", color: colors.ma._1y, ratio: ma.price1yEma },
|
||||
{ id: "2y", color: colors.ma._2y, ratio: ma.price2yEma },
|
||||
{ id: "200w", color: colors.ma._200w, ratio: ma.price200wEma },
|
||||
{ id: "4y", color: colors.ma._4y, ratio: ma.price4yEma },
|
||||
];
|
||||
{ id: "1w", ratio: ma.price1wEma },
|
||||
{ id: "8d", ratio: ma.price8dEma },
|
||||
{ id: "12d", ratio: ma.price12dEma },
|
||||
{ id: "13d", ratio: ma.price13dEma },
|
||||
{ id: "21d", ratio: ma.price21dEma },
|
||||
{ id: "26d", ratio: ma.price26dEma },
|
||||
{ id: "1m", ratio: ma.price1mEma },
|
||||
{ id: "34d", ratio: ma.price34dEma },
|
||||
{ id: "55d", ratio: ma.price55dEma },
|
||||
{ id: "89d", ratio: ma.price89dEma },
|
||||
{ id: "144d", ratio: ma.price144dEma },
|
||||
{ id: "200d", ratio: ma.price200dEma },
|
||||
{ id: "1y", ratio: ma.price1yEma },
|
||||
{ id: "2y", ratio: ma.price2yEma },
|
||||
{ id: "200w", ratio: ma.price200wEma },
|
||||
{ id: "4y", ratio: ma.price4yEma },
|
||||
].map((p, i, arr) => ({ ...p, color: colors.at(i, arr.length) }));
|
||||
|
||||
// SMA vs EMA comparison periods (common periods only)
|
||||
const smaVsEma = [
|
||||
{
|
||||
id: "1w",
|
||||
name: "1 Week",
|
||||
color: colors.ma._1w,
|
||||
sma: ma.price1wSma,
|
||||
ema: ma.price1wEma,
|
||||
},
|
||||
{
|
||||
id: "1m",
|
||||
name: "1 Month",
|
||||
color: colors.ma._1m,
|
||||
sma: ma.price1mSma,
|
||||
ema: ma.price1mEma,
|
||||
},
|
||||
{
|
||||
id: "200d",
|
||||
name: "200 Day",
|
||||
color: colors.ma._200d,
|
||||
sma: ma.price200dSma,
|
||||
ema: ma.price200dEma,
|
||||
},
|
||||
{
|
||||
id: "1y",
|
||||
name: "1 Year",
|
||||
color: colors.ma._1y,
|
||||
sma: ma.price1ySma,
|
||||
ema: ma.price1yEma,
|
||||
},
|
||||
{
|
||||
id: "200w",
|
||||
name: "200 Week",
|
||||
color: colors.ma._200w,
|
||||
sma: ma.price200wSma,
|
||||
ema: ma.price200wEma,
|
||||
},
|
||||
{
|
||||
id: "4y",
|
||||
name: "4 Year",
|
||||
color: colors.ma._4y,
|
||||
sma: ma.price4ySma,
|
||||
ema: ma.price4yEma,
|
||||
},
|
||||
];
|
||||
].map((p, i, arr) => ({ ...p, color: colors.at(i, arr.length) }));
|
||||
|
||||
return {
|
||||
name: "Market",
|
||||
tree: [
|
||||
{ name: "Price", title: "Bitcoin Price" },
|
||||
{
|
||||
name: "Oracle",
|
||||
title: "On-chain Price",
|
||||
top: [
|
||||
// @ts-ignore
|
||||
candlestick({
|
||||
metric: priceMetrics.oracle.ohlcDollars,
|
||||
name: "Oracle",
|
||||
unit: Unit.usd,
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: "Sats/$",
|
||||
@@ -401,13 +391,53 @@ export function createMarketSection() {
|
||||
|
||||
{
|
||||
name: "Capitalization",
|
||||
title: "Market Capitalization",
|
||||
bottom: [
|
||||
line({
|
||||
metric: supply.marketCap,
|
||||
name: "Capitalization",
|
||||
unit: Unit.usd,
|
||||
}),
|
||||
tree: [
|
||||
{
|
||||
name: "Market Cap",
|
||||
title: "Market Capitalization",
|
||||
bottom: [
|
||||
line({
|
||||
metric: supply.marketCap,
|
||||
name: "Market Cap",
|
||||
unit: Unit.usd,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Realized Cap",
|
||||
title: "Realized Capitalization",
|
||||
bottom: [
|
||||
line({
|
||||
metric: distribution.utxoCohorts.all.realized.realizedCap,
|
||||
name: "Realized Cap",
|
||||
color: colors.realized,
|
||||
unit: Unit.usd,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Growth Rate",
|
||||
title: "Capitalization Growth Rate",
|
||||
bottom: [
|
||||
line({
|
||||
metric: supply.marketCapGrowthRate,
|
||||
name: "Market Cap",
|
||||
color: colors.bitcoin,
|
||||
unit: Unit.percentage,
|
||||
}),
|
||||
line({
|
||||
metric: supply.realizedCapGrowthRate,
|
||||
name: "Realized Cap",
|
||||
color: colors.usd,
|
||||
unit: Unit.percentage,
|
||||
}),
|
||||
baseline({
|
||||
metric: supply.capGrowthRateDiff,
|
||||
name: "Difference",
|
||||
unit: Unit.percentage,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -632,7 +662,7 @@ export function createMarketSection() {
|
||||
price({
|
||||
metric: ma.price200dSma.price,
|
||||
name: "200d SMA",
|
||||
color: colors.ma._200d,
|
||||
color: colors.indicator.main,
|
||||
}),
|
||||
price({
|
||||
metric: ma.price200dSmaX24,
|
||||
|
||||
@@ -244,7 +244,7 @@ export function createMiningSection() {
|
||||
line({
|
||||
metric: blocks.mining.hashRate2mSma,
|
||||
name: "2m SMA",
|
||||
color: colors.ma._2m,
|
||||
color: colors.indicator.main,
|
||||
unit: Unit.hashRate,
|
||||
defaultActive: false,
|
||||
}),
|
||||
@@ -677,7 +677,7 @@ export function createMiningSection() {
|
||||
line({
|
||||
metric: p.pool._1mDominance,
|
||||
name: p.name,
|
||||
color: colors.at(i),
|
||||
color: colors.at(i, majorPools.length),
|
||||
unit: Unit.percentage,
|
||||
}),
|
||||
),
|
||||
@@ -689,7 +689,7 @@ export function createMiningSection() {
|
||||
line({
|
||||
metric: p.pool._1mBlocksMined,
|
||||
name: p.name,
|
||||
color: colors.at(i),
|
||||
color: colors.at(i, majorPools.length),
|
||||
unit: Unit.count,
|
||||
}),
|
||||
),
|
||||
@@ -702,7 +702,7 @@ export function createMiningSection() {
|
||||
source: p.pool.coinbase,
|
||||
key: "sum",
|
||||
name: p.name,
|
||||
color: colors.at(i),
|
||||
color: colors.at(i, majorPools.length),
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -719,7 +719,7 @@ export function createMiningSection() {
|
||||
line({
|
||||
metric: p.pool._1mDominance,
|
||||
name: p.name,
|
||||
color: colors.at(i),
|
||||
color: colors.at(i, antpoolFriends.length),
|
||||
unit: Unit.percentage,
|
||||
}),
|
||||
),
|
||||
@@ -731,7 +731,7 @@ export function createMiningSection() {
|
||||
line({
|
||||
metric: p.pool._1mBlocksMined,
|
||||
name: p.name,
|
||||
color: colors.at(i),
|
||||
color: colors.at(i, antpoolFriends.length),
|
||||
unit: Unit.count,
|
||||
}),
|
||||
),
|
||||
@@ -744,7 +744,7 @@ export function createMiningSection() {
|
||||
source: p.pool.coinbase,
|
||||
key: "sum",
|
||||
name: p.name,
|
||||
color: colors.at(i),
|
||||
color: colors.at(i, antpoolFriends.length),
|
||||
}),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { colors } from "../utils/colors.js";
|
||||
import { brk } from "../client.js";
|
||||
import { Unit } from "../utils/units.js";
|
||||
import { entries } from "../utils/array.js";
|
||||
import { priceLine } from "./constants.js";
|
||||
import {
|
||||
line,
|
||||
@@ -375,25 +376,13 @@ export function createNetworkSection() {
|
||||
});
|
||||
|
||||
// Script type groups for Output Counts
|
||||
const legacyScripts = /** @type {const} */ ([
|
||||
{ key: "p2pkh", name: "P2PKH", color: st.p2pkh },
|
||||
{ key: "p2pk33", name: "P2PK33", color: st.p2pk33 },
|
||||
{ key: "p2pk65", name: "P2PK65", color: st.p2pk65 },
|
||||
]);
|
||||
const scriptHashScripts = /** @type {const} */ ([
|
||||
{ key: "p2sh", name: "P2SH", color: st.p2sh },
|
||||
{ key: "p2ms", name: "P2MS", color: st.p2ms },
|
||||
]);
|
||||
const segwitScripts = /** @type {const} */ ([
|
||||
{ key: "segwit", name: "All SegWit", color: colors.segwit },
|
||||
{ key: "p2wsh", name: "P2WSH", color: st.p2wsh },
|
||||
{ key: "p2wpkh", name: "P2WPKH", color: st.p2wpkh },
|
||||
]);
|
||||
const otherScripts = /** @type {const} */ ([
|
||||
{ key: "opreturn", name: "OP_RETURN", color: st.opreturn },
|
||||
{ key: "emptyoutput", name: "Empty", color: st.empty },
|
||||
{ key: "unknownoutput", name: "Unknown", color: st.unknown },
|
||||
]);
|
||||
const legacyScripts = legacyAddresses.slice(1); // p2pkh, p2pk33, p2pk65
|
||||
const scriptHashScripts = [legacyAddresses[0], nonAddressableTypes[0]]; // p2sh, p2ms
|
||||
const segwitScripts = [
|
||||
/** @type {const} */ ({ key: "segwit", name: "All SegWit", color: colors.segwit }),
|
||||
...segwitAddresses,
|
||||
];
|
||||
const otherScripts = nonAddressableTypes.slice(1); // opreturn, empty, unknown
|
||||
|
||||
/**
|
||||
* Create Compare charts for a script group
|
||||
@@ -558,50 +547,28 @@ export function createNetworkSection() {
|
||||
{
|
||||
name: "Sum",
|
||||
title: "Transaction Versions",
|
||||
bottom: [
|
||||
line({
|
||||
metric: transactions.versions.v1.sum,
|
||||
name: "v1",
|
||||
color: colors.txVersion.v1,
|
||||
unit: Unit.count,
|
||||
}),
|
||||
line({
|
||||
metric: transactions.versions.v2.sum,
|
||||
name: "v2",
|
||||
color: colors.txVersion.v2,
|
||||
unit: Unit.count,
|
||||
}),
|
||||
line({
|
||||
metric: transactions.versions.v3.sum,
|
||||
name: "v3",
|
||||
color: colors.txVersion.v3,
|
||||
unit: Unit.count,
|
||||
}),
|
||||
],
|
||||
bottom: entries(transactions.versions).map(
|
||||
([v, data], i, arr) =>
|
||||
line({
|
||||
metric: data.sum,
|
||||
name: v,
|
||||
color: colors.at(i, arr.length),
|
||||
unit: Unit.count,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Cumulative",
|
||||
title: "Transaction Versions (Total)",
|
||||
bottom: [
|
||||
line({
|
||||
metric: transactions.versions.v1.cumulative,
|
||||
name: "v1",
|
||||
color: colors.txVersion.v1,
|
||||
unit: Unit.count,
|
||||
}),
|
||||
line({
|
||||
metric: transactions.versions.v2.cumulative,
|
||||
name: "v2",
|
||||
color: colors.txVersion.v2,
|
||||
unit: Unit.count,
|
||||
}),
|
||||
line({
|
||||
metric: transactions.versions.v3.cumulative,
|
||||
name: "v3",
|
||||
color: colors.txVersion.v3,
|
||||
unit: Unit.count,
|
||||
}),
|
||||
],
|
||||
bottom: entries(transactions.versions).map(
|
||||
([v, data], i, arr) =>
|
||||
line({
|
||||
metric: data.cumulative,
|
||||
name: v,
|
||||
color: colors.at(i, arr.length),
|
||||
unit: Unit.count,
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1248,7 +1215,7 @@ export function createNetworkSection() {
|
||||
line({
|
||||
metric: scripts.count.taprootAdoption.cumulative,
|
||||
name: "Taproot",
|
||||
color: colors.scriptType.p2tr,
|
||||
color: taprootAddresses[1].color,
|
||||
unit: Unit.percentage,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -321,14 +321,14 @@ export function sdBandsRatio(sd) {
|
||||
* @param {AnyRatioPattern} ratio
|
||||
*/
|
||||
export function ratioSmas(ratio) {
|
||||
return /** @type {const} */ ([
|
||||
{ name: "1w SMA", metric: ratio.ratio1wSma, color: colors.ma._1w },
|
||||
{ name: "1m SMA", metric: ratio.ratio1mSma, color: colors.ma._1m },
|
||||
{ name: "1y SMA", metric: ratio.ratio1ySd.sma, color: colors.ma._1y },
|
||||
{ name: "2y SMA", metric: ratio.ratio2ySd.sma, color: colors.ma._2y },
|
||||
{ name: "4y SMA", metric: ratio.ratio4ySd.sma, color: colors.ma._4y },
|
||||
return [
|
||||
{ name: "1w SMA", metric: ratio.ratio1wSma },
|
||||
{ name: "1m SMA", metric: ratio.ratio1mSma },
|
||||
{ name: "1y SMA", metric: ratio.ratio1ySd.sma },
|
||||
{ name: "2y SMA", metric: ratio.ratio2ySd.sma },
|
||||
{ name: "4y SMA", metric: ratio.ratio4ySd.sma },
|
||||
{ name: "All SMA", metric: ratio.ratioSd.sma, color: colors.time.all },
|
||||
]);
|
||||
].map((s, i, arr) => ({ color: colors.at(i, arr.length), ...s }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -400,6 +400,13 @@ export function createZScoresFolder({
|
||||
}) {
|
||||
const sdPats = sdPatterns(ratio);
|
||||
|
||||
const zscorePeriods = [
|
||||
{ name: "1y", sd: ratio.ratio1ySd },
|
||||
{ name: "2y", sd: ratio.ratio2ySd },
|
||||
{ name: "4y", sd: ratio.ratio4ySd },
|
||||
{ name: "all", sd: ratio.ratioSd, color: colors.time.all },
|
||||
].map((s, i, arr) => ({ color: colors.at(i, arr.length), ...s }));
|
||||
|
||||
return {
|
||||
name: "Z-Scores",
|
||||
tree: [
|
||||
@@ -408,56 +415,24 @@ export function createZScoresFolder({
|
||||
title: formatTitle("Z-Scores"),
|
||||
top: [
|
||||
price({ metric: pricePattern, name: legend, color }),
|
||||
price({
|
||||
metric: ratio.ratio1ySd._0sdUsd,
|
||||
name: "1y 0σ",
|
||||
color: colors.ma._1y,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: ratio.ratio2ySd._0sdUsd,
|
||||
name: "2y 0σ",
|
||||
color: colors.ma._2y,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: ratio.ratio4ySd._0sdUsd,
|
||||
name: "4y 0σ",
|
||||
color: colors.ma._4y,
|
||||
defaultActive: false,
|
||||
}),
|
||||
price({
|
||||
metric: ratio.ratioSd._0sdUsd,
|
||||
name: "all 0σ",
|
||||
color: colors.time.all,
|
||||
defaultActive: false,
|
||||
}),
|
||||
...zscorePeriods.map((p) =>
|
||||
price({
|
||||
metric: p.sd._0sdUsd,
|
||||
name: `${p.name} 0σ`,
|
||||
color: p.color,
|
||||
defaultActive: false,
|
||||
}),
|
||||
),
|
||||
],
|
||||
bottom: [
|
||||
line({
|
||||
metric: ratio.ratioSd.zscore,
|
||||
name: "All",
|
||||
color: colors.time.all,
|
||||
unit: Unit.sd,
|
||||
}),
|
||||
line({
|
||||
metric: ratio.ratio4ySd.zscore,
|
||||
name: "4y",
|
||||
color: colors.ma._4y,
|
||||
unit: Unit.sd,
|
||||
}),
|
||||
line({
|
||||
metric: ratio.ratio2ySd.zscore,
|
||||
name: "2y",
|
||||
color: colors.ma._2y,
|
||||
unit: Unit.sd,
|
||||
}),
|
||||
line({
|
||||
metric: ratio.ratio1ySd.zscore,
|
||||
name: "1y",
|
||||
color: colors.ma._1y,
|
||||
unit: Unit.sd,
|
||||
}),
|
||||
...zscorePeriods.reverse().map((p) =>
|
||||
line({
|
||||
metric: p.sd.zscore,
|
||||
name: p.name,
|
||||
color: p.color,
|
||||
unit: Unit.sd,
|
||||
}),
|
||||
),
|
||||
...priceLines({
|
||||
unit: Unit.sd,
|
||||
numbers: [0, 1, -1, 2, -2, 3, -3],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createShadow, createHeader } from "../utils/dom.js";
|
||||
import { createHeader } from "../utils/dom.js";
|
||||
import { chartElement } from "../utils/elements.js";
|
||||
import { serdeChartableIndex } from "../utils/serde.js";
|
||||
import { Unit } from "../utils/units.js";
|
||||
@@ -21,15 +21,11 @@ export function setOption(opt) {
|
||||
}
|
||||
|
||||
export function init() {
|
||||
chartElement.append(createShadow("left"));
|
||||
chartElement.append(createShadow("right"));
|
||||
|
||||
const { headerElement, headingElement } = createHeader();
|
||||
chartElement.append(headerElement);
|
||||
|
||||
const chart = createChart({
|
||||
parent: chartElement,
|
||||
id: "charts",
|
||||
brk,
|
||||
});
|
||||
|
||||
@@ -66,15 +62,12 @@ export function init() {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @type {ReturnType<typeof chart.setBlueprints> | null} */
|
||||
let blueprints = null;
|
||||
|
||||
function updatePriceWithLatest() {
|
||||
const latest = webSockets.kraken1dCandle.latest();
|
||||
if (!latest || !blueprints) return;
|
||||
if (!latest) return;
|
||||
|
||||
const priceSeries = blueprints.panes[0].series[0];
|
||||
const unit = blueprints.panes[0].unit;
|
||||
const priceSeries = chart.panes[0].series[0];
|
||||
const unit = chart.panes[0].unit;
|
||||
if (!priceSeries?.hasData() || !unit) return;
|
||||
|
||||
const last = /** @type {CandlestickData | undefined} */ (
|
||||
@@ -96,7 +89,7 @@ export function init() {
|
||||
headingElement.innerHTML = opt.title;
|
||||
|
||||
// Set blueprints first so storageId is correct before any index change
|
||||
blueprints = chart.setBlueprints({
|
||||
chart.setBlueprints({
|
||||
name: opt.title,
|
||||
top: buildTopBlueprints(opt.top),
|
||||
bottom: opt.bottom,
|
||||
|
||||
+59
-233
@@ -51,20 +51,32 @@ function createColor(getter) {
|
||||
|
||||
const globalComputedStyle = getComputedStyle(window.document.documentElement);
|
||||
|
||||
/**
|
||||
* Resolve a light-dark() value based on current theme
|
||||
* @param {string} value
|
||||
*/
|
||||
function resolveLightDark(value) {
|
||||
if (value.startsWith("light-dark(")) {
|
||||
const [light, _dark] = value.slice(11, -1).split(", ");
|
||||
return dark ? _dark : light;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
function getColor(name) {
|
||||
return globalComputedStyle.getPropertyValue(`--${name}`);
|
||||
return globalComputedStyle.getPropertyValue(`--${name}`).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} property
|
||||
*/
|
||||
function getLightDarkValue(property) {
|
||||
const value = globalComputedStyle.getPropertyValue(property);
|
||||
const [light, _dark] = value.slice(11, -1).split(", ");
|
||||
return dark ? _dark : light;
|
||||
return resolveLightDark(
|
||||
globalComputedStyle.getPropertyValue(property).trim(),
|
||||
);
|
||||
}
|
||||
|
||||
const palette = {
|
||||
@@ -88,6 +100,29 @@ const palette = {
|
||||
rose: createColor(() => getColor("rose")),
|
||||
};
|
||||
|
||||
const paletteArr = Object.values(palette);
|
||||
|
||||
/**
|
||||
* Get a palette color by index, spreading small groups for better separation
|
||||
* @param {number} index
|
||||
* @param {number} [length]
|
||||
*/
|
||||
function at(index, length) {
|
||||
const n = paletteArr.length;
|
||||
if (length && length <= n / 2) {
|
||||
return paletteArr[Math.round((index * n) / length) % n];
|
||||
}
|
||||
return paletteArr[index % n];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a named color map from keys, using position-based palette assignment
|
||||
* @param {readonly string[]} keys
|
||||
*/
|
||||
function seq(keys) {
|
||||
return Object.fromEntries(keys.map((key, i) => [key, at(i, keys.length)]));
|
||||
}
|
||||
|
||||
export const colors = {
|
||||
default: createColor(() => getLightDarkValue("--color")),
|
||||
gray: createColor(() => getColor("gray")),
|
||||
@@ -138,21 +173,13 @@ export const colors = {
|
||||
plRatio: palette.yellow,
|
||||
|
||||
// Mining
|
||||
mining: {
|
||||
coinbase: palette.red,
|
||||
subsidy: palette.orange,
|
||||
fee: palette.yellow,
|
||||
},
|
||||
mining: seq(["coinbase", "subsidy", "fee"]),
|
||||
|
||||
// Network
|
||||
segwit: palette.cyan,
|
||||
|
||||
// Entity (transactions, inputs, outputs)
|
||||
entity: {
|
||||
tx: palette.red,
|
||||
input: palette.orange,
|
||||
output: palette.yellow,
|
||||
},
|
||||
entity: seq(["tx", "input", "output"]),
|
||||
|
||||
// Technical indicators
|
||||
indicator: {
|
||||
@@ -182,9 +209,9 @@ export const colors = {
|
||||
_99: palette.rose,
|
||||
_98: palette.pink,
|
||||
_95: palette.fuchsia,
|
||||
_5: palette.cyan,
|
||||
_5: palette.teal,
|
||||
_2: palette.sky,
|
||||
_1: palette.blue,
|
||||
_1: palette.indigo,
|
||||
},
|
||||
|
||||
// Standard deviation bands (warm = positive, cool = negative)
|
||||
@@ -204,37 +231,6 @@ export const colors = {
|
||||
m3: palette.violet,
|
||||
},
|
||||
|
||||
// Transaction versions
|
||||
txVersion: {
|
||||
v1: palette.red,
|
||||
v2: palette.orange,
|
||||
v3: palette.yellow,
|
||||
},
|
||||
|
||||
pct: {
|
||||
_100: palette.red,
|
||||
_95: palette.orange,
|
||||
_90: palette.amber,
|
||||
_85: palette.yellow,
|
||||
_80: palette.avocado,
|
||||
_75: palette.lime,
|
||||
_70: palette.green,
|
||||
_65: palette.emerald,
|
||||
_60: palette.teal,
|
||||
_55: palette.cyan,
|
||||
_50: palette.sky,
|
||||
_45: palette.blue,
|
||||
_40: palette.indigo,
|
||||
_35: palette.violet,
|
||||
_30: palette.purple,
|
||||
_25: palette.fuchsia,
|
||||
_20: palette.pink,
|
||||
_15: palette.rose,
|
||||
_10: palette.red,
|
||||
_05: palette.orange,
|
||||
_0: palette.amber,
|
||||
},
|
||||
|
||||
time: {
|
||||
_24h: palette.red,
|
||||
_1w: palette.yellow,
|
||||
@@ -248,192 +244,22 @@ export const colors = {
|
||||
long: palette.fuchsia,
|
||||
},
|
||||
|
||||
age: {
|
||||
_1d: palette.red,
|
||||
_1w: palette.orange,
|
||||
_1m: palette.yellow,
|
||||
_2m: palette.lime,
|
||||
_3m: palette.green,
|
||||
_4m: palette.teal,
|
||||
_5m: palette.cyan,
|
||||
_6m: palette.blue,
|
||||
_1y: palette.indigo,
|
||||
_2y: palette.violet,
|
||||
_3y: palette.purple,
|
||||
_4y: palette.fuchsia,
|
||||
_5y: palette.pink,
|
||||
_6y: palette.rose,
|
||||
_7y: palette.red,
|
||||
_8y: palette.orange,
|
||||
_10y: palette.yellow,
|
||||
_12y: palette.lime,
|
||||
_15y: palette.green,
|
||||
},
|
||||
scriptType: seq([
|
||||
"p2pk65",
|
||||
"p2pk33",
|
||||
"p2pkh",
|
||||
"p2ms",
|
||||
"p2sh",
|
||||
"p2wpkh",
|
||||
"p2wsh",
|
||||
"p2tr",
|
||||
"p2a",
|
||||
"opreturn",
|
||||
"unknown",
|
||||
"empty",
|
||||
]),
|
||||
|
||||
ageRange: {
|
||||
upTo1h: palette.red,
|
||||
_1hTo1d: palette.orange,
|
||||
_1dTo1w: palette.amber,
|
||||
_1wTo1m: palette.yellow,
|
||||
_1mTo2m: palette.avocado,
|
||||
_2mTo3m: palette.lime,
|
||||
_3mTo4m: palette.green,
|
||||
_4mTo5m: palette.emerald,
|
||||
_5mTo6m: palette.teal,
|
||||
_6mTo1y: palette.cyan,
|
||||
_1yTo2y: palette.sky,
|
||||
_2yTo3y: palette.blue,
|
||||
_3yTo4y: palette.indigo,
|
||||
_4yTo5y: palette.violet,
|
||||
_5yTo6y: palette.purple,
|
||||
_6yTo7y: palette.fuchsia,
|
||||
_7yTo8y: palette.pink,
|
||||
_8yTo10y: palette.rose,
|
||||
_10yTo12y: palette.red,
|
||||
_12yTo15y: palette.orange,
|
||||
from15y: palette.amber,
|
||||
},
|
||||
arr: paletteArr,
|
||||
|
||||
amount: {
|
||||
_1sat: palette.red,
|
||||
_10sats: palette.orange,
|
||||
_100sats: palette.yellow,
|
||||
_1kSats: palette.lime,
|
||||
_10kSats: palette.green,
|
||||
_100kSats: palette.teal,
|
||||
_1mSats: palette.cyan,
|
||||
_10mSats: palette.blue,
|
||||
_1btc: palette.indigo,
|
||||
_10btc: palette.violet,
|
||||
_100btc: palette.purple,
|
||||
_1kBtc: palette.fuchsia,
|
||||
_10kBtc: palette.pink,
|
||||
_100kBtc: palette.rose,
|
||||
},
|
||||
|
||||
amountRange: {
|
||||
_0sats: palette.red,
|
||||
_1satTo10sats: palette.orange,
|
||||
_10satsTo100sats: palette.yellow,
|
||||
_100satsTo1kSats: palette.lime,
|
||||
_1kSatsTo10kSats: palette.green,
|
||||
_10kSatsTo100kSats: palette.teal,
|
||||
_100kSatsTo1mSats: palette.cyan,
|
||||
_1mSatsTo10mSats: palette.blue,
|
||||
_10mSatsTo1btc: palette.indigo,
|
||||
_1btcTo10btc: palette.violet,
|
||||
_10btcTo100btc: palette.purple,
|
||||
_100btcTo1kBtc: palette.fuchsia,
|
||||
_1kBtcTo10kBtc: palette.pink,
|
||||
_10kBtcTo100kBtc: palette.rose,
|
||||
_100kBtcOrMore: palette.red,
|
||||
},
|
||||
|
||||
epoch: {
|
||||
_0: palette.red,
|
||||
_1: palette.orange,
|
||||
_2: palette.yellow,
|
||||
_3: palette.lime,
|
||||
_4: palette.green,
|
||||
},
|
||||
|
||||
year: {
|
||||
_2009: palette.red,
|
||||
_2010: palette.orange,
|
||||
_2011: palette.amber,
|
||||
_2012: palette.yellow,
|
||||
_2013: palette.lime,
|
||||
_2014: palette.green,
|
||||
_2015: palette.teal,
|
||||
_2016: palette.cyan,
|
||||
_2017: palette.sky,
|
||||
_2018: palette.blue,
|
||||
_2019: palette.indigo,
|
||||
_2020: palette.violet,
|
||||
_2021: palette.purple,
|
||||
_2022: palette.fuchsia,
|
||||
_2023: palette.pink,
|
||||
_2024: palette.rose,
|
||||
_2025: palette.red,
|
||||
_2026: palette.orange,
|
||||
},
|
||||
|
||||
returns: {
|
||||
_1d: palette.red,
|
||||
_1w: palette.orange,
|
||||
_1m: palette.yellow,
|
||||
_3m: palette.lime,
|
||||
_6m: palette.green,
|
||||
_1y: palette.teal,
|
||||
_2y: palette.cyan,
|
||||
_3y: palette.sky,
|
||||
_4y: palette.blue,
|
||||
_5y: palette.indigo,
|
||||
_6y: palette.violet,
|
||||
_8y: palette.purple,
|
||||
_10y: palette.fuchsia,
|
||||
},
|
||||
|
||||
ma: {
|
||||
_1w: palette.red,
|
||||
_8d: palette.orange,
|
||||
_12d: palette.amber,
|
||||
_13d: palette.yellow,
|
||||
_14d: palette.avocado,
|
||||
_21d: palette.avocado,
|
||||
_26d: palette.lime,
|
||||
_1m: palette.green,
|
||||
_34d: palette.emerald,
|
||||
_55d: palette.teal,
|
||||
_2m: palette.cyan,
|
||||
_89d: palette.sky,
|
||||
_111d: palette.blue,
|
||||
_144d: palette.indigo,
|
||||
_200d: palette.violet,
|
||||
_350d: palette.purple,
|
||||
_1y: palette.fuchsia,
|
||||
_2y: palette.pink,
|
||||
_200w: palette.rose,
|
||||
_4y: palette.red,
|
||||
},
|
||||
|
||||
dca: {
|
||||
_1w: palette.red,
|
||||
_1m: palette.orange,
|
||||
_3m: palette.yellow,
|
||||
_6m: palette.lime,
|
||||
_1y: palette.green,
|
||||
_2y: palette.teal,
|
||||
_3y: palette.cyan,
|
||||
_4y: palette.sky,
|
||||
_5y: palette.blue,
|
||||
_6y: palette.indigo,
|
||||
_8y: palette.violet,
|
||||
_10y: palette.purple,
|
||||
},
|
||||
|
||||
scriptType: {
|
||||
p2pk65: palette.red,
|
||||
p2pk33: palette.orange,
|
||||
p2pkh: palette.yellow,
|
||||
p2ms: palette.lime,
|
||||
p2sh: palette.green,
|
||||
p2wpkh: palette.teal,
|
||||
p2wsh: palette.blue,
|
||||
p2tr: palette.indigo,
|
||||
p2a: palette.violet,
|
||||
opreturn: palette.purple,
|
||||
unknown: palette.fuchsia,
|
||||
empty: palette.pink,
|
||||
},
|
||||
|
||||
arr: Object.values(palette),
|
||||
|
||||
/**
|
||||
* Get a color by index (cycles through palette)
|
||||
* @param {number} index
|
||||
*/
|
||||
at(index) {
|
||||
return this.arr[index % this.arr.length];
|
||||
},
|
||||
at,
|
||||
};
|
||||
|
||||
@@ -17,8 +17,8 @@ export const Unit = /** @type {const} */ ({
|
||||
sd: { id: "sd", name: "Std Dev" },
|
||||
|
||||
// Relative percentages
|
||||
pctSupply: { id: "pct-supply", name: "% of circulating Supply" },
|
||||
pctOwn: { id: "pct-own", name: "% of Own Supply" },
|
||||
pctSupply: { id: "pct-supply", name: "% of circulating" },
|
||||
pctOwn: { id: "pct-own", name: "% of Own" },
|
||||
pctMcap: { id: "pct-mcap", name: "% of Market Cap" },
|
||||
pctRcap: { id: "pct-rcap", name: "% of Realized Cap" },
|
||||
pctOwnRcap: { id: "pct-own-rcap", name: "% of Own Realized Cap" },
|
||||
|
||||
@@ -262,6 +262,12 @@ fieldset {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
|
||||
label,
|
||||
select {
|
||||
margin: -0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
legend {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -305,13 +311,18 @@ fieldset {
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
padding: 0 var(--main-padding);
|
||||
padding-top: 0.25rem;
|
||||
padding-top: 0.278rem;
|
||||
padding-bottom: 0.75rem;
|
||||
pointer-events: auto;
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
> span {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
> div:has(> select) {
|
||||
> select {
|
||||
@@ -338,9 +349,6 @@ fieldset {
|
||||
align-items: center;
|
||||
|
||||
> label {
|
||||
margin: -0.375rem 0;
|
||||
color: var(--color);
|
||||
|
||||
> span {
|
||||
display: flex !important;
|
||||
}
|
||||
@@ -363,6 +371,10 @@ fieldset {
|
||||
text-decoration-color: var(--orange) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--orange);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,40 +401,78 @@ fieldset {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
table > tr:first-child {
|
||||
position: relative;
|
||||
table > tr {
|
||||
&:first-child > td:nth-child(2) {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--main-padding);
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--background-color),
|
||||
transparent
|
||||
);
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--main-padding);
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--background-color),
|
||||
transparent
|
||||
);
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table > tr:last-child {
|
||||
position: relative;
|
||||
|
||||
> td {
|
||||
&:last-child > td {
|
||||
border-top: 1px;
|
||||
|
||||
&:nth-child(2) {
|
||||
position: relative;
|
||||
|
||||
> .field {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: auto;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-xs);
|
||||
text-transform: uppercase;
|
||||
background-color: var(--background-color);
|
||||
padding-left: var(--main-padding);
|
||||
padding-right: 0.25rem;
|
||||
|
||||
> select {
|
||||
width: auto;
|
||||
background: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 100%;
|
||||
width: var(--main-padding);
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--background-color),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td:last-child > .field {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
font-size: var(--font-size-xs);
|
||||
gap: 0.25rem;
|
||||
@@ -430,62 +480,25 @@ fieldset {
|
||||
align-items: center;
|
||||
text-transform: uppercase;
|
||||
padding-left: 0.625rem;
|
||||
padding-top: 0.125rem;
|
||||
padding-bottom: 0.25rem;
|
||||
padding-top: 0.35rem;
|
||||
padding-bottom: 0.125rem;
|
||||
|
||||
label {
|
||||
margin: -0.25rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2rem;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
table > tr:last-child > div:has(> select) {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: auto;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-xs);
|
||||
text-transform: uppercase;
|
||||
background-color: var(--background-color);
|
||||
padding-left: var(--main-padding);
|
||||
padding-right: 0.25rem;
|
||||
margin-top: 0.5px;
|
||||
|
||||
> select {
|
||||
width: auto;
|
||||
background: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 100%;
|
||||
width: var(--main-padding);
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1rem;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--background-color),
|
||||
transparent
|
||||
to top,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
color: var(--white);
|
||||
background-color: var(--orange);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -376,7 +377,6 @@ sup {
|
||||
summary > small {
|
||||
margin-left: 0.375rem;
|
||||
opacity: 0.5;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
small {
|
||||
|
||||
@@ -6,6 +6,37 @@
|
||||
min-height: 0;
|
||||
padding: var(--main-padding);
|
||||
background-color: var(--background-color);
|
||||
position: relative;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: var(--main-padding);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
background-image: linear-gradient(
|
||||
to left,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
|
||||
header {
|
||||
flex-shrink: 0;
|
||||
@@ -13,16 +44,15 @@
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: -1.25rem;
|
||||
margin-bottom: -1rem;
|
||||
padding-left: var(--main-padding);
|
||||
margin-left: var(--negative-main-padding);
|
||||
padding-right: var(--main-padding);
|
||||
margin-right: var(--negative-main-padding);
|
||||
margin-top: -0.5rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.375rem;
|
||||
letter-spacing: 0.05rem;
|
||||
letter-spacing: 0.075rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
}
|
||||
@@ -30,5 +60,6 @@
|
||||
.chart {
|
||||
flex: 1;
|
||||
margin-bottom: -0.25rem;
|
||||
z-index: 20;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
/*oklch(0.9333 0.0059 59.65)*/
|
||||
--light-gray: oklch(85% 0.01 75);
|
||||
--gray: oklch(60% 0.01 44);
|
||||
--dark-gray: oklch(30% 0.01 44);
|
||||
--dark-gray: oklch(25% 0.006 90);
|
||||
--black: oklch(16.5% 0.006 90);
|
||||
/*oklch(0.2038 0.0076 196.57)*/
|
||||
--red: oklch(0.607 0.241 26.328);
|
||||
|
||||
Reference in New Issue
Block a user