mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-12 16:03:31 -07:00
global: snapshot
This commit is contained in:
@@ -5145,7 +5145,9 @@ impl MetricsTree_Price_Usd {
|
||||
pub struct MetricsTree_Price_Oracle {
|
||||
pub price_cents: MetricPattern11<CentsUnsigned>,
|
||||
pub ohlc_cents: MetricPattern6<OHLCCentsUnsigned>,
|
||||
pub ohlc_dollars: MetricPattern6<OHLCDollars>,
|
||||
pub split: CloseHighLowOpenPattern2<CentsUnsigned>,
|
||||
pub ohlc: MetricPattern1<OHLCCentsUnsigned>,
|
||||
pub ohlc_dollars: MetricPattern1<OHLCDollars>,
|
||||
}
|
||||
|
||||
impl MetricsTree_Price_Oracle {
|
||||
@@ -5153,7 +5155,9 @@ impl MetricsTree_Price_Oracle {
|
||||
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()),
|
||||
split: CloseHighLowOpenPattern2::new(client.clone(), "oracle_price".to_string()),
|
||||
ohlc: MetricPattern1::new(client.clone(), "oracle_price_ohlc".to_string()),
|
||||
ohlc_dollars: MetricPattern1::new(client.clone(), "oracle_ohlc_dollars".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6318,10 +6322,10 @@ impl BrkClient {
|
||||
|
||||
/// 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.
|
||||
/// Returns the current BTC/USD price in dollars, 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> {
|
||||
pub fn get_live_price(&self) -> Result<Dollars> {
|
||||
self.base.get_json(&format!("/api/mempool/price"))
|
||||
}
|
||||
|
||||
|
||||
@@ -249,8 +249,10 @@ impl UTXOCohorts {
|
||||
.try_for_each(|v| v.compute_rest_part1(indexes, price, starting_indexes, exit))?;
|
||||
|
||||
// 2. Compute net_sentiment.height for separate cohorts (greed - pain)
|
||||
self.par_iter_separate_mut()
|
||||
.try_for_each(|v| v.metrics.compute_net_sentiment_height(starting_indexes, exit))?;
|
||||
self.par_iter_separate_mut().try_for_each(|v| {
|
||||
v.metrics
|
||||
.compute_net_sentiment_height(starting_indexes, exit)
|
||||
})?;
|
||||
|
||||
// 3. Compute net_sentiment.height for aggregate cohorts (weighted average)
|
||||
self.for_each_aggregate(|vecs, sources| {
|
||||
@@ -260,8 +262,10 @@ impl UTXOCohorts {
|
||||
})?;
|
||||
|
||||
// 4. Compute net_sentiment dateindex for ALL cohorts
|
||||
self.par_iter_mut()
|
||||
.try_for_each(|v| v.metrics.compute_net_sentiment_rest(indexes, starting_indexes, exit))
|
||||
self.par_iter_mut().try_for_each(|v| {
|
||||
v.metrics
|
||||
.compute_net_sentiment_rest(indexes, starting_indexes, exit)
|
||||
})
|
||||
}
|
||||
|
||||
/// Second phase of post-processing: compute relative metrics.
|
||||
@@ -468,7 +472,8 @@ impl UTXOCohorts {
|
||||
// Collect merged entries during the merge (already in sorted order)
|
||||
// Pre-allocate with max possible unique prices (actual count likely lower due to dedup)
|
||||
let max_unique_prices = relevant.iter().map(|e| e.len()).max().unwrap_or(0);
|
||||
let mut merged: Vec<(CentsUnsignedCompact, Sats)> = Vec::with_capacity(max_unique_prices);
|
||||
let mut merged: Vec<(CentsUnsignedCompact, Sats)> =
|
||||
Vec::with_capacity(max_unique_prices);
|
||||
|
||||
// Finalize a price point: compute percentiles and accumulate for merged vec
|
||||
let mut finalize_price = |price: CentsUnsigned, sats: u64, usd: u128| {
|
||||
@@ -489,7 +494,8 @@ impl UTXOCohorts {
|
||||
}
|
||||
|
||||
// Round to nearest dollar with N significant digits for storage
|
||||
let rounded: CentsUnsignedCompact = price.round_to_dollar(COST_BASIS_PRICE_DIGITS).into();
|
||||
let rounded: CentsUnsignedCompact =
|
||||
price.round_to_dollar(COST_BASIS_PRICE_DIGITS).into();
|
||||
|
||||
// Merge entries with same rounded price using last_mut
|
||||
if let Some((last_price, last_sats)) = merged.last_mut()
|
||||
@@ -562,7 +568,10 @@ impl UTXOCohorts {
|
||||
let dir = states_path.join(format!("utxo_{cohort_name}_cost_basis/by_date"));
|
||||
fs::create_dir_all(&dir)?;
|
||||
let path = dir.join(date.to_string());
|
||||
fs::write(path, CostBasisDistribution::serialize_iter(merged.into_iter())?)?;
|
||||
fs::write(
|
||||
path,
|
||||
CostBasisDistribution::serialize_iter(merged.into_iter())?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -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, indexes)?;
|
||||
|
||||
Ok(Self {
|
||||
db: db.clone(),
|
||||
|
||||
@@ -4,8 +4,8 @@ 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,
|
||||
CentsUnsigned, Close, DateIndex, Height, High, Low, OHLCCentsUnsigned, OHLCDollars, Open,
|
||||
OutputType, Sats, TxIndex, TxOutIndex,
|
||||
};
|
||||
use tracing::info;
|
||||
use vecdb::{
|
||||
@@ -26,6 +26,224 @@ impl Vecs {
|
||||
) -> Result<()> {
|
||||
self.compute_prices(indexer, starting_indexes, exit)?;
|
||||
self.compute_daily_ohlc(indexes, starting_indexes, exit)?;
|
||||
self.compute_split_and_ohlc(starting_indexes, exit)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compute_split_and_ohlc(
|
||||
&mut self,
|
||||
starting_indexes: &ComputeIndexes,
|
||||
exit: &Exit,
|
||||
) -> Result<()> {
|
||||
// Destructure to allow simultaneous borrows of different fields
|
||||
let Self {
|
||||
price_cents,
|
||||
ohlc_cents,
|
||||
split,
|
||||
ohlc,
|
||||
ohlc_dollars,
|
||||
} = self;
|
||||
|
||||
// Open: first-value aggregation
|
||||
split.open.height.compute_transform(
|
||||
starting_indexes.height,
|
||||
&*price_cents,
|
||||
|(h, price, ..)| (h, Open::new(price)),
|
||||
exit,
|
||||
)?;
|
||||
split.open.compute_rest(starting_indexes, exit, |v| {
|
||||
v.compute_transform(
|
||||
starting_indexes.dateindex,
|
||||
&*ohlc_cents,
|
||||
|(di, ohlc_val, ..)| (di, ohlc_val.open),
|
||||
exit,
|
||||
)?;
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
// High: max-value aggregation
|
||||
split.high.height.compute_transform(
|
||||
starting_indexes.height,
|
||||
&*price_cents,
|
||||
|(h, price, ..)| (h, High::new(price)),
|
||||
exit,
|
||||
)?;
|
||||
split.high.compute_rest(starting_indexes, exit, |v| {
|
||||
v.compute_transform(
|
||||
starting_indexes.dateindex,
|
||||
&*ohlc_cents,
|
||||
|(di, ohlc_val, ..)| (di, ohlc_val.high),
|
||||
exit,
|
||||
)?;
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
// Low: min-value aggregation
|
||||
split.low.height.compute_transform(
|
||||
starting_indexes.height,
|
||||
&*price_cents,
|
||||
|(h, price, ..)| (h, Low::new(price)),
|
||||
exit,
|
||||
)?;
|
||||
split.low.compute_rest(starting_indexes, exit, |v| {
|
||||
v.compute_transform(
|
||||
starting_indexes.dateindex,
|
||||
&*ohlc_cents,
|
||||
|(di, ohlc_val, ..)| (di, ohlc_val.low),
|
||||
exit,
|
||||
)?;
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
// Close: last-value aggregation
|
||||
split.close.height.compute_transform(
|
||||
starting_indexes.height,
|
||||
&*price_cents,
|
||||
|(h, price, ..)| (h, Close::new(price)),
|
||||
exit,
|
||||
)?;
|
||||
split.close.compute_rest(starting_indexes, exit, |v| {
|
||||
v.compute_transform(
|
||||
starting_indexes.dateindex,
|
||||
&*ohlc_cents,
|
||||
|(di, ohlc_val, ..)| (di, ohlc_val.close),
|
||||
exit,
|
||||
)?;
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
// Period OHLC aggregates - time based
|
||||
ohlc.dateindex.compute_transform4(
|
||||
starting_indexes.dateindex,
|
||||
&split.open.dateindex,
|
||||
&split.high.dateindex,
|
||||
&split.low.dateindex,
|
||||
&split.close.dateindex,
|
||||
|(i, open, high, low, close, _)| {
|
||||
(i, OHLCCentsUnsigned { open, high, low, close })
|
||||
},
|
||||
exit,
|
||||
)?;
|
||||
|
||||
ohlc.week.compute_transform4(
|
||||
starting_indexes.weekindex,
|
||||
&*split.open.weekindex,
|
||||
&*split.high.weekindex,
|
||||
&*split.low.weekindex,
|
||||
&*split.close.weekindex,
|
||||
|(i, open, high, low, close, _)| {
|
||||
(i, OHLCCentsUnsigned { open, high, low, close })
|
||||
},
|
||||
exit,
|
||||
)?;
|
||||
|
||||
ohlc.month.compute_transform4(
|
||||
starting_indexes.monthindex,
|
||||
&*split.open.monthindex,
|
||||
&*split.high.monthindex,
|
||||
&*split.low.monthindex,
|
||||
&*split.close.monthindex,
|
||||
|(i, open, high, low, close, _)| {
|
||||
(i, OHLCCentsUnsigned { open, high, low, close })
|
||||
},
|
||||
exit,
|
||||
)?;
|
||||
|
||||
ohlc.quarter.compute_transform4(
|
||||
starting_indexes.quarterindex,
|
||||
&*split.open.quarterindex,
|
||||
&*split.high.quarterindex,
|
||||
&*split.low.quarterindex,
|
||||
&*split.close.quarterindex,
|
||||
|(i, open, high, low, close, _)| {
|
||||
(i, OHLCCentsUnsigned { open, high, low, close })
|
||||
},
|
||||
exit,
|
||||
)?;
|
||||
|
||||
ohlc.semester.compute_transform4(
|
||||
starting_indexes.semesterindex,
|
||||
&*split.open.semesterindex,
|
||||
&*split.high.semesterindex,
|
||||
&*split.low.semesterindex,
|
||||
&*split.close.semesterindex,
|
||||
|(i, open, high, low, close, _)| {
|
||||
(i, OHLCCentsUnsigned { open, high, low, close })
|
||||
},
|
||||
exit,
|
||||
)?;
|
||||
|
||||
ohlc.year.compute_transform4(
|
||||
starting_indexes.yearindex,
|
||||
&*split.open.yearindex,
|
||||
&*split.high.yearindex,
|
||||
&*split.low.yearindex,
|
||||
&*split.close.yearindex,
|
||||
|(i, open, high, low, close, _)| {
|
||||
(i, OHLCCentsUnsigned { open, high, low, close })
|
||||
},
|
||||
exit,
|
||||
)?;
|
||||
|
||||
ohlc.decade.compute_transform4(
|
||||
starting_indexes.decadeindex,
|
||||
&*split.open.decadeindex,
|
||||
&*split.high.decadeindex,
|
||||
&*split.low.decadeindex,
|
||||
&*split.close.decadeindex,
|
||||
|(i, open, high, low, close, _)| {
|
||||
(i, OHLCCentsUnsigned { open, high, low, close })
|
||||
},
|
||||
exit,
|
||||
)?;
|
||||
|
||||
// Period OHLC aggregates - chain based
|
||||
ohlc.height.compute_transform4(
|
||||
starting_indexes.height,
|
||||
&split.open.height,
|
||||
&split.high.height,
|
||||
&split.low.height,
|
||||
&split.close.height,
|
||||
|(i, open, high, low, close, _)| {
|
||||
(i, OHLCCentsUnsigned { open, high, low, close })
|
||||
},
|
||||
exit,
|
||||
)?;
|
||||
|
||||
ohlc.difficultyepoch.compute_transform4(
|
||||
starting_indexes.difficultyepoch,
|
||||
&*split.open.difficultyepoch,
|
||||
&*split.high.difficultyepoch,
|
||||
&*split.low.difficultyepoch,
|
||||
&*split.close.difficultyepoch,
|
||||
|(i, open, high, low, close, _)| {
|
||||
(i, OHLCCentsUnsigned { open, high, low, close })
|
||||
},
|
||||
exit,
|
||||
)?;
|
||||
|
||||
// OHLC dollars - transform cents to dollars at every period level
|
||||
macro_rules! cents_to_dollars {
|
||||
($field:ident, $idx:expr) => {
|
||||
ohlc_dollars.$field.compute_transform(
|
||||
$idx,
|
||||
&ohlc.$field,
|
||||
|(i, c, ..)| (i, OHLCDollars::from(c)),
|
||||
exit,
|
||||
)?;
|
||||
};
|
||||
}
|
||||
|
||||
cents_to_dollars!(dateindex, starting_indexes.dateindex);
|
||||
cents_to_dollars!(week, starting_indexes.weekindex);
|
||||
cents_to_dollars!(month, starting_indexes.monthindex);
|
||||
cents_to_dollars!(quarter, starting_indexes.quarterindex);
|
||||
cents_to_dollars!(semester, starting_indexes.semesterindex);
|
||||
cents_to_dollars!(year, starting_indexes.yearindex);
|
||||
cents_to_dollars!(decade, starting_indexes.decadeindex);
|
||||
cents_to_dollars!(height, starting_indexes.height);
|
||||
cents_to_dollars!(difficultyepoch, starting_indexes.difficultyepoch);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,53 @@
|
||||
use brk_error::Result;
|
||||
use brk_types::{DateIndex, OHLCCentsUnsigned, OHLCDollars, Version};
|
||||
use vecdb::{BytesVec, Database, ImportableVec, IterableCloneableVec, LazyVecFrom1, PcoVec};
|
||||
use brk_types::Version;
|
||||
use vecdb::{BytesVec, Database, EagerVec, ImportableVec, PcoVec};
|
||||
|
||||
use super::Vecs;
|
||||
use crate::indexes;
|
||||
use crate::internal::{ComputedOHLC, LazyFromHeightAndDateOHLC};
|
||||
|
||||
impl Vecs {
|
||||
pub fn forced_import(db: &Database, parent_version: Version) -> Result<Self> {
|
||||
let version = parent_version + Version::new(10);
|
||||
pub fn forced_import(
|
||||
db: &Database,
|
||||
parent_version: Version,
|
||||
indexes: &indexes::Vecs,
|
||||
) -> Result<Self> {
|
||||
let version = parent_version + Version::new(11);
|
||||
|
||||
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))
|
||||
},
|
||||
);
|
||||
let split = ComputedOHLC::forced_import(db, "oracle_price", version, indexes)?;
|
||||
|
||||
let ohlc = LazyFromHeightAndDateOHLC {
|
||||
dateindex: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
|
||||
week: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
|
||||
month: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
|
||||
quarter: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
|
||||
semester: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
|
||||
year: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
|
||||
decade: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
|
||||
height: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
|
||||
difficultyepoch: EagerVec::forced_import(db, "oracle_price_ohlc", version)?,
|
||||
};
|
||||
|
||||
let ohlc_dollars = LazyFromHeightAndDateOHLC {
|
||||
dateindex: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
|
||||
week: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
|
||||
month: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
|
||||
quarter: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
|
||||
semester: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
|
||||
year: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
|
||||
decade: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
|
||||
height: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
|
||||
difficultyepoch: EagerVec::forced_import(db, "oracle_ohlc_dollars", version)?,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
price_cents,
|
||||
ohlc_cents,
|
||||
split,
|
||||
ohlc,
|
||||
ohlc_dollars,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use brk_traversable::Traversable;
|
||||
use brk_types::{CentsUnsigned, DateIndex, Height, OHLCCentsUnsigned, OHLCDollars};
|
||||
use vecdb::{BytesVec, LazyVecFrom1, PcoVec};
|
||||
use vecdb::{BytesVec, PcoVec};
|
||||
|
||||
use crate::internal::{ComputedOHLC, LazyFromHeightAndDateOHLC};
|
||||
|
||||
#[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>,
|
||||
pub split: ComputedOHLC<CentsUnsigned>,
|
||||
pub ohlc: LazyFromHeightAndDateOHLC<OHLCCentsUnsigned>,
|
||||
pub ohlc_dollars: LazyFromHeightAndDateOHLC<OHLCDollars>,
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ impl Vecs {
|
||||
vec.compute_percentage_change(
|
||||
starting_indexes.dateindex,
|
||||
mcap_dateindex,
|
||||
30,
|
||||
365,
|
||||
exit,
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -66,7 +66,7 @@ impl Vecs {
|
||||
vec.compute_percentage_change(
|
||||
starting_indexes.dateindex,
|
||||
rcap_dateindex,
|
||||
30,
|
||||
365,
|
||||
exit,
|
||||
)?;
|
||||
Ok(())
|
||||
|
||||
@@ -7,11 +7,12 @@ use vecdb::{Database, IterableCloneableVec, LazyVecFrom2, PAGE_SIZE};
|
||||
|
||||
use super::Vecs;
|
||||
use crate::{
|
||||
distribution, indexes, price,
|
||||
distribution, indexes,
|
||||
internal::{
|
||||
ComputedFromDateAverage, ComputedFromDateLast, DifferenceF32, DollarsIdentity,
|
||||
LazyFromHeightLast, LazyValueFromHeightLast, SatsIdentity,
|
||||
},
|
||||
price,
|
||||
};
|
||||
|
||||
const VERSION: Version = Version::ONE;
|
||||
@@ -61,10 +62,18 @@ impl Vecs {
|
||||
});
|
||||
|
||||
// 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 market_cap_growth_rate = ComputedFromDateLast::forced_import(
|
||||
&db,
|
||||
"market_cap_growth_rate",
|
||||
version + Version::ONE,
|
||||
indexes,
|
||||
)?;
|
||||
let realized_cap_growth_rate = ComputedFromDateLast::forced_import(
|
||||
&db,
|
||||
"realized_cap_growth_rate",
|
||||
version + Version::ONE,
|
||||
indexes,
|
||||
)?;
|
||||
let cap_growth_rate_diff = LazyVecFrom2::transformed::<DifferenceF32>(
|
||||
"cap_growth_rate_diff",
|
||||
version,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
|
||||
use brk_types::RecommendedFees;
|
||||
|
||||
use super::{fees, stats::{self, BlockStats}};
|
||||
@@ -36,4 +38,14 @@ impl Snapshot {
|
||||
fees,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash of the first projected block (the one about to be mined).
|
||||
pub fn next_block_hash(&self) -> u64 {
|
||||
let Some(block) = self.blocks.first() else {
|
||||
return 0;
|
||||
};
|
||||
let mut hasher = DefaultHasher::new();
|
||||
block.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::{
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
@@ -9,7 +10,7 @@ use std::{
|
||||
|
||||
use brk_error::Result;
|
||||
use brk_rpc::Client;
|
||||
use brk_types::{MempoolEntryInfo, MempoolInfo, TxWithHex, Txid, TxidPrefix};
|
||||
use brk_types::{AddressBytes, MempoolEntryInfo, MempoolInfo, TxWithHex, Txid, TxidPrefix};
|
||||
use derive_more::Deref;
|
||||
use parking_lot::{RwLock, RwLockReadGuard};
|
||||
use rustc_hash::FxHashMap;
|
||||
@@ -87,6 +88,20 @@ impl MempoolInner {
|
||||
self.snapshot.read().block_stats.clone()
|
||||
}
|
||||
|
||||
pub fn next_block_hash(&self) -> u64 {
|
||||
self.snapshot.read().next_block_hash()
|
||||
}
|
||||
|
||||
pub fn address_hash(&self, address: &AddressBytes) -> u64 {
|
||||
let addresses = self.addresses.read();
|
||||
let Some((stats, _)) = addresses.get(address) else {
|
||||
return 0;
|
||||
};
|
||||
let mut hasher = DefaultHasher::new();
|
||||
stats.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
pub fn get_txs(&self) -> RwLockReadGuard<'_, TxStore> {
|
||||
self.txs.read()
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ All parameters are exposed via Config with sensible defaults:
|
||||
- **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
|
||||
- **exclude_common_round_values** (true): exclude common round values (d × 10^n, d ∈ {1,2,3,5,6}) that create false stencil matches
|
||||
- **excluded_output_types** (P2TR, P2WSH): script types dominated by protocol activity, not round-dollar purchases
|
||||
|
||||
## Inspiration
|
||||
@@ -69,11 +69,11 @@ Tested over 361,245 blocks (heights 575,000 to 936,244) against exchange OHLC da
|
||||
| 95th percentile | 0.55% |
|
||||
| 99th percentile | 1.4% |
|
||||
| 99.9th percentile | 4.4% |
|
||||
| RMSE | 0.39% |
|
||||
| Max error | 18.2% |
|
||||
| RMSE | 0.38% |
|
||||
| Max error | 18.1% |
|
||||
| Bias | +0.04 bins (essentially zero) |
|
||||
| Blocks > 5% error | 261 (0.07%) |
|
||||
| Blocks > 10% error | 40 (0.01%) |
|
||||
| Blocks > 5% error | 237 (0.07%) |
|
||||
| Blocks > 10% error | 22 (0.006%) |
|
||||
| Blocks > 20% error | 0 |
|
||||
|
||||
### Daily candles
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Compare specific digit filter configurations across multiple start heights.
|
||||
//!
|
||||
//! Run with: cargo run -p brk_oracle --example compare_digits --release
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use brk_indexer::Indexer;
|
||||
use brk_oracle::{Config, NUM_BINS, Oracle, PRICES, START_HEIGHT, cents_to_bin, sats_to_bin};
|
||||
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)
|
||||
}
|
||||
|
||||
fn leading_digit(sats: u64) -> u8 {
|
||||
let log = (sats as f64).log10();
|
||||
let magnitude = 10.0_f64.powf(log.floor());
|
||||
let d = (sats as f64 / magnitude).round() as u8;
|
||||
if d >= 10 { 1 } else { d }
|
||||
}
|
||||
|
||||
fn is_round(sats: u64) -> bool {
|
||||
let log = (sats as f64).log10();
|
||||
let magnitude = 10.0_f64.powf(log.floor());
|
||||
let leading = (sats as f64 / magnitude).round();
|
||||
let round_val = leading * magnitude;
|
||||
(sats as f64 - round_val).abs() <= round_val * 0.001
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let t0 = Instant::now();
|
||||
|
||||
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 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();
|
||||
|
||||
// Configs to compare.
|
||||
// 987654321
|
||||
let masks: &[(u16, &str)] = &[
|
||||
(0b0_0111_0111, "{1,2,3,5,6,7}"),
|
||||
(0b0_0011_0111, "{1,2,3,5,6}"),
|
||||
(0b0_0001_1111, "{1,2,3,4,5}"),
|
||||
(0b0_0001_0111, "{1,2,3,5}"),
|
||||
];
|
||||
|
||||
let start_heights: &[usize] = &[575_000, 600_000, 630_000];
|
||||
|
||||
// (mask_idx, start_idx) -> (Oracle, Stats)
|
||||
let n = masks.len() * start_heights.len();
|
||||
let mut oracles: Vec<Option<Oracle>> = (0..n).map(|_| None).collect();
|
||||
let mut stats: Vec<Stats> = (0..n).map(|_| Stats::new()).collect();
|
||||
|
||||
let idx = |m: usize, s: usize| -> usize { m * start_heights.len() + s };
|
||||
|
||||
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 earliest_start = *start_heights.iter().min().unwrap();
|
||||
|
||||
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();
|
||||
|
||||
if h < earliest_start {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build full histogram and per-digit histograms.
|
||||
let mut full_hist = [0u32; NUM_BINS];
|
||||
let mut digit_hist = [[0u32; NUM_BINS]; 9];
|
||||
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
if let Some(bin) = sats_to_bin(sats) {
|
||||
full_hist[bin] += 1;
|
||||
if is_round(*sats) {
|
||||
let d = leading_digit(*sats);
|
||||
if (1..=9).contains(&d) {
|
||||
digit_hist[(d - 1) as usize][bin] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Feed each (mask, start_height) combo.
|
||||
for (mi, &(mask, _)) in masks.iter().enumerate() {
|
||||
// Build filtered histogram for this mask.
|
||||
let mut hist = full_hist;
|
||||
(0..9usize).for_each(|d| {
|
||||
if mask & (1 << d) != 0 {
|
||||
for bin in 0..NUM_BINS {
|
||||
hist[bin] -= digit_hist[d][bin];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (si, &sh) in start_heights.iter().enumerate() {
|
||||
if h < sh {
|
||||
continue;
|
||||
}
|
||||
let i = idx(mi, si);
|
||||
if oracles[i].is_none() {
|
||||
oracles[i] = Some(Oracle::new(
|
||||
seed_bin(sh),
|
||||
Config {
|
||||
exclude_common_round_values: false,
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let ref_bin = oracles[i].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
|
||||
};
|
||||
stats[i].update(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print results grouped by start height.
|
||||
for (si, &sh) in start_heights.iter().enumerate() {
|
||||
println!();
|
||||
println!("@ {}k:", sh / 1000);
|
||||
println!(
|
||||
" {:<16} {:>8} {:>10} {:>10} {:>6} {:>6} {:>6} {:>8}",
|
||||
"Digits", "Blocks", "RMSE%", "Max%", ">5%", ">10%", ">20%", "Bias"
|
||||
);
|
||||
println!(" {}", "-".repeat(72));
|
||||
for (mi, &(_, label)) in masks.iter().enumerate() {
|
||||
let s = &stats[idx(mi, si)];
|
||||
println!(
|
||||
" {:<16} {:>8} {:>7.3}% {:>7.1}% {:>6} {:>6} {:>6} {:>+8.2}",
|
||||
label,
|
||||
s.total_blocks,
|
||||
s.rmse_pct(),
|
||||
s.max_pct(),
|
||||
s.gt_5pct,
|
||||
s.gt_10pct,
|
||||
s.gt_20pct,
|
||||
s.bias()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nDone in {:.1}s", t0.elapsed().as_secs_f64());
|
||||
}
|
||||
@@ -229,7 +229,8 @@ fn main() {
|
||||
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())
|
||||
if *sats < ref_config.min_sats
|
||||
|| (ref_config.exclude_common_round_values && sats.is_common_round_value())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -339,7 +340,7 @@ fn main() {
|
||||
daily_days += 1;
|
||||
}
|
||||
|
||||
fn daily_stats(errors: &mut Vec<f64>) -> (f64, f64, f64) {
|
||||
fn daily_stats(errors: &mut [f64]) -> (f64, f64, f64) {
|
||||
let n = errors.len() as f64;
|
||||
let rmse = (errors.iter().map(|e| e * e).sum::<f64>() / n).sqrt();
|
||||
errors.sort_by(|a, b| a.abs().partial_cmp(&b.abs()).unwrap());
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
//! Sweep round-value digit filter to find optimal configuration.
|
||||
//!
|
||||
//! Tests all 512 subsets of leading digits {1,...,9} to find which
|
||||
//! digits to filter out for best oracle accuracy.
|
||||
//!
|
||||
//! Phase 1: single pass over indexer, precompute per-block histograms.
|
||||
//! Phase 2: run 512 configs in parallel across CPU cores.
|
||||
//!
|
||||
//! Run with: cargo run -p brk_oracle --example sweep_digits --release
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use brk_indexer::Indexer;
|
||||
use brk_oracle::{Config, NUM_BINS, Oracle, PRICES, START_HEIGHT, cents_to_bin, sats_to_bin};
|
||||
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)
|
||||
}
|
||||
|
||||
fn leading_digit(sats: u64) -> u8 {
|
||||
let log = (sats as f64).log10();
|
||||
let magnitude = 10.0_f64.powf(log.floor());
|
||||
let d = (sats as f64 / magnitude).round() as u8;
|
||||
if d >= 10 { 1 } else { d }
|
||||
}
|
||||
|
||||
fn is_round(sats: u64) -> bool {
|
||||
let log = (sats as f64).log10();
|
||||
let magnitude = 10.0_f64.powf(log.floor());
|
||||
let leading = (sats as f64 / magnitude).round();
|
||||
let round_val = leading * magnitude;
|
||||
(sats as f64 - round_val).abs() <= round_val * 0.001
|
||||
}
|
||||
|
||||
fn mask_label(mask: u16) -> String {
|
||||
let digits: String = (1..=9u8)
|
||||
.filter(|&d| mask & (1 << (d - 1)) != 0)
|
||||
.map(|d| char::from_digit(d as u32, 10).unwrap())
|
||||
.collect();
|
||||
if digits.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
digits
|
||||
}
|
||||
}
|
||||
|
||||
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 BlockData {
|
||||
full_hist: Box<[u32; NUM_BINS]>,
|
||||
/// (bin_index, leading_digit) for outputs that are round values.
|
||||
round_outputs: Vec<(u16, u8)>,
|
||||
high_bin: f64,
|
||||
low_bin: f64,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let t0 = Instant::now();
|
||||
|
||||
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 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 sweep_start: usize = 575_000;
|
||||
|
||||
// Phase 1: precompute per-block data in a single pass over the indexer.
|
||||
eprintln!("Phase 1: precomputing block data...");
|
||||
|
||||
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 total_blocks = total_heights - sweep_start;
|
||||
let mut blocks: Vec<BlockData> = Vec::with_capacity(total_blocks);
|
||||
|
||||
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();
|
||||
|
||||
if h < sweep_start {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut full_hist = Box::new([0u32; NUM_BINS]);
|
||||
let mut round_outputs = Vec::new();
|
||||
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
if let Some(bin) = sats_to_bin(sats) {
|
||||
full_hist[bin] += 1;
|
||||
if is_round(*sats) {
|
||||
let d = leading_digit(*sats);
|
||||
if (1..=9).contains(&d) {
|
||||
round_outputs.push((bin as u16, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (high_bin, low_bin) = if h < height_bands.len() {
|
||||
height_bands[h]
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
|
||||
blocks.push(BlockData {
|
||||
full_hist,
|
||||
round_outputs,
|
||||
high_bin,
|
||||
low_bin,
|
||||
});
|
||||
|
||||
if (h - sweep_start).is_multiple_of(50_000) {
|
||||
eprint!(
|
||||
"\r {}/{} ({:.0}%)",
|
||||
h - sweep_start,
|
||||
total_blocks,
|
||||
(h - sweep_start) as f64 / total_blocks as f64 * 100.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mem_hists = blocks.len() * std::mem::size_of::<[u32; NUM_BINS]>();
|
||||
let mem_rounds: usize = blocks.iter().map(|b| b.round_outputs.len() * 3).sum();
|
||||
eprintln!(
|
||||
"\r {} blocks precomputed ({:.1} GB hists + {:.0} MB rounds) in {:.1}s",
|
||||
blocks.len(),
|
||||
mem_hists as f64 / 1e9,
|
||||
mem_rounds as f64 / 1e6,
|
||||
t0.elapsed().as_secs_f64()
|
||||
);
|
||||
|
||||
// Phase 2: sweep digit masks in parallel.
|
||||
// Always filter digit 1 (powers of 10), sweep digits 2-9.
|
||||
let base_mask: u16 = 1 << 0; // digit 1 always on
|
||||
let num_masks: usize = 256; // 2^8 subsets of {2,...,9}
|
||||
let num_threads = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(8);
|
||||
eprintln!(
|
||||
"Phase 2: sweeping {} masks across {} threads...",
|
||||
num_masks, num_threads
|
||||
);
|
||||
|
||||
let t1 = Instant::now();
|
||||
let blocks = &blocks; // shared reference for threads
|
||||
|
||||
let all_results: Vec<(u16, Stats)> = std::thread::scope(|s| {
|
||||
let masks_per_thread = num_masks.div_ceil(num_threads);
|
||||
|
||||
let handles: Vec<_> = (0..num_threads)
|
||||
.map(|t| {
|
||||
s.spawn(move || {
|
||||
let mask_start = t * masks_per_thread;
|
||||
let mask_end = ((t + 1) * masks_per_thread).min(num_masks);
|
||||
let mut results = Vec::with_capacity(mask_end - mask_start);
|
||||
|
||||
for idx in mask_start..mask_end {
|
||||
// Shift idx bits into positions 1-8 (digits 2-9) and add base_mask (digit 1).
|
||||
let mask = base_mask | ((idx as u16) << 1);
|
||||
let mut oracle = Oracle::new(
|
||||
seed_bin(sweep_start),
|
||||
Config {
|
||||
exclude_common_round_values: false,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let mut stats = Stats::new();
|
||||
|
||||
for bd in blocks.iter() {
|
||||
let mut hist = *bd.full_hist;
|
||||
for &(bin, digit) in &bd.round_outputs {
|
||||
if mask & (1 << (digit - 1)) != 0 {
|
||||
hist[bin as usize] -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
let ref_bin = oracle.process_histogram(&hist);
|
||||
|
||||
if bd.high_bin > 0.0 && bd.low_bin > 0.0 {
|
||||
let err = if ref_bin < bd.high_bin {
|
||||
ref_bin - bd.high_bin
|
||||
} else if ref_bin > bd.low_bin {
|
||||
ref_bin - bd.low_bin
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
stats.update(err);
|
||||
}
|
||||
}
|
||||
|
||||
results.push((mask, stats));
|
||||
}
|
||||
|
||||
results
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
handles
|
||||
.into_iter()
|
||||
.flat_map(|h| h.join().unwrap())
|
||||
.collect()
|
||||
});
|
||||
|
||||
eprintln!(" Done in {:.1}s.", t1.elapsed().as_secs_f64());
|
||||
|
||||
// Sort by RMSE.
|
||||
let mut results: Vec<&(u16, Stats)> = all_results.iter().collect();
|
||||
results.sort_by(|a, b| a.1.rmse_pct().partial_cmp(&b.1.rmse_pct()).unwrap());
|
||||
|
||||
// Print top 20.
|
||||
println!();
|
||||
println!("Top 20 (by RMSE):");
|
||||
println!(
|
||||
"{:>4} {:>12} {:>10} {:>10} {:>6} {:>6} {:>6} {:>8}",
|
||||
"#", "Digits", "RMSE%", "Max%", ">5%", ">10%", ">20%", "Bias"
|
||||
);
|
||||
println!("{}", "-".repeat(70));
|
||||
for (rank, (mask, s)) in results.iter().take(20).enumerate() {
|
||||
println!(
|
||||
"{:>4} {:>12} {:>8.3}% {:>8.1}% {:>6} {:>6} {:>6} {:>+8.2}",
|
||||
rank + 1,
|
||||
mask_label(*mask),
|
||||
s.rmse_pct(),
|
||||
s.max_pct(),
|
||||
s.gt_5pct,
|
||||
s.gt_10pct,
|
||||
s.gt_20pct,
|
||||
s.bias()
|
||||
);
|
||||
}
|
||||
|
||||
// Print bottom 5.
|
||||
println!();
|
||||
println!("Bottom 5 (worst):");
|
||||
println!(
|
||||
"{:>4} {:>12} {:>10} {:>10} {:>6} {:>6} {:>6} {:>8}",
|
||||
"#", "Digits", "RMSE%", "Max%", ">5%", ">10%", ">20%", "Bias"
|
||||
);
|
||||
println!("{}", "-".repeat(70));
|
||||
for (mask, s) in results.iter().rev().take(5) {
|
||||
println!(
|
||||
"{:>4} {:>12} {:>8.3}% {:>8.1}% {:>6} {:>6} {:>6} {:>+8.2}",
|
||||
"",
|
||||
mask_label(*mask),
|
||||
s.rmse_pct(),
|
||||
s.max_pct(),
|
||||
s.gt_5pct,
|
||||
s.gt_10pct,
|
||||
s.gt_20pct,
|
||||
s.bias()
|
||||
);
|
||||
}
|
||||
|
||||
// Print current config {1,2,3,5} for reference.
|
||||
let current_mask: u16 = (1 << 0) | (1 << 1) | (1 << 2) | (1 << 4); // digits 1,2,3,5
|
||||
let current_stats = all_results
|
||||
.iter()
|
||||
.find(|(m, _)| *m == current_mask)
|
||||
.map(|(_, s)| s)
|
||||
.unwrap();
|
||||
let current_rank = results
|
||||
.iter()
|
||||
.position(|(m, _)| *m == current_mask)
|
||||
.unwrap();
|
||||
println!();
|
||||
println!(
|
||||
"Current {{1,2,3,5}} = rank {}/{}: RMSE {:.3}%, Max {:.1}%, >5%: {}, >10%: {}, >20%: {}",
|
||||
current_rank + 1,
|
||||
num_masks,
|
||||
current_stats.rmse_pct(),
|
||||
current_stats.max_pct(),
|
||||
current_stats.gt_5pct,
|
||||
current_stats.gt_10pct,
|
||||
current_stats.gt_20pct,
|
||||
);
|
||||
|
||||
println!("\nTotal time: {:.1}s", t0.elapsed().as_secs_f64());
|
||||
}
|
||||
@@ -174,7 +174,7 @@ fn main() {
|
||||
continue;
|
||||
}
|
||||
if *sats < ref_config.min_sats
|
||||
|| (ref_config.exclude_round_btc && sats.is_round_btc())
|
||||
|| (ref_config.exclude_common_round_values && sats.is_common_round_value())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -237,8 +237,8 @@ fn main() {
|
||||
// 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 @ 575k", 237, 22, 0),
|
||||
("w12 @ 600k", 152, 15, 0),
|
||||
("w12 @ 630k", 84, 9, 0),
|
||||
];
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ pub struct Config {
|
||||
/// 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,
|
||||
pub exclude_common_round_values: bool,
|
||||
/// Output types to ignore (e.g. P2TR, P2WSH are noisy).
|
||||
pub excluded_output_types: Vec<OutputType>,
|
||||
}
|
||||
@@ -156,7 +156,7 @@ impl Default for Config {
|
||||
search_below: 9,
|
||||
search_above: 11,
|
||||
min_sats: 1000,
|
||||
exclude_round_btc: true,
|
||||
exclude_common_round_values: true,
|
||||
excluded_output_types: vec![OutputType::P2TR, OutputType::P2WSH],
|
||||
}
|
||||
}
|
||||
@@ -241,7 +241,7 @@ impl Oracle {
|
||||
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()) {
|
||||
if *sats < self.config.min_sats || (self.config.exclude_common_round_values && sats.is_common_round_value()) {
|
||||
return None;
|
||||
}
|
||||
sats_to_bin(sats)
|
||||
|
||||
@@ -201,6 +201,16 @@ impl Query {
|
||||
Ok(utxos)
|
||||
}
|
||||
|
||||
pub fn address_mempool_hash(&self, address: &Address) -> u64 {
|
||||
let Some(mempool) = self.mempool() else {
|
||||
return 0;
|
||||
};
|
||||
let Ok(bytes) = AddressBytes::from_str(address) else {
|
||||
return 0;
|
||||
};
|
||||
mempool.address_hash(&bytes)
|
||||
}
|
||||
|
||||
pub fn address_mempool_txids(&self, address: Address) -> Result<Vec<Txid>> {
|
||||
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
|
||||
|
||||
|
||||
@@ -91,8 +91,8 @@ impl AddressRoutes for ApiRouter<AppState> {
|
||||
Path(path): Path<AddressParam>,
|
||||
State(state): State<AppState>
|
||||
| {
|
||||
// Mempool txs for an address - use MaxAge since it's volatile
|
||||
state.cached_json(&headers, CacheStrategy::MaxAge(5), move |q| q.address_mempool_txids(path.address)).await
|
||||
let hash = state.sync(|q| q.address_mempool_hash(&path.address));
|
||||
state.cached_json(&headers, CacheStrategy::MempoolHash(hash), move |q| q.address_mempool_txids(path.address)).await
|
||||
}, |op| op
|
||||
.id("get_address_mempool_txs")
|
||||
.addresses_tag()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use aide::axum::{ApiRouter, routing::get_with};
|
||||
use axum::{extract::State, http::HeaderMap, response::Redirect, routing::get};
|
||||
use brk_types::{MempoolBlock, MempoolInfo, RecommendedFees, Txid};
|
||||
use brk_types::{Dollars, MempoolBlock, MempoolInfo, RecommendedFees, Txid};
|
||||
|
||||
use crate::{CacheStrategy, extended::TransformResponseExtended};
|
||||
use crate::extended::TransformResponseExtended;
|
||||
|
||||
use super::AppState;
|
||||
|
||||
@@ -18,7 +18,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
"/api/mempool/info",
|
||||
get_with(
|
||||
async |headers: HeaderMap, State(state): State<AppState>| {
|
||||
state.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.mempool_info()).await
|
||||
state.cached_json(&headers, state.mempool_cache(), |q| q.mempool_info()).await
|
||||
},
|
||||
|op| {
|
||||
op.id("get_mempool")
|
||||
@@ -34,7 +34,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
"/api/mempool/txids",
|
||||
get_with(
|
||||
async |headers: HeaderMap, State(state): State<AppState>| {
|
||||
state.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.mempool_txids()).await
|
||||
state.cached_json(&headers, state.mempool_cache(), |q| q.mempool_txids()).await
|
||||
},
|
||||
|op| {
|
||||
op.id("get_mempool_txids")
|
||||
@@ -50,7 +50,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
"/api/v1/fees/recommended",
|
||||
get_with(
|
||||
async |headers: HeaderMap, State(state): State<AppState>| {
|
||||
state.cached_json(&headers, CacheStrategy::MaxAge(3), |q| q.recommended_fees()).await
|
||||
state.cached_json(&headers, state.mempool_cache(), |q| q.recommended_fees()).await
|
||||
},
|
||||
|op| {
|
||||
op.id("get_recommended_fees")
|
||||
@@ -67,7 +67,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
get_with(
|
||||
async |headers: HeaderMap, State(state): State<AppState>| {
|
||||
state
|
||||
.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.live_price())
|
||||
.cached_json(&headers, state.mempool_cache(), |q| q.live_price())
|
||||
.await
|
||||
},
|
||||
|op| {
|
||||
@@ -75,11 +75,11 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
.mempool_tag()
|
||||
.summary("Live BTC/USD price")
|
||||
.description(
|
||||
"Returns the current BTC/USD price in cents, derived from \
|
||||
"Returns the current BTC/USD price in dollars, derived from \
|
||||
on-chain round-dollar output patterns in the last 12 blocks \
|
||||
plus mempool.",
|
||||
)
|
||||
.ok_response::<u64>()
|
||||
.ok_response::<Dollars>()
|
||||
.server_error()
|
||||
},
|
||||
),
|
||||
@@ -88,7 +88,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
"/api/v1/fees/mempool-blocks",
|
||||
get_with(
|
||||
async |headers: HeaderMap, State(state): State<AppState>| {
|
||||
state.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.mempool_blocks()).await
|
||||
state.cached_json(&headers, state.mempool_cache(), |q| q.mempool_blocks()).await
|
||||
},
|
||||
|op| {
|
||||
op.id("get_mempool_blocks")
|
||||
|
||||
@@ -12,9 +12,9 @@ pub enum CacheStrategy {
|
||||
/// Etag = VERSION only, Cache-Control: must-revalidate
|
||||
Static,
|
||||
|
||||
/// Volatile data (mempool) - no etag, just max-age
|
||||
/// Cache-Control: max-age={seconds}
|
||||
MaxAge(u64),
|
||||
/// Mempool data - etag from next projected block hash + short max-age
|
||||
/// Etag = VERSION-m{hash:x}, Cache-Control: max-age=1, must-revalidate
|
||||
MempoolHash(u64),
|
||||
}
|
||||
|
||||
/// Resolved cache parameters
|
||||
@@ -50,9 +50,9 @@ impl CacheParams {
|
||||
etag: Some(VERSION.to_string()),
|
||||
cache_control: "public, max-age=1, must-revalidate".into(),
|
||||
},
|
||||
MaxAge(secs) => Self {
|
||||
etag: None,
|
||||
cache_control: format!("public, max-age={secs}"),
|
||||
MempoolHash(hash) => Self {
|
||||
etag: Some(format!("{VERSION}-m{hash:x}")),
|
||||
cache_control: "public, max-age=1, must-revalidate".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ pub struct AppState {
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn mempool_cache(&self) -> CacheStrategy {
|
||||
let hash = self.sync(|q| q.mempool().map(|m| m.next_block_hash()).unwrap_or(0));
|
||||
CacheStrategy::MempoolHash(hash)
|
||||
}
|
||||
|
||||
/// JSON response with caching
|
||||
pub async fn cached_json<T, F>(
|
||||
&self,
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
///
|
||||
/// Based on mempool.space's format.
|
||||
///
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Debug, Default, Clone, Hash, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AddressMempoolStats {
|
||||
/// Number of unconfirmed transaction outputs funding this address
|
||||
#[schemars(example = 0)]
|
||||
|
||||
@@ -26,6 +26,7 @@ use super::{Bitcoin, CentsUnsigned, Dollars, Height};
|
||||
Default,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Hash,
|
||||
Pco,
|
||||
JsonSchema,
|
||||
)]
|
||||
@@ -76,39 +77,51 @@ impl Sats {
|
||||
*self == Self::MAX
|
||||
}
|
||||
|
||||
/// Check if value is a "round" BTC amount (±0.1% of common round values).
|
||||
/// Check if value is a "round" BTC amount (±0.1% of d × 10^n, d ∈ {1,2,3,5,6}).
|
||||
/// Used to filter out non-price-related transactions.
|
||||
/// Round amounts: 1k, 10k, 20k, 30k, 50k, 100k, 200k, 300k, 500k sats,
|
||||
/// 0.01, 0.02, 0.03, 0.05, 0.1, 0.2, 0.3, 0.5, 1, 10 BTC
|
||||
pub fn is_round_btc(&self) -> bool {
|
||||
const ROUND_SATS: [u64; 19] = [
|
||||
1_000, // 1k sats
|
||||
10_000, // 10k sats
|
||||
20_000, // 20k sats
|
||||
30_000, // 30k sats
|
||||
50_000, // 50k sats
|
||||
100_000, // 100k sats (0.001 BTC)
|
||||
200_000, // 200k sats
|
||||
300_000, // 300k sats
|
||||
500_000, // 500k sats
|
||||
1_000_000, // 0.01 BTC
|
||||
2_000_000, // 0.02 BTC
|
||||
3_000_000, // 0.03 BTC
|
||||
5_000_000, // 0.05 BTC
|
||||
10_000_000, // 0.1 BTC
|
||||
20_000_000, // 0.2 BTC
|
||||
30_000_000, // 0.3 BTC
|
||||
50_000_000, // 0.5 BTC
|
||||
100_000_000, // 1 BTC
|
||||
1_000_000_000, // 10 BTC
|
||||
];
|
||||
const TOLERANCE: f64 = 0.001; // 0.1%
|
||||
|
||||
let v = self.0 as f64;
|
||||
ROUND_SATS
|
||||
.iter()
|
||||
.any(|&r| (v - r as f64).abs() <= r as f64 * TOLERANCE)
|
||||
pub fn is_common_round_value(&self) -> bool {
|
||||
if self.0 == 0 {
|
||||
return false;
|
||||
}
|
||||
let log = (self.0 as f64).log10();
|
||||
let magnitude = 10.0_f64.powf(log.floor());
|
||||
let leading = (self.0 as f64 / magnitude).round() as u64;
|
||||
if !matches!(leading, 1 | 2 | 3 | 5 | 6 | 10) {
|
||||
return false;
|
||||
}
|
||||
let round_val = leading as f64 * magnitude;
|
||||
(self.0 as f64 - round_val).abs() <= round_val * 0.001
|
||||
}
|
||||
|
||||
// pub fn is_common_round_value(&self) -> bool {
|
||||
// const ROUND_SATS: [u64; 19] = [
|
||||
// 1_000, // 1k sats
|
||||
// 10_000, // 10k sats
|
||||
// 20_000, // 20k sats
|
||||
// 30_000, // 30k sats
|
||||
// 50_000, // 50k sats
|
||||
// 100_000, // 100k sats (0.001 BTC)
|
||||
// 200_000, // 200k sats
|
||||
// 300_000, // 300k sats
|
||||
// 500_000, // 500k sats
|
||||
// 1_000_000, // 0.01 BTC
|
||||
// 2_000_000, // 0.02 BTC
|
||||
// 3_000_000, // 0.03 BTC
|
||||
// 5_000_000, // 0.05 BTC
|
||||
// 10_000_000, // 0.1 BTC
|
||||
// 20_000_000, // 0.2 BTC
|
||||
// 30_000_000, // 0.3 BTC
|
||||
// 50_000_000, // 0.5 BTC
|
||||
// 100_000_000, // 1 BTC
|
||||
// 1_000_000_000, // 10 BTC
|
||||
// ];
|
||||
// const TOLERANCE: f64 = 0.001; // 0.1%
|
||||
//
|
||||
// let v = self.0 as f64;
|
||||
// ROUND_SATS
|
||||
// .iter()
|
||||
// .any(|&r| (v - r as f64).abs() <= r as f64 * TOLERANCE)
|
||||
// }
|
||||
}
|
||||
|
||||
impl Add for Sats {
|
||||
|
||||
@@ -4577,7 +4577,9 @@ function createRatioPattern2(client, acc) {
|
||||
* @typedef {Object} MetricsTree_Price_Oracle
|
||||
* @property {MetricPattern11<CentsUnsigned>} priceCents
|
||||
* @property {MetricPattern6<OHLCCentsUnsigned>} ohlcCents
|
||||
* @property {MetricPattern6<OHLCDollars>} ohlcDollars
|
||||
* @property {CloseHighLowOpenPattern2<CentsUnsigned>} split
|
||||
* @property {MetricPattern1<OHLCCentsUnsigned>} ohlc
|
||||
* @property {MetricPattern1<OHLCDollars>} ohlcDollars
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -6744,7 +6746,9 @@ class BrkClient extends BrkClientBase {
|
||||
oracle: {
|
||||
priceCents: createMetricPattern11(this, 'oracle_price_cents'),
|
||||
ohlcCents: createMetricPattern6(this, 'oracle_ohlc_cents'),
|
||||
ohlcDollars: createMetricPattern6(this, 'oracle_ohlc_dollars'),
|
||||
split: createCloseHighLowOpenPattern2(this, 'oracle_price'),
|
||||
ohlc: createMetricPattern1(this, 'oracle_price_ohlc'),
|
||||
ohlcDollars: createMetricPattern1(this, 'oracle_ohlc_dollars'),
|
||||
},
|
||||
},
|
||||
distribution: {
|
||||
@@ -7361,10 +7365,10 @@ class BrkClient extends BrkClientBase {
|
||||
/**
|
||||
* 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.
|
||||
* Returns the current BTC/USD price in dollars, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool.
|
||||
*
|
||||
* Endpoint: `GET /api/mempool/price`
|
||||
* @returns {Promise<number>}
|
||||
* @returns {Promise<Dollars>}
|
||||
*/
|
||||
async getLivePrice() {
|
||||
return this.getJson(`/api/mempool/price`);
|
||||
|
||||
@@ -3939,7 +3939,9 @@ class MetricsTree_Price_Oracle:
|
||||
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')
|
||||
self.split: CloseHighLowOpenPattern2[CentsUnsigned] = CloseHighLowOpenPattern2(client, 'oracle_price')
|
||||
self.ohlc: MetricPattern1[OHLCCentsUnsigned] = MetricPattern1(client, 'oracle_price_ohlc')
|
||||
self.ohlc_dollars: MetricPattern1[OHLCDollars] = MetricPattern1(client, 'oracle_ohlc_dollars')
|
||||
|
||||
class MetricsTree_Price:
|
||||
"""Metrics tree node."""
|
||||
@@ -5503,10 +5505,10 @@ class BrkClient(BrkClientBase):
|
||||
Endpoint: `GET /api/mempool/info`"""
|
||||
return self.get_json('/api/mempool/info')
|
||||
|
||||
def get_live_price(self) -> float:
|
||||
def get_live_price(self) -> Dollars:
|
||||
"""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.
|
||||
Returns the current BTC/USD price in dollars, 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')
|
||||
|
||||
+4
-5
@@ -17,6 +17,10 @@
|
||||
<link rel="stylesheet" href="/styles/variables.css" />
|
||||
<link rel="stylesheet" href="/styles/elements.css" />
|
||||
<link rel="stylesheet" href="/styles/components.css" />
|
||||
<link rel="stylesheet" href="/styles/main.css" />
|
||||
<link rel="stylesheet" href="/styles/nav.css" />
|
||||
<link rel="stylesheet" href="/styles/search.css" />
|
||||
<link rel="stylesheet" href="/styles/chart.css" />
|
||||
<link rel="stylesheet" href="/styles/panes/chart.css" />
|
||||
<!-- /IMPORTMAP -->
|
||||
|
||||
@@ -83,8 +87,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<main id="main">
|
||||
<div class="shadow-top"></div>
|
||||
<div class="shadow-bottom"></div>
|
||||
<div id="resize-bar"></div>
|
||||
|
||||
<nav id="nav" hidden></nav>
|
||||
@@ -107,8 +109,6 @@
|
||||
</search>
|
||||
|
||||
<footer>
|
||||
<div class="shadow-left"></div>
|
||||
<div class="shadow-right"></div>
|
||||
<fieldset id="frame-selectors">
|
||||
<label
|
||||
id="aside-selector-label"
|
||||
@@ -153,7 +153,6 @@
|
||||
<div id="explorer" hidden></div>
|
||||
<div id="chart" hidden></div>
|
||||
<div id="table" hidden></div>
|
||||
<div id="simulation" hidden></div>
|
||||
</aside>
|
||||
<div id="share-div" hidden>
|
||||
<div id="share-content-div">
|
||||
|
||||
@@ -472,10 +472,48 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
lastStamp: null,
|
||||
/** @type {string | null} */
|
||||
lastTimeStamp: null,
|
||||
/** @type {number | null} */
|
||||
lastVersion: null,
|
||||
/** @type {number | null} */
|
||||
lastTimeVersion: null,
|
||||
/** @type {VoidFunction | null} */
|
||||
fetch: null,
|
||||
/** @type {((data: MetricData<number>) => void) | null} */
|
||||
onTime: null,
|
||||
reset() {
|
||||
this.hasData = false;
|
||||
this.lastTime = -Infinity;
|
||||
this.lastStamp = null;
|
||||
this.lastTimeStamp = null;
|
||||
this.lastVersion = null;
|
||||
this.lastTimeVersion = null;
|
||||
},
|
||||
/**
|
||||
* @param {string | null} valuesStamp
|
||||
* @param {string} timeStamp
|
||||
* @param {number | null} valuesVersion
|
||||
* @param {number} timeVersion
|
||||
*/
|
||||
shouldProcess(valuesStamp, timeStamp, valuesVersion, timeVersion) {
|
||||
if (
|
||||
valuesStamp === this.lastStamp &&
|
||||
timeStamp === this.lastTimeStamp
|
||||
)
|
||||
return false;
|
||||
// Version change means data was recomputed, needs full reload
|
||||
if (
|
||||
valuesVersion !== this.lastVersion ||
|
||||
timeVersion !== this.lastTimeVersion
|
||||
) {
|
||||
this.hasData = false;
|
||||
this.lastTime = -Infinity;
|
||||
}
|
||||
this.lastStamp = valuesStamp;
|
||||
this.lastTimeStamp = timeStamp;
|
||||
this.lastVersion = valuesVersion;
|
||||
this.lastTimeVersion = timeVersion;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {AnySeries} */
|
||||
@@ -523,8 +561,7 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
/** @param {ChartableIndex} idx */
|
||||
function setupIndexEffect(idx) {
|
||||
// Reset data state for new index
|
||||
state.hasData = false;
|
||||
state.lastTime = -Infinity;
|
||||
state.reset();
|
||||
state.fetch = null;
|
||||
|
||||
const _valuesEndpoint = metric.by[idx];
|
||||
@@ -663,17 +700,21 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
let valuesData = null;
|
||||
/** @type {string | null} */
|
||||
let valuesStamp = null;
|
||||
/** @type {number | null} */
|
||||
let valuesVersion = null;
|
||||
|
||||
function tryProcess() {
|
||||
if (seriesGeneration !== generation) return;
|
||||
if (!timeData || !valuesData) return;
|
||||
if (
|
||||
valuesStamp === state.lastStamp &&
|
||||
timeData.stamp === state.lastTimeStamp
|
||||
!state.shouldProcess(
|
||||
valuesStamp,
|
||||
timeData.stamp,
|
||||
valuesVersion,
|
||||
timeData.version,
|
||||
)
|
||||
)
|
||||
return;
|
||||
state.lastStamp = valuesStamp;
|
||||
state.lastTimeStamp = timeData.stamp;
|
||||
if (timeData.data.length && valuesData.length) {
|
||||
processData(timeData.data, valuesData);
|
||||
}
|
||||
@@ -691,12 +732,14 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
if (cachedValues) {
|
||||
valuesData = cachedValues.data;
|
||||
valuesStamp = cachedValues.stamp;
|
||||
valuesVersion = cachedValues.version;
|
||||
tryProcess();
|
||||
}
|
||||
await valuesEndpoint.slice(-10000).fetch((result) => {
|
||||
cache.set(valuesEndpoint.path, result);
|
||||
valuesData = result.data;
|
||||
valuesStamp = result.stamp;
|
||||
valuesVersion = result.version;
|
||||
tryProcess();
|
||||
});
|
||||
}
|
||||
@@ -1253,6 +1296,7 @@ export function createChart({ parent, brk, fitContent }) {
|
||||
persisted.set(value);
|
||||
applyScaleForUnit(paneIndex);
|
||||
},
|
||||
toTitle: (c) => (c === "lin" ? "Linear scale" : "Logarithmic scale"),
|
||||
});
|
||||
td.append(radios);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ export function createLegend() {
|
||||
|
||||
/** @param {HTMLElement} el */
|
||||
function captureScroll(el) {
|
||||
el.addEventListener("wheel", (e) => e.stopPropagation());
|
||||
el.addEventListener("touchstart", (e) => e.stopPropagation());
|
||||
el.addEventListener("touchmove", (e) => e.stopPropagation());
|
||||
el.addEventListener("wheel", (e) => e.stopPropagation(), { passive: true });
|
||||
el.addEventListener("touchstart", (e) => e.stopPropagation(), { passive: true });
|
||||
el.addEventListener("touchmove", (e) => e.stopPropagation(), { passive: true });
|
||||
}
|
||||
captureScroll(items);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { webSockets } from "./utils/ws.js";
|
||||
import * as formatters from "./utils/format.js";
|
||||
import { initPrice, onPrice } from "./utils/price.js";
|
||||
import { brk } from "./client.js";
|
||||
import { stringToId } from "./utils/format.js";
|
||||
import { onFirstIntersection, getElementById, isHidden } from "./utils/dom.js";
|
||||
import { initOptions } from "./options/full.js";
|
||||
import {
|
||||
@@ -105,9 +106,11 @@ function initFrameSelectors() {
|
||||
}
|
||||
initFrameSelectors();
|
||||
|
||||
webSockets.kraken1dCandle.onLatest((latest) => {
|
||||
console.log("close:", latest.close);
|
||||
window.document.title = `${latest.close.toLocaleString("en-us")} | ${window.location.host}`;
|
||||
initPrice(brk);
|
||||
|
||||
onPrice((price) => {
|
||||
console.log("close:", price);
|
||||
window.document.title = `${price.toLocaleString("en-us")} | ${window.location.host}`;
|
||||
});
|
||||
|
||||
const options = initOptions();
|
||||
@@ -118,7 +121,7 @@ window.addEventListener("popstate", (_event) => {
|
||||
|
||||
while (path.length) {
|
||||
const id = path.shift();
|
||||
const res = folder.find((v) => id === formatters.stringToId(v.name));
|
||||
const res = folder.find((v) => id === stringToId(v.name));
|
||||
if (!res) throw "Option not found";
|
||||
if (path.length >= 1) {
|
||||
if (!("tree" in res)) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { serdeChartableIndex } from "../utils/serde.js";
|
||||
import { Unit } from "../utils/units.js";
|
||||
import { createChart } from "../chart/index.js";
|
||||
import { colors } from "../utils/colors.js";
|
||||
import { webSockets } from "../utils/ws.js";
|
||||
import { latestPrice, onPrice } from "../utils/price.js";
|
||||
import { brk } from "../client.js";
|
||||
|
||||
const ONE_BTC_IN_SATS = 100_000_000;
|
||||
@@ -63,8 +63,8 @@ export function init() {
|
||||
}
|
||||
|
||||
function updatePriceWithLatest() {
|
||||
const latest = webSockets.kraken1dCandle.latest();
|
||||
if (!latest) return;
|
||||
const latest = latestPrice();
|
||||
if (latest === null) return;
|
||||
|
||||
const priceSeries = chart.panes[0].series[0];
|
||||
const unit = chart.panes[0].unit;
|
||||
@@ -78,8 +78,8 @@ export function init() {
|
||||
// Convert to sats if needed
|
||||
const close =
|
||||
unit === Unit.sats
|
||||
? Math.floor(ONE_BTC_IN_SATS / latest.close)
|
||||
: latest.close;
|
||||
? Math.floor(ONE_BTC_IN_SATS / latest)
|
||||
: latest;
|
||||
|
||||
priceSeries.update({ ...last, close });
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export function init() {
|
||||
};
|
||||
|
||||
// Live price update listener
|
||||
webSockets.kraken1dCandle.onLatest(updatePriceWithLatest);
|
||||
onPrice(updatePriceWithLatest);
|
||||
}
|
||||
|
||||
const ALL_CHOICES = /** @satisfies {ChartableIndexName[]} */ ([
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
*
|
||||
* @import { Color } from "./utils/colors.js"
|
||||
*
|
||||
* @import { WebSockets } from "./utils/ws.js"
|
||||
*
|
||||
* @import { Option, PartialChartOption, ChartOption, AnyPartialOption, ProcessedOptionAddons, OptionsTree, SimulationOption, AnySeriesBlueprint, SeriesType, AnyFetchedSeriesBlueprint, TableOption, ExplorerOption, UrlOption, PartialOptionsGroup, OptionsGroup, PartialOptionsTree, UtxoCohortObject, AddressCohortObject, CohortObject, CohortGroupObject, FetchedLineSeriesBlueprint, FetchedBaselineSeriesBlueprint, FetchedHistogramSeriesBlueprint, FetchedDotsBaselineSeriesBlueprint, PatternAll, PatternFull, PatternWithAdjusted, PatternWithPercentiles, PatternBasic, PatternBasicWithMarketCap, PatternBasicWithoutMarketCap, PatternWithoutRelative, CohortAll, CohortFull, CohortWithAdjusted, CohortWithPercentiles, CohortBasic, CohortBasicWithMarketCap, CohortBasicWithoutMarketCap, CohortWithoutRelative, CohortAddress, CohortLongTerm, CohortAgeRange, CohortMinAge, CohortGroupFull, CohortGroupWithAdjusted, CohortGroupWithPercentiles, CohortGroupLongTerm, CohortGroupAgeRange, CohortGroupBasic, CohortGroupBasicWithMarketCap, CohortGroupBasicWithoutMarketCap, CohortGroupWithoutRelative, CohortGroupMinAge, CohortGroupAddress, UtxoCohortGroupObject, AddressCohortGroupObject, FetchedDotsSeriesBlueprint, FetchedCandlestickSeriesBlueprint, FetchedPriceSeriesBlueprint, AnyPricePattern, AnyValuePattern } from "./options/partial.js"
|
||||
*
|
||||
*
|
||||
|
||||
@@ -1,24 +1,3 @@
|
||||
/**
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
*/
|
||||
export function range(start, end) {
|
||||
const range = [];
|
||||
while (start <= end) {
|
||||
range.push(start);
|
||||
start += 1;
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T[]} array
|
||||
*/
|
||||
export function randomFromArray(array) {
|
||||
return array[Math.floor(Math.random() * array.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed Object.entries that preserves key types
|
||||
* @template {Record<string, any>} T
|
||||
|
||||
@@ -244,20 +244,20 @@ export const colors = {
|
||||
long: palette.fuchsia,
|
||||
},
|
||||
|
||||
scriptType: seq([
|
||||
"p2pk65",
|
||||
"p2pk33",
|
||||
"p2pkh",
|
||||
"p2ms",
|
||||
"p2sh",
|
||||
"p2wpkh",
|
||||
"p2wsh",
|
||||
"p2tr",
|
||||
"p2a",
|
||||
"opreturn",
|
||||
"unknown",
|
||||
"empty",
|
||||
]),
|
||||
scriptType: {
|
||||
p2pk65: palette.rose,
|
||||
p2pk33: palette.pink,
|
||||
p2pkh: palette.orange,
|
||||
p2ms: palette.teal,
|
||||
p2sh: palette.green,
|
||||
p2wpkh: palette.red,
|
||||
p2wsh: palette.yellow,
|
||||
p2tr: palette.cyan,
|
||||
p2a: palette.indigo,
|
||||
opreturn: palette.purple,
|
||||
unknown: palette.violet,
|
||||
empty: palette.fuchsia,
|
||||
},
|
||||
|
||||
arr: paletteArr,
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
const ONE_DAY_IN_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
export function todayUTC() {
|
||||
const today = new Date();
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
today.getUTCFullYear(),
|
||||
today.getUTCMonth(),
|
||||
today.getUTCDate(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} date
|
||||
*/
|
||||
export function dateToDateIndex(date) {
|
||||
if (
|
||||
date.getUTCFullYear() === 2009 &&
|
||||
date.getUTCMonth() === 0 &&
|
||||
date.getUTCDate() === 3
|
||||
)
|
||||
return 0;
|
||||
return differenceBetweenDates(date, new Date("2009-01-09"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} start
|
||||
* @param {Date} end
|
||||
*/
|
||||
export function createDateRange(start, end) {
|
||||
const dates = /** @type {Date[]} */ ([]);
|
||||
let currentDate = new Date(start);
|
||||
while (currentDate <= end) {
|
||||
dates.push(new Date(currentDate));
|
||||
currentDate.setUTCDate(currentDate.getUTCDate() + 1);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} date1
|
||||
* @param {Date} date2
|
||||
*/
|
||||
export function differenceBetweenDates(date1, date2) {
|
||||
return Math.abs(date1.valueOf() - date2.valueOf()) / ONE_DAY_IN_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} date1
|
||||
* @param {Date} date2
|
||||
*/
|
||||
export function roundedDifferenceBetweenDates(date1, date2) {
|
||||
return Math.round(differenceBetweenDates(date1, date2));
|
||||
}
|
||||
@@ -176,6 +176,7 @@ export function createLabeledInput({
|
||||
* @param {(value: T) => void} [args.onChange]
|
||||
* @param {(choice: T) => string} [args.toKey]
|
||||
* @param {(choice: T) => string} [args.toLabel]
|
||||
* @param {(choice: T) => string | undefined} [args.toTitle]
|
||||
*/
|
||||
export function createRadios({
|
||||
id,
|
||||
@@ -184,6 +185,7 @@ export function createRadios({
|
||||
onChange,
|
||||
toKey = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
|
||||
toLabel = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
|
||||
toTitle,
|
||||
}) {
|
||||
const field = window.document.createElement("div");
|
||||
field.classList.add("field");
|
||||
@@ -208,6 +210,7 @@ export function createRadios({
|
||||
inputName: fieldId,
|
||||
inputValue: choiceKey,
|
||||
inputChecked: choiceKey === initialKey,
|
||||
title: toTitle?.(choice),
|
||||
type: "radio",
|
||||
});
|
||||
|
||||
@@ -314,31 +317,3 @@ export function createHeader(title = "", level = 1) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {string} Name
|
||||
* @template {string} Value
|
||||
* @template {Value | {name: Name; value: Value}} T
|
||||
* @param {T} arg
|
||||
*/
|
||||
export function createOption(arg) {
|
||||
const option = window.document.createElement("option");
|
||||
if (typeof arg === "object") {
|
||||
option.value = arg.value;
|
||||
option.innerText = arg.name;
|
||||
} else {
|
||||
option.value = arg;
|
||||
option.innerText = arg;
|
||||
}
|
||||
return option;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param {'left' | 'bottom' | 'top' | 'right'} position
|
||||
*/
|
||||
export function createShadow(position) {
|
||||
const div = window.document.createElement("div");
|
||||
div.classList.add(`shadow-${position}`);
|
||||
return div;
|
||||
}
|
||||
|
||||
@@ -10,9 +10,6 @@ export const asideElement = getElementById("aside");
|
||||
export const searchElement = getElementById("search");
|
||||
export const navElement = getElementById("nav");
|
||||
export const chartElement = getElementById("chart");
|
||||
export const tableElement = getElementById("table");
|
||||
export const explorerElement = getElementById("explorer");
|
||||
export const simulationElement = getElementById("simulation");
|
||||
|
||||
export const asideLabelElement = getElementById("aside-selector-label");
|
||||
export const navLabelElement = getElementById(`nav-selector-label`);
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
export const localhost = window.location.hostname === "localhost";
|
||||
export const standalone =
|
||||
"standalone" in window.navigator && !!window.navigator.standalone;
|
||||
export const userAgent = navigator.userAgent.toLowerCase();
|
||||
export const isChrome = userAgent.includes("chrome");
|
||||
export const safari = userAgent.includes("safari");
|
||||
export const safariOnly = safari && !isChrome;
|
||||
export const macOS = userAgent.includes("mac os");
|
||||
export const iphone = userAgent.includes("iphone");
|
||||
export const ipad = userAgent.includes("ipad");
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const iphone = userAgent.includes("iphone");
|
||||
const ipad = userAgent.includes("ipad");
|
||||
export const ios = iphone || ipad;
|
||||
export const canShare = "canShare" in navigator;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @param {number} [digits]
|
||||
* @param {Intl.NumberFormatOptions} [options]
|
||||
*/
|
||||
export function numberToUSNumber(value, digits, options) {
|
||||
function numberToUSNumber(value, digits, options) {
|
||||
return value.toLocaleString("en-us", {
|
||||
...options,
|
||||
minimumFractionDigits: digits,
|
||||
@@ -11,19 +11,6 @@ export function numberToUSNumber(value, digits, options) {
|
||||
});
|
||||
}
|
||||
|
||||
export const numberToDollars = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export const numberToPercentage = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @param {0 | 2} [digits]
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
let _latest = /** @type {number | null} */ (null);
|
||||
|
||||
/** @type {Set<(price: number) => void>} */
|
||||
const listeners = new Set();
|
||||
|
||||
/** @param {(price: number) => void} callback */
|
||||
export function onPrice(callback) {
|
||||
listeners.add(callback);
|
||||
if (_latest !== null) callback(_latest);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
export function latestPrice() {
|
||||
return _latest;
|
||||
}
|
||||
|
||||
/** @param {BrkClient} brk */
|
||||
export function initPrice(brk) {
|
||||
async function poll() {
|
||||
try {
|
||||
const price = await brk.getLivePrice();
|
||||
if (price !== _latest) {
|
||||
_latest = price;
|
||||
listeners.forEach((cb) => cb(price));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("price poll:", e);
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
setInterval(poll, 5_000);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
!document.hidden && poll();
|
||||
});
|
||||
}
|
||||
@@ -1,96 +1,3 @@
|
||||
const localhost = window.location.hostname === "localhost";
|
||||
console.log({ localhost });
|
||||
|
||||
export const serdeString = {
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
serialize(v) {
|
||||
return v;
|
||||
},
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
deserialize(v) {
|
||||
return v;
|
||||
},
|
||||
};
|
||||
|
||||
export const serdeMetrics = {
|
||||
/**
|
||||
* @param {Metric[]} v
|
||||
*/
|
||||
serialize(v) {
|
||||
return v.join(",");
|
||||
},
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
deserialize(v) {
|
||||
return /** @type {Metric[]} */ (v.split(","));
|
||||
},
|
||||
};
|
||||
|
||||
export const serdeNumber = {
|
||||
/**
|
||||
* @param {number} v
|
||||
*/
|
||||
serialize(v) {
|
||||
return String(v);
|
||||
},
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
deserialize(v) {
|
||||
return Number(v);
|
||||
},
|
||||
};
|
||||
|
||||
export const serdeOptNumber = {
|
||||
/**
|
||||
* @param {number | null} v
|
||||
*/
|
||||
serialize(v) {
|
||||
return v !== null ? String(v) : "";
|
||||
},
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
deserialize(v) {
|
||||
return v ? Number(v) : null;
|
||||
},
|
||||
};
|
||||
|
||||
export const serdeDate = {
|
||||
/**
|
||||
* @param {Date} date
|
||||
*/
|
||||
serialize(date) {
|
||||
return date.toString();
|
||||
},
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
deserialize(v) {
|
||||
return new Date(v);
|
||||
},
|
||||
};
|
||||
|
||||
export const serdeOptDate = {
|
||||
/**
|
||||
* @param {Date | null} date
|
||||
*/
|
||||
serialize(date) {
|
||||
return date !== null ? date.toString() : "";
|
||||
},
|
||||
/**
|
||||
* @param {string} v
|
||||
*/
|
||||
deserialize(v) {
|
||||
return new Date(v);
|
||||
},
|
||||
};
|
||||
|
||||
export const serdeBool = {
|
||||
/**
|
||||
* @param {boolean} v
|
||||
|
||||
@@ -18,7 +18,7 @@ export function onChange(callback) {
|
||||
}
|
||||
|
||||
/** @param {boolean} value */
|
||||
export function setDark(value) {
|
||||
function setDark(value) {
|
||||
if (dark === value) return;
|
||||
dark = value;
|
||||
apply(value);
|
||||
|
||||
@@ -67,35 +67,6 @@ export function writeParam(key, value) {
|
||||
replaceHistory({ urlParams });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
export function removeParam(key) {
|
||||
writeParam(key, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
export function readBoolParam(key) {
|
||||
const param = readParam(key);
|
||||
if (param) {
|
||||
return param === "true" || param === "1";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
export function readNumberParam(key) {
|
||||
const param = readParam(key);
|
||||
if (param) {
|
||||
return Number(param);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} key
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* @template T
|
||||
* @param {(callback: (value: T) => void) => WebSocket} creator
|
||||
*/
|
||||
function createWebsocket(creator) {
|
||||
let ws = /** @type {WebSocket | null} */ (null);
|
||||
let _live = false;
|
||||
let _latest = /** @type {T | null} */ (null);
|
||||
|
||||
/** @type {Set<(value: T) => void>} */
|
||||
const listeners = new Set();
|
||||
|
||||
function reinitWebSocket() {
|
||||
if (!ws || ws.readyState === ws.CLOSED) {
|
||||
console.log("ws: reinit");
|
||||
resource.open();
|
||||
}
|
||||
}
|
||||
|
||||
function reinitWebSocketIfDocumentNotHidden() {
|
||||
!window.document.hidden && reinitWebSocket();
|
||||
}
|
||||
|
||||
const resource = {
|
||||
live() {
|
||||
return _live;
|
||||
},
|
||||
latest() {
|
||||
return _latest;
|
||||
},
|
||||
/** @param {(value: T) => void} callback */
|
||||
onLatest(callback) {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
},
|
||||
open() {
|
||||
ws = creator((value) => {
|
||||
_latest = value;
|
||||
listeners.forEach((cb) => cb(value));
|
||||
});
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
console.log("ws: open");
|
||||
_live = true;
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
console.log("ws: close");
|
||||
_live = false;
|
||||
});
|
||||
|
||||
window.document.addEventListener(
|
||||
"visibilitychange",
|
||||
reinitWebSocketIfDocumentNotHidden,
|
||||
);
|
||||
|
||||
window.document.addEventListener("online", reinitWebSocket);
|
||||
},
|
||||
close() {
|
||||
ws?.close();
|
||||
window.document.removeEventListener(
|
||||
"visibilitychange",
|
||||
reinitWebSocketIfDocumentNotHidden,
|
||||
);
|
||||
window.document.removeEventListener("online", reinitWebSocket);
|
||||
_live = false;
|
||||
ws = null;
|
||||
},
|
||||
};
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(candle: CandlestickData) => void} callback
|
||||
*/
|
||||
function krakenCandleWebSocketCreator(callback) {
|
||||
const ws = new WebSocket("wss://ws.kraken.com/v2");
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
method: "subscribe",
|
||||
params: {
|
||||
channel: "ohlc",
|
||||
symbol: ["BTC/USD"],
|
||||
interval: 1440,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (message) => {
|
||||
const result = JSON.parse(message.data);
|
||||
|
||||
if (result.channel !== "ohlc") return;
|
||||
|
||||
const { interval_begin, open, high, low, close } = result.data.at(-1);
|
||||
|
||||
/** @type {CandlestickData} */
|
||||
const candle = {
|
||||
// index: -1,
|
||||
time: /** @type {Time} */ (new Date(interval_begin).valueOf() / 1000),
|
||||
open: Number(open),
|
||||
high: Number(high),
|
||||
low: Number(low),
|
||||
close: Number(close),
|
||||
};
|
||||
|
||||
candle && callback({ ...candle });
|
||||
});
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
/** @type {ReturnType<typeof createWebsocket<CandlestickData>>} */
|
||||
const kraken1dCandle = createWebsocket((callback) =>
|
||||
krakenCandleWebSocketCreator(callback),
|
||||
);
|
||||
|
||||
kraken1dCandle.open();
|
||||
|
||||
export const webSockets = {
|
||||
kraken1dCandle,
|
||||
};
|
||||
/** @typedef {typeof webSockets} WebSockets */
|
||||
@@ -0,0 +1,245 @@
|
||||
.chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
|
||||
label,
|
||||
select {
|
||||
margin: -0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
select {
|
||||
width: auto;
|
||||
background: none;
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-xs);
|
||||
text-transform: lowercase;
|
||||
pointer-events: none;
|
||||
|
||||
select {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: var(--main-padding);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
background-image: linear-gradient(
|
||||
to left,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 0;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
padding: 0 var(--main-padding);
|
||||
padding-top: 0.278rem;
|
||||
padding-bottom: 0.75rem;
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
> span {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
small {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> div:last-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
|
||||
> div {
|
||||
flex: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> label {
|
||||
> span {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
&:has(input:not(:checked)) {
|
||||
> span.main > span.name {
|
||||
text-decoration: line-through 1.5px var(--color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
* {
|
||||
color: var(--off-color) !important;
|
||||
}
|
||||
|
||||
> span.main > span.name {
|
||||
text-decoration-color: var(--orange) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--orange);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> a {
|
||||
padding-inline: 0.375rem;
|
||||
margin-inline: -0.375rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
margin-right: var(--negative-main-padding);
|
||||
margin-left: var(--negative-main-padding);
|
||||
|
||||
div.field {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
&: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;
|
||||
|
||||
&::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;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
font-size: var(--font-size-xs);
|
||||
gap: 0.375rem;
|
||||
background-color: var(--background-color);
|
||||
align-items: center;
|
||||
text-transform: uppercase;
|
||||
padding-left: 0.625rem;
|
||||
padding-top: 0.35rem;
|
||||
padding-bottom: 0.125rem;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1rem;
|
||||
background-image: linear-gradient(
|
||||
to top,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
button.capture {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 50;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-xs);
|
||||
color: var(--off-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,3 @@
|
||||
nav {
|
||||
margin-top: -0.125rem;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
button::after {
|
||||
color: var(--off-color);
|
||||
content: "→";
|
||||
align-self: baseline;
|
||||
font-size: 75%;
|
||||
margin-left: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
li[data-highlight] {
|
||||
> details > summary,
|
||||
> a {
|
||||
color: var(--color);
|
||||
}
|
||||
|
||||
> details > summary > small {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
> a::after,
|
||||
> details:not([open]) > summary::after {
|
||||
color: var(--orange) !important;
|
||||
content: "";
|
||||
background-color: var(--orange);
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 9999px;
|
||||
display: inline-block;
|
||||
margin-bottom: 0.125rem;
|
||||
margin-left: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
> ul > li {
|
||||
text-transform: uppercase;
|
||||
|
||||
& + * {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
> details,
|
||||
> label {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-base);
|
||||
|
||||
ul {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
summary {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
color: var(--off-color);
|
||||
overflow: hidden;
|
||||
|
||||
li {
|
||||
text-transform: lowercase;
|
||||
display: block;
|
||||
position: relative;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 1px;
|
||||
/*border-style: dotted !important;*/
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#share-div {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
@@ -115,94 +37,11 @@ nav {
|
||||
}
|
||||
}
|
||||
|
||||
search {
|
||||
text-transform: uppercase;
|
||||
gap: 1rem;
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.shadow-top {
|
||||
position: absolute;
|
||||
background-image: linear-gradient(
|
||||
to top,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
height: var(--main-padding);
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.shadow-bottom {
|
||||
position: absolute;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
var(--background-color) 90%,
|
||||
var(--background-color)
|
||||
);
|
||||
height: 21rem;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.shadow-left {
|
||||
position: absolute;
|
||||
background-image: linear-gradient(
|
||||
to left,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
width: var(--main-padding);
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.shadow-right {
|
||||
position: absolute;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
width: var(--main-padding);
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&[data-size="sm"] {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
}
|
||||
|
||||
&[data-size="xs"] {
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-xs);
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
> div.field {
|
||||
text-transform: lowercase;
|
||||
display: flex;
|
||||
@@ -214,18 +53,6 @@ fieldset {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> hr {
|
||||
min-width: 2rem;
|
||||
|
||||
fieldset[data-size="sm"] & {
|
||||
min-width: 1.5rem;
|
||||
}
|
||||
|
||||
fieldset[data-size="xs"] & {
|
||||
min-width: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
padding: 0.5rem;
|
||||
margin: -0.5rem;
|
||||
@@ -234,10 +61,6 @@ fieldset {
|
||||
> div {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
|
||||
fieldset[data-size="xs"] & {
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,8 +68,6 @@ fieldset {
|
||||
#chart > fieldset {
|
||||
text-transform: lowercase;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: -0.5rem var(--negative-main-padding);
|
||||
padding: 0.75rem var(--main-padding);
|
||||
overflow-x: auto;
|
||||
@@ -255,262 +76,3 @@ fieldset {
|
||||
line-height: var(--line-height-sm);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
|
||||
label,
|
||||
select {
|
||||
margin: -0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
legend {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-xs);
|
||||
text-transform: lowercase;
|
||||
pointer-events: none;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: var(--main-padding);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
background-image: linear-gradient(
|
||||
to left,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 0;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
padding: 0 var(--main-padding);
|
||||
padding-top: 0.278rem;
|
||||
padding-bottom: 0.75rem;
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
> span {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
> div:has(> select) {
|
||||
> select {
|
||||
width: auto;
|
||||
background: none;
|
||||
}
|
||||
|
||||
small {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> div:last-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> div:last-child > div {
|
||||
flex: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> label {
|
||||
> span {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
&:has(input:not(:checked)) {
|
||||
color: var(--off-color);
|
||||
|
||||
> span.main > span.name {
|
||||
text-decoration-thickness: 1.5px;
|
||||
text-decoration-color: var(--color);
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
* {
|
||||
color: var(--off-color) !important;
|
||||
}
|
||||
|
||||
> span.main > span.name {
|
||||
text-decoration-color: var(--orange) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--orange);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> a {
|
||||
padding-left: 0.375rem;
|
||||
padding-right: 0.375rem;
|
||||
margin-left: -0.375rem;
|
||||
margin-right: -0.375rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
margin-right: var(--negative-main-padding);
|
||||
margin-left: var(--negative-main-padding);
|
||||
|
||||
div:has(> select) {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
&: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;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
font-size: var(--font-size-xs);
|
||||
gap: 0.25rem;
|
||||
background-color: var(--background-color);
|
||||
align-items: center;
|
||||
text-transform: uppercase;
|
||||
padding-left: 0.625rem;
|
||||
padding-top: 0.35rem;
|
||||
padding-bottom: 0.125rem;
|
||||
|
||||
label {
|
||||
margin: -0.25rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1rem;
|
||||
background-image: linear-gradient(
|
||||
to top,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
button.capture {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 50;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-xs);
|
||||
color: var(--off-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-198
@@ -1,17 +1,19 @@
|
||||
* {
|
||||
border-width: 0;
|
||||
border-color: var(--border-color) !important;
|
||||
border-style: solid !important;
|
||||
|
||||
background-color: transparent;
|
||||
|
||||
&::selection {
|
||||
color: var(--white);
|
||||
background-color: var(--orange);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
display: flow-root;
|
||||
align-items: baseline;
|
||||
text-decoration-style: dotted;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
@@ -22,19 +24,9 @@ a {
|
||||
|
||||
&:hover {
|
||||
text-decoration-style: solid;
|
||||
&,
|
||||
&::after,
|
||||
* {
|
||||
color: var(--color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
&,
|
||||
*,
|
||||
&::after {
|
||||
color: var(--orange) !important;
|
||||
}
|
||||
text-decoration-color: inherit;
|
||||
}
|
||||
|
||||
@@ -55,7 +47,7 @@ a {
|
||||
}
|
||||
|
||||
aside {
|
||||
min-width: 0px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@@ -73,7 +65,6 @@ aside {
|
||||
@media (min-width: 768px) {
|
||||
border-left: 1px;
|
||||
order: 2;
|
||||
margin-bottom: 0rem;
|
||||
}
|
||||
|
||||
body > &[hidden] {
|
||||
@@ -81,10 +72,6 @@ aside {
|
||||
}
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
body {
|
||||
font-weight: var(--font-weight-base);
|
||||
height: 100dvh;
|
||||
@@ -102,54 +89,16 @@ body {
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-transform: inherit;
|
||||
|
||||
&:disabled {
|
||||
color: var(--off-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&,
|
||||
&::after,
|
||||
* {
|
||||
color: var(--color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
&,
|
||||
&::after,
|
||||
* {
|
||||
color: var(--orange) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
details {
|
||||
display: block;
|
||||
}
|
||||
|
||||
div {
|
||||
&:has(> * + button[type="reset"]) {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
|
||||
button {
|
||||
color: var(--off-color);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
h1 {
|
||||
text-transform: uppercase;
|
||||
font-size: var(--font-size-xl);
|
||||
line-height: var(--line-height-xl);
|
||||
font-weight: 350;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -157,15 +106,6 @@ h3 {
|
||||
line-height: var(--line-height-lg);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: var(--background-color);
|
||||
color: var(--color);
|
||||
@@ -175,14 +115,7 @@ html {
|
||||
}
|
||||
|
||||
input {
|
||||
text-transform: inherit;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--off-color);
|
||||
@@ -239,7 +172,7 @@ label {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
> *:not(input):not(svg) {
|
||||
> *:not(input) {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -253,125 +186,19 @@ label {
|
||||
|
||||
&:has(input:checked) {
|
||||
color: var(--color);
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
order: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
min-width: 300px;
|
||||
width: var(--default-main-width);
|
||||
max-width: 65dvw;
|
||||
}
|
||||
|
||||
> footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
text-transform: uppercase;
|
||||
margin: var(--main-padding) 0;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
justify-content: center;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
html[data-display="standalone"] & {
|
||||
margin-bottom: calc(var(--main-padding) + 0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
> fieldset {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
min-width: 0;
|
||||
margin: -0.5rem 0;
|
||||
padding: 0.5rem var(--main-padding);
|
||||
pointer-events: auto;
|
||||
|
||||
> label,
|
||||
> button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> button {
|
||||
color: var(--off-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> #resize-bar {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
margin: 0 -2px;
|
||||
z-index: 50;
|
||||
|
||||
html[data-resize] &,
|
||||
&:hover {
|
||||
border-right: 4px;
|
||||
cursor: col-resize;
|
||||
border-color: var(--off-color) !important;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background: url('data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="gray"><path fill-rule="evenodd" d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>')
|
||||
100% 50% no-repeat transparent;
|
||||
/*flex: 1;*/
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
|
||||
&:focus-visible {
|
||||
border: 0;
|
||||
outline: none;
|
||||
}
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
nav,
|
||||
search {
|
||||
flex: 1;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: var(--main-padding);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--bottom-area);
|
||||
}
|
||||
|
||||
sup {
|
||||
opacity: 0.5;
|
||||
margin-left: 0.25rem;
|
||||
font-weight: 500;
|
||||
:is(input, select):focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
summary > small {
|
||||
@@ -383,13 +210,7 @@ small {
|
||||
color: var(--off-color);
|
||||
font-weight: var(--font-weight-base);
|
||||
|
||||
h4 + & {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
select + & {
|
||||
font-weight: var(--font-weight-base);
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
+ span {
|
||||
@@ -409,15 +230,10 @@ span {
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
summary {
|
||||
list-style: none;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -427,16 +243,21 @@ summary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
:is(a, button, summary) {
|
||||
&:hover {
|
||||
&,
|
||||
* {
|
||||
*,
|
||||
&::after {
|
||||
color: var(--color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
&,
|
||||
* {
|
||||
*,
|
||||
&::after {
|
||||
color: var(--orange) !important;
|
||||
}
|
||||
}
|
||||
@@ -447,3 +268,11 @@ summary {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
[data-resize] {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
font-weight: 100 700;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Lilex;
|
||||
src: url("/assets/fonts/Lilex-Italic[wght]-v2_620.woff2") format("woff2");
|
||||
@@ -12,3 +13,9 @@
|
||||
font-weight: 100 700;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family:
|
||||
"Lilex", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
main {
|
||||
order: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
height: var(--main-padding);
|
||||
background-image: linear-gradient(
|
||||
to top,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 0;
|
||||
height: 21rem;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
var(--background-color) 90%,
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
min-width: 300px;
|
||||
width: var(--default-main-width);
|
||||
max-width: 65dvw;
|
||||
}
|
||||
|
||||
> nav,
|
||||
> search {
|
||||
flex: 1;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: var(--main-padding);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--bottom-area);
|
||||
}
|
||||
|
||||
> footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
text-transform: uppercase;
|
||||
z-index: 100;
|
||||
padding-bottom: var(--main-padding);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: var(--main-padding);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
background-image: linear-gradient(
|
||||
to left,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 0;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--background-color)
|
||||
);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
html[data-display="standalone"] & {
|
||||
margin-bottom: calc(var(--main-padding) + 0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
> fieldset {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
min-width: 0;
|
||||
margin: -0.5rem 0;
|
||||
padding: 0.5rem var(--main-padding);
|
||||
pointer-events: auto;
|
||||
|
||||
> label,
|
||||
> button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> button {
|
||||
color: var(--off-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> #resize-bar {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
margin: 0 -2px;
|
||||
z-index: 50;
|
||||
|
||||
html[data-resize] &,
|
||||
&:hover {
|
||||
border-right: 4px;
|
||||
cursor: col-resize;
|
||||
border-color: var(--off-color) !important;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
nav {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-base);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
> ul > li {
|
||||
text-transform: uppercase;
|
||||
|
||||
& + * {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
> details,
|
||||
> label {
|
||||
ul {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
a,
|
||||
summary {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
color: var(--off-color);
|
||||
overflow: hidden;
|
||||
|
||||
li {
|
||||
text-transform: lowercase;
|
||||
display: block;
|
||||
position: relative;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
li[data-highlight] {
|
||||
> details > summary,
|
||||
> a {
|
||||
text-transform: uppercase;
|
||||
color: var(--color);
|
||||
}
|
||||
|
||||
> details > summary > small {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
> a::after,
|
||||
> details:not([open]) > summary::after {
|
||||
color: var(--orange) !important;
|
||||
content: "";
|
||||
background-color: var(--orange);
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 9999px;
|
||||
display: inline-block;
|
||||
margin-bottom: 0.125rem;
|
||||
margin-left: 0.375rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,6 @@
|
||||
h1 {
|
||||
font-size: 1.375rem;
|
||||
letter-spacing: 0.075rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,56 @@
|
||||
/*
|
||||
* https://www.joshwcomeau.com/css/custom-css-reset
|
||||
*/
|
||||
|
||||
/* 1. Use a more-intuitive box-sizing model */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 2. Remove default margin */
|
||||
*:not(dialog) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
/* 4. Add accessible line-height */
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 7. Inherit fonts for form controls */
|
||||
input,
|
||||
button,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* 8. Avoid text overflows */
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 9. Improve line wrapping */
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/**,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
@@ -177,4 +229,4 @@ video {
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
}*/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
search {
|
||||
text-transform: uppercase;
|
||||
gap: 1rem;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,6 @@
|
||||
--off-color: var(--gray);
|
||||
--border-color: light-dark(var(--light-gray), var(--dark-gray));
|
||||
|
||||
--font-size-2xs: 0.625rem;
|
||||
--line-height-2xs: 1rem;
|
||||
--font-size-xs: 0.75rem;
|
||||
--line-height-xs: calc(1 / 0.75);
|
||||
--font-size-sm: 0.875rem;
|
||||
@@ -44,24 +42,10 @@
|
||||
--line-height-lg: calc(1.75 / 1.125);
|
||||
--font-size-xl: 1.25rem;
|
||||
--line-height-xl: calc(1.75 / 1.25);
|
||||
--font-size-2xl: 1.5rem;
|
||||
--line-height-2xl: calc(2 / 1.5);
|
||||
--font-size-3xl: 1.75rem;
|
||||
--line-height-3xl: calc(2.25 / 1.75);
|
||||
|
||||
--main-padding: 2rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
--main-padding: 2rem;
|
||||
}
|
||||
|
||||
--negative-main-padding: calc(-1 * var(--main-padding));
|
||||
--font-weight-base: 400;
|
||||
--transform-scale-active: scaleY(0.9);
|
||||
--default-main-width: 25rem;
|
||||
--bottom-area: 69vh;
|
||||
}
|
||||
|
||||
[data-resize] {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user