global: snapshot

This commit is contained in:
nym21
2026-02-13 13:54:09 +01:00
parent b779edc0d6
commit 80b2c636b0
53 changed files with 1819 additions and 1184 deletions
+8 -4
View File
@@ -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(())
})
+1 -1
View File
@@ -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(),
+220 -2
View File
@@ -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(())
}
+37 -13
View File
@@ -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,
})
}
+6 -2
View File
@@ -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>,
}
+2 -2
View File
@@ -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(())
+14 -5
View File
@@ -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()
}
}
+16 -1
View File
@@ -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()
}
+5 -5
View File
@@ -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());
}
+3 -2
View File
@@ -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());
+407
View File
@@ -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());
}
+3 -3
View File
@@ -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),
];
+3 -3
View File
@@ -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)
+10
View File
@@ -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)?;
+2 -2
View File
@@ -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()
+9 -9
View File
@@ -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")
+6 -6
View File
@@ -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(),
},
}
}
+5
View File
@@ -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,
+1 -1
View File
@@ -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)]
+44 -31
View File
@@ -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 {
+8 -4
View File
@@ -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`);
+5 -3
View File
@@ -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
View File
@@ -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">
+50 -6
View File
@@ -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);
}
+3 -3
View File
@@ -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);
+9 -6
View File
@@ -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)) {
+6 -6
View File
@@ -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[]} */ ([
-2
View File
@@ -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"
*
*
-21
View File
@@ -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
+14 -14
View File
@@ -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,
-58
View File
@@ -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));
}
+3 -28
View File
@@ -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;
}
-3
View File
@@ -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`);
+3 -9
View File
@@ -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;
+1 -14
View File
@@ -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]
+36
View File
@@ -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();
});
}
-93
View File
@@ -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
+1 -1
View File
@@ -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);
-29
View File
@@ -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
-126
View File
@@ -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 */
+245
View File
@@ -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);
}
}
}
-438
View File
@@ -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
View File
@@ -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;
}
+7
View File
@@ -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;
}
+147
View File
@@ -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;
}
}
}
+69
View File
@@ -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;
}
}
}
-1
View File
@@ -53,7 +53,6 @@
h1 {
font-size: 1.375rem;
letter-spacing: 0.075rem;
font-weight: 300;
}
}
+53 -1
View File
@@ -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;
}
}*/
+12
View File
@@ -0,0 +1,12 @@
search {
text-transform: uppercase;
gap: 1rem;
ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0;
}
}
-16
View File
@@ -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;
}