Compare commits

...

27 Commits

Author SHA1 Message Date
nym21 2377f51718 release: v0.1.9 2026-02-13 21:29:44 +01:00
nym21 ff2c29c34f docs: update generated docs 2026-02-13 21:29:26 +01:00
nym21 4a06caec67 global: fixes 2026-02-13 21:16:35 +01:00
nym21 2a79211aee release: v0.1.8 2026-02-13 17:08:34 +01:00
nym21 cd5334215a docs: update generated docs 2026-02-13 17:08:14 +01:00
nym21 dfcb04484b global: snapshot 2026-02-13 16:54:09 +01:00
nym21 d18c872072 global: snapshot 2026-02-13 15:25:13 +01:00
nym21 80b2c636b0 global: snapshot 2026-02-13 13:54:09 +01:00
nym21 b779edc0d6 global: snapshot 2026-02-12 22:52:57 +01:00
nym21 3bc20a0a46 website: snapshot 2026-02-11 12:42:21 +01:00
nym21 121928bc57 website: chart style changes 2026-02-11 12:22:32 +01:00
nym21 1d63b8901d website: fetch on focus + split zscore charts 2026-02-10 11:47:51 +01:00
nym21 474c430ad1 deps: upgrade 2026-02-09 22:55:46 +01:00
nym21 f968ae4fd4 clients: bump versions 2026-02-08 16:41:31 +01:00
nym21 aa61e327f6 release: v0.1.7 2026-02-07 22:51:30 +01:00
nym21 605a8b86b8 docs: update generated docs 2026-02-07 22:51:10 +01:00
nym21 ba60b7e4f6 computer: fixes 2026-02-07 22:38:25 +01:00
nym21 9cba9bfec4 computer: snapshot 2026-02-06 21:40:34 +01:00
nym21 ed10e21ee9 release: v0.1.6 2026-02-05 23:23:32 +01:00
nym21 9d8fcbe866 docs: update generated docs 2026-02-05 23:23:11 +01:00
nym21 afe4123a17 computer: distribution: feat cost basis distribution 2026-02-05 23:10:02 +01:00
nym21 bbba8f4373 website: safari fixes 2026-02-05 11:43:40 +01:00
nym21 897aab032e release: v0.1.5 2026-02-05 10:43:42 +01:00
nym21 5b2c83ae6e docs: update generated docs 2026-02-05 10:43:23 +01:00
nym21 dc15cceb1e website: chart improvements 2026-02-05 10:31:28 +01:00
nym21 b5c2d6ce9e changelog: updated 2026-02-05 00:34:56 +01:00
nym21 0eeda63abb clients: versions 2026-02-04 23:32:21 +01:00
536 changed files with 17958 additions and 7214 deletions
+1
View File
@@ -24,6 +24,7 @@ _*
/oracle*
/playground
/*.txt
/*.csv
# Logs
*.log*
Generated
+259 -69
View File
@@ -86,9 +86,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.100"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "arrayvec"
@@ -98,9 +98,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-compression"
version = "0.4.37"
version = "0.4.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40"
checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f"
dependencies = [
"compression-codecs",
"compression-core",
@@ -334,7 +334,7 @@ dependencies = [
[[package]]
name = "brk"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"brk_bencher",
"brk_bindgen",
@@ -347,6 +347,7 @@ dependencies = [
"brk_iterator",
"brk_logger",
"brk_mempool",
"brk_oracle",
"brk_query",
"brk_reader",
"brk_rpc",
@@ -358,7 +359,7 @@ dependencies = [
[[package]]
name = "brk_alloc"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"libmimalloc-sys",
"mimalloc",
@@ -366,7 +367,7 @@ dependencies = [
[[package]]
name = "brk_bencher"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"brk_error",
"brk_logger",
@@ -376,14 +377,14 @@ dependencies = [
[[package]]
name = "brk_bencher_visualizer"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"plotters",
]
[[package]]
name = "brk_bindgen"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"brk_cohort",
"brk_query",
@@ -396,7 +397,7 @@ dependencies = [
[[package]]
name = "brk_cli"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"anyhow",
"brk_alloc",
@@ -423,7 +424,7 @@ dependencies = [
[[package]]
name = "brk_client"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"brk_cohort",
"brk_types",
@@ -434,7 +435,7 @@ dependencies = [
[[package]]
name = "brk_cohort"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"brk_error",
"brk_traversable",
@@ -446,7 +447,7 @@ dependencies = [
[[package]]
name = "brk_computer"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"bitcoin",
"brk_alloc",
@@ -457,6 +458,7 @@ dependencies = [
"brk_indexer",
"brk_iterator",
"brk_logger",
"brk_oracle",
"brk_reader",
"brk_rpc",
"brk_store",
@@ -476,13 +478,14 @@ dependencies = [
[[package]]
name = "brk_error"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"bitcoin",
"bitcoincore-rpc",
"fjall",
"jiff",
"minreq",
"pco",
"serde_json",
"thiserror",
"tokio",
@@ -491,7 +494,7 @@ dependencies = [
[[package]]
name = "brk_fetcher"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"brk_error",
"brk_logger",
@@ -503,7 +506,7 @@ dependencies = [
[[package]]
name = "brk_indexer"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"bitcoin",
"brk_alloc",
@@ -528,7 +531,7 @@ dependencies = [
[[package]]
name = "brk_iterator"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"brk_error",
"brk_reader",
@@ -538,7 +541,7 @@ dependencies = [
[[package]]
name = "brk_logger"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"jiff",
"owo-colors",
@@ -549,7 +552,7 @@ dependencies = [
[[package]]
name = "brk_mempool"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"brk_error",
"brk_logger",
@@ -562,9 +565,19 @@ dependencies = [
"tracing",
]
[[package]]
name = "brk_oracle"
version = "0.1.9"
dependencies = [
"brk_indexer",
"brk_types",
"serde_json",
"vecdb",
]
[[package]]
name = "brk_query"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"bitcoin",
"brk_computer",
@@ -584,7 +597,7 @@ dependencies = [
[[package]]
name = "brk_reader"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"bitcoin",
"brk_error",
@@ -599,7 +612,7 @@ dependencies = [
[[package]]
name = "brk_rpc"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"bitcoin",
"bitcoincore-rpc",
@@ -612,7 +625,7 @@ dependencies = [
[[package]]
name = "brk_server"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"aide",
"axum",
@@ -644,7 +657,7 @@ dependencies = [
[[package]]
name = "brk_store"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"brk_error",
"brk_types",
@@ -655,7 +668,7 @@ dependencies = [
[[package]]
name = "brk_traversable"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"brk_traversable_derive",
"brk_types",
@@ -668,7 +681,7 @@ dependencies = [
[[package]]
name = "brk_traversable_derive"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"proc-macro2",
"quote",
@@ -677,7 +690,7 @@ dependencies = [
[[package]]
name = "brk_types"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"bitcoin",
"brk_error",
@@ -686,7 +699,9 @@ dependencies = [
"indexmap",
"itoa",
"jiff",
"pco",
"rapidhash",
"rustc-hash",
"ryu",
"schemars",
"serde",
@@ -698,7 +713,7 @@ dependencies = [
[[package]]
name = "brk_website"
version = "0.1.4"
version = "0.1.9"
dependencies = [
"axum",
"brk_logger",
@@ -764,15 +779,15 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "byteview"
version = "0.10.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dda4398f387cc6395a3e93b3867cd9abda914c97a0b344d1eefb2e5c51785fca"
checksum = "1c53ba0f290bfc610084c05582d9c5d421662128fc69f4bf236707af6fd321b9"
[[package]]
name = "cc"
version = "1.2.55"
version = "1.2.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1028,9 +1043,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "ctrlc"
version = "3.5.1"
version = "3.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790"
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix",
@@ -1135,9 +1150,9 @@ dependencies = [
[[package]]
name = "dtype_dispatch"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63cb69518c750a905135325cb009ffb24e1dff48cfc7bee026cd8e7e90d14f26"
checksum = "ab23e69df104e2fd85ee63a533a22d2132ef5975dc6b36f9f3e5a7305e4a8ed7"
[[package]]
name = "dwrote"
@@ -1264,6 +1279,12 @@ dependencies = [
"spin",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "font-kit"
version = "0.14.3"
@@ -1448,6 +1469,19 @@ dependencies = [
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]]
name = "gif"
version = "0.12.0"
@@ -1487,6 +1521,15 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1700,6 +1743,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
version = "1.1.0"
@@ -1809,9 +1858,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jiff"
version = "0.2.18"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50"
checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543"
dependencies = [
"jiff-static",
"log",
@@ -1823,9 +1872,9 @@ dependencies = [
[[package]]
name = "jiff-static"
version = "0.2.18"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78"
checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5"
dependencies = [
"proc-macro2",
"quote",
@@ -1876,6 +1925,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lexopt"
version = "0.3.1"
@@ -2011,9 +2066,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.7.6"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memmap2"
@@ -2081,9 +2136,9 @@ dependencies = [
[[package]]
name = "nix"
version = "0.30.1"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
@@ -2213,9 +2268,9 @@ dependencies = [
[[package]]
name = "pco"
version = "1.0.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4acf50a078a796b341d327c18a0d3b2d2ca7dd630f04958558608169e2686e87"
checksum = "e89d71ab3c07ed898defa4915bdc2a963131d811a1eab0eeacfac65c94cdeae8"
dependencies = [
"better_io",
"dtype_dispatch",
@@ -2339,6 +2394,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -2425,9 +2490,9 @@ dependencies = [
[[package]]
name = "rapidhash"
version = "4.2.1"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d8b5b858a440a0bc02625b62dd95131b9201aa9f69f411195dd4a7cfb1de3d7"
checksum = "84816e4c99c467e92cf984ee6328caa976dfecd33a673544489d79ca2caaefe5"
dependencies = [
"rustversion",
]
@@ -2623,9 +2688,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.22"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
@@ -2913,9 +2978,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.114"
version = "2.0.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12"
dependencies = [
"proc-macro2",
"quote",
@@ -2941,12 +3006,12 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.24.0"
version = "3.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -3031,9 +3096,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.9.11+spec-1.1.0"
version = "1.0.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
checksum = "bbe30f93627849fa362d4a602212d41bb237dc2bd0f8ba0b2ce785012e124220"
dependencies = [
"indexmap",
"serde_core",
@@ -3046,18 +3111,18 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
version = "1.0.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.0.6+spec-1.1.0"
version = "1.0.8+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc"
dependencies = [
"winnow",
]
@@ -3199,9 +3264,9 @@ checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
[[package]]
name = "unicode-ident"
version = "1.0.22"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
[[package]]
name = "unicode-segmentation"
@@ -3314,6 +3379,15 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
@@ -3359,6 +3433,40 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.10.0",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.85"
@@ -3647,6 +3755,88 @@ name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.10.0",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "writeable"
@@ -3696,18 +3886,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.38"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.38"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
dependencies = [
"proc-macro2",
"quote",
@@ -3770,9 +3960,9 @@ dependencies = [
[[package]]
name = "zmij"
version = "1.0.19"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zstd"
+27 -25
View File
@@ -4,7 +4,7 @@ members = ["crates/*"]
package.description = "The Bitcoin Research Kit is a suite of tools designed to extract, compute and display data stored on a Bitcoin Core node"
package.license = "MIT"
package.edition = "2024"
package.version = "0.1.4"
package.version = "0.1.9"
package.homepage = "https://bitcoinresearchkit.org"
package.repository = "https://github.com/bitcoinresearchkit/brk"
package.readme = "README.md"
@@ -40,37 +40,39 @@ aide = { version = "0.16.0-alpha.2", features = ["axum-json", "axum-query"] }
axum = { version = "0.8.8", default-features = false, features = ["http1", "json", "query", "tokio", "tracing"] }
bitcoin = { version = "0.32.8", features = ["serde"] }
bitcoincore-rpc = "0.19.0"
brk_alloc = { version = "0.1.4", path = "crates/brk_alloc" }
brk_bencher = { version = "0.1.4", path = "crates/brk_bencher" }
brk_bindgen = { version = "0.1.4", path = "crates/brk_bindgen" }
brk_cli = { version = "0.1.4", path = "crates/brk_cli" }
brk_client = { version = "0.1.4", path = "crates/brk_client" }
brk_cohort = { version = "0.1.4", path = "crates/brk_cohort" }
brk_computer = { version = "0.1.4", path = "crates/brk_computer" }
brk_error = { version = "0.1.4", path = "crates/brk_error" }
brk_fetcher = { version = "0.1.4", path = "crates/brk_fetcher" }
brk_indexer = { version = "0.1.4", path = "crates/brk_indexer" }
brk_iterator = { version = "0.1.4", path = "crates/brk_iterator" }
brk_logger = { version = "0.1.4", path = "crates/brk_logger" }
brk_mempool = { version = "0.1.4", path = "crates/brk_mempool" }
brk_query = { version = "0.1.4", path = "crates/brk_query", features = ["tokio"] }
brk_reader = { version = "0.1.4", path = "crates/brk_reader" }
brk_rpc = { version = "0.1.4", path = "crates/brk_rpc" }
brk_server = { version = "0.1.4", path = "crates/brk_server" }
brk_store = { version = "0.1.4", path = "crates/brk_store" }
brk_traversable = { version = "0.1.4", path = "crates/brk_traversable", features = ["pco", "derive"] }
brk_traversable_derive = { version = "0.1.4", path = "crates/brk_traversable_derive" }
brk_types = { version = "0.1.4", path = "crates/brk_types" }
brk_website = { version = "0.1.4", path = "crates/brk_website" }
byteview = "0.10.0"
brk_alloc = { version = "0.1.9", path = "crates/brk_alloc" }
brk_bencher = { version = "0.1.9", path = "crates/brk_bencher" }
brk_bindgen = { version = "0.1.9", path = "crates/brk_bindgen" }
brk_cli = { version = "0.1.9", path = "crates/brk_cli" }
brk_client = { version = "0.1.9", path = "crates/brk_client" }
brk_cohort = { version = "0.1.9", path = "crates/brk_cohort" }
brk_computer = { version = "0.1.9", path = "crates/brk_computer" }
brk_error = { version = "0.1.9", path = "crates/brk_error" }
brk_fetcher = { version = "0.1.9", path = "crates/brk_fetcher" }
brk_indexer = { version = "0.1.9", path = "crates/brk_indexer" }
brk_iterator = { version = "0.1.9", path = "crates/brk_iterator" }
brk_logger = { version = "0.1.9", path = "crates/brk_logger" }
brk_oracle = { version = "0.1.9", path = "crates/brk_oracle" }
brk_mempool = { version = "0.1.9", path = "crates/brk_mempool" }
brk_query = { version = "0.1.9", path = "crates/brk_query", features = ["tokio"] }
brk_reader = { version = "0.1.9", path = "crates/brk_reader" }
brk_rpc = { version = "0.1.9", path = "crates/brk_rpc" }
brk_server = { version = "0.1.9", path = "crates/brk_server" }
brk_store = { version = "0.1.9", path = "crates/brk_store" }
brk_traversable = { version = "0.1.9", path = "crates/brk_traversable", features = ["pco", "derive"] }
brk_traversable_derive = { version = "0.1.9", path = "crates/brk_traversable_derive" }
brk_types = { version = "0.1.9", path = "crates/brk_types" }
brk_website = { version = "0.1.9", path = "crates/brk_website" }
byteview = "0.10.1"
color-eyre = "0.6.5"
derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] }
fjall = "3.0.1"
indexmap = { version = "2.13.0", features = ["serde"] }
jiff = { version = "0.2.18", features = ["perf-inline", "tz-system"], default-features = false }
jiff = { version = "0.2.20", features = ["perf-inline", "tz-system"], default-features = false }
minreq = { version = "2.14.1", features = ["https", "json-using-serde"] }
owo-colors = "4.2.3"
parking_lot = "0.12.5"
pco = "1.0.1"
rayon = "1.11.0"
rustc-hash = "2.1.1"
schemars = { version = "1.2.1", features = ["indexmap2"] }
+3
View File
@@ -20,6 +20,7 @@ full = [
"iterator",
"logger",
"mempool",
"oracle",
"query",
"reader",
"rpc",
@@ -39,6 +40,7 @@ indexer = ["brk_indexer"]
iterator = ["brk_iterator"]
logger = ["brk_logger"]
mempool = ["brk_mempool"]
oracle = ["brk_oracle"]
query = ["brk_query"]
reader = ["brk_reader"]
rpc = ["brk_rpc"]
@@ -59,6 +61,7 @@ brk_indexer = { workspace = true, optional = true }
brk_iterator = { workspace = true, optional = true }
brk_logger = { workspace = true, optional = true }
brk_mempool = { workspace = true, optional = true }
brk_oracle = { workspace = true, optional = true }
brk_query = { workspace = true, optional = true }
brk_reader = { workspace = true, optional = true }
brk_rpc = { workspace = true, optional = true }
+1
View File
@@ -30,6 +30,7 @@ Feature flags match crate names without the `brk_` prefix. Use `full` to enable
| [brk_indexer](https://docs.rs/brk_indexer) | Index transactions, addresses, and UTXOs |
| [brk_computer](https://docs.rs/brk_computer) | Compute derived metrics (realized cap, MVRV, SOPR, cohorts, etc.) |
| [brk_mempool](https://docs.rs/brk_mempool) | Monitor mempool, estimate fees, project upcoming blocks |
| [brk_oracle](https://docs.rs/brk_oracle) | Pure on-chain BTC/USD price oracle |
| [brk_query](https://docs.rs/brk_query) | Query interface for indexed and computed data |
| [brk_server](https://docs.rs/brk_server) | REST API with OpenAPI docs |
+4
View File
@@ -44,6 +44,10 @@ pub use brk_logger as logger;
#[doc(inline)]
pub use brk_mempool as mempool;
#[cfg(feature = "oracle")]
#[doc(inline)]
pub use brk_oracle as oracle;
#[cfg(feature = "query")]
#[doc(inline)]
pub use brk_query as query;
@@ -10,7 +10,7 @@ pub fn js_type_to_rust(js_type: &str) -> String {
"integer" => "i64".to_string(),
"number" => "f64".to_string(),
"boolean" => "bool".to_string(),
"*" => "serde_json::Value".to_string(),
"*" | "Object" => "serde_json::Value".to_string(),
other => other.to_string(),
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ owo-colors = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
tokio = { workspace = true }
toml = "0.9.11"
toml = "1.0.1"
vecdb = { workspace = true }
[[bin]]
+119 -29
View File
@@ -858,7 +858,7 @@ impl<T: DeserializeOwned> MetricPattern<T> for MetricPattern32<T> { fn get(&self
// Reusable pattern structs
/// Pattern struct for repeated tree structure.
pub struct AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern {
pub struct AdjustedCapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern {
pub adjusted_sopr: MetricPattern6<StoredF64>,
pub adjusted_sopr_30d_ema: MetricPattern6<StoredF64>,
pub adjusted_sopr_7d_ema: MetricPattern6<StoredF64>,
@@ -872,6 +872,7 @@ pub struct AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSe
pub investor_price_extra: RatioPattern,
pub loss_value_created: MetricPattern1<Dollars>,
pub loss_value_destroyed: MetricPattern1<Dollars>,
pub lower_price_band: DollarsSatsPattern,
pub mvrv: MetricPattern4<StoredF32>,
pub neg_realized_loss: CumulativeSumPattern2<Dollars>,
pub net_realized_pnl: CumulativeSumPattern<Dollars>,
@@ -910,11 +911,12 @@ pub struct AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSe
pub sopr_30d_ema: MetricPattern6<StoredF64>,
pub sopr_7d_ema: MetricPattern6<StoredF64>,
pub total_realized_pnl: MetricPattern1<Dollars>,
pub upper_price_band: DollarsSatsPattern,
pub value_created: MetricPattern1<Dollars>,
pub value_destroyed: MetricPattern1<Dollars>,
}
impl AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern {
impl AdjustedCapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern {
/// Create a new pattern node with accumulated metric name.
pub fn new(client: Arc<BrkClientBase>, acc: String) -> Self {
Self {
@@ -931,6 +933,7 @@ impl AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSopr
investor_price_extra: RatioPattern::new(client.clone(), _m(&acc, "investor_price_ratio")),
loss_value_created: MetricPattern1::new(client.clone(), _m(&acc, "loss_value_created")),
loss_value_destroyed: MetricPattern1::new(client.clone(), _m(&acc, "loss_value_destroyed")),
lower_price_band: DollarsSatsPattern::new(client.clone(), _m(&acc, "lower_price_band")),
mvrv: MetricPattern4::new(client.clone(), _m(&acc, "mvrv")),
neg_realized_loss: CumulativeSumPattern2::new(client.clone(), _m(&acc, "neg_realized_loss")),
net_realized_pnl: CumulativeSumPattern::new(client.clone(), _m(&acc, "net_realized_pnl")),
@@ -969,6 +972,7 @@ impl AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSopr
sopr_30d_ema: MetricPattern6::new(client.clone(), _m(&acc, "sopr_30d_ema")),
sopr_7d_ema: MetricPattern6::new(client.clone(), _m(&acc, "sopr_7d_ema")),
total_realized_pnl: MetricPattern1::new(client.clone(), _m(&acc, "total_realized_pnl")),
upper_price_band: DollarsSatsPattern::new(client.clone(), _m(&acc, "upper_price_band")),
value_created: MetricPattern1::new(client.clone(), _m(&acc, "value_created")),
value_destroyed: MetricPattern1::new(client.clone(), _m(&acc, "value_destroyed")),
}
@@ -976,7 +980,7 @@ impl AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSopr
}
/// Pattern struct for repeated tree structure.
pub struct AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern2 {
pub struct AdjustedCapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern2 {
pub adjusted_sopr: MetricPattern6<StoredF64>,
pub adjusted_sopr_30d_ema: MetricPattern6<StoredF64>,
pub adjusted_sopr_7d_ema: MetricPattern6<StoredF64>,
@@ -990,6 +994,7 @@ pub struct AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSe
pub investor_price_extra: RatioPattern2,
pub loss_value_created: MetricPattern1<Dollars>,
pub loss_value_destroyed: MetricPattern1<Dollars>,
pub lower_price_band: DollarsSatsPattern,
pub mvrv: MetricPattern4<StoredF32>,
pub neg_realized_loss: CumulativeSumPattern2<Dollars>,
pub net_realized_pnl: CumulativeSumPattern<Dollars>,
@@ -1026,11 +1031,12 @@ pub struct AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSe
pub sopr_30d_ema: MetricPattern6<StoredF64>,
pub sopr_7d_ema: MetricPattern6<StoredF64>,
pub total_realized_pnl: MetricPattern1<Dollars>,
pub upper_price_band: DollarsSatsPattern,
pub value_created: MetricPattern1<Dollars>,
pub value_destroyed: MetricPattern1<Dollars>,
}
impl AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern2 {
impl AdjustedCapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern2 {
/// Create a new pattern node with accumulated metric name.
pub fn new(client: Arc<BrkClientBase>, acc: String) -> Self {
Self {
@@ -1047,6 +1053,7 @@ impl AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSopr
investor_price_extra: RatioPattern2::new(client.clone(), _m(&acc, "investor_price_ratio")),
loss_value_created: MetricPattern1::new(client.clone(), _m(&acc, "loss_value_created")),
loss_value_destroyed: MetricPattern1::new(client.clone(), _m(&acc, "loss_value_destroyed")),
lower_price_band: DollarsSatsPattern::new(client.clone(), _m(&acc, "lower_price_band")),
mvrv: MetricPattern4::new(client.clone(), _m(&acc, "mvrv")),
neg_realized_loss: CumulativeSumPattern2::new(client.clone(), _m(&acc, "neg_realized_loss")),
net_realized_pnl: CumulativeSumPattern::new(client.clone(), _m(&acc, "net_realized_pnl")),
@@ -1083,6 +1090,7 @@ impl AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSopr
sopr_30d_ema: MetricPattern6::new(client.clone(), _m(&acc, "sopr_30d_ema")),
sopr_7d_ema: MetricPattern6::new(client.clone(), _m(&acc, "sopr_7d_ema")),
total_realized_pnl: MetricPattern1::new(client.clone(), _m(&acc, "total_realized_pnl")),
upper_price_band: DollarsSatsPattern::new(client.clone(), _m(&acc, "upper_price_band")),
value_created: MetricPattern1::new(client.clone(), _m(&acc, "value_created")),
value_destroyed: MetricPattern1::new(client.clone(), _m(&acc, "value_destroyed")),
}
@@ -1090,7 +1098,7 @@ impl AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSopr
}
/// Pattern struct for repeated tree structure.
pub struct CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern2 {
pub struct CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern2 {
pub cap_raw: MetricPattern11<CentsSats>,
pub capitulation_flow: MetricPattern1<Dollars>,
pub investor_cap_raw: MetricPattern11<CentsSquaredSats>,
@@ -1099,6 +1107,7 @@ pub struct CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTo
pub investor_price_extra: RatioPattern,
pub loss_value_created: MetricPattern1<Dollars>,
pub loss_value_destroyed: MetricPattern1<Dollars>,
pub lower_price_band: DollarsSatsPattern,
pub mvrv: MetricPattern4<StoredF32>,
pub neg_realized_loss: CumulativeSumPattern2<Dollars>,
pub net_realized_pnl: CumulativeSumPattern<Dollars>,
@@ -1137,11 +1146,12 @@ pub struct CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTo
pub sopr_30d_ema: MetricPattern6<StoredF64>,
pub sopr_7d_ema: MetricPattern6<StoredF64>,
pub total_realized_pnl: MetricPattern1<Dollars>,
pub upper_price_band: DollarsSatsPattern,
pub value_created: MetricPattern1<Dollars>,
pub value_destroyed: MetricPattern1<Dollars>,
}
impl CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern2 {
impl CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern2 {
/// Create a new pattern node with accumulated metric name.
pub fn new(client: Arc<BrkClientBase>, acc: String) -> Self {
Self {
@@ -1153,6 +1163,7 @@ impl CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalVal
investor_price_extra: RatioPattern::new(client.clone(), _m(&acc, "investor_price_ratio")),
loss_value_created: MetricPattern1::new(client.clone(), _m(&acc, "loss_value_created")),
loss_value_destroyed: MetricPattern1::new(client.clone(), _m(&acc, "loss_value_destroyed")),
lower_price_band: DollarsSatsPattern::new(client.clone(), _m(&acc, "lower_price_band")),
mvrv: MetricPattern4::new(client.clone(), _m(&acc, "mvrv")),
neg_realized_loss: CumulativeSumPattern2::new(client.clone(), _m(&acc, "neg_realized_loss")),
net_realized_pnl: CumulativeSumPattern::new(client.clone(), _m(&acc, "net_realized_pnl")),
@@ -1191,6 +1202,7 @@ impl CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalVal
sopr_30d_ema: MetricPattern6::new(client.clone(), _m(&acc, "sopr_30d_ema")),
sopr_7d_ema: MetricPattern6::new(client.clone(), _m(&acc, "sopr_7d_ema")),
total_realized_pnl: MetricPattern1::new(client.clone(), _m(&acc, "total_realized_pnl")),
upper_price_band: DollarsSatsPattern::new(client.clone(), _m(&acc, "upper_price_band")),
value_created: MetricPattern1::new(client.clone(), _m(&acc, "value_created")),
value_destroyed: MetricPattern1::new(client.clone(), _m(&acc, "value_destroyed")),
}
@@ -1198,7 +1210,7 @@ impl CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalVal
}
/// Pattern struct for repeated tree structure.
pub struct CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern {
pub struct CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern {
pub cap_raw: MetricPattern11<CentsSats>,
pub capitulation_flow: MetricPattern1<Dollars>,
pub investor_cap_raw: MetricPattern11<CentsSquaredSats>,
@@ -1207,6 +1219,7 @@ pub struct CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTo
pub investor_price_extra: RatioPattern2,
pub loss_value_created: MetricPattern1<Dollars>,
pub loss_value_destroyed: MetricPattern1<Dollars>,
pub lower_price_band: DollarsSatsPattern,
pub mvrv: MetricPattern4<StoredF32>,
pub neg_realized_loss: CumulativeSumPattern2<Dollars>,
pub net_realized_pnl: CumulativeSumPattern<Dollars>,
@@ -1243,11 +1256,12 @@ pub struct CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTo
pub sopr_30d_ema: MetricPattern6<StoredF64>,
pub sopr_7d_ema: MetricPattern6<StoredF64>,
pub total_realized_pnl: MetricPattern1<Dollars>,
pub upper_price_band: DollarsSatsPattern,
pub value_created: MetricPattern1<Dollars>,
pub value_destroyed: MetricPattern1<Dollars>,
}
impl CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern {
impl CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern {
/// Create a new pattern node with accumulated metric name.
pub fn new(client: Arc<BrkClientBase>, acc: String) -> Self {
Self {
@@ -1259,6 +1273,7 @@ impl CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalVal
investor_price_extra: RatioPattern2::new(client.clone(), _m(&acc, "investor_price_ratio")),
loss_value_created: MetricPattern1::new(client.clone(), _m(&acc, "loss_value_created")),
loss_value_destroyed: MetricPattern1::new(client.clone(), _m(&acc, "loss_value_destroyed")),
lower_price_band: DollarsSatsPattern::new(client.clone(), _m(&acc, "lower_price_band")),
mvrv: MetricPattern4::new(client.clone(), _m(&acc, "mvrv")),
neg_realized_loss: CumulativeSumPattern2::new(client.clone(), _m(&acc, "neg_realized_loss")),
net_realized_pnl: CumulativeSumPattern::new(client.clone(), _m(&acc, "net_realized_pnl")),
@@ -1295,6 +1310,7 @@ impl CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalVal
sopr_30d_ema: MetricPattern6::new(client.clone(), _m(&acc, "sopr_30d_ema")),
sopr_7d_ema: MetricPattern6::new(client.clone(), _m(&acc, "sopr_7d_ema")),
total_realized_pnl: MetricPattern1::new(client.clone(), _m(&acc, "total_realized_pnl")),
upper_price_band: DollarsSatsPattern::new(client.clone(), _m(&acc, "upper_price_band")),
value_created: MetricPattern1::new(client.clone(), _m(&acc, "value_created")),
value_destroyed: MetricPattern1::new(client.clone(), _m(&acc, "value_destroyed")),
}
@@ -2032,7 +2048,7 @@ pub struct ActivityAddrCostOutputsRealizedRelativeSupplyUnrealizedPattern {
pub addr_count_30d_change: MetricPattern4<StoredF64>,
pub cost_basis: MaxMinPattern,
pub outputs: UtxoPattern,
pub realized: CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern,
pub realized: CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern,
pub relative: InvestedNegNetNuplSupplyUnrealizedPattern,
pub supply: _30dHalvedTotalPattern,
pub unrealized: GreedInvestedInvestorNegNetPainSupplyTotalUnrealizedPattern,
@@ -2047,7 +2063,7 @@ impl ActivityAddrCostOutputsRealizedRelativeSupplyUnrealizedPattern {
addr_count_30d_change: MetricPattern4::new(client.clone(), _m(&acc, "addr_count_30d_change")),
cost_basis: MaxMinPattern::new(client.clone(), acc.clone()),
outputs: UtxoPattern::new(client.clone(), _m(&acc, "utxo_count")),
realized: CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern::new(client.clone(), acc.clone()),
realized: CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern::new(client.clone(), acc.clone()),
relative: InvestedNegNetNuplSupplyUnrealizedPattern::new(client.clone(), acc.clone()),
supply: _30dHalvedTotalPattern::new(client.clone(), acc.clone()),
unrealized: GreedInvestedInvestorNegNetPainSupplyTotalUnrealizedPattern::new(client.clone(), acc.clone()),
@@ -2176,7 +2192,7 @@ pub struct ActivityCostOutputsRealizedRelativeSupplyUnrealizedPattern {
pub activity: CoinblocksCoindaysSatblocksSatdaysSentPattern,
pub cost_basis: InvestedMaxMinPercentilesSpotPattern,
pub outputs: UtxoPattern,
pub realized: CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern2,
pub realized: CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern2,
pub relative: InvestedNegNetSupplyUnrealizedPattern,
pub supply: _30dHalvedTotalPattern,
pub unrealized: GreedInvestedInvestorNegNetPainPeakSupplyTotalUnrealizedPattern,
@@ -2189,7 +2205,7 @@ impl ActivityCostOutputsRealizedRelativeSupplyUnrealizedPattern {
activity: CoinblocksCoindaysSatblocksSatdaysSentPattern::new(client.clone(), acc.clone()),
cost_basis: InvestedMaxMinPercentilesSpotPattern::new(client.clone(), acc.clone()),
outputs: UtxoPattern::new(client.clone(), _m(&acc, "utxo_count")),
realized: CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern2::new(client.clone(), acc.clone()),
realized: CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern2::new(client.clone(), acc.clone()),
relative: InvestedNegNetSupplyUnrealizedPattern::new(client.clone(), acc.clone()),
supply: _30dHalvedTotalPattern::new(client.clone(), acc.clone()),
unrealized: GreedInvestedInvestorNegNetPainPeakSupplyTotalUnrealizedPattern::new(client.clone(), acc.clone()),
@@ -2202,7 +2218,7 @@ pub struct ActivityCostOutputsRealizedRelativeSupplyUnrealizedPattern5 {
pub activity: CoinblocksCoindaysSatblocksSatdaysSentPattern,
pub cost_basis: MaxMinPattern,
pub outputs: UtxoPattern,
pub realized: AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern2,
pub realized: AdjustedCapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern2,
pub relative: InvestedNegNetNuplSupplyUnrealizedPattern3,
pub supply: _30dHalvedTotalPattern,
pub unrealized: GreedInvestedInvestorNegNetPainPeakSupplyTotalUnrealizedPattern,
@@ -2215,7 +2231,7 @@ impl ActivityCostOutputsRealizedRelativeSupplyUnrealizedPattern5 {
activity: CoinblocksCoindaysSatblocksSatdaysSentPattern::new(client.clone(), acc.clone()),
cost_basis: MaxMinPattern::new(client.clone(), acc.clone()),
outputs: UtxoPattern::new(client.clone(), _m(&acc, "utxo_count")),
realized: AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern2::new(client.clone(), acc.clone()),
realized: AdjustedCapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern2::new(client.clone(), acc.clone()),
relative: InvestedNegNetNuplSupplyUnrealizedPattern3::new(client.clone(), acc.clone()),
supply: _30dHalvedTotalPattern::new(client.clone(), acc.clone()),
unrealized: GreedInvestedInvestorNegNetPainPeakSupplyTotalUnrealizedPattern::new(client.clone(), acc.clone()),
@@ -2228,7 +2244,7 @@ pub struct ActivityCostOutputsRealizedRelativeSupplyUnrealizedPattern4 {
pub activity: CoinblocksCoindaysSatblocksSatdaysSentPattern,
pub cost_basis: MaxMinPattern,
pub outputs: UtxoPattern,
pub realized: CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern,
pub realized: CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern,
pub relative: InvestedNegNetNuplSupplyUnrealizedPattern,
pub supply: _30dHalvedTotalPattern,
pub unrealized: GreedInvestedInvestorNegNetPainSupplyTotalUnrealizedPattern,
@@ -2241,7 +2257,7 @@ impl ActivityCostOutputsRealizedRelativeSupplyUnrealizedPattern4 {
activity: CoinblocksCoindaysSatblocksSatdaysSentPattern::new(client.clone(), acc.clone()),
cost_basis: MaxMinPattern::new(client.clone(), acc.clone()),
outputs: UtxoPattern::new(client.clone(), _m(&acc, "utxo_count")),
realized: CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern::new(client.clone(), acc.clone()),
realized: CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern::new(client.clone(), acc.clone()),
relative: InvestedNegNetNuplSupplyUnrealizedPattern::new(client.clone(), acc.clone()),
supply: _30dHalvedTotalPattern::new(client.clone(), acc.clone()),
unrealized: GreedInvestedInvestorNegNetPainSupplyTotalUnrealizedPattern::new(client.clone(), acc.clone()),
@@ -2254,7 +2270,7 @@ pub struct ActivityCostOutputsRealizedRelativeSupplyUnrealizedPattern6 {
pub activity: CoinblocksCoindaysSatblocksSatdaysSentPattern,
pub cost_basis: MaxMinPattern,
pub outputs: UtxoPattern,
pub realized: CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern,
pub realized: CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern,
pub relative: InvestedNegNetNuplSupplyUnrealizedPattern3,
pub supply: _30dHalvedTotalPattern,
pub unrealized: GreedInvestedInvestorNegNetPainPeakSupplyTotalUnrealizedPattern,
@@ -2267,7 +2283,7 @@ impl ActivityCostOutputsRealizedRelativeSupplyUnrealizedPattern6 {
activity: CoinblocksCoindaysSatblocksSatdaysSentPattern::new(client.clone(), acc.clone()),
cost_basis: MaxMinPattern::new(client.clone(), acc.clone()),
outputs: UtxoPattern::new(client.clone(), _m(&acc, "utxo_count")),
realized: CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern::new(client.clone(), acc.clone()),
realized: CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern::new(client.clone(), acc.clone()),
relative: InvestedNegNetNuplSupplyUnrealizedPattern3::new(client.clone(), acc.clone()),
supply: _30dHalvedTotalPattern::new(client.clone(), acc.clone()),
unrealized: GreedInvestedInvestorNegNetPainPeakSupplyTotalUnrealizedPattern::new(client.clone(), acc.clone()),
@@ -2280,7 +2296,7 @@ pub struct ActivityCostOutputsRealizedRelativeSupplyUnrealizedPattern3 {
pub activity: CoinblocksCoindaysSatblocksSatdaysSentPattern,
pub cost_basis: MaxMinPattern,
pub outputs: UtxoPattern,
pub realized: CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern,
pub realized: CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern,
pub relative: InvestedSupplyPattern,
pub supply: _30dHalvedTotalPattern,
pub unrealized: GreedInvestedInvestorNegNetPainSupplyTotalUnrealizedPattern,
@@ -2293,7 +2309,7 @@ impl ActivityCostOutputsRealizedRelativeSupplyUnrealizedPattern3 {
activity: CoinblocksCoindaysSatblocksSatdaysSentPattern::new(client.clone(), acc.clone()),
cost_basis: MaxMinPattern::new(client.clone(), acc.clone()),
outputs: UtxoPattern::new(client.clone(), _m(&acc, "utxo_count")),
realized: CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern::new(client.clone(), acc.clone()),
realized: CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern::new(client.clone(), acc.clone()),
relative: InvestedSupplyPattern::new(client.clone(), acc.clone()),
supply: _30dHalvedTotalPattern::new(client.clone(), acc.clone()),
unrealized: GreedInvestedInvestorNegNetPainSupplyTotalUnrealizedPattern::new(client.clone(), acc.clone()),
@@ -2306,7 +2322,7 @@ pub struct ActivityCostOutputsRealizedSupplyUnrealizedPattern {
pub activity: CoinblocksCoindaysSatblocksSatdaysSentPattern,
pub cost_basis: MaxMinPattern,
pub outputs: UtxoPattern,
pub realized: CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern,
pub realized: CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern,
pub supply: _30dHalvedTotalPattern,
pub unrealized: GreedInvestedInvestorNegNetPainSupplyTotalUnrealizedPattern,
}
@@ -2318,7 +2334,7 @@ impl ActivityCostOutputsRealizedSupplyUnrealizedPattern {
activity: CoinblocksCoindaysSatblocksSatdaysSentPattern::new(client.clone(), acc.clone()),
cost_basis: MaxMinPattern::new(client.clone(), acc.clone()),
outputs: UtxoPattern::new(client.clone(), _m(&acc, "utxo_count")),
realized: CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern::new(client.clone(), acc.clone()),
realized: CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern::new(client.clone(), acc.clone()),
supply: _30dHalvedTotalPattern::new(client.clone(), acc.clone()),
unrealized: GreedInvestedInvestorNegNetPainSupplyTotalUnrealizedPattern::new(client.clone(), acc.clone()),
}
@@ -5062,6 +5078,7 @@ pub struct MetricsTree_Price {
pub cents: MetricsTree_Price_Cents,
pub usd: MetricsTree_Price_Usd,
pub sats: OhlcSplitPattern2<OHLCSats>,
pub oracle: MetricsTree_Price_Oracle,
}
impl MetricsTree_Price {
@@ -5070,6 +5087,7 @@ impl MetricsTree_Price {
cents: MetricsTree_Price_Cents::new(client.clone(), format!("{base_path}_cents")),
usd: MetricsTree_Price_Usd::new(client.clone(), format!("{base_path}_usd")),
sats: OhlcSplitPattern2::new(client.clone(), "price".to_string()),
oracle: MetricsTree_Price_Oracle::new(client.clone(), format!("{base_path}_oracle")),
}
}
}
@@ -5123,6 +5141,27 @@ impl MetricsTree_Price_Usd {
}
}
/// Metrics tree node.
pub struct MetricsTree_Price_Oracle {
pub price_cents: MetricPattern11<CentsUnsigned>,
pub ohlc_cents: MetricPattern6<OHLCCentsUnsigned>,
pub split: CloseHighLowOpenPattern2<CentsUnsigned>,
pub ohlc: MetricPattern1<OHLCCentsUnsigned>,
pub ohlc_dollars: MetricPattern1<OHLCDollars>,
}
impl MetricsTree_Price_Oracle {
pub fn new(client: Arc<BrkClientBase>, base_path: String) -> Self {
Self {
price_cents: MetricPattern11::new(client.clone(), "oracle_price_cents".to_string()),
ohlc_cents: MetricPattern6::new(client.clone(), "oracle_ohlc_cents".to_string()),
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()),
}
}
}
/// Metrics tree node.
pub struct MetricsTree_Distribution {
pub supply_state: MetricPattern11<SupplyState>,
@@ -5240,7 +5279,7 @@ pub struct MetricsTree_Distribution_UtxoCohorts_All {
pub supply: _30dHalvedTotalPattern,
pub outputs: UtxoPattern,
pub activity: CoinblocksCoindaysSatblocksSatdaysSentPattern,
pub realized: AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern,
pub realized: AdjustedCapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern,
pub unrealized: GreedInvestedInvestorNegNetPainPeakSupplyTotalUnrealizedPattern,
pub cost_basis: InvestedMaxMinPercentilesSpotPattern,
pub relative: MetricsTree_Distribution_UtxoCohorts_All_Relative,
@@ -5252,7 +5291,7 @@ impl MetricsTree_Distribution_UtxoCohorts_All {
supply: _30dHalvedTotalPattern::new(client.clone(), "".to_string()),
outputs: UtxoPattern::new(client.clone(), "utxo_count".to_string()),
activity: CoinblocksCoindaysSatblocksSatdaysSentPattern::new(client.clone(), "".to_string()),
realized: AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern::new(client.clone(), "".to_string()),
realized: AdjustedCapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern::new(client.clone(), "".to_string()),
unrealized: GreedInvestedInvestorNegNetPainPeakSupplyTotalUnrealizedPattern::new(client.clone(), "".to_string()),
cost_basis: InvestedMaxMinPercentilesSpotPattern::new(client.clone(), "".to_string()),
relative: MetricsTree_Distribution_UtxoCohorts_All_Relative::new(client.clone(), format!("{base_path}_relative")),
@@ -5565,7 +5604,7 @@ pub struct MetricsTree_Distribution_UtxoCohorts_Term_Short {
pub supply: _30dHalvedTotalPattern,
pub outputs: UtxoPattern,
pub activity: CoinblocksCoindaysSatblocksSatdaysSentPattern,
pub realized: AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern,
pub realized: AdjustedCapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern,
pub unrealized: GreedInvestedInvestorNegNetPainPeakSupplyTotalUnrealizedPattern,
pub cost_basis: InvestedMaxMinPercentilesSpotPattern,
pub relative: InvestedNegNetNuplSupplyUnrealizedPattern4,
@@ -5577,7 +5616,7 @@ impl MetricsTree_Distribution_UtxoCohorts_Term_Short {
supply: _30dHalvedTotalPattern::new(client.clone(), "sth".to_string()),
outputs: UtxoPattern::new(client.clone(), "sth_utxo_count".to_string()),
activity: CoinblocksCoindaysSatblocksSatdaysSentPattern::new(client.clone(), "sth".to_string()),
realized: AdjustedCapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern::new(client.clone(), "sth".to_string()),
realized: AdjustedCapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern::new(client.clone(), "sth".to_string()),
unrealized: GreedInvestedInvestorNegNetPainPeakSupplyTotalUnrealizedPattern::new(client.clone(), "sth".to_string()),
cost_basis: InvestedMaxMinPercentilesSpotPattern::new(client.clone(), "sth".to_string()),
relative: InvestedNegNetNuplSupplyUnrealizedPattern4::new(client.clone(), "sth".to_string()),
@@ -5590,7 +5629,7 @@ pub struct MetricsTree_Distribution_UtxoCohorts_Term_Long {
pub supply: _30dHalvedTotalPattern,
pub outputs: UtxoPattern,
pub activity: CoinblocksCoindaysSatblocksSatdaysSentPattern,
pub realized: CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern2,
pub realized: CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern2,
pub unrealized: GreedInvestedInvestorNegNetPainPeakSupplyTotalUnrealizedPattern,
pub cost_basis: InvestedMaxMinPercentilesSpotPattern,
pub relative: InvestedNegNetNuplSupplyUnrealizedPattern4,
@@ -5602,7 +5641,7 @@ impl MetricsTree_Distribution_UtxoCohorts_Term_Long {
supply: _30dHalvedTotalPattern::new(client.clone(), "lth".to_string()),
outputs: UtxoPattern::new(client.clone(), "lth_utxo_count".to_string()),
activity: CoinblocksCoindaysSatblocksSatdaysSentPattern::new(client.clone(), "lth".to_string()),
realized: CapCapitulationInvestorLossMvrvNegNetPeakProfitRealizedSellSentSoprTotalValuePattern2::new(client.clone(), "lth".to_string()),
realized: CapCapitulationInvestorLossLowerMvrvNegNetPeakProfitRealizedSellSentSoprTotalUpperValuePattern2::new(client.clone(), "lth".to_string()),
unrealized: GreedInvestedInvestorNegNetPainPeakSupplyTotalUnrealizedPattern::new(client.clone(), "lth".to_string()),
cost_basis: InvestedMaxMinPercentilesSpotPattern::new(client.clone(), "lth".to_string()),
relative: InvestedNegNetNuplSupplyUnrealizedPattern4::new(client.clone(), "lth".to_string()),
@@ -5982,6 +6021,9 @@ pub struct MetricsTree_Supply {
pub inflation: MetricPattern4<StoredF32>,
pub velocity: MetricsTree_Supply_Velocity,
pub market_cap: MetricPattern1<Dollars>,
pub market_cap_growth_rate: MetricPattern4<StoredF32>,
pub realized_cap_growth_rate: MetricPattern4<StoredF32>,
pub cap_growth_rate_diff: MetricPattern6<StoredF32>,
}
impl MetricsTree_Supply {
@@ -5992,6 +6034,9 @@ impl MetricsTree_Supply {
inflation: MetricPattern4::new(client.clone(), "inflation_rate".to_string()),
velocity: MetricsTree_Supply_Velocity::new(client.clone(), format!("{base_path}_velocity")),
market_cap: MetricPattern1::new(client.clone(), "market_cap".to_string()),
market_cap_growth_rate: MetricPattern4::new(client.clone(), "market_cap_growth_rate".to_string()),
realized_cap_growth_rate: MetricPattern4::new(client.clone(), "realized_cap_growth_rate".to_string()),
cap_growth_rate_diff: MetricPattern6::new(client.clone(), "cap_growth_rate_diff".to_string()),
}
}
}
@@ -6051,7 +6096,7 @@ pub struct BrkClient {
impl BrkClient {
/// Client version.
pub const VERSION: &'static str = "v0.1.3";
pub const VERSION: &'static str = "v0.1.7";
/// Create a new client with the given base URL.
pub fn new(base_url: impl Into<String>) -> Self {
@@ -6275,6 +6320,15 @@ impl BrkClient {
self.base.get_json(&format!("/api/mempool/info"))
}
/// Live BTC/USD price
///
/// 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<Dollars> {
self.base.get_json(&format!("/api/mempool/price"))
}
/// Mempool transaction IDs
///
/// Get all transaction IDs currently in the mempool.
@@ -6346,6 +6400,42 @@ impl BrkClient {
}
}
/// Available cost basis cohorts
///
/// List available cohorts for cost basis distribution.
///
/// Endpoint: `GET /api/metrics/cost-basis`
pub fn get_cost_basis_cohorts(&self) -> Result<Vec<String>> {
self.base.get_json(&format!("/api/metrics/cost-basis"))
}
/// Available cost basis dates
///
/// List available dates for a cohort's cost basis distribution.
///
/// Endpoint: `GET /api/metrics/cost-basis/{cohort}/dates`
pub fn get_cost_basis_dates(&self, cohort: Cohort) -> Result<Vec<Date>> {
self.base.get_json(&format!("/api/metrics/cost-basis/{cohort}/dates"))
}
/// Cost basis distribution
///
/// Get the cost basis distribution for a cohort on a specific date.
///
/// Query params:
/// - `bucket`: raw (default), lin200, lin500, lin1000, log10, log50, log100
/// - `value`: supply (default, in BTC), realized (USD), unrealized (USD)
///
/// Endpoint: `GET /api/metrics/cost-basis/{cohort}/{date}`
pub fn get_cost_basis(&self, cohort: Cohort, date: &str, bucket: Option<CostBasisBucket>, value: Option<CostBasisValue>) -> Result<serde_json::Value> {
let mut query = Vec::new();
if let Some(v) = bucket { query.push(format!("bucket={}", v)); }
if let Some(v) = value { query.push(format!("value={}", v)); }
let query_str = if query.is_empty() { String::new() } else { format!("?{}", query.join("&")) };
let path = format!("/api/metrics/cost-basis/{cohort}/{date}{}", query_str);
self.base.get_json(&path)
}
/// Metric count
///
/// Returns the number of metrics available per index type.
+2 -1
View File
@@ -13,6 +13,7 @@ brk_error = { workspace = true, features = ["vecdb"] }
brk_fetcher = { workspace = true }
brk_cohort = { workspace = true }
brk_indexer = { workspace = true }
brk_oracle = { workspace = true }
brk_iterator = { workspace = true }
brk_logger = { workspace = true }
brk_reader = { workspace = true }
@@ -21,8 +22,8 @@ brk_store = { workspace = true }
brk_traversable = { workspace = true }
brk_types = { workspace = true }
derive_more = { workspace = true }
pco = { workspace = true }
tracing = { workspace = true }
pco = "1.0.0"
rayon = { workspace = true }
rustc-hash = { workspace = true }
schemars = { workspace = true }
@@ -1,13 +1,15 @@
use std::{cmp::Reverse, collections::BinaryHeap, path::Path};
use std::{cmp::Reverse, collections::BinaryHeap, fs, path::Path};
use brk_cohort::{
AGE_BOUNDARIES, ByAgeRange, ByAmountRange, ByEpoch, ByGreatEqualAmount, ByLowerThanAmount,
ByMaxAge, ByMinAge, BySpendableType, ByTerm, ByYear, Filter, Filtered, StateLevel, UTXOGroups,
ByMaxAge, ByMinAge, BySpendableType, ByTerm, ByYear, Filter, Filtered, StateLevel, TERM_NAMES,
Term, UTXOGroups,
};
use brk_error::Result;
use brk_traversable::Traversable;
use brk_types::{
CentsUnsigned, DateIndex, Dollars, Height, ONE_HOUR_IN_SEC, Sats, StoredF32, Timestamp, Version,
CentsUnsigned, CentsUnsignedCompact, CostBasisDistribution, Date, DateIndex, Dollars, Height,
ONE_HOUR_IN_SEC, Sats, StoredF32, Timestamp, Version,
};
use derive_more::{Deref, DerefMut};
use rayon::prelude::*;
@@ -25,6 +27,9 @@ use super::{super::traits::CohortVecs, vecs::UTXOCohortVecs};
const VERSION: Version = Version::new(0);
/// Significant digits for cost basis prices (after rounding to dollars).
const COST_BASIS_PRICE_DIGITS: i32 = 5;
/// All UTXO cohorts organized by filter type.
#[derive(Clone, Deref, DerefMut, Traversable)]
pub struct UTXOCohorts(pub(crate) UTXOGroups<UTXOCohortVecs>);
@@ -244,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| {
@@ -255,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.
@@ -358,10 +367,12 @@ impl UTXOCohorts {
/// Computes on-demand by merging age_range cohorts' cost_basis_data data.
/// This avoids maintaining redundant aggregate cost_basis_data maps.
/// Computes both sat-weighted (percentiles) and USD-weighted (invested_capital) percentiles.
/// Also writes daily cost basis snapshots to states_path.
pub fn truncate_push_aggregate_percentiles(
&mut self,
dateindex: DateIndex,
spot: Dollars,
states_path: &Path,
) -> Result<()> {
// Collect (filter, entries, total_sats, total_usd) from age_range cohorts.
// Keep data in CentsUnsigned to avoid float conversions until output.
@@ -432,6 +443,7 @@ impl UTXOCohorts {
}
// K-way merge using min-heap: O(n log k) where k = number of cohorts
// Collects merged price->sats map while computing percentiles
let mut heap: BinaryHeap<Reverse<(CentsUnsigned, usize, usize)>> = BinaryHeap::new();
// Initialize heap with first entry from each cohort
@@ -457,6 +469,44 @@ impl UTXOCohorts {
let mut sats_at_price: u64 = 0;
let mut usd_at_price: u128 = 0;
// 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);
// Finalize a price point: compute percentiles and accumulate for merged vec
let mut finalize_price = |price: CentsUnsigned, sats: u64, usd: u128| {
// Percentile computation uses exact price for accuracy
cumsum_sats += sats;
cumsum_usd += usd;
if sat_idx < PERCENTILES_LEN || usd_idx < PERCENTILES_LEN {
let dollars = price.to_dollars();
while sat_idx < PERCENTILES_LEN && cumsum_sats >= sat_targets[sat_idx] {
sat_result[sat_idx] = dollars;
sat_idx += 1;
}
while usd_idx < PERCENTILES_LEN && cumsum_usd >= usd_targets[usd_idx] {
usd_result[usd_idx] = dollars;
usd_idx += 1;
}
}
// Round to nearest dollar with N significant digits for storage
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()
&& *last_price == rounded
{
*last_sats += Sats::from(sats);
} else {
merged.push((rounded, Sats::from(sats)));
}
};
while let Some(Reverse((price, cohort_idx, entry_idx))) = heap.pop() {
let entries = relevant[cohort_idx];
let (_, amount) = entries[entry_idx];
@@ -467,27 +517,7 @@ impl UTXOCohorts {
if let Some(prev_price) = current_price
&& prev_price != price
{
cumsum_sats += sats_at_price;
cumsum_usd += usd_at_price;
// Only convert to dollars if we still need percentiles
if sat_idx < PERCENTILES_LEN || usd_idx < PERCENTILES_LEN {
let prev_dollars = prev_price.to_dollars();
while sat_idx < PERCENTILES_LEN && cumsum_sats >= sat_targets[sat_idx] {
sat_result[sat_idx] = prev_dollars;
sat_idx += 1;
}
while usd_idx < PERCENTILES_LEN && cumsum_usd >= usd_targets[usd_idx] {
usd_result[usd_idx] = prev_dollars;
usd_idx += 1;
}
// Early exit if all percentiles found
if sat_idx >= PERCENTILES_LEN && usd_idx >= PERCENTILES_LEN {
break;
}
}
finalize_price(prev_price, sats_at_price, usd_at_price);
sats_at_price = 0;
usd_at_price = 0;
}
@@ -503,22 +533,9 @@ impl UTXOCohorts {
}
}
// Finalize last price (skip if we already found all percentiles via early exit)
if (sat_idx < PERCENTILES_LEN || usd_idx < PERCENTILES_LEN)
&& let Some(price) = current_price
{
cumsum_sats += sats_at_price;
cumsum_usd += usd_at_price;
let price_dollars = price.to_dollars();
while sat_idx < PERCENTILES_LEN && cumsum_sats >= sat_targets[sat_idx] {
sat_result[sat_idx] = price_dollars;
sat_idx += 1;
}
while usd_idx < PERCENTILES_LEN && cumsum_usd >= usd_targets[usd_idx] {
usd_result[usd_idx] = price_dollars;
usd_idx += 1;
}
// Finalize last price
if let Some(price) = current_price {
finalize_price(price, sats_at_price, usd_at_price);
}
// Push both sat-weighted and USD-weighted results
@@ -539,6 +556,23 @@ impl UTXOCohorts {
spot_pct.dateindex.truncate_push(dateindex, rank)?;
}
// Write daily cost basis snapshot
let cohort_name = match &filter {
Filter::All => "all",
Filter::Term(Term::Sth) => TERM_NAMES.short.id,
Filter::Term(Term::Lth) => TERM_NAMES.long.id,
_ => return Ok(()),
};
let date = Date::from(dateindex);
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())?,
)?;
Ok(())
})
}
@@ -399,7 +399,7 @@ pub fn process_blocks(
.map(|c| c.to_dollars())
.unwrap_or(Dollars::NAN);
vecs.utxo_cohorts
.truncate_push_aggregate_percentiles(dateindex, spot)?;
.truncate_push_aggregate_percentiles(dateindex, spot, &vecs.states_path)?;
// Compute unrealized peak regret by age range (once per day)
// Aggregate cohorts (all, term, etc.) get values via compute_from_stateful
@@ -17,7 +17,8 @@ use crate::{
internal::{
CentsUnsignedToDollars, ComputedFromDateLast, ComputedFromDateRatio,
ComputedFromHeightLast, ComputedFromHeightSum, ComputedFromHeightSumCum, DollarsMinus,
DollarsPlus, LazyBinaryFromHeightSum, LazyBinaryFromHeightSumCum,
DollarsPlus, DollarsSquaredDivide, LazyBinaryFromHeightSum, LazyBinaryFromHeightSumCum,
LazyBinaryPriceFromHeight,
LazyComputedValueFromHeightSumCum, LazyFromDateLast, LazyFromHeightLast, LazyFromHeightSum,
LazyFromHeightSumCum, LazyPriceFromCents, PercentageDollarsF32, PriceFromHeight,
StoredF32Identity, ValueFromDateLast,
@@ -43,6 +44,10 @@ pub struct RealizedMetrics {
pub investor_price: LazyPriceFromCents,
pub investor_price_extra: ComputedFromDateRatio,
// === Floor/Ceiling Price Bands (lazy: realized²/investor, investor²/realized) ===
pub lower_price_band: LazyBinaryPriceFromHeight,
pub upper_price_band: LazyBinaryPriceFromHeight,
// === Raw values for aggregation (needed to compute investor_price for aggregated cohorts) ===
/// Raw Σ(price × sats) for realized cap aggregation
pub cap_raw: BytesVec<Height, CentsSats>,
@@ -286,6 +291,24 @@ impl RealizedMetrics {
extended,
)?;
// Floor price = realized² / investor (lower band)
let lower_price_band =
LazyBinaryPriceFromHeight::from_price_and_lazy_price::<DollarsSquaredDivide>(
&cfg.name("lower_price_band"),
cfg.version,
&realized_price,
&investor_price,
);
// Ceiling price = investor² / realized (upper band)
let upper_price_band =
LazyBinaryPriceFromHeight::from_lazy_price_and_price::<DollarsSquaredDivide>(
&cfg.name("upper_price_band"),
cfg.version,
&investor_price,
&realized_price,
);
// Raw values for aggregation
let cap_raw = BytesVec::forced_import(cfg.db, &cfg.name("cap_raw"), cfg.version)?;
let investor_cap_raw =
@@ -429,6 +452,11 @@ impl RealizedMetrics {
investor_price_cents,
investor_price,
investor_price_extra,
// === Floor/Ceiling Price Bands ===
lower_price_band,
upper_price_band,
cap_raw,
investor_cap_raw,
@@ -6,6 +6,9 @@ use vecdb::unlikely;
use super::{super::cost_basis::RealizedState, base::CohortState};
/// Significant digits for address cost basis prices (after rounding to dollars).
const COST_BASIS_PRICE_DIGITS: i32 = 4;
#[derive(Clone)]
pub struct AddressCohortState {
pub addr_count: u64,
@@ -16,7 +19,8 @@ impl AddressCohortState {
pub fn new(path: &Path, name: &str, compute_dollars: bool) -> Self {
Self {
addr_count: 0,
inner: CohortState::new(path, name, compute_dollars),
inner: CohortState::new(path, name, compute_dollars)
.with_price_rounding(COST_BASIS_PRICE_DIGITS),
}
}
@@ -3,9 +3,7 @@ use std::path::Path;
use brk_error::Result;
use brk_types::{Age, CentsSats, CentsUnsigned, CostBasisSnapshot, Height, Sats, SupplyState};
use super::super::cost_basis::{
CachedUnrealizedState, Percentiles, CostBasisData, RealizedState, UnrealizedState,
};
use super::super::cost_basis::{CostBasisData, Percentiles, RealizedState, UnrealizedState};
#[derive(Clone)]
pub struct CohortState {
@@ -15,7 +13,6 @@ pub struct CohortState {
pub satblocks_destroyed: Sats,
pub satdays_destroyed: Sats,
cost_basis_data: Option<CostBasisData>,
cached_unrealized: Option<CachedUnrealizedState>,
}
impl CohortState {
@@ -27,12 +24,18 @@ impl CohortState {
satblocks_destroyed: Sats::ZERO,
satdays_destroyed: Sats::ZERO,
cost_basis_data: compute_dollars.then_some(CostBasisData::create(path, name)),
cached_unrealized: None,
}
}
/// Enable price rounding for cost basis data.
pub fn with_price_rounding(mut self, digits: i32) -> Self {
if let Some(data) = self.cost_basis_data.take() {
self.cost_basis_data = Some(data.with_price_rounding(digits));
}
self
}
pub fn import_at_or_before(&mut self, height: Height) -> Result<Height> {
self.cached_unrealized = None;
match self.cost_basis_data.as_mut() {
Some(p) => p.import_at_or_before(height),
None => Ok(height),
@@ -55,7 +58,6 @@ impl CohortState {
p.clean()?;
p.init();
}
self.cached_unrealized = None;
Ok(())
}
@@ -66,11 +68,17 @@ impl CohortState {
}
pub fn cost_basis_data_first_key_value(&self) -> Option<(CentsUnsigned, &Sats)> {
self.cost_basis_data.as_ref()?.first_key_value().map(|(k, v)| (k.into(), v))
self.cost_basis_data
.as_ref()?
.first_key_value()
.map(|(k, v)| (k.into(), v))
}
pub fn cost_basis_data_last_key_value(&self) -> Option<(CentsUnsigned, &Sats)> {
self.cost_basis_data.as_ref()?.last_key_value().map(|(k, v)| (k.into(), v))
self.cost_basis_data
.as_ref()?
.last_key_value()
.map(|(k, v)| (k.into(), v))
}
pub fn reset_single_iteration_values(&mut self) {
@@ -102,10 +110,6 @@ impl CohortState {
s.price_sats,
s.investor_cap,
);
if let Some(cache) = self.cached_unrealized.as_mut() {
cache.on_receive(s.realized_price, s.supply_state.value);
}
}
}
@@ -129,10 +133,6 @@ impl CohortState {
s.price_sats,
s.investor_cap,
);
if let Some(cache) = self.cached_unrealized.as_mut() {
cache.on_send(s.realized_price, s.supply_state.value);
}
}
}
@@ -151,16 +151,10 @@ impl CohortState {
realized.receive(price, sats);
self.cost_basis_data.as_mut().unwrap().increment(
price,
sats,
price_sats,
investor_cap,
);
if let Some(cache) = self.cached_unrealized.as_mut() {
cache.on_receive(price, sats);
}
self.cost_basis_data
.as_mut()
.unwrap()
.increment(price, sats, price_sats, investor_cap);
}
}
@@ -185,10 +179,6 @@ impl CohortState {
current.price_sats,
current.investor_cap,
);
if let Some(cache) = self.cached_unrealized.as_mut() {
cache.on_receive(current.realized_price, current.supply_state.value);
}
}
if prev.supply_state.value.is_not_zero() {
@@ -198,10 +188,6 @@ impl CohortState {
prev.price_sats,
prev.investor_cap,
);
if let Some(cache) = self.cached_unrealized.as_mut() {
cache.on_send(prev.realized_price, prev.supply_state.value);
}
}
}
}
@@ -245,10 +231,6 @@ impl CohortState {
prev_ps,
prev_investor_cap,
);
if let Some(cache) = self.cached_unrealized.as_mut() {
cache.on_send(pp, sats);
}
}
}
}
@@ -293,10 +275,6 @@ impl CohortState {
current.price_sats,
current.investor_cap,
);
if let Some(cache) = self.cached_unrealized.as_mut() {
cache.on_receive(current.realized_price, current.supply_state.value);
}
}
if prev.supply_state.value.is_not_zero() {
@@ -306,10 +284,6 @@ impl CohortState {
prev.price_sats,
prev.investor_cap,
);
if let Some(cache) = self.cached_unrealized.as_mut() {
cache.on_send(prev.realized_price, prev.supply_state.value);
}
}
}
}
@@ -324,25 +298,13 @@ impl CohortState {
height_price: CentsUnsigned,
date_price: Option<CentsUnsigned>,
) -> (UnrealizedState, Option<UnrealizedState>) {
let cost_basis_data = match self.cost_basis_data.as_ref() {
Some(p) if !p.is_empty() => p,
_ => return (UnrealizedState::ZERO, date_price.map(|_| UnrealizedState::ZERO)),
};
let date_state = date_price.map(|date_price| {
CachedUnrealizedState::compute_full_standalone(date_price.into(), cost_basis_data)
});
let height_state = if let Some(cache) = self.cached_unrealized.as_mut() {
cache.get_at_price(height_price, cost_basis_data)
} else {
let cache = CachedUnrealizedState::compute_fresh(height_price, cost_basis_data);
let state = cache.current_state();
self.cached_unrealized = Some(cache);
state
};
(height_state, date_state)
match self.cost_basis_data.as_mut() {
Some(p) => p.compute_unrealized_states(height_price, date_price),
None => (
UnrealizedState::ZERO,
date_price.map(|_| UnrealizedState::ZERO),
),
}
}
pub fn write(&mut self, height: Height, cleanup: bool) -> Result<()> {
@@ -353,16 +315,22 @@ impl CohortState {
}
pub fn min_price(&self) -> Option<CentsUnsigned> {
self.cost_basis_data.as_ref()?.first_key_value().map(|(k, _)| k.into())
self.cost_basis_data
.as_ref()?
.first_key_value()
.map(|(k, _)| k.into())
}
pub fn max_price(&self) -> Option<CentsUnsigned> {
self.cost_basis_data.as_ref()?.last_key_value().map(|(k, _)| k.into())
self.cost_basis_data
.as_ref()?
.last_key_value()
.map(|(k, _)| k.into())
}
pub fn cost_basis_data_iter(
&self,
) -> Option<impl Iterator<Item = (CentsUnsigned, &Sats)>> {
self.cost_basis_data.as_ref().map(|p| p.iter().map(|(k, v)| (k.into(), v)))
pub fn cost_basis_data_iter(&self) -> Option<impl Iterator<Item = (CentsUnsigned, &Sats)>> {
self.cost_basis_data
.as_ref()
.map(|p| p.iter().map(|(k, v)| (k.into(), v)))
}
}
@@ -1,22 +1,23 @@
use std::{
collections::BTreeMap,
fs,
ops::Bound,
path::{Path, PathBuf},
};
use brk_error::{Error, Result};
use brk_types::{CentsSats, CentsSquaredSats, CentsUnsigned, CentsUnsignedCompact, Height, Sats};
use pco::{
ChunkConfig,
standalone::{simple_compress, simple_decompress},
use brk_types::{
CentsSats, CentsSquaredSats, CentsUnsigned, CentsUnsignedCompact, CostBasisDistribution,
Height, Sats,
};
use rustc_hash::FxHashMap;
use vecdb::Bytes;
use crate::utils::OptionExt;
use super::Percentiles;
use super::{CachedUnrealizedState, Percentiles, UnrealizedState};
/// Type alias for the price-to-sats map used in cost basis data.
pub(super) type CostBasisMap = BTreeMap<CentsUnsignedCompact, Sats>;
#[derive(Clone, Debug, Default)]
struct PendingRaw {
@@ -32,6 +33,8 @@ pub struct CostBasisData {
state: Option<State>,
pending: FxHashMap<CentsUnsignedCompact, (Sats, Sats)>,
pending_raw: PendingRaw,
cache: Option<CachedUnrealizedState>,
rounding_digits: Option<i32>,
}
const STATE_TO_KEEP: usize = 10;
@@ -43,6 +46,21 @@ impl CostBasisData {
state: None,
pending: FxHashMap::default(),
pending_raw: PendingRaw::default(),
cache: None,
rounding_digits: None,
}
}
pub fn with_price_rounding(mut self, digits: i32) -> Self {
self.rounding_digits = Some(digits);
self
}
#[inline]
fn round_price(&self, price: CentsUnsigned) -> CentsUnsigned {
match self.rounding_digits {
Some(digits) => price.round_to_dollar(digits),
None => price,
}
}
@@ -54,6 +72,7 @@ impl CostBasisData {
self.state = Some(State::deserialize(&fs::read(path)?)?);
self.pending.clear();
self.pending_raw = PendingRaw::default();
self.cache = None;
Ok(height)
}
@@ -73,29 +92,31 @@ impl CostBasisData {
pub fn iter(&self) -> impl Iterator<Item = (CentsUnsignedCompact, &Sats)> {
self.assert_pending_empty();
self.state.u().map.iter().map(|(&k, v)| (k, v))
}
pub fn range(
&self,
bounds: (Bound<CentsUnsignedCompact>, Bound<CentsUnsignedCompact>),
) -> impl Iterator<Item = (CentsUnsignedCompact, &Sats)> {
self.assert_pending_empty();
self.state.u().map.range(bounds).map(|(&k, v)| (k, v))
self.state.u().base.map.iter().map(|(&k, v)| (k, v))
}
pub fn is_empty(&self) -> bool {
self.pending.is_empty() && self.state.u().map.is_empty()
self.pending.is_empty() && self.state.u().base.map.is_empty()
}
pub fn first_key_value(&self) -> Option<(CentsUnsignedCompact, &Sats)> {
self.assert_pending_empty();
self.state.u().map.first_key_value().map(|(&k, v)| (k, v))
self.state
.u()
.base
.map
.first_key_value()
.map(|(&k, v)| (k, v))
}
pub fn last_key_value(&self) -> Option<(CentsUnsignedCompact, &Sats)> {
self.assert_pending_empty();
self.state.u().map.last_key_value().map(|(&k, v)| (k, v))
self.state
.u()
.base
.map
.last_key_value()
.map(|(&k, v)| (k, v))
}
/// Get the exact cap_raw value (not recomputed from map).
@@ -110,7 +131,8 @@ impl CostBasisData {
self.state.u().investor_cap_raw
}
/// Increment with pre-computed typed values
/// Increment with pre-computed typed values.
/// Handles rounding and cache update.
pub fn increment(
&mut self,
price: CentsUnsigned,
@@ -118,14 +140,19 @@ impl CostBasisData {
price_sats: CentsSats,
investor_cap: CentsSquaredSats,
) {
let price = self.round_price(price);
self.pending.entry(price.into()).or_default().0 += sats;
self.pending_raw.cap_inc += price_sats;
if investor_cap != CentsSquaredSats::ZERO {
self.pending_raw.investor_cap_inc += investor_cap;
}
if let Some(cache) = self.cache.as_mut() {
cache.on_receive(price, sats);
}
}
/// Decrement with pre-computed typed values
/// Decrement with pre-computed typed values.
/// Handles rounding and cache update.
pub fn decrement(
&mut self,
price: CentsUnsigned,
@@ -133,16 +160,20 @@ impl CostBasisData {
price_sats: CentsSats,
investor_cap: CentsSquaredSats,
) {
let price = self.round_price(price);
self.pending.entry(price.into()).or_default().1 += sats;
self.pending_raw.cap_dec += price_sats;
if investor_cap != CentsSquaredSats::ZERO {
self.pending_raw.investor_cap_dec += investor_cap;
}
if let Some(cache) = self.cache.as_mut() {
cache.on_send(price, sats);
}
}
pub fn apply_pending(&mut self) {
for (cents, (inc, dec)) in self.pending.drain() {
let entry = self.state.um().map.entry(cents).or_default();
let entry = self.state.um().base.map.entry(cents).or_default();
*entry += inc;
if *entry < dec {
panic!(
@@ -159,7 +190,7 @@ impl CostBasisData {
}
*entry -= dec;
if *entry == Sats::ZERO {
self.state.um().map.remove(&cents);
self.state.um().base.map.remove(&cents);
}
}
@@ -205,6 +236,7 @@ impl CostBasisData {
self.state.replace(State::default());
self.pending.clear();
self.pending_raw = PendingRaw::default();
self.cache = None;
}
pub fn compute_percentiles(&self) -> Option<Percentiles> {
@@ -212,14 +244,49 @@ impl CostBasisData {
Percentiles::compute(self.iter().map(|(k, &v)| (k, v)))
}
pub fn compute_unrealized_states(
&mut self,
height_price: CentsUnsigned,
date_price: Option<CentsUnsigned>,
) -> (UnrealizedState, Option<UnrealizedState>) {
if self.is_empty() {
return (UnrealizedState::ZERO, date_price.map(|_| UnrealizedState::ZERO));
}
let map = &self.state.u().base.map;
let date_state =
date_price.map(|p| CachedUnrealizedState::compute_full_standalone(p.into(), map));
let height_state = if let Some(cache) = self.cache.as_mut() {
cache.get_at_price(height_price, map)
} else {
let cache = CachedUnrealizedState::compute_fresh(height_price, map);
let state = cache.current_state();
self.cache = Some(cache);
state
};
(height_state, date_state)
}
pub fn clean(&mut self) -> Result<()> {
let _ = fs::remove_dir_all(&self.pathbuf);
fs::create_dir_all(&self.pathbuf)?;
fs::create_dir_all(self.path_by_height())?;
self.cache = None;
Ok(())
}
fn path_by_height(&self) -> PathBuf {
self.pathbuf.join("by_height")
}
fn read_dir(&self, keep_only_before: Option<Height>) -> Result<BTreeMap<Height, PathBuf>> {
Ok(fs::read_dir(&self.pathbuf)?
let by_height = self.path_by_height();
if !by_height.exists() {
return Ok(BTreeMap::new());
}
Ok(fs::read_dir(&by_height)?
.filter_map(|entry| {
let path = entry.ok()?.path();
let name = path.file_name()?.to_str()?;
@@ -257,13 +324,13 @@ impl CostBasisData {
}
fn path_state(&self, height: Height) -> PathBuf {
self.pathbuf.join(u32::from(height).to_string())
self.path_by_height().join(height.to_string())
}
}
#[derive(Clone, Default, Debug)]
struct State {
map: BTreeMap<CentsUnsignedCompact, Sats>,
base: CostBasisDistribution,
/// Exact realized cap: Σ(price × sats)
cap_raw: CentsSats,
/// Exact investor cap: Σ(price² × sats)
@@ -271,51 +338,20 @@ struct State {
}
impl State {
fn serialize(&self) -> vecdb::Result<Vec<u8>> {
let keys: Vec<u32> = self.map.keys().map(|k| k.inner()).collect();
let values: Vec<u64> = self.map.values().map(|v| u64::from(*v)).collect();
let config = ChunkConfig::default();
let compressed_keys = simple_compress(&keys, &config)?;
let compressed_values = simple_compress(&values, &config)?;
let mut buffer = Vec::new();
buffer.extend(keys.len().to_bytes());
buffer.extend(compressed_keys.len().to_bytes());
buffer.extend(compressed_values.len().to_bytes());
buffer.extend(compressed_keys);
buffer.extend(compressed_values);
fn serialize(&self) -> Result<Vec<u8>> {
let mut buffer = self.base.serialize()?;
buffer.extend(self.cap_raw.to_bytes());
buffer.extend(self.investor_cap_raw.to_bytes());
Ok(buffer)
}
fn deserialize(data: &[u8]) -> vecdb::Result<Self> {
let entry_count = usize::from_bytes(&data[0..8])?;
let keys_len = usize::from_bytes(&data[8..16])?;
let values_len = usize::from_bytes(&data[16..24])?;
let keys_start = 24;
let values_start = keys_start + keys_len;
let raw_start = values_start + values_len;
let keys: Vec<u32> = simple_decompress(&data[keys_start..values_start])?;
let values: Vec<u64> = simple_decompress(&data[values_start..raw_start])?;
let map: BTreeMap<CentsUnsignedCompact, Sats> = keys
.into_iter()
.zip(values)
.map(|(k, v)| (CentsUnsignedCompact::new(k), Sats::from(v)))
.collect();
assert_eq!(map.len(), entry_count);
let cap_raw = CentsSats::from_bytes(&data[raw_start..raw_start + 16])?;
let investor_cap_raw = CentsSquaredSats::from_bytes(&data[raw_start + 16..raw_start + 32])?;
fn deserialize(data: &[u8]) -> Result<Self> {
let (base, rest) = CostBasisDistribution::deserialize_with_rest(data)?;
let cap_raw = CentsSats::from_bytes(&rest[0..16])?;
let investor_cap_raw = CentsSquaredSats::from_bytes(&rest[16..32])?;
Ok(Self {
map,
base,
cap_raw,
investor_cap_raw,
})
@@ -1,9 +1,12 @@
mod cost_basis_data;
mod data;
mod percentiles;
mod realized;
mod unrealized;
pub use cost_basis_data::*;
pub use data::*;
pub use percentiles::*;
pub use realized::*;
pub use unrealized::*;
pub use unrealized::UnrealizedState;
// Internal use only
pub(super) use unrealized::CachedUnrealizedState;
@@ -2,7 +2,7 @@ use std::ops::Bound;
use brk_types::{CentsUnsigned, CentsUnsignedCompact, Sats};
use super::cost_basis_data::CostBasisData;
use super::CostBasisMap;
#[derive(Debug, Default, Clone)]
pub struct UnrealizedState {
@@ -91,9 +91,7 @@ impl CachedStateRaw {
unrealized_profit: CentsUnsigned::new(
(self.unrealized_profit / Sats::ONE_BTC_U128) as u64,
),
unrealized_loss: CentsUnsigned::new(
(self.unrealized_loss / Sats::ONE_BTC_U128) as u64,
),
unrealized_loss: CentsUnsigned::new((self.unrealized_loss / Sats::ONE_BTC_U128) as u64),
invested_capital_in_profit: CentsUnsigned::new(
(self.invested_capital_in_profit / Sats::ONE_BTC_U128) as u64,
),
@@ -115,10 +113,13 @@ pub struct CachedUnrealizedState {
}
impl CachedUnrealizedState {
pub fn compute_fresh(price: CentsUnsigned, cost_basis_data: &CostBasisData) -> Self {
pub fn compute_fresh(price: CentsUnsigned, map: &CostBasisMap) -> Self {
let price: CentsUnsignedCompact = price.into();
let state = Self::compute_raw(price, cost_basis_data);
Self { state, at_price: price }
let state = Self::compute_raw(price, map);
Self {
state,
at_price: price,
}
}
/// Get the current cached state as output (without price update).
@@ -129,11 +130,11 @@ impl CachedUnrealizedState {
pub fn get_at_price(
&mut self,
new_price: CentsUnsigned,
cost_basis_data: &CostBasisData,
map: &CostBasisMap,
) -> UnrealizedState {
let new_price: CentsUnsignedCompact = new_price.into();
if new_price != self.at_price {
self.update_for_price_change(new_price, cost_basis_data);
self.update_for_price_change(new_price, map);
}
self.state.to_output()
}
@@ -186,11 +187,7 @@ impl CachedUnrealizedState {
}
}
fn update_for_price_change(
&mut self,
new_price: CentsUnsignedCompact,
cost_basis_data: &CostBasisData,
) {
fn update_for_price_change(&mut self, new_price: CentsUnsignedCompact, map: &CostBasisMap) {
let old_price = self.at_price;
if new_price > old_price {
@@ -201,8 +198,7 @@ impl CachedUnrealizedState {
// First, process UTXOs crossing from loss to profit
// Range (old_price, new_price] means: old_price < price <= new_price
for (price, &sats) in
cost_basis_data.range((Bound::Excluded(old_price), Bound::Included(new_price)))
for (&price, &sats) in map.range((Bound::Excluded(old_price), Bound::Included(new_price)))
{
let sats_u128 = sats.as_u128();
let price_u128 = price.as_u128();
@@ -233,8 +229,7 @@ impl CachedUnrealizedState {
// Non-crossing profit UTXOs: their profit increases by delta
self.state.unrealized_profit += delta * original_supply_in_profit;
// Non-crossing loss UTXOs: their loss decreases by delta
let non_crossing_loss_sats =
self.state.supply_in_loss.as_u128(); // Already excludes crossing
let non_crossing_loss_sats = self.state.supply_in_loss.as_u128(); // Already excludes crossing
self.state.unrealized_loss -= delta * non_crossing_loss_sats;
} else if new_price < old_price {
let delta = (old_price - new_price).as_u128();
@@ -244,8 +239,7 @@ impl CachedUnrealizedState {
// First, process UTXOs crossing from profit to loss
// Range (new_price, old_price] means: new_price < price <= old_price
for (price, &sats) in
cost_basis_data.range((Bound::Excluded(new_price), Bound::Included(old_price)))
for (&price, &sats) in map.range((Bound::Excluded(new_price), Bound::Included(old_price)))
{
let sats_u128 = sats.as_u128();
let price_u128 = price.as_u128();
@@ -276,22 +270,18 @@ impl CachedUnrealizedState {
// Non-crossing loss UTXOs: their loss increases by delta
self.state.unrealized_loss += delta * original_supply_in_loss;
// Non-crossing profit UTXOs: their profit decreases by delta
let non_crossing_profit_sats =
self.state.supply_in_profit.as_u128(); // Already excludes crossing
let non_crossing_profit_sats = self.state.supply_in_profit.as_u128(); // Already excludes crossing
self.state.unrealized_profit -= delta * non_crossing_profit_sats;
}
self.at_price = new_price;
}
/// Compute raw cached state from cost_basis_data.
fn compute_raw(
current_price: CentsUnsignedCompact,
cost_basis_data: &CostBasisData,
) -> CachedStateRaw {
/// Compute raw cached state from the map.
fn compute_raw(current_price: CentsUnsignedCompact, map: &CostBasisMap) -> CachedStateRaw {
let mut state = CachedStateRaw::default();
for (price, &sats) in cost_basis_data.iter() {
for (&price, &sats) in map.iter() {
let sats_u128 = sats.as_u128();
let price_u128 = price.as_u128();
let invested_capital = price_u128 * sats_u128;
@@ -321,8 +311,8 @@ impl CachedUnrealizedState {
/// Used for date_state which doesn't use the cache.
pub fn compute_full_standalone(
current_price: CentsUnsignedCompact,
cost_basis_data: &CostBasisData,
map: &CostBasisMap,
) -> UnrealizedState {
Self::compute_raw(current_price, cost_basis_data).to_output()
Self::compute_raw(current_price, map).to_output()
}
}
+4 -1
View File
@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use brk_error::Result;
use brk_indexer::Indexer;
@@ -37,6 +37,8 @@ const VERSION: Version = Version::new(22);
pub struct Vecs {
#[traversable(skip)]
db: Database,
#[traversable(skip)]
pub states_path: PathBuf,
pub supply_state: BytesVec<Height, SupplyState>,
pub any_address_indexes: AnyAddressIndexesVecs,
@@ -163,6 +165,7 @@ impl Vecs {
emptyaddressindex,
db,
states_path,
};
this.db.retain_regions(
@@ -457,6 +457,88 @@ where
}
}
/// Create from a ComputedFromHeightLast (block last with derived dates) and a LazyFromHeightLast.
pub fn from_block_last_and_lazy_block_last<F, S2SourceT>(
name: &str,
version: Version,
source1: &ComputedFromHeightLast<S1T>,
source2: &LazyFromHeightLast<S2T, S2SourceT>,
) -> Self
where
F: BinaryTransform<S1T, S2T, T>,
S1T: NumericValue,
S2SourceT: ComputedVecValue + JsonSchema,
{
let v = version + VERSION;
macro_rules! period {
($p:ident) => {
LazyBinaryTransformLast::from_vecs::<F>(
name,
v,
source1.$p.boxed_clone(),
source2.$p.boxed_clone(),
)
};
}
Self {
dateindex: LazyVecFrom2::transformed::<F>(
name,
v,
source1.dateindex.boxed_clone(),
source2.dateindex.boxed_clone(),
),
weekindex: period!(weekindex),
monthindex: period!(monthindex),
quarterindex: period!(quarterindex),
semesterindex: period!(semesterindex),
yearindex: period!(yearindex),
decadeindex: period!(decadeindex),
}
}
/// Create from a LazyFromHeightLast and a ComputedFromHeightLast (reversed source order).
pub fn from_lazy_block_last_and_block_last<F, S1SourceT>(
name: &str,
version: Version,
source1: &LazyFromHeightLast<S1T, S1SourceT>,
source2: &ComputedFromHeightLast<S2T>,
) -> Self
where
F: BinaryTransform<S1T, S2T, T>,
S2T: NumericValue,
S1SourceT: ComputedVecValue + JsonSchema,
{
let v = version + VERSION;
macro_rules! period {
($p:ident) => {
LazyBinaryTransformLast::from_vecs::<F>(
name,
v,
source1.$p.boxed_clone(),
source2.$p.boxed_clone(),
)
};
}
Self {
dateindex: LazyVecFrom2::transformed::<F>(
name,
v,
source1.dateindex.boxed_clone(),
source2.dateindex.boxed_clone(),
),
weekindex: period!(weekindex),
monthindex: period!(monthindex),
quarterindex: period!(quarterindex),
semesterindex: period!(semesterindex),
yearindex: period!(yearindex),
decadeindex: period!(decadeindex),
}
}
/// Create from a LazyDateDerivedLast source and a BinaryDateLast source.
pub fn from_derived_last_and_binary_last<F, S2aT, S2bT>(
name: &str,
@@ -7,7 +7,7 @@ use brk_types::{
use schemars::JsonSchema;
use vecdb::{IterableBoxedVec, IterableCloneableVec, UnaryTransform};
use crate::internal::{ComputedFromHeightLast, ComputedFromDateLast, ComputedVecValue, LazyDateDerivedLast, LazyTransformLast, NumericValue};
use crate::internal::{ComputedFromHeightLast, ComputedFromDateLast, ComputedVecValue, LazyBinaryFromDateLast, LazyDateDerivedLast, LazyTransformLast, NumericValue};
const VERSION: Version = Version::ZERO;
@@ -84,4 +84,34 @@ where
{
Self::from_derived::<F>(name, version, source.dateindex.boxed_clone(), &source.dates)
}
/// Create by unary-transforming a LazyBinaryFromDateLast source.
pub fn from_binary<F, S1aT, S1bT>(
name: &str,
version: Version,
source: &LazyBinaryFromDateLast<S1T, S1aT, S1bT>,
) -> Self
where
F: UnaryTransform<S1T, T>,
S1aT: ComputedVecValue + JsonSchema,
S1bT: ComputedVecValue + JsonSchema,
{
let v = version + VERSION;
macro_rules! period {
($p:ident) => {
LazyTransformLast::from_boxed::<F>(name, v, source.$p.boxed_clone())
};
}
Self {
dateindex: period!(dateindex),
weekindex: period!(weekindex),
monthindex: period!(monthindex),
quarterindex: period!(quarterindex),
semesterindex: period!(semesterindex),
yearindex: period!(yearindex),
decadeindex: period!(decadeindex),
}
}
}
@@ -2,6 +2,8 @@
//!
//! Both dollars and sats are computed from the same source.
use std::marker::PhantomData;
use brk_traversable::Traversable;
use brk_types::{Dollars, SatsFract, Version};
use derive_more::{Deref, DerefMut};
@@ -28,7 +30,7 @@ where
}
/// Composed transform: ST -> Dollars -> SatsFract
pub struct ComposedDollarsToSatsFract<F>(std::marker::PhantomData<F>);
pub struct ComposedDollarsToSatsFract<F>(PhantomData<F>);
impl<F, ST> UnaryTransform<ST, SatsFract> for ComposedDollarsToSatsFract<F>
where
@@ -80,6 +80,58 @@ where
}
}
pub fn from_block_last_and_lazy_block_last<F, S2SourceT>(
name: &str,
version: Version,
source1: &ComputedFromHeightLast<S1T>,
source2: &LazyFromHeightLast<S2T, S2SourceT>,
) -> Self
where
F: BinaryTransform<S1T, S2T, T>,
S1T: NumericValue,
S2SourceT: ComputedVecValue + JsonSchema,
{
let v = version + VERSION;
Self {
height: LazyVecFrom2::transformed::<F>(
name,
v,
source1.height.boxed_clone(),
source2.height.boxed_clone(),
),
rest: LazyBinaryHeightDerivedLast::from_block_last_and_lazy_block_last::<F, _>(
name, v, source1, source2,
),
}
}
pub fn from_lazy_block_last_and_block_last<F, S1SourceT>(
name: &str,
version: Version,
source1: &LazyFromHeightLast<S1T, S1SourceT>,
source2: &ComputedFromHeightLast<S2T>,
) -> Self
where
F: BinaryTransform<S1T, S2T, T>,
S2T: NumericValue,
S1SourceT: ComputedVecValue + JsonSchema,
{
let v = version + VERSION;
Self {
height: LazyVecFrom2::transformed::<F>(
name,
v,
source1.height.boxed_clone(),
source2.height.boxed_clone(),
),
rest: LazyBinaryHeightDerivedLast::from_lazy_block_last_and_block_last::<F, _>(
name, v, source1, source2,
),
}
}
pub fn from_computed_height_date_last<F: BinaryTransform<S1T, S2T, T>>(
name: &str,
version: Version,
@@ -0,0 +1,72 @@
//! Fully lazy binary price wrapper with both USD and sats representations.
//!
//! All levels (height, dateindex, date periods, difficultyepoch) are lazy.
//! Derives dateindex from the two source dateindexes rather than storing it.
use brk_traversable::Traversable;
use brk_types::{CentsUnsigned, Dollars, SatsFract, Version};
use derive_more::{Deref, DerefMut};
use vecdb::BinaryTransform;
use crate::internal::{
DollarsToSatsFract, LazyBinaryFromHeightLast, LazyFromHeightLast, LazyPriceFromCents,
PriceFromHeight,
};
/// Fully lazy binary price metric with both USD and sats representations.
///
/// Dollars: lazy binary transform at all levels (height, dateindex, date periods, difficultyepoch).
/// Sats: lazy unary transform of dollars.
#[derive(Clone, Deref, DerefMut, Traversable)]
#[traversable(merge)]
pub struct LazyBinaryPriceFromHeight {
#[deref]
#[deref_mut]
#[traversable(flatten)]
pub dollars: LazyBinaryFromHeightLast<Dollars, Dollars, Dollars>,
pub sats: LazyFromHeightLast<SatsFract, Dollars>,
}
impl LazyBinaryPriceFromHeight {
/// Create from a PriceFromHeight (source1) and a LazyPriceFromCents (source2).
pub fn from_price_and_lazy_price<F: BinaryTransform<Dollars, Dollars, Dollars>>(
name: &str,
version: Version,
source1: &PriceFromHeight,
source2: &LazyPriceFromCents,
) -> Self {
let dollars = LazyBinaryFromHeightLast::from_block_last_and_lazy_block_last::<
F,
CentsUnsigned,
>(name, version, &source1.dollars, &source2.dollars);
let sats = LazyFromHeightLast::from_binary::<DollarsToSatsFract, _, _>(
&format!("{name}_sats"),
version,
&dollars,
);
Self { dollars, sats }
}
/// Create from a LazyPriceFromCents (source1) and a PriceFromHeight (source2).
pub fn from_lazy_price_and_price<F: BinaryTransform<Dollars, Dollars, Dollars>>(
name: &str,
version: Version,
source1: &LazyPriceFromCents,
source2: &PriceFromHeight,
) -> Self {
let dollars = LazyBinaryFromHeightLast::from_lazy_block_last_and_block_last::<
F,
CentsUnsigned,
>(name, version, &source1.dollars, &source2.dollars);
let sats = LazyFromHeightLast::from_binary::<DollarsToSatsFract, _, _>(
&format!("{name}_sats"),
version,
&dollars,
);
Self { dollars, sats }
}
}
@@ -8,7 +8,8 @@ use vecdb::{IterableBoxedVec, IterableCloneableVec, LazyVecFrom1, UnaryTransform
use crate::internal::{
ComputedFromHeightAndDateLast, ComputedFromHeightLast, ComputedHeightDerivedLast,
ComputedVecValue, LazyBinaryComputedFromHeightLast, LazyHeightDerivedLast, NumericValue,
ComputedVecValue, LazyBinaryComputedFromHeightLast, LazyBinaryFromHeightLast,
LazyHeightDerivedLast, NumericValue,
};
#[derive(Clone, Deref, DerefMut, Traversable)]
#[traversable(merge)]
@@ -96,4 +97,22 @@ where
rest: LazyHeightDerivedLast::from_derived_computed::<F>(name, v, &source.rest),
}
}
/// Create by unary-transforming a LazyBinaryFromHeightLast source.
pub fn from_binary<F, S1aT, S1bT>(
name: &str,
version: Version,
source: &LazyBinaryFromHeightLast<S1T, S1aT, S1bT>,
) -> Self
where
F: UnaryTransform<S1T, T>,
S1aT: ComputedVecValue + JsonSchema,
S1bT: ComputedVecValue + JsonSchema,
{
let v = version + VERSION;
Self {
height: LazyVecFrom1::transformed::<F>(name, v, source.height.boxed_clone()),
rest: LazyHeightDerivedLast::from_binary::<F, _, _>(name, v, &source.rest),
}
}
}
@@ -5,29 +5,30 @@ mod binary_sum_cum;
mod distribution;
mod full;
mod last;
mod lazy_distribution;
mod lazy_full;
mod lazy_transform_distribution;
mod lazy_binary_computed_distribution;
mod lazy_binary_computed_full;
mod lazy_binary_computed_last;
mod lazy_binary_computed_sum;
mod lazy_binary_computed_sum_cum;
mod lazy_binary_price;
mod lazy_computed_full;
mod lazy_computed_sum_cum;
mod lazy_distribution;
mod lazy_full;
mod lazy_last;
mod lazy_price_from_cents;
mod lazy_sum;
mod price;
mod unary_last;
mod lazy_sum_cum;
mod lazy_transform_distribution;
mod lazy_value;
mod price;
mod sum;
mod sum_cum;
mod unary_last;
mod value_binary;
mod value_full;
mod value_last;
mod value_lazy_binary_last;
mod lazy_value;
mod value_lazy_computed_sum_cum;
mod value_lazy_last;
mod value_lazy_sum_cum;
@@ -41,29 +42,30 @@ pub use binary_sum_cum::*;
pub use distribution::*;
pub use full::*;
pub use last::*;
pub use lazy_distribution::*;
pub use lazy_full::*;
pub use lazy_transform_distribution::*;
pub use lazy_binary_computed_distribution::*;
pub use lazy_binary_computed_full::*;
pub use lazy_binary_computed_last::*;
pub use lazy_binary_computed_sum::*;
pub use lazy_binary_computed_sum_cum::*;
pub use lazy_binary_price::*;
pub use lazy_computed_full::*;
pub use lazy_computed_sum_cum::*;
pub use lazy_distribution::*;
pub use lazy_full::*;
pub use lazy_last::*;
pub use lazy_price_from_cents::*;
pub use lazy_sum::*;
pub use price::*;
pub use unary_last::*;
pub use lazy_sum_cum::*;
pub use lazy_transform_distribution::*;
pub use lazy_value::*;
pub use price::*;
pub use sum::*;
pub use sum_cum::*;
pub use unary_last::*;
pub use value_binary::*;
pub use value_full::*;
pub use value_last::*;
pub use value_lazy_binary_last::*;
pub use lazy_value::*;
pub use value_lazy_computed_sum_cum::*;
pub use value_lazy_last::*;
pub use value_lazy_sum_cum::*;
@@ -86,6 +86,32 @@ where
}
}
pub fn from_lazy_block_last_and_block_last<F, S1SourceT>(
name: &str,
version: Version,
source1: &LazyFromHeightLast<S1T, S1SourceT>,
source2: &ComputedFromHeightLast<S2T>,
) -> Self
where
F: BinaryTransform<S1T, S2T, T>,
S2T: NumericValue,
S1SourceT: ComputedVecValue + JsonSchema,
{
let v = version + VERSION;
Self {
dates: LazyBinaryFromDateLast::from_lazy_block_last_and_block_last::<F, _>(
name, v, source1, source2,
),
difficultyepoch: LazyBinaryTransformLast::from_vecs::<F>(
name,
v,
source1.rest.difficultyepoch.boxed_clone(),
source2.rest.difficultyepoch.boxed_clone(),
),
}
}
pub fn from_computed_height_date_last<F: BinaryTransform<S1T, S2T, T>>(
name: &str,
version: Version,
@@ -114,6 +140,32 @@ where
}
}
pub fn from_block_last_and_lazy_block_last<F, S2SourceT>(
name: &str,
version: Version,
source1: &ComputedFromHeightLast<S1T>,
source2: &LazyFromHeightLast<S2T, S2SourceT>,
) -> Self
where
F: BinaryTransform<S1T, S2T, T>,
S1T: NumericValue,
S2SourceT: ComputedVecValue + JsonSchema,
{
let v = version + VERSION;
Self {
dates: LazyBinaryFromDateLast::from_block_last_and_lazy_block_last::<F, _>(
name, v, source1, source2,
),
difficultyepoch: LazyBinaryTransformLast::from_vecs::<F>(
name,
v,
source1.rest.difficultyepoch.boxed_clone(),
source2.rest.difficultyepoch.boxed_clone(),
),
}
}
pub fn from_computed_height_date_and_block_last<F: BinaryTransform<S1T, S2T, T>>(
name: &str,
version: Version,
@@ -8,7 +8,7 @@ use vecdb::{IterableCloneableVec, UnaryTransform};
use crate::internal::{
ComputedFromHeightLast, ComputedHeightDerivedLast, ComputedFromHeightAndDateLast, ComputedVecValue,
LazyFromDateLast, LazyTransformLast, NumericValue,
LazyBinaryHeightDerivedLast, LazyFromDateLast, LazyTransformLast, NumericValue,
};
#[derive(Clone, Deref, DerefMut, Traversable)]
@@ -81,6 +81,29 @@ where
}
}
/// Create by unary-transforming a LazyBinaryHeightDerivedLast source.
pub fn from_binary<F, S1aT, S1bT>(
name: &str,
version: Version,
source: &LazyBinaryHeightDerivedLast<S1T, S1aT, S1bT>,
) -> Self
where
F: UnaryTransform<S1T, T>,
S1aT: ComputedVecValue + JsonSchema,
S1bT: ComputedVecValue + JsonSchema,
{
let v = version + VERSION;
Self {
dates: LazyFromDateLast::from_binary::<F, _, _>(name, v, &source.dates),
difficultyepoch: LazyTransformLast::from_boxed::<F>(
name,
v,
source.difficultyepoch.boxed_clone(),
),
}
}
pub fn from_computed_height_date<F: UnaryTransform<S1T, T>>(
name: &str,
version: Version,
@@ -0,0 +1,18 @@
use brk_types::Dollars;
use vecdb::BinaryTransform;
/// (Dollars, Dollars) -> Dollars: a² / b
pub struct DollarsSquaredDivide;
impl BinaryTransform<Dollars, Dollars, Dollars> for DollarsSquaredDivide {
#[inline(always)]
fn apply(a: Dollars, b: Dollars) -> Dollars {
let af = f64::from(a);
let bf = f64::from(b);
if bf == 0.0 {
Dollars::NAN
} else {
Dollars::from(af * af / bf)
}
}
}
@@ -7,6 +7,7 @@ mod dollar_identity;
mod dollar_minus;
mod dollar_plus;
mod dollar_times_tenths;
mod dollars_squared_divide;
mod dollars_to_sats_fract;
mod f32_identity;
mod half_close_price_times_sats;
@@ -49,6 +50,7 @@ pub use dollar_identity::*;
pub use dollar_minus::*;
pub use dollar_plus::*;
pub use dollar_times_tenths::*;
pub use dollars_squared_divide::*;
pub use dollars_to_sats_fract::*;
pub use f32_identity::*;
pub use half_close_price_times_sats::*;
-1
View File
@@ -1 +0,0 @@
oracle
+2 -13
View File
@@ -6,7 +6,6 @@ use super::Vecs;
use crate::{indexes, ComputeIndexes};
impl Vecs {
#[allow(unused_variables)]
pub fn compute(
&mut self,
indexer: &Indexer,
@@ -18,18 +17,8 @@ impl Vecs {
self.sats.compute(starting_indexes, &self.usd, exit)?;
// Oracle price computation is slow and still WIP, only run in dev builds
// #[cfg(debug_assertions)]
// {
// use std::time::Instant;
// use tracing::info;
//
// info!("Computing oracle prices...");
// let i = Instant::now();
// self.oracle
// .compute(indexer, indexes, &self.cents, starting_indexes, exit)?;
// info!("Computed oracle prices in {:?}", i.elapsed());
// }
self.oracle
.compute(indexer, indexes, starting_indexes, exit)?;
let _lock = exit.lock();
self.db().compact()?;
+5 -5
View File
@@ -2,12 +2,12 @@ mod compute;
mod fetch;
pub mod cents;
// pub mod oracle;
pub mod oracle;
pub mod sats;
pub mod usd;
pub use cents::Vecs as CentsVecs;
// pub use oracle::Vecs as OracleVecs;
pub use oracle::Vecs as OracleVecs;
pub use sats::Vecs as SatsVecs;
pub use usd::Vecs as UsdVecs;
@@ -33,7 +33,7 @@ pub struct Vecs {
pub cents: CentsVecs,
pub usd: UsdVecs,
pub sats: SatsVecs,
// pub oracle: OracleVecs,
pub oracle: OracleVecs,
}
impl Vecs {
@@ -67,7 +67,7 @@ impl Vecs {
let cents = CentsVecs::forced_import(db, version)?;
let usd = UsdVecs::forced_import(db, version, indexes)?;
let sats = SatsVecs::forced_import(db, version, indexes)?;
// let oracle = OracleVecs::forced_import(db, version)?;
let oracle = OracleVecs::forced_import(db, version, indexes)?;
Ok(Self {
db: db.clone(),
@@ -75,7 +75,7 @@ impl Vecs {
cents,
usd,
sats,
// oracle,
oracle,
})
}
@@ -0,0 +1,491 @@
use std::ops::Range;
use brk_error::Result;
use brk_indexer::Indexer;
use brk_oracle::{Config, NUM_BINS, Oracle, START_HEIGHT, bin_to_cents, cents_to_bin};
use brk_types::{
CentsUnsigned, Close, DateIndex, Height, High, Low, OHLCCentsUnsigned, OHLCDollars, Open,
OutputType, Sats, TxIndex, TxOutIndex,
};
use tracing::info;
use vecdb::{
AnyStoredVec, AnyVec, Exit, GenericStoredVec, IterableVec, TypedVecIterator, VecIndex,
VecIterator,
};
use super::Vecs;
use crate::{ComputeIndexes, indexes};
impl Vecs {
pub fn compute(
&mut self,
indexer: &Indexer,
indexes: &indexes::Vecs,
starting_indexes: &ComputeIndexes,
exit: &Exit,
) -> Result<()> {
self.compute_prices(indexer, starting_indexes, exit)?;
self.compute_daily_ohlc(indexes, starting_indexes, exit)?;
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(())
}
fn compute_prices(
&mut self,
indexer: &Indexer,
starting_indexes: &ComputeIndexes,
exit: &Exit,
) -> Result<()> {
let source_version =
indexer.vecs.outputs.value.version() + indexer.vecs.outputs.outputtype.version();
self.price_cents
.validate_computed_version_or_reset(source_version)?;
let total_heights = indexer.vecs.blocks.timestamp.len();
if total_heights <= START_HEIGHT {
return Ok(());
}
// Reorg: truncate to starting_indexes
let truncate_to = self
.price_cents
.len()
.min(starting_indexes.height.to_usize());
self.price_cents.truncate_if_needed_at(truncate_to)?;
if self.price_cents.len() < START_HEIGHT {
for line in brk_oracle::PRICES.lines().skip(self.price_cents.len()) {
if self.price_cents.len() >= START_HEIGHT {
break;
}
let dollars: f64 = line.parse().unwrap_or(0.0);
let cents = (dollars * 100.0).round() as u64;
self.price_cents.push(CentsUnsigned::new(cents));
}
}
if self.price_cents.len() >= total_heights {
return Ok(());
}
let config = Config::default();
let committed = self.price_cents.len();
let prev_cents = self.price_cents
.iter()?
.get(Height::from(committed - 1))
.unwrap();
let seed_bin = cents_to_bin(prev_cents.inner() as f64);
let warmup = config.window_size.min(committed - START_HEIGHT);
let mut oracle = Oracle::from_checkpoint(seed_bin, config, |o| {
Self::feed_blocks(o, indexer, (committed - warmup)..committed);
});
let num_new = total_heights - committed;
info!(
"Computing oracle prices: {} to {} ({warmup} warmup)",
committed, total_heights
);
let ref_bins = Self::feed_blocks(&mut oracle, indexer, committed..total_heights);
for (i, ref_bin) in ref_bins.into_iter().enumerate() {
self.price_cents.push(CentsUnsigned::new(bin_to_cents(ref_bin)));
let progress = ((i + 1) * 100 / num_new) as u8;
if i > 0 && progress > ((i * 100 / num_new) as u8) {
info!("Oracle price computation: {}%", progress);
}
}
{
let _lock = exit.lock();
self.price_cents.write()?;
}
info!(
"Oracle prices complete: {} committed",
self.price_cents.len()
);
Ok(())
}
/// Returns an Oracle seeded from the last committed price, with the last
/// window_size blocks already processed. Ready for additional blocks (e.g. mempool).
pub fn live_oracle(&self, indexer: &Indexer) -> Result<Oracle> {
let config = Config::default();
let height = indexer.vecs.blocks.timestamp.len();
let last_cents = self.price_cents
.iter()?
.get(Height::from(self.price_cents.len() - 1))
.unwrap();
let seed_bin = cents_to_bin(last_cents.inner() as f64);
let window_size = config.window_size;
let oracle = Oracle::from_checkpoint(seed_bin, config, |o| {
Self::feed_blocks(o, indexer, height.saturating_sub(window_size)..height);
});
Ok(oracle)
}
/// Feed a range of blocks from the indexer into an Oracle (skipping coinbase),
/// returning per-block ref_bin values.
fn feed_blocks(oracle: &mut Oracle, indexer: &Indexer, range: Range<usize>) -> Vec<f64> {
let total_txs = indexer.vecs.transactions.height.len();
let total_outputs = indexer.vecs.outputs.value.len();
let mut first_txindex_iter = indexer.vecs.transactions.first_txindex.into_iter();
let mut first_txoutindex_iter = indexer.vecs.transactions.first_txoutindex.into_iter();
let mut out_first_iter = indexer.vecs.outputs.first_txoutindex.into_iter();
let mut value_iter = indexer.vecs.outputs.value.into_iter();
let mut outputtype_iter = indexer.vecs.outputs.outputtype.into_iter();
let mut ref_bins = Vec::with_capacity(range.len());
for h in range {
let first_txindex: TxIndex = first_txindex_iter.get_at_unwrap(h);
let next_first_txindex = first_txindex_iter
.get_at(h + 1)
.unwrap_or(TxIndex::from(total_txs));
let out_start = if first_txindex.to_usize() + 1 < next_first_txindex.to_usize() {
first_txoutindex_iter
.get_at_unwrap(first_txindex.to_usize() + 1)
.to_usize()
} else {
out_first_iter
.get_at(h + 1)
.unwrap_or(TxOutIndex::from(total_outputs))
.to_usize()
};
let out_end = out_first_iter
.get_at(h + 1)
.unwrap_or(TxOutIndex::from(total_outputs))
.to_usize();
let mut hist = [0u32; NUM_BINS];
for i in out_start..out_end {
let sats: Sats = value_iter.get_at_unwrap(i);
let output_type: OutputType = outputtype_iter.get_at_unwrap(i);
if let Some(bin) = oracle.output_to_bin(sats, output_type) {
hist[bin] += 1;
}
}
ref_bins.push(oracle.process_histogram(&hist));
}
ref_bins
}
fn compute_daily_ohlc(
&mut self,
indexes: &indexes::Vecs,
starting_indexes: &ComputeIndexes,
exit: &Exit,
) -> Result<()> {
let last_dateindex = DateIndex::from(indexes.dateindex.date.len());
let start_dateindex = starting_indexes
.dateindex
.min(DateIndex::from(self.ohlc_cents.len()));
if start_dateindex >= last_dateindex {
return Ok(());
}
let last_height = Height::from(self.price_cents.len());
let mut height_to_price_iter = self.price_cents.iter()?;
let mut dateindex_to_first_height_iter = indexes.dateindex.first_height.iter();
let mut height_count_iter = indexes.dateindex.height_count.iter();
for dateindex_usize in start_dateindex.to_usize()..last_dateindex.to_usize() {
let dateindex = DateIndex::from(dateindex_usize);
let first_height = dateindex_to_first_height_iter.get_unwrap(dateindex);
let count = height_count_iter.get_unwrap(dateindex);
if *count == 0 || first_height >= last_height {
self.ohlc_cents
.truncate_push(dateindex, self.previous_ohlc(dateindex)?)?;
continue;
}
let count = *count as usize;
let mut open = None;
let mut high = CentsUnsigned::ZERO;
let mut low = CentsUnsigned::MAX;
let mut close = CentsUnsigned::ZERO;
for i in 0..count {
let height = first_height + Height::from(i);
if height >= last_height {
break;
}
if let Some(price) = height_to_price_iter.get(height) {
if price == CentsUnsigned::ZERO {
continue;
}
if open.is_none() {
open = Some(price);
}
if price > high {
high = price;
}
if price < low {
low = price;
}
close = price;
}
}
let ohlc = if let Some(open_price) = open {
OHLCCentsUnsigned {
open: Open::new(open_price),
high: High::new(high),
low: Low::new(low),
close: Close::new(close),
}
} else {
self.previous_ohlc(dateindex)?
};
self.ohlc_cents.truncate_push(dateindex, ohlc)?;
}
{
let _lock = exit.lock();
self.ohlc_cents.write()?;
}
Ok(())
}
fn previous_ohlc(&self, dateindex: DateIndex) -> Result<OHLCCentsUnsigned> {
Ok(if dateindex > DateIndex::from(0usize) {
self.ohlc_cents
.iter()?
.get(dateindex.decremented().unwrap())
.unwrap_or_default()
} else {
OHLCCentsUnsigned::default()
})
}
}
@@ -0,0 +1,54 @@
use brk_error::Result;
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,
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 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,
})
}
}
@@ -0,0 +1,14 @@
use brk_traversable::Traversable;
use brk_types::{CentsUnsigned, DateIndex, Height, OHLCCentsUnsigned, OHLCDollars};
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 split: ComputedOHLC<CentsUnsigned>,
pub ohlc: LazyFromHeightAndDateOHLC<OHLCCentsUnsigned>,
pub ohlc_dollars: LazyFromHeightAndDateOHLC<OHLCDollars>,
}
@@ -1,20 +0,0 @@
use brk_types::Version;
use super::Vecs;
use crate::{
distribution,
internal::{DollarsIdentity, LazyValueFromHeightLast, SatsIdentity},
};
impl Vecs {
pub fn import(version: Version, distribution: &distribution::Vecs) -> Self {
let supply_metrics = &distribution.utxo_cohorts.all.metrics.supply;
Self(LazyValueFromHeightLast::from_block_source::<
SatsIdentity,
DollarsIdentity,
>(
"circulating_supply", &supply_metrics.total, version)
)
}
}
@@ -1,4 +0,0 @@
mod import;
mod vecs;
pub use vecs::Vecs;
@@ -1,8 +0,0 @@
use brk_traversable::Traversable;
use derive_more::{Deref, DerefMut};
use crate::internal::LazyValueFromHeightLast;
/// Circulating supply - lazy references to distribution's actual supply (KISS)
#[derive(Clone, Deref, DerefMut, Traversable)]
pub struct Vecs(pub LazyValueFromHeightLast);
+50 -5
View File
@@ -2,7 +2,7 @@ use brk_error::Result;
use vecdb::Exit;
use super::Vecs;
use crate::{blocks, distribution, indexes, scripts, transactions, ComputeIndexes};
use crate::{ComputeIndexes, blocks, distribution, indexes, scripts, transactions};
impl Vecs {
#[allow(clippy::too_many_arguments)]
@@ -20,15 +20,60 @@ impl Vecs {
self.burned
.compute(indexes, scripts, blocks, starting_indexes, exit)?;
// 2. Compute inflation rate
self.inflation
.compute(blocks, distribution, starting_indexes, exit)?;
// 2. Compute inflation rate: daily_subsidy / circulating_supply * 365 * 100
let circulating_supply = &distribution.utxo_cohorts.all.metrics.supply.total.sats;
self.inflation.compute_all(starting_indexes, exit, |v| {
v.compute_transform2(
starting_indexes.dateindex,
&blocks.rewards.subsidy.sats.dateindex.sum_cum.sum.0,
&circulating_supply.dateindex.0,
|(i, subsidy_1d_sum, supply, ..)| {
let inflation = if *supply > 0 {
365.0 * *subsidy_1d_sum as f64 / *supply as f64 * 100.0
} else {
0.0
};
(i, inflation.into())
},
exit,
)?;
Ok(())
})?;
// 3. Compute velocity
self.velocity
.compute(transactions, distribution, starting_indexes, exit)?;
// Note: circulating and market_cap are lazy - no compute needed
// 4. Compute cap growth rates
if let Some(market_cap) = self.market_cap.as_ref() {
let mcap_dateindex = &market_cap.dateindex.0;
self.market_cap_growth_rate
.compute_all(starting_indexes, exit, |vec| {
vec.compute_percentage_change(
starting_indexes.dateindex,
mcap_dateindex,
365,
exit,
)?;
Ok(())
})?;
}
if let Some(realized) = distribution.utxo_cohorts.all.metrics.realized.as_ref() {
let rcap_dateindex = &realized.realized_cap.dateindex.0;
self.realized_cap_growth_rate
.compute_all(starting_indexes, exit, |vec| {
vec.compute_percentage_change(
starting_indexes.dateindex,
rcap_dateindex,
365,
exit,
)?;
Ok(())
})?;
}
// Note: circulating, market_cap, cap_growth_rate_diff are lazy
let _lock = exit.lock();
self.db.compact()?;
+51 -7
View File
@@ -3,12 +3,19 @@ use std::path::Path;
use brk_error::Result;
use brk_traversable::Traversable;
use brk_types::Version;
use vecdb::{Database, PAGE_SIZE};
use vecdb::{Database, IterableCloneableVec, LazyVecFrom2, PAGE_SIZE};
use super::Vecs;
use crate::{distribution, indexes, price};
use crate::{
distribution, indexes,
internal::{
ComputedFromDateAverage, ComputedFromDateLast, DifferenceF32, DollarsIdentity,
LazyFromHeightLast, LazyValueFromHeightLast, SatsIdentity,
},
price,
};
const VERSION: Version = Version::ZERO;
const VERSION: Version = Version::ONE;
impl Vecs {
pub fn forced_import(
@@ -24,21 +31,55 @@ impl Vecs {
let version = parent_version + VERSION;
let compute_dollars = price.is_some();
let supply_metrics = &distribution.utxo_cohorts.all.metrics.supply;
// Circulating supply - lazy refs to distribution
let circulating = super::circulating::Vecs::import(version, distribution);
let circulating = LazyValueFromHeightLast::from_block_source::<SatsIdentity, DollarsIdentity>(
"circulating_supply",
&supply_metrics.total,
version,
);
// Burned/unspendable supply - computed from scripts
let burned = super::burned::Vecs::forced_import(&db, version, indexes, price)?;
// Inflation rate
let inflation = super::inflation::Vecs::forced_import(&db, version, indexes)?;
let inflation =
ComputedFromDateAverage::forced_import(&db, "inflation_rate", version, indexes)?;
// Velocity
let velocity =
super::velocity::Vecs::forced_import(&db, version, indexes, compute_dollars)?;
// Market cap - lazy refs to supply in USD
let market_cap = super::market_cap::Vecs::import(version, distribution);
// Market cap - lazy identity from distribution supply in USD
let market_cap = supply_metrics.total.dollars.as_ref().map(|d| {
LazyFromHeightLast::from_lazy_binary_computed::<DollarsIdentity, _, _>(
"market_cap",
version,
d.height.boxed_clone(),
d,
)
});
// Growth rates
let market_cap_growth_rate = ComputedFromDateLast::forced_import(
&db,
"market_cap_growth_rate",
version + 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,
market_cap_growth_rate.dateindex.boxed_clone(),
realized_cap_growth_rate.dateindex.boxed_clone(),
);
let this = Self {
db,
@@ -47,6 +88,9 @@ impl Vecs {
inflation,
velocity,
market_cap,
market_cap_growth_rate,
realized_cap_growth_rate,
cap_growth_rate_diff,
};
this.db.retain_regions(
@@ -1,38 +0,0 @@
use brk_error::Result;
use vecdb::Exit;
use super::Vecs;
use crate::{ComputeIndexes, blocks, distribution};
impl Vecs {
pub fn compute(
&mut self,
blocks: &blocks::Vecs,
distribution: &distribution::Vecs,
starting_indexes: &ComputeIndexes,
exit: &Exit,
) -> Result<()> {
// inflation = daily_subsidy / circulating_supply * 365 * 100
let circulating_supply = &distribution.utxo_cohorts.all.metrics.supply.total.sats;
self.compute_all(starting_indexes, exit, |v| {
v.compute_transform2(
starting_indexes.dateindex,
&blocks.rewards.subsidy.sats.dateindex.sum_cum.sum.0,
&circulating_supply.dateindex.0,
|(i, subsidy_1d_sum, supply, ..)| {
let inflation = if *supply > 0 {
365.0 * *subsidy_1d_sum as f64 / *supply as f64 * 100.0
} else {
0.0
};
(i, inflation.into())
},
exit,
)?;
Ok(())
})?;
Ok(())
}
}
@@ -1,17 +0,0 @@
use brk_error::Result;
use brk_types::Version;
use vecdb::Database;
use super::Vecs;
use crate::{indexes, internal::ComputedFromDateAverage};
impl Vecs {
pub fn forced_import(db: &Database, version: Version, indexes: &indexes::Vecs) -> Result<Self> {
Ok(Self(ComputedFromDateAverage::forced_import(
db,
"inflation_rate",
version,
indexes,
)?))
}
}
@@ -1,10 +0,0 @@
use brk_traversable::Traversable;
use brk_types::StoredF32;
use derive_more::{Deref, DerefMut};
use crate::internal::ComputedFromDateAverage;
/// Inflation rate metrics
#[derive(Clone, Deref, DerefMut, Traversable)]
#[traversable(transparent)]
pub struct Vecs(pub ComputedFromDateAverage<StoredF32>);
@@ -1,23 +0,0 @@
use brk_types::Version;
use vecdb::IterableCloneableVec;
use super::Vecs;
use crate::{
distribution,
internal::{DollarsIdentity, LazyFromHeightLast},
};
impl Vecs {
pub fn import(version: Version, distribution: &distribution::Vecs) -> Option<Self> {
let supply_metrics = &distribution.utxo_cohorts.all.metrics.supply;
supply_metrics.total.dollars.as_ref().map(|d| {
Self(LazyFromHeightLast::from_lazy_binary_computed::<DollarsIdentity, _, _>(
"market_cap",
version,
d.height.boxed_clone(),
d,
))
})
}
}
@@ -1,4 +0,0 @@
mod import;
mod vecs;
pub use vecs::Vecs;
@@ -1,8 +0,0 @@
use brk_traversable::Traversable;
use brk_types::Dollars;
use derive_more::{Deref, DerefMut};
use crate::internal::LazyFromHeightLast;
#[derive(Clone, Deref, DerefMut, Traversable)]
pub struct Vecs(pub LazyFromHeightLast<Dollars>);
-3
View File
@@ -1,7 +1,4 @@
pub mod burned;
pub mod circulating;
pub mod inflation;
pub mod market_cap;
pub mod velocity;
mod compute;
+13 -13
View File
@@ -1,24 +1,24 @@
use brk_traversable::Traversable;
use vecdb::Database;
use brk_types::{DateIndex, Dollars, StoredF32};
use vecdb::{Database, LazyVecFrom2};
use super::{burned, circulating, inflation, market_cap, velocity};
use super::{burned, velocity};
use crate::internal::{
ComputedFromDateAverage, ComputedFromDateLast, LazyFromHeightLast, LazyValueFromHeightLast,
};
/// Supply metrics module
///
/// This module owns all supply-related metrics:
/// - circulating: Lazy references to distribution's actual circulating supply
/// - burned: Cumulative opreturn and unspendable supply
/// - inflation: Inflation rate derived from supply
/// - velocity: BTC and USD velocity metrics
/// - market_cap: Lazy references to supply in USD (circulating * price)
#[derive(Clone, Traversable)]
pub struct Vecs {
#[traversable(skip)]
pub(crate) db: Database,
pub circulating: circulating::Vecs,
pub circulating: LazyValueFromHeightLast,
pub burned: burned::Vecs,
pub inflation: inflation::Vecs,
pub inflation: ComputedFromDateAverage<StoredF32>,
pub velocity: velocity::Vecs,
pub market_cap: Option<market_cap::Vecs>,
pub market_cap: Option<LazyFromHeightLast<Dollars>>,
pub market_cap_growth_rate: ComputedFromDateLast<StoredF32>,
pub realized_cap_growth_rate: ComputedFromDateLast<StoredF32>,
pub cap_growth_rate_diff:
LazyVecFrom2<DateIndex, StoredF32, DateIndex, StoredF32, DateIndex, StoredF32>,
}
+2
View File
@@ -13,6 +13,7 @@ bitcoincore-rpc = ["dep:bitcoincore-rpc"]
fjall = ["dep:fjall"]
jiff = ["dep:jiff"]
minreq = ["dep:minreq"]
pco = ["dep:pco"]
serde_json = ["dep:serde_json"]
tokio = ["dep:tokio"]
vecdb = ["dep:vecdb"]
@@ -23,6 +24,7 @@ bitcoincore-rpc = { workspace = true, optional = true }
fjall = { workspace = true, optional = true }
jiff = { workspace = true, optional = true }
minreq = { workspace = true, optional = true }
pco = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
thiserror = "2.0"
tokio = { workspace = true, optional = true }
+4
View File
@@ -58,6 +58,10 @@ pub enum Error {
#[error(transparent)]
BitcoinHexToArrayError(#[from] bitcoin::hex::HexToArrayError),
#[cfg(feature = "pco")]
#[error(transparent)]
Pco(#[from] pco::errors::PcoError),
#[cfg(feature = "serde_json")]
#[error(transparent)]
SerdeJSON(#[from] serde_json::Error),
@@ -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()
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "brk_oracle"
description = "Pure on-chain BTC/USD price oracle algorithm"
version.workspace = true
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
[dependencies]
brk_types = { workspace = true }
[dev-dependencies]
brk_indexer = { workspace = true }
serde_json = { workspace = true }
vecdb = { workspace = true }
+103
View File
@@ -0,0 +1,103 @@
# brk_oracle
BTC/USD price oracle from on-chain Bitcoin data alone. No exchange feeds, no external APIs. Given an initial price estimate, tracks block by block from height 575,000 (May 2019) onward.
## The insight
When someone buys $100 of bitcoin at $50,000/BTC, the output is 200,000 sats. At $60,000 it would be 166,667 sats. Millions of round-dollar purchases happen every day at common amounts like $1, $5, $10, $20, $50, $100, $200, $500. Each amount creates its own spike in the histogram of transaction outputs, at a position that depends on the current price. As the price moves, all spikes shift together. The oracle finds those spikes and reads the price from their position.
## How it works
For each new block:
1. **Filter outputs.** Skip the coinbase transaction, then apply the configured filters: excluded script types, dust threshold, and round BTC exclusion.
2. **Map to bins.** Each output's satoshi value is placed into a log-scale histogram with 2,400 bins (200 per 10x): bin = round(log10(sats) * 200). Log-scale is key: if the price doubles, all spikes shift by 60 bins whether bitcoin goes from $1k to $2k or from $50k to $100k.
3. **Store in ring buffer.** The block histogram goes into a ring buffer of configurable depth. A single block is too sparse to get a clean signal, so the oracle accumulates several.
4. **Compute EMA.** The stored histograms are combined into a weighted average where recent blocks count more than older ones: weight = alpha * (1 - alpha)^age. Fully recomputed from the ring buffer each block.
5. **Score with stencil.** A 19-point stencil encodes where the spikes from round-dollar amounts ($1 through $10,000) should appear relative to each other. The oracle slides this stencil across the EMA histogram within a search window around the previous estimate. At each position, it reads the EMA value at each of the 19 expected spike locations, divides each by that offset's peak in the window, and sums them into a score. This gives every dollar amount, common or rare, an equal vote.
6. **Pick the best.** The position with the highest score is the new price estimate. Parabolic interpolation between neighbors refines it to fractional-bin precision.
The resulting bin converts to a dollar price: 10^(10 - bin/200). The search is bounded to prevent the stencil from matching at wrong price levels, so the oracle tracks incrementally block by block.
The oracle accepts three input formats: raw block data, an iterator of (sats, output type) pairs, or a pre-built histogram. Each call returns the current estimate as a fractional bin, convertible to cents or dollars. Daily candles can be built from the per-block prices.
The initial seed must be close to the real price at the starting height. The crate includes a PRICES constant with exchange prices for every height before 630,000 to derive a seed from.
## Config
All parameters are exposed via Config with sensible defaults:
- **alpha** (2/7): EMA decay rate, ~6-block span
- **window_size** (12): number of block histograms in the ring buffer
- **search_below / search_above** (9 / 11): how far to search around the previous estimate, in bins
- **min_sats** (1,000): minimum output value, filters dust
- **exclude_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
Inspired by [UTXOracle](https://utxo.live/oracle/) by [@SteveSimple](https://x.com/SteveSimple), which showed that the BTC/USD price can be derived from on-chain data alone. brk_oracle takes the same core insight (round-dollar detection via log-scale histogram) and redesigns the algorithm for per-block resolution and rolling operation.
### Differences from UTXOracle
| | brk_oracle | UTXOracle |
|---|---|---|
| Resolution | Per-block (~10 min) and daily candles | Per-day |
| Algorithm | Single-pass stencil scoring | Multi-step: rough stencil match, output-to-USD mapping, iterative median convergence |
| Operation | Rolling EMA over configurable window | Stateless, processes a full day from scratch |
| Stencil | 19 offsets with per-offset peak normalization | Gaussian smooth + empirically weighted spikes |
| Round BTC handling | Excludes outputs entirely | Smooths histogram bins by averaging neighbors |
| Output filtering | Script type, dust threshold, round BTC | 2-output txs only, input count limits, same-day exclusion, witness size limits |
| Validated from | Height 575,000 (May 2019) | December 2023 |
Both use 200 bins per 10x on a log scale.
## Accuracy
Tested over 361,245 blocks (heights 575,000 to 936,244) against exchange OHLC data. Error is measured per block as the distance from the oracle's estimate to the exchange high-low range at that height. If the oracle falls within the range, the error is zero.
### Per-block
| Metric | Value |
|--------|-------|
| Median error | 0.10% |
| 95th percentile | 0.55% |
| 99th percentile | 1.4% |
| 99.9th percentile | 4.4% |
| RMSE | 0.38% |
| Max error | 18.1% |
| Bias | +0.04 bins (essentially zero) |
| Blocks > 5% error | 237 (0.07%) |
| Blocks > 10% error | 22 (0.006%) |
| Blocks > 20% error | 0 |
### Daily candles
Oracle daily OHLC built from per-block prices vs exchange daily OHLC:
| | Median | RMSE | Max |
|-------|--------|------|-----|
| Open | 0.20% | 0.49% | 5.9% |
| High | 0.54% | 0.87% | 9.1% |
| Low | 0.48% | 1.31% | 19.7% |
| Close | 0.23% | 0.58% | 6.9% |
### By year
| Year | Blocks | Median | RMSE | Max | >5% | >10% | Price range |
|------|--------|--------|------|-----|-----|------|-------------|
| 2019 | 35,764 | 0.10% | 0.61% | 17.2% | 103 | 16 | $5,656$13,868 |
| 2020 | 53,102 | 0.10% | 0.48% | 18.2% | 85 | 15 | $3,858$29,322 |
| 2021 | 52,733 | 0.07% | 0.47% | 14.4% | 38 | 9 | $27,678$69,000 |
| 2022 | 53,230 | 0.07% | 0.32% | 6.8% | 10 | 0 | $15,460$48,240 |
| 2023 | 54,032 | 0.10% | 0.25% | 6.7% | 5 | 0 | $16,490$44,700 |
| 2024 | 53,367 | 0.11% | 0.31% | 9.7% | 16 | 0 | $38,555$108,298 |
| 2025 | 53,113 | 0.11% | 0.25% | 5.8% | 4 | 0 | $74,409$126,198 |
| 2026 | 5,904 | 0.11% | 0.27% | 3.3% | 0 | 0 | $60,000$97,900 |
Accuracy improves over time as on-chain transaction volume grows. Since 2022, zero blocks exceed 10% error. All worst-case errors occur during the fastest intraday price moves in 20192021.
@@ -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());
}
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
//! Verify oracle determinism: oracles started from different heights converge
//! to identical ref_bin values after the ring buffer fills.
//!
//! Creates a reference oracle at height 575k and test oracles every 1000 blocks
//! up to 630k. After window_size blocks, each test oracle should produce the
//! same ref_bin as the reference, proving the truncated EMA provides
//! start-point independence.
//!
//! Run with: cargo run -p brk_oracle --example determinism --release
use std::path::PathBuf;
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};
fn seed_bin(height: usize) -> f64 {
let price: f64 = PRICES
.lines()
.nth(height - 1)
.expect("prices.txt too short")
.parse()
.expect("Failed to parse seed price");
cents_to_bin(price * 100.0)
}
struct TestRun {
start_height: usize,
oracle: Option<Oracle>,
converged_at: Option<usize>,
diverged_after: bool,
}
fn main() {
let data_dir = std::env::var("BRK_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap();
PathBuf::from(home).join(".brk")
});
let indexer = Indexer::forced_import(&data_dir).expect("Failed to load indexer");
let total_heights = indexer.vecs.blocks.timestamp.len();
let config = Config::default();
let window_size = config.window_size;
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();
// Reference oracle at 575k.
let ref_start = START_HEIGHT;
let mut ref_oracle = Oracle::new(seed_bin(ref_start), Config::default());
// Test oracles every 1000 blocks from 576k to 630k.
let mut runs: Vec<TestRun> = (576_000..=630_000)
.step_by(1000)
.map(|h| TestRun {
start_height: h,
oracle: None,
converged_at: None,
diverged_after: false,
})
.collect();
let last_start = runs.last().map(|r| r.start_height).unwrap_or(ref_start);
// Process enough blocks for all oracles to converge + verification margin.
let end_height = (last_start + window_size + 100).min(total_heights);
for h in START_HEIGHT..end_height {
let first_txindex: TxIndex = first_txindex_iter.get_at_unwrap(h);
let next_first_txindex = first_txindex_iter
.get_at(h + 1)
.unwrap_or(TxIndex::from(total_txs));
let out_start = if first_txindex.to_usize() + 1 < next_first_txindex.to_usize() {
first_txoutindex_iter
.get_at_unwrap(first_txindex.to_usize() + 1)
.to_usize()
} else {
out_first_iter
.get_at(h + 1)
.unwrap_or(TxOutIndex::from(total_outputs))
.to_usize()
};
let out_end = out_first_iter
.get_at(h + 1)
.unwrap_or(TxOutIndex::from(total_outputs))
.to_usize();
let mut hist = [0u32; NUM_BINS];
for i in out_start..out_end {
let sats: Sats = value_iter.get_at_unwrap(i);
let output_type: OutputType = outputtype_iter.get_at_unwrap(i);
if ref_config.excluded_output_types.contains(&output_type) {
continue;
}
if *sats < ref_config.min_sats
|| (ref_config.exclude_common_round_values && sats.is_common_round_value())
{
continue;
}
if let Some(bin) = sats_to_bin(sats) {
hist[bin] += 1;
}
}
let ref_bin = ref_oracle.process_histogram(&hist);
for run in &mut runs {
if h < run.start_height {
continue;
}
if run.oracle.is_none() {
run.oracle = Some(Oracle::new(seed_bin(run.start_height), Config::default()));
}
let test_bin = run.oracle.as_mut().unwrap().process_histogram(&hist);
if run.converged_at.is_some() {
if test_bin != ref_bin {
run.diverged_after = true;
}
} else if test_bin == ref_bin {
run.converged_at = Some(h);
}
}
}
// Print results.
println!();
println!(
"{:<12} {:>16} {:>8}",
"Start", "Converged at", "Blocks"
);
println!("{}", "-".repeat(40));
let mut max_blocks = 0usize;
let mut failed = Vec::new();
let mut diverged = Vec::new();
for run in &runs {
if let Some(converged) = run.converged_at {
let blocks = converged - run.start_height;
if blocks > max_blocks {
max_blocks = blocks;
}
println!(
"{:<12} {:>16} {:>8}",
run.start_height, converged, blocks
);
if run.diverged_after {
diverged.push(run.start_height);
}
} else {
println!("{:<12} {:>16} {:>8}", run.start_height, "NEVER", "-");
failed.push(run.start_height);
}
}
println!();
println!(
"{}/{} converged, max {} blocks to converge (window_size={})",
runs.len() - failed.len(),
runs.len(),
max_blocks,
window_size,
);
if !diverged.is_empty() {
println!("DIVERGED after convergence: {:?}", diverged);
}
if !failed.is_empty() {
println!("NEVER converged: {:?}", failed);
}
// Assertions.
assert!(
failed.is_empty(),
"{} oracles never converged: {:?}",
failed.len(),
failed
);
assert!(
diverged.is_empty(),
"{} oracles diverged after convergence: {:?}",
diverged.len(),
diverged
);
assert!(
max_blocks <= window_size * 2,
"Convergence took {} blocks, expected <= {} (2 * window_size)",
max_blocks,
window_size * 2
);
println!();
println!("All assertions passed!");
}
File diff suppressed because one or more lines are too long
+461
View File
@@ -0,0 +1,461 @@
//! Generate detailed oracle accuracy report for README / documentation.
//!
//! Run with: cargo run -p brk_oracle --example report --release
use std::path::PathBuf;
use brk_indexer::Indexer;
use brk_oracle::{
Config, NUM_BINS, Oracle, PRICES, START_HEIGHT, bin_to_cents, cents_to_bin, sats_to_bin,
};
use brk_types::{OutputType, Sats, TxIndex, TxOutIndex};
use vecdb::{AnyVec, VecIndex, VecIterator};
/// DateIndex 1 = Jan 9, 2009 (block 1). For dates after genesis week:
/// dateindex = floor(timestamp / 86400) - 14252.
const GENESIS_DAY: u32 = 14252;
const BINS_5PCT: f64 = 4.24;
const BINS_10PCT: f64 = 8.28;
const BINS_20PCT: f64 = 15.84;
fn bins_to_pct(bins: f64) -> f64 {
(10.0_f64.powf(bins / 200.0) - 1.0) * 100.0
}
fn timestamp_to_year(ts: u32) -> u16 {
let years_since_1970 = ts as f64 / 31557600.0;
(1970.0 + years_since_1970) as u16
}
struct YearStats {
year: u16,
total_sq_err: f64,
max_err: f64,
total_blocks: u64,
gt_5pct: u64,
gt_10pct: u64,
gt_20pct: u64,
min_price: f64,
max_price: f64,
errors: Vec<f64>,
}
impl YearStats {
fn new(year: u16) -> Self {
Self {
year,
total_sq_err: 0.0,
max_err: 0.0,
total_blocks: 0,
gt_5pct: 0,
gt_10pct: 0,
gt_20pct: 0,
min_price: f64::MAX,
max_price: 0.0,
errors: Vec::new(),
}
}
fn update(&mut self, err: f64, exchange_high: f64, exchange_low: f64) {
let abs_err = err.abs();
self.total_sq_err += err * err;
self.total_blocks += 1;
self.errors.push(bins_to_pct(abs_err));
if abs_err > self.max_err {
self.max_err = abs_err;
}
if abs_err > BINS_5PCT {
self.gt_5pct += 1;
}
if abs_err > BINS_10PCT {
self.gt_10pct += 1;
}
if abs_err > BINS_20PCT {
self.gt_20pct += 1;
}
if exchange_high > self.max_price {
self.max_price = exchange_high;
}
if exchange_low > 0.0 && exchange_low < self.min_price {
self.min_price = exchange_low;
}
}
fn rmse_pct(&self) -> f64 {
bins_to_pct((self.total_sq_err / self.total_blocks as f64).sqrt())
}
fn max_pct(&self) -> f64 {
bins_to_pct(self.max_err)
}
fn median_pct(&mut self) -> f64 {
self.errors.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = self.errors.len();
if n == 0 { 0.0 } else { self.errors[n / 2] }
}
fn percentile(&self, p: f64) -> f64 {
let n = self.errors.len();
if n == 0 {
return 0.0;
}
let idx = ((p / 100.0) * (n - 1) as f64).round() as usize;
self.errors[idx.min(n - 1)]
}
}
/// Oracle OHLC for a single day, built from per-block prices.
struct DayCandle {
dateindex: usize,
open: f64,
high: f64,
low: f64,
close: f64,
}
struct BlockError {
height: usize,
oracle_price: f64,
exchange_low: f64,
exchange_high: f64,
error_pct: f64,
}
fn main() {
let data_dir = std::env::var("BRK_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap();
PathBuf::from(home).join(".brk")
});
let indexer = Indexer::forced_import(&data_dir).expect("Failed to load indexer");
let total_heights = indexer.vecs.blocks.timestamp.len();
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let height_ohlc: Vec<[f64; 4]> = serde_json::from_str(
&std::fs::read_to_string(format!("{manifest_dir}/examples/height_price_ohlc.json"))
.expect("Failed to read height_price_ohlc.json"),
)
.expect("Failed to parse height OHLC");
let daily_ohlc: Vec<[f64; 4]> = serde_json::from_str(
&std::fs::read_to_string(format!("{manifest_dir}/examples/date_price_ohlc.json"))
.expect("Failed to read date_price_ohlc.json"),
)
.expect("Failed to parse daily OHLC");
let height_bands: Vec<(f64, f64)> = height_ohlc
.iter()
.map(|ohlc| {
let high = ohlc[1];
let low = ohlc[2];
if high > 0.0 && low > 0.0 {
(cents_to_bin(high * 100.0), cents_to_bin(low * 100.0))
} else {
(0.0, 0.0)
}
})
.collect();
// Read block timestamps for year + dateindex mapping.
let mut timestamp_iter = indexer.vecs.blocks.timestamp.into_iter();
let mut height_years: Vec<u16> = Vec::with_capacity(total_heights);
let mut height_dateindexes: Vec<usize> = Vec::with_capacity(total_heights);
for h in 0..total_heights {
let ts: brk_types::Timestamp = timestamp_iter.get_at_unwrap(h);
let ts_u32 = *ts as u32;
height_years.push(timestamp_to_year(ts_u32));
height_dateindexes.push((ts_u32 / 86400).saturating_sub(GENESIS_DAY) as usize);
}
let start_price: f64 = PRICES
.lines()
.nth(START_HEIGHT - 1)
.expect("prices.txt too short")
.parse()
.expect("Failed to parse seed price");
let config = Config::default();
let mut oracle = Oracle::new(cents_to_bin(start_price * 100.0), config);
let total_txs = indexer.vecs.transactions.height.len();
let total_outputs = indexer.vecs.outputs.value.len();
let mut first_txindex_iter = indexer.vecs.transactions.first_txindex.into_iter();
let mut first_txoutindex_iter = indexer.vecs.transactions.first_txoutindex.into_iter();
let mut out_first_iter = indexer.vecs.outputs.first_txoutindex.into_iter();
let mut value_iter = indexer.vecs.outputs.value.into_iter();
let mut outputtype_iter = indexer.vecs.outputs.outputtype.into_iter();
let ref_config = Config::default();
let mut year_stats: Vec<YearStats> = Vec::new();
let mut overall = YearStats::new(0);
let mut worst_blocks: Vec<BlockError> = Vec::new();
let mut total_bias = 0.0f64;
// Track oracle daily candles.
let mut oracle_candles: Vec<DayCandle> = Vec::new();
let mut current_di: Option<usize> = None;
for h in START_HEIGHT..total_heights {
let first_txindex: TxIndex = first_txindex_iter.get_at_unwrap(h);
let next_first_txindex = first_txindex_iter
.get_at(h + 1)
.unwrap_or(TxIndex::from(total_txs));
let out_start = if first_txindex.to_usize() + 1 < next_first_txindex.to_usize() {
first_txoutindex_iter
.get_at_unwrap(first_txindex.to_usize() + 1)
.to_usize()
} else {
out_first_iter
.get_at(h + 1)
.unwrap_or(TxOutIndex::from(total_outputs))
.to_usize()
};
let out_end = out_first_iter
.get_at(h + 1)
.unwrap_or(TxOutIndex::from(total_outputs))
.to_usize();
let mut hist = [0u32; NUM_BINS];
for i in out_start..out_end {
let sats: Sats = value_iter.get_at_unwrap(i);
let output_type: OutputType = outputtype_iter.get_at_unwrap(i);
if ref_config.excluded_output_types.contains(&output_type) {
continue;
}
if *sats < ref_config.min_sats
|| (ref_config.exclude_common_round_values && sats.is_common_round_value())
{
continue;
}
if let Some(bin) = sats_to_bin(sats) {
hist[bin] += 1;
}
}
let ref_bin = oracle.process_histogram(&hist);
let oracle_price = bin_to_cents(ref_bin) as f64 / 100.0;
// Build oracle daily candle.
let di = height_dateindexes[h];
if current_di != Some(di) {
current_di = Some(di);
oracle_candles.push(DayCandle {
dateindex: di,
open: oracle_price,
high: oracle_price,
low: oracle_price,
close: oracle_price,
});
} else {
let candle = oracle_candles.last_mut().unwrap();
if oracle_price > candle.high {
candle.high = oracle_price;
}
if oracle_price < candle.low {
candle.low = oracle_price;
}
candle.close = oracle_price;
}
// Per-block error stats.
if h < height_bands.len() {
let (high_bin, low_bin) = height_bands[h];
if high_bin > 0.0 && low_bin > 0.0 {
let err = if ref_bin < high_bin {
ref_bin - high_bin
} else if ref_bin > low_bin {
ref_bin - low_bin
} else {
0.0
};
let exchange_high = height_ohlc[h][1];
let exchange_low = height_ohlc[h][2];
overall.update(err, exchange_high, exchange_low);
total_bias += err;
let year = height_years[h];
if year_stats.is_empty() || year_stats.last().unwrap().year != year {
year_stats.push(YearStats::new(year));
}
year_stats
.last_mut()
.unwrap()
.update(err, exchange_high, exchange_low);
if err.abs() > BINS_5PCT {
worst_blocks.push(BlockError {
height: h,
oracle_price,
exchange_low,
exchange_high,
error_pct: if err < 0.0 {
-bins_to_pct(err.abs())
} else {
bins_to_pct(err.abs())
},
});
}
}
}
}
worst_blocks.sort_by(|a, b| b.error_pct.abs().partial_cmp(&a.error_pct.abs()).unwrap());
overall.errors.sort_by(|a, b| a.partial_cmp(b).unwrap());
// Daily candle comparison: oracle OHLC vs exchange OHLC.
let mut daily_open_errors: Vec<f64> = Vec::new();
let mut daily_high_errors: Vec<f64> = Vec::new();
let mut daily_low_errors: Vec<f64> = Vec::new();
let mut daily_close_errors: Vec<f64> = Vec::new();
let mut daily_days = 0u64;
for candle in &oracle_candles {
let di = candle.dateindex;
if di >= daily_ohlc.len() {
continue;
}
let ex = &daily_ohlc[di];
if ex[0] <= 0.0 || ex[3] <= 0.0 {
continue;
}
let ex_open = ex[0];
let ex_high = ex[1];
let ex_low = ex[2];
let ex_close = ex[3];
// Error as percentage: (oracle - exchange) / exchange * 100
daily_open_errors.push((candle.open - ex_open) / ex_open * 100.0);
daily_high_errors.push((candle.high - ex_high) / ex_high * 100.0);
daily_low_errors.push((candle.low - ex_low) / ex_low * 100.0);
daily_close_errors.push((candle.close - ex_close) / ex_close * 100.0);
daily_days += 1;
}
fn daily_stats(errors: &mut [f64]) -> (f64, f64, f64) {
let n = errors.len() as f64;
let rmse = (errors.iter().map(|e| e * e).sum::<f64>() / n).sqrt();
errors.sort_by(|a, b| a.abs().partial_cmp(&b.abs()).unwrap());
let max = errors.last().map(|e| e.abs()).unwrap_or(0.0);
let median = errors[errors.len() / 2].abs();
(median, rmse, max)
}
let (open_med, open_rmse, open_max) = daily_stats(&mut daily_open_errors);
let (high_med, high_rmse, high_max) = daily_stats(&mut daily_high_errors);
let (low_med, low_rmse, low_max) = daily_stats(&mut daily_low_errors);
let (close_med, close_rmse, close_max) = daily_stats(&mut daily_close_errors);
// Print report.
println!();
println!(" brk_oracle accuracy report");
println!(" ══════════════════════════");
println!();
println!(" Config: w12, alpha=2/7, search -9/+11, noisy/dust/round-btc filtered");
println!(
" Test range: height {} .. {} ({} blocks)",
START_HEIGHT,
total_heights - 1,
overall.total_blocks
);
println!(
" Price range: ${:.0} .. ${:.0}",
overall.min_price, overall.max_price
);
println!();
println!(" Per-block accuracy (vs per-height exchange OHLC):");
println!(" Median: {:.3}%", overall.percentile(50.0));
println!(" 95th pct: {:.3}%", overall.percentile(95.0));
println!(" 99th pct: {:.3}%", overall.percentile(99.0));
println!(" 99.9th pct: {:.3}%", overall.percentile(99.9));
println!(" RMSE: {:.3}%", overall.rmse_pct());
println!(" Max: {:.1}%", overall.max_pct());
println!(
" Bias: {:+.2} bins",
total_bias / overall.total_blocks as f64
);
println!(
" > 5%: {} blocks ({:.3}%)",
overall.gt_5pct,
overall.gt_5pct as f64 / overall.total_blocks as f64 * 100.0
);
println!(" > 10%: {} blocks", overall.gt_10pct);
println!(" > 20%: {} blocks", overall.gt_20pct);
println!();
println!(
" Daily candle accuracy ({} days, vs exchange daily OHLC):",
daily_days
);
println!(
" {:>8} {:>10} {:>10} {:>10}",
"", "Median", "RMSE", "Max"
);
println!(
" {:>8} {:>9.2}% {:>9.2}% {:>9.1}%",
"Open", open_med, open_rmse, open_max
);
println!(
" {:>8} {:>9.2}% {:>9.2}% {:>9.1}%",
"High", high_med, high_rmse, high_max
);
println!(
" {:>8} {:>9.2}% {:>9.2}% {:>9.1}%",
"Low", low_med, low_rmse, low_max
);
println!(
" {:>8} {:>9.2}% {:>9.2}% {:>9.1}%",
"Close", close_med, close_rmse, close_max
);
println!();
println!(" By year:");
println!(
" {:<6} {:>7} {:>9} {:>9} {:>9} {:>6} {:>5} {:>5} {:>14}",
"Year", "Blocks", "Median", "RMSE", "Max", ">5%", ">10%", ">20%", "Price range"
);
println!(" {}", "-".repeat(80));
for ys in &mut year_stats {
let median = ys.median_pct();
println!(
" {:<6} {:>7} {:>8.3}% {:>8.3}% {:>8.1}% {:>6} {:>5} {:>5} ${:.0}..${:.0}",
ys.year,
ys.total_blocks,
median,
ys.rmse_pct(),
ys.max_pct(),
ys.gt_5pct,
ys.gt_10pct,
ys.gt_20pct,
ys.min_price,
ys.max_price,
);
}
if !worst_blocks.is_empty() {
println!();
println!(" Worst blocks:");
let show = worst_blocks.len().min(10);
for wb in &worst_blocks[..show] {
let dir = if wb.error_pct < 0.0 { "above" } else { "below" };
println!(
" height {:>7}: oracle ${:>9.0}, exchange ${:.0}..${:.0} ({:+.1}%, {})",
wb.height, wb.oracle_price, wb.exchange_low, wb.exchange_high, wb.error_pct, dir
);
}
if worst_blocks.len() > show {
println!(" ... and {} more", worst_blocks.len() - show);
}
}
println!();
}
+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());
}
@@ -0,0 +1,447 @@
//! Sweep round-value tolerance to find optimal rounding threshold.
//!
//! Tests different tolerance percentages (0%, 0.01%, 0.1%, 1%, etc.) for
//! detecting round BTC amounts, combined with several digit filter masks.
//!
//! Phase 1: single pass over indexer, store per-output relative errors.
//! Phase 2: sweep tolerance × mask combos across CPU cores.
//!
//! Run with: cargo run -p brk_oracle --example sweep_tolerance --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 }
}
/// Returns the relative error of `sats` from its nearest round value (d × 10^n).
/// e.g. 10_050 → leading=1, round_val=10_000, rel_err = 50/10000 = 0.005
fn relative_roundness(sats: u64) -> f64 {
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
}
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
}
}
/// Per-output data: bin index, leading digit, relative error from round value.
struct RoundOutput {
bin: u16,
digit: u8,
rel_err: f32, // f32 is plenty of precision, saves memory
}
struct BlockData {
full_hist: Box<[u32; NUM_BINS]>,
round_outputs: Vec<RoundOutput>,
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.
// Store all potentially-round outputs with their relative error so we can
// filter at different tolerance thresholds in Phase 2.
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);
// Use the widest tolerance we'll test (5%) to decide what to store.
// Outputs beyond 5% relative error will never be filtered at any tolerance.
let max_tolerance: f64 = 0.05;
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;
let d = leading_digit(*sats);
if (1..=9).contains(&d) {
let rel_err = relative_roundness(*sats);
if rel_err <= max_tolerance {
round_outputs.push(RoundOutput {
bin: bin as u16,
digit: d,
rel_err: rel_err as f32,
});
}
}
}
}
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() * std::mem::size_of::<RoundOutput>())
.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 tolerance × mask combos.
// Tolerances as fractions (not percentages).
let tolerances: &[(f64, &str)] = &[
(0.0, "0%"),
(0.0001, "0.01%"),
(0.0005, "0.05%"),
(0.001, "0.1%"),
(0.002, "0.2%"),
(0.005, "0.5%"),
(0.01, "1%"),
(0.02, "2%"),
(0.05, "5%"),
];
// 987654321
let masks: &[(u16, &str)] = &[
(0b0_0000_0000, "none"),
(0b0_0001_0111, "{1,2,3,5}"),
(0b0_0001_1111, "{1,2,3,4,5}"),
(0b0_0011_0111, "{1,2,3,5,6}"),
(0b0_0111_0111, "{1,2,3,5,6,7}"),
(0b1_1111_1111, "{1-9}"),
];
let num_configs = tolerances.len() * masks.len();
let num_threads = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(8);
eprintln!(
"Phase 2: sweeping {} configs ({} tolerances × {} masks) across {} threads...",
num_configs,
tolerances.len(),
masks.len(),
num_threads
);
let t1 = Instant::now();
let blocks = &blocks;
let tolerances_ref = tolerances;
let masks_ref = masks;
let all_results: Vec<(usize, usize, Stats)> = std::thread::scope(|s| {
let configs_per_thread = num_configs.div_ceil(num_threads);
let handles: Vec<_> = (0..num_threads)
.map(|t| {
s.spawn(move || {
let cfg_start = t * configs_per_thread;
let cfg_end = ((t + 1) * configs_per_thread).min(num_configs);
if cfg_start >= cfg_end {
return vec![];
}
let mut results = Vec::with_capacity(cfg_end - cfg_start);
for cfg_idx in cfg_start..cfg_end {
let ti = cfg_idx / masks_ref.len();
let mi = cfg_idx % masks_ref.len();
let (tolerance, _) = tolerances_ref[ti];
let (mask, _) = masks_ref[mi];
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;
// Remove outputs matching this tolerance + mask.
let tol_f32 = tolerance as f32;
for ro in &bd.round_outputs {
if mask & (1 << (ro.digit - 1)) != 0
&& ro.rel_err <= tol_f32
{
hist[ro.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((ti, mi, stats));
}
results
})
})
.collect();
handles
.into_iter()
.flat_map(|h| h.join().unwrap())
.collect()
});
eprintln!(" Done in {:.1}s.", t1.elapsed().as_secs_f64());
// Print results grouped by tolerance.
println!();
println!(
"{:>8} {:>16} {:>8} {:>10} {:>10} {:>6} {:>6} {:>6} {:>8}",
"Tol", "Digits", "Blocks", "RMSE%", "Max%", ">5%", ">10%", ">20%", "Bias"
);
println!("{}", "-".repeat(88));
for (ti, &(_, tol_label)) in tolerances.iter().enumerate() {
for (mi, &(_, mask_label)) in masks.iter().enumerate() {
let (_, _, stats) = all_results
.iter()
.find(|(t, m, _)| *t == ti && *m == mi)
.unwrap();
println!(
"{:>8} {:>16} {:>8} {:>8.3}% {:>8.1}% {:>6} {:>6} {:>6} {:>+8.2}",
tol_label,
mask_label,
stats.total_blocks,
stats.rmse_pct(),
stats.max_pct(),
stats.gt_5pct,
stats.gt_10pct,
stats.gt_20pct,
stats.bias()
);
}
println!();
}
// Find overall best config by RMSE.
let best = all_results
.iter()
.min_by(|a, b| a.2.rmse_pct().partial_cmp(&b.2.rmse_pct()).unwrap())
.unwrap();
let (bti, bmi, bs) = best;
println!(
"Best: tolerance={}, digits={} → RMSE {:.3}%, Max {:.1}%, >5%: {}, >10%: {}, >20%: {}",
tolerances[*bti].1,
masks[*bmi].1,
bs.rmse_pct(),
bs.max_pct(),
bs.gt_5pct,
bs.gt_10pct,
bs.gt_20pct,
);
// Show current config for reference.
let current = all_results
.iter()
.find(|(t, m, _)| {
tolerances[*t].0 == 0.001 && masks[*m].0 == 0b0_0011_0111
})
.unwrap();
let (_, _, cs) = current;
println!(
"Current: tolerance=0.1%, digits={{1,2,3,5,6}} → RMSE {:.3}%, Max {:.1}%, >5%: {}, >10%: {}, >20%: {}",
cs.rmse_pct(),
cs.max_pct(),
cs.gt_5pct,
cs.gt_10pct,
cs.gt_20pct,
);
println!("\nTotal time: {:.1}s", t0.elapsed().as_secs_f64());
}
+253
View File
@@ -0,0 +1,253 @@
//! Validate oracle accuracy against exchange reference prices.
//!
//! Run with: cargo run -p brk_oracle --example validate --release
//!
//! Requires:
//! - ~/.brk indexed blockchain data (brk_indexer)
//! - examples/height_price_ohlc.json (per-height [open, high, low, close] in dollars)
use std::path::PathBuf;
use brk_indexer::Indexer;
use brk_oracle::{cents_to_bin, sats_to_bin, Config, Oracle, NUM_BINS, PRICES, START_HEIGHT};
use brk_types::{OutputType, Sats, TxIndex, TxOutIndex};
use vecdb::{AnyVec, VecIndex, VecIterator};
const BINS_5PCT: f64 = 4.24;
const BINS_10PCT: f64 = 8.28;
const BINS_20PCT: f64 = 15.84;
fn bins_to_pct(bins: f64) -> f64 {
(10.0_f64.powf(bins / 200.0) - 1.0) * 100.0
}
fn seed_bin(start_height: usize) -> f64 {
let price: f64 = PRICES
.lines()
.nth(start_height - 1)
.expect("prices.txt too short")
.parse()
.expect("Failed to parse seed price");
cents_to_bin(price * 100.0)
}
struct Stats {
total_sq_err: f64,
total_bias: f64,
max_err: f64,
total_blocks: u64,
gt_5pct: u64,
gt_10pct: u64,
gt_20pct: u64,
}
impl Stats {
fn new() -> Self {
Self {
total_sq_err: 0.0,
total_bias: 0.0,
max_err: 0.0,
total_blocks: 0,
gt_5pct: 0,
gt_10pct: 0,
gt_20pct: 0,
}
}
fn update(&mut self, err: f64) {
self.total_sq_err += err * err;
self.total_bias += err;
self.total_blocks += 1;
let abs_err = err.abs();
if abs_err > self.max_err {
self.max_err = abs_err;
}
if abs_err > BINS_5PCT {
self.gt_5pct += 1;
}
if abs_err > BINS_10PCT {
self.gt_10pct += 1;
}
if abs_err > BINS_20PCT {
self.gt_20pct += 1;
}
}
fn rmse_pct(&self) -> f64 {
bins_to_pct((self.total_sq_err / self.total_blocks as f64).sqrt())
}
fn max_pct(&self) -> f64 {
bins_to_pct(self.max_err)
}
fn bias(&self) -> f64 {
self.total_bias / self.total_blocks as f64
}
}
struct Run {
label: &'static str,
start_height: usize,
oracle: Option<Oracle>,
stats: Stats,
}
fn main() {
let data_dir = std::env::var("BRK_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap();
PathBuf::from(home).join(".brk")
});
let indexer = Indexer::forced_import(&data_dir).expect("Failed to load indexer");
let total_heights = indexer.vecs.blocks.timestamp.len();
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let height_ohlc: Vec<[f64; 4]> = serde_json::from_str(
&std::fs::read_to_string(format!("{manifest_dir}/examples/height_price_ohlc.json"))
.expect("Failed to read height_price_ohlc.json"),
)
.expect("Failed to parse height OHLC");
// Pre-compute per-height (high_bin, low_bin) tolerance band.
let height_bands: Vec<(f64, f64)> = height_ohlc
.iter()
.map(|ohlc| {
let high = ohlc[1];
let low = ohlc[2];
if high > 0.0 && low > 0.0 {
(cents_to_bin(high * 100.0), cents_to_bin(low * 100.0))
} else {
(0.0, 0.0)
}
})
.collect();
let mut runs = vec![
Run { label: "w12 @ 575k", start_height: 575_000, oracle: None, stats: Stats::new() },
Run { label: "w12 @ 600k", start_height: 600_000, oracle: None, stats: Stats::new() },
Run { label: "w12 @ 630k", start_height: 630_000, oracle: None, stats: Stats::new() },
];
// Build per-block filtered histograms from the indexer, feeding all oracles in one pass.
let total_txs = indexer.vecs.transactions.height.len();
let total_outputs = indexer.vecs.outputs.value.len();
let mut first_txindex_iter = indexer.vecs.transactions.first_txindex.into_iter();
let mut first_txoutindex_iter = indexer.vecs.transactions.first_txoutindex.into_iter();
let mut out_first_iter = indexer.vecs.outputs.first_txoutindex.into_iter();
let mut value_iter = indexer.vecs.outputs.value.into_iter();
let mut outputtype_iter = indexer.vecs.outputs.outputtype.into_iter();
let ref_config = Config::default();
for h in START_HEIGHT..total_heights {
let first_txindex: TxIndex = first_txindex_iter.get_at_unwrap(h);
let next_first_txindex = first_txindex_iter
.get_at(h + 1)
.unwrap_or(TxIndex::from(total_txs));
let out_start = if first_txindex.to_usize() + 1 < next_first_txindex.to_usize() {
first_txoutindex_iter
.get_at_unwrap(first_txindex.to_usize() + 1)
.to_usize()
} else {
out_first_iter
.get_at(h + 1)
.unwrap_or(TxOutIndex::from(total_outputs))
.to_usize()
};
let out_end = out_first_iter
.get_at(h + 1)
.unwrap_or(TxOutIndex::from(total_outputs))
.to_usize();
// Build filtered histogram once for all oracles.
let mut hist = [0u32; NUM_BINS];
for i in out_start..out_end {
let sats: Sats = value_iter.get_at_unwrap(i);
let output_type: OutputType = outputtype_iter.get_at_unwrap(i);
if ref_config.excluded_output_types.contains(&output_type) {
continue;
}
if *sats < ref_config.min_sats
|| (ref_config.exclude_common_round_values && sats.is_common_round_value())
{
continue;
}
if let Some(bin) = sats_to_bin(sats) {
hist[bin] += 1;
}
}
for run in &mut runs {
if h < run.start_height {
continue;
}
if run.oracle.is_none() {
let config = Config::default();
run.oracle = Some(Oracle::new(seed_bin(run.start_height), config));
}
let ref_bin = run.oracle.as_mut().unwrap().process_histogram(&hist);
if h < height_bands.len() {
let (high_bin, low_bin) = height_bands[h];
if high_bin > 0.0 && low_bin > 0.0 {
let err = if ref_bin < high_bin {
ref_bin - high_bin
} else if ref_bin > low_bin {
ref_bin - low_bin
} else {
0.0
};
run.stats.update(err);
}
}
}
}
// Print results.
println!();
println!(
"{:<14} {:>8} {:>10} {:>10} {:>6} {:>6} {:>6} {:>8}",
"Config", "Blocks", "RMSE%", "Max%", ">5%", ">10%", ">20%", "Bias"
);
println!("{}", "-".repeat(72));
for run in &runs {
let s = &run.stats;
println!(
"{:<14} {:>8} {:>7.2}% {:>7.1}% {:>6} {:>6} {:>6} {:>+8.2}",
run.label,
s.total_blocks,
s.rmse_pct(),
s.max_pct(),
s.gt_5pct,
s.gt_10pct,
s.gt_20pct,
s.bias()
);
}
println!();
// Verify exact counts against reference.
// Reference: trunc w12 @ 575k: 261 >5%, 40 >10%, 0 >20%
// trunc w12 @ 600k: 174 >5%, 31 >10%, 0 >20%
// trunc w12 @ 630k: 84 >5%, 9 >10%, 0 >20%
let expected: &[(&str, u64, u64, u64)] = &[
("w12 @ 575k", 237, 22, 0),
("w12 @ 600k", 152, 15, 0),
("w12 @ 630k", 84, 9, 0),
];
for (run, &(label, exp_5, exp_10, exp_20)) in runs.iter().zip(expected) {
let s = &run.stats;
assert_eq!(s.gt_20pct, exp_20, "{label}: expected {exp_20} blocks >20%, got {}", s.gt_20pct);
assert_eq!(s.gt_10pct, exp_10, "{label}: expected {exp_10} blocks >10%, got {}", s.gt_10pct);
assert_eq!(s.gt_5pct, exp_5, "{label}: expected {exp_5} blocks >5%, got {}", s.gt_5pct);
}
println!("All assertions passed!");
}
+335
View File
@@ -0,0 +1,335 @@
//! Pure on-chain BTC/USD price oracle.
//!
//! Detects round-dollar transaction patterns ($1, $5, $10, ... $10,000) in Bitcoin
//! block outputs to derive the current price without any exchange data.
use brk_types::{Block, CentsUnsigned, Dollars, OutputType, Sats};
/// Pre-oracle dollar prices, one per line, heights 0..630_000.
pub const PRICES: &str = include_str!("prices.txt");
/// First height where the oracle computes from on-chain data.
pub const START_HEIGHT: usize = 575_000;
pub const BINS_PER_DECADE: usize = 200;
const MIN_LOG_BTC: i32 = -8;
const MAX_LOG_BTC: i32 = 4;
pub const NUM_BINS: usize = BINS_PER_DECADE * (MAX_LOG_BTC - MIN_LOG_BTC) as usize;
/// Bin offsets for 19 round-USD amounts relative to the $100 reference (offset 0).
/// Each offset = log10(amount / 100) * BINS_PER_DECADE.
const STENCIL_OFFSETS: [i32; 19] = [
-400, // $1
-340, // $2
-305, // $3
-260, // $5
-200, // $10
-165, // $15
-140, // $20
-120, // $25
-105, // $30
-60, // $50
0, // $100
35, // $150
60, // $200
95, // $300
140, // $500
200, // $1000
260, // $2000
340, // $5000
400, // $10000
];
/// Maps a satoshi value to its log-scale bin index.
/// bin = round(log10(sats) * BINS_PER_DECADE).
#[inline(always)]
pub fn sats_to_bin(sats: Sats) -> Option<usize> {
if sats.is_zero() {
return None;
}
let bin = ((*sats as f64).log10() * BINS_PER_DECADE as f64).round() as i64;
if bin >= 0 && (bin as usize) < NUM_BINS {
Some(bin as usize)
} else {
None
}
}
/// Converts a fractional bin to a USD price in cents.
/// For a $D output at price P: sats = D * 1e8 / P, so P = 10^(10 - bin/200) dollars,
/// where 10 = log10($100 reference * 1e8 sats/BTC).
#[inline]
pub fn bin_to_cents(bin: f64) -> u64 {
let dollars = 10.0_f64.powf(10.0 - bin / BINS_PER_DECADE as f64);
(dollars * 100.0).round() as u64
}
/// Converts a USD price in cents to a fractional bin (inverse of bin_to_cents).
#[inline]
pub fn cents_to_bin(cents: f64) -> f64 {
(10.0 - (cents / 100.0).log10()) * BINS_PER_DECADE as f64
}
/// Scores each candidate bin in the search window by summing normalized stencil
/// matches across the EMA histogram, then refines with parabolic interpolation.
fn find_best_bin(
ema: &[f64; NUM_BINS],
prev_bin: f64,
search_below: usize,
search_above: usize,
) -> f64 {
let center = prev_bin.round() as usize;
let search_start = center.saturating_sub(search_below);
let search_end = (center + search_above + 1).min(NUM_BINS);
if search_start >= search_end {
return prev_bin;
}
// Per-offset peak within the search window (for normalization).
let mut track_norm = [0.0f64; 19];
for (i, &offset) in STENCIL_OFFSETS.iter().enumerate() {
for bin in search_start..search_end {
let idx = bin as i32 + offset;
if idx >= 0 && (idx as usize) < NUM_BINS {
track_norm[i] = track_norm[i].max(ema[idx as usize]);
}
}
}
let score = |bin: usize| -> f64 {
let mut total = 0.0;
for (i, &offset) in STENCIL_OFFSETS.iter().enumerate() {
let idx = bin as i32 + offset;
if idx >= 0 && (idx as usize) < NUM_BINS && track_norm[i] > 0.0 {
total += ema[idx as usize] / track_norm[i];
}
}
total
};
let mut best_bin = search_start;
let mut best_score = score(search_start);
for bin in (search_start + 1)..search_end {
let candidate = score(bin);
if candidate > best_score {
best_score = candidate;
best_bin = bin;
}
}
// Parabolic sub-bin interpolation for fractional precision.
let score_center = best_score;
let score_left = if best_bin > search_start { score(best_bin - 1) } else { score_center };
let score_right = if best_bin + 1 < search_end { score(best_bin + 1) } else { score_center };
let denom = score_left - 2.0 * score_center + score_right;
let sub_bin = if denom.abs() > 1e-10 {
(0.5 * (score_left - score_right) / denom).clamp(-0.5, 0.5)
} else {
0.0
};
best_bin as f64 + sub_bin
}
#[derive(Clone)]
pub struct Config {
/// EMA decay: 2/(N+1) where N is span in blocks. 2/7 = 6-block span.
pub alpha: f64,
/// Ring buffer depth. 12 blocks for deterministic convergence at any start height.
pub window_size: usize,
/// Search window bins below/above previous estimate. Asymmetric for log-scale.
pub search_below: usize,
pub search_above: usize,
/// Minimum output value in sats (dust filter).
pub min_sats: u64,
/// Exclude round BTC amounts that create false stencil matches.
pub exclude_common_round_values: bool,
/// Output types to ignore (e.g. P2TR, P2WSH are noisy).
pub excluded_output_types: Vec<OutputType>,
}
impl Default for Config {
fn default() -> Self {
Self {
alpha: 2.0 / 7.0,
window_size: 12,
search_below: 9,
search_above: 11,
min_sats: 1000,
exclude_common_round_values: true,
excluded_output_types: vec![OutputType::P2TR, OutputType::P2WSH],
}
}
}
#[derive(Clone)]
pub struct Oracle {
histograms: Vec<[u32; NUM_BINS]>,
ema: Box<[f64; NUM_BINS]>,
cursor: usize,
filled: usize,
ref_bin: f64,
config: Config,
weights: Vec<f64>,
excluded_mask: u16,
warmup: bool,
}
impl Oracle {
pub fn new(start_bin: f64, config: Config) -> Self {
let window_size = config.window_size;
let decay = 1.0 - config.alpha;
let weights: Vec<f64> = (0..window_size)
.map(|i| config.alpha * decay.powi(i as i32))
.collect();
let excluded_mask = config
.excluded_output_types
.iter()
.fold(0u16, |mask, ot| mask | (1 << *ot as u8));
Self {
histograms: vec![[0u32; NUM_BINS]; window_size],
ema: Box::new([0.0; NUM_BINS]),
cursor: 0,
filled: 0,
ref_bin: start_bin,
weights,
excluded_mask,
warmup: false,
config,
}
}
pub fn process_block(&mut self, block: &Block) -> f64 {
self.process_outputs(
block
.txdata
.iter()
.skip(1) // skip coinbase
.flat_map(|tx| &tx.output)
.map(|txout| (Sats::from(txout.value), OutputType::from(&txout.script_pubkey))),
)
}
pub fn process_outputs(&mut self, outputs: impl Iterator<Item = (Sats, OutputType)>) -> f64 {
let mut hist = [0u32; NUM_BINS];
for (sats, output_type) in outputs {
if let Some(bin) = self.eligible_bin(sats, output_type) {
hist[bin] += 1;
}
}
self.ingest(&hist)
}
/// Create an oracle restored from a known price.
/// `fill` should feed warmup blocks to populate the ring buffer.
/// ref_bin is anchored to the checkpoint regardless of warmup drift.
pub fn from_checkpoint(ref_bin: f64, config: Config, fill: impl FnOnce(&mut Self)) -> Self {
let mut oracle = Self::new(ref_bin, config);
oracle.warmup = true;
fill(&mut oracle);
oracle.warmup = false;
oracle.recompute_ema();
oracle.ref_bin = ref_bin;
oracle
}
pub fn process_histogram(&mut self, hist: &[u32; NUM_BINS]) -> f64 {
self.ingest(hist)
}
pub fn ref_bin(&self) -> f64 {
self.ref_bin
}
pub fn price_cents(&self) -> CentsUnsigned {
bin_to_cents(self.ref_bin).into()
}
pub fn price_dollars(&self) -> Dollars {
self.price_cents().into()
}
#[inline(always)]
pub fn output_to_bin(&self, sats: Sats, output_type: OutputType) -> Option<usize> {
self.eligible_bin(sats, output_type)
}
#[inline(always)]
fn eligible_bin(&self, sats: Sats, output_type: OutputType) -> Option<usize> {
if self.excluded_mask & (1 << output_type as u8) != 0 {
return None;
}
if *sats < self.config.min_sats
|| (self.config.exclude_common_round_values && sats.is_common_round_value())
{
return None;
}
sats_to_bin(sats)
}
fn ingest(&mut self, hist: &[u32; NUM_BINS]) -> f64 {
self.histograms[self.cursor] = *hist;
self.cursor = (self.cursor + 1) % self.config.window_size;
if self.filled < self.config.window_size {
self.filled += 1;
}
if !self.warmup {
self.recompute_ema();
self.ref_bin = find_best_bin(
&self.ema,
self.ref_bin,
self.config.search_below,
self.config.search_above,
);
}
self.ref_bin
}
fn recompute_ema(&mut self) {
self.ema.fill(0.0);
for age in 0..self.filled {
let idx =
(self.cursor + self.config.window_size - 1 - age) % self.config.window_size;
let weight = self.weights[age];
let h = &self.histograms[idx];
for bin in 0..NUM_BINS {
self.ema[bin] += weight * h[bin] as f64;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sats_to_bin_round_trip() {
assert_eq!(sats_to_bin(Sats::new(100_000_000)), Some(1600));
assert_eq!(sats_to_bin(Sats::new(1)), Some(0));
assert_eq!(sats_to_bin(Sats::ZERO), None);
}
#[test]
fn bin_to_cents_known_values() {
assert_eq!(bin_to_cents(1600.0), 10000);
assert_eq!(bin_to_cents(1800.0), 1000);
}
#[test]
fn sats_to_bin_boundary() {
assert_eq!(sats_to_bin(Sats::new(1_000_000_000_000)), None);
let sats = 10.0_f64.powf(11.995) as u64;
assert!(sats_to_bin(Sats::new(sats)).is_some());
}
#[test]
fn oracle_basic() {
let oracle = Oracle::new(1600.0, Config::default());
assert_eq!(oracle.ref_bin(), 1600.0);
assert_eq!(oracle.price_cents(), bin_to_cents(1600.0).into());
}
}
+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)?;
+101
View File
@@ -0,0 +1,101 @@
use std::{fs, path::PathBuf};
use brk_error::{Error, Result};
use brk_types::{
CostBasisBucket, CostBasisDistribution, CostBasisFormatted, CostBasisValue, Date, DateIndex,
};
use vecdb::IterableVec;
use crate::Query;
impl Query {
/// List available cohorts for cost basis distribution.
pub fn cost_basis_cohorts(&self) -> Result<Vec<String>> {
let states_path = &self.computer().distribution.states_path;
let mut cohorts: Vec<String> = fs::read_dir(states_path)?
.filter_map(|entry| {
let name = entry.ok()?.file_name().into_string().ok()?;
let cohort = name.strip_prefix("utxo_")?.strip_suffix("_cost_basis")?;
states_path
.join(&name)
.join("by_date")
.exists()
.then(|| cohort.to_string())
})
.collect();
cohorts.sort();
Ok(cohorts)
}
fn cost_basis_dir(&self, cohort: &str) -> Result<PathBuf> {
let dir = self
.computer()
.distribution
.states_path
.join(format!("utxo_{cohort}_cost_basis/by_date"));
if !dir.exists() {
return Err(Error::NotFound(format!("Unknown cohort '{cohort}'")));
}
Ok(dir)
}
/// Get the cost basis distribution for a cohort on a specific date.
pub fn cost_basis_distribution(
&self,
cohort: &str,
date: Date,
) -> Result<CostBasisDistribution> {
let path = self.cost_basis_dir(cohort)?.join(date.to_string());
if !path.exists() {
return Err(Error::NotFound(format!(
"No data for cohort '{cohort}' on {date}"
)));
}
CostBasisDistribution::deserialize(&fs::read(&path)?)
}
/// List available dates for a cohort's cost basis distribution.
pub fn cost_basis_dates(&self, cohort: &str) -> Result<Vec<Date>> {
let dir = self.cost_basis_dir(cohort)?;
let mut dates: Vec<Date> = fs::read_dir(&dir)?
.filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok())
.collect();
dates.sort();
Ok(dates)
}
/// Get the formatted cost basis distribution.
pub fn cost_basis_formatted(
&self,
cohort: &str,
date: Date,
bucket: CostBasisBucket,
value: CostBasisValue,
) -> Result<CostBasisFormatted> {
let distribution = self.cost_basis_distribution(cohort, date)?;
let dateindex =
DateIndex::try_from(date).map_err(|e| Error::Parse(e.to_string()))?;
let price = self
.computer()
.price
.as_ref()
.ok_or_else(|| Error::NotFound("Price data not available".to_string()))?;
let spot = *price
.cents
.split
.dateindex
.close
.iter()
.get(dateindex)
.ok_or_else(|| Error::NotFound(format!("No price data for {date}")))?;
Ok(distribution.format(bucket, value, spot))
}
}
+2
View File
@@ -1,9 +1,11 @@
mod address;
mod block;
mod cost_basis;
mod mempool;
mod metrics;
mod metrics_legacy;
mod mining;
mod price;
mod transaction;
pub use block::BLOCK_TXS_PAGE_SIZE;
+28
View File
@@ -0,0 +1,28 @@
use brk_error::{Error, Result};
use brk_types::Dollars;
use crate::Query;
impl Query {
pub fn live_price(&self) -> Result<Dollars> {
let oracle_vecs = &self
.computer()
.price
.as_ref()
.ok_or_else(|| Error::OutOfRange("Oracle prices not computed yet".into()))?
.oracle;
let mut oracle = oracle_vecs.live_oracle(self.indexer())?;
if let Some(mempool) = self.mempool() {
let txs = mempool.get_txs();
oracle.process_outputs(
txs.values()
.flat_map(|tx| &tx.tx().output)
.map(|txout| (txout.value, txout.type_())),
);
}
Ok(oracle.price_dollars())
}
}
+14 -8
View File
@@ -1,7 +1,7 @@
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
http::{HeaderMap, Uri},
response::Redirect,
routing::get,
};
@@ -26,11 +26,12 @@ impl AddressRoutes for ApiRouter<AppState> {
.api_route(
"/api/address/{address}",
get_with(async |
uri: Uri,
headers: HeaderMap,
Path(path): Path<AddressParam>,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.address(path.address)).await
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address(path.address)).await
}, |op| op
.id("get_address")
.addresses_tag()
@@ -46,12 +47,13 @@ impl AddressRoutes for ApiRouter<AppState> {
.api_route(
"/api/address/{address}/txs",
get_with(async |
uri: Uri,
headers: HeaderMap,
Path(path): Path<AddressParam>,
Query(params): Query<AddressTxidsParam>,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.address_txids(path.address, params.after_txid, params.limit)).await
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address_txids(path.address, params.after_txid, params.limit)).await
}, |op| op
.id("get_address_txs")
.addresses_tag()
@@ -67,11 +69,12 @@ impl AddressRoutes for ApiRouter<AppState> {
.api_route(
"/api/address/{address}/utxo",
get_with(async |
uri: Uri,
headers: HeaderMap,
Path(path): Path<AddressParam>,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.address_utxos(path.address)).await
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address_utxos(path.address)).await
}, |op| op
.id("get_address_utxos")
.addresses_tag()
@@ -87,12 +90,13 @@ impl AddressRoutes for ApiRouter<AppState> {
.api_route(
"/api/address/{address}/txs/mempool",
get_with(async |
uri: Uri,
headers: HeaderMap,
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), &uri, move |q| q.address_mempool_txids(path.address)).await
}, |op| op
.id("get_address_mempool_txs")
.addresses_tag()
@@ -107,12 +111,13 @@ impl AddressRoutes for ApiRouter<AppState> {
.api_route(
"/api/address/{address}/txs/chain",
get_with(async |
uri: Uri,
headers: HeaderMap,
Path(path): Path<AddressParam>,
Query(params): Query<AddressTxidsParam>,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.address_txids(path.address, params.after_txid, 25)).await
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.address_txids(path.address, params.after_txid, 25)).await
}, |op| op
.id("get_address_confirmed_txs")
.addresses_tag()
@@ -128,11 +133,12 @@ impl AddressRoutes for ApiRouter<AppState> {
.api_route(
"/api/v1/validate-address/{address}",
get_with(async |
uri: Uri,
headers: HeaderMap,
Path(path): Path<ValidateAddressParam>,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Static, move |_q| Ok(AddressValidation::from_address(&path.address))).await
state.cached_json(&headers, CacheStrategy::Static, &uri, move |_q| Ok(AddressValidation::from_address(&path.address))).await
}, |op| op
.id("validate_address")
.addresses_tag()
+30 -21
View File
@@ -1,7 +1,7 @@
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::{Path, State},
http::HeaderMap,
http::{HeaderMap, Uri},
};
use brk_query::BLOCK_TXS_PAGE_SIZE;
use brk_types::{
@@ -22,9 +22,9 @@ impl BlockRoutes for ApiRouter<AppState> {
self.api_route(
"/api/blocks",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Height, move |q| q.blocks(None))
.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.blocks(None))
.await
},
|op| {
@@ -41,10 +41,11 @@ impl BlockRoutes for ApiRouter<AppState> {
.api_route(
"/api/block/{hash}",
get_with(
async |headers: HeaderMap,
async |uri: Uri,
headers: HeaderMap,
Path(path): Path<BlockHashParam>,
State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Static, move |q| q.block(&path.hash)).await
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| q.block(&path.hash)).await
},
|op| {
op.id("get_block")
@@ -64,10 +65,11 @@ impl BlockRoutes for ApiRouter<AppState> {
.api_route(
"/api/block/{hash}/status",
get_with(
async |headers: HeaderMap,
async |uri: Uri,
headers: HeaderMap,
Path(path): Path<BlockHashParam>,
State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_status(&path.hash)).await
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.block_status(&path.hash)).await
},
|op| {
op.id("get_block_status")
@@ -87,10 +89,11 @@ impl BlockRoutes for ApiRouter<AppState> {
.api_route(
"/api/block-height/{height}",
get_with(
async |headers: HeaderMap,
async |uri: Uri,
headers: HeaderMap,
Path(path): Path<HeightParam>,
State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_by_height(path.height)).await
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.block_by_height(path.height)).await
},
|op| {
op.id("get_block_by_height")
@@ -110,10 +113,11 @@ impl BlockRoutes for ApiRouter<AppState> {
.api_route(
"/api/blocks/{height}",
get_with(
async |headers: HeaderMap,
async |uri: Uri,
headers: HeaderMap,
Path(path): Path<HeightParam>,
State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.blocks(Some(path.height))).await
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.blocks(Some(path.height))).await
},
|op| {
op.id("get_blocks_from_height")
@@ -132,10 +136,11 @@ impl BlockRoutes for ApiRouter<AppState> {
.api_route(
"/api/block/{hash}/txids",
get_with(
async |headers: HeaderMap,
async |uri: Uri,
headers: HeaderMap,
Path(path): Path<BlockHashParam>,
State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Static, move |q| q.block_txids(&path.hash)).await
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| q.block_txids(&path.hash)).await
},
|op| {
op.id("get_block_txids")
@@ -155,10 +160,11 @@ impl BlockRoutes for ApiRouter<AppState> {
.api_route(
"/api/block/{hash}/txs/{start_index}",
get_with(
async |headers: HeaderMap,
async |uri: Uri,
headers: HeaderMap,
Path(path): Path<BlockHashStartIndex>,
State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Static, move |q| q.block_txs(&path.hash, path.start_index)).await
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| q.block_txs(&path.hash, path.start_index)).await
},
|op| {
op.id("get_block_txs")
@@ -179,10 +185,11 @@ impl BlockRoutes for ApiRouter<AppState> {
.api_route(
"/api/block/{hash}/txid/{index}",
get_with(
async |headers: HeaderMap,
async |uri: Uri,
headers: HeaderMap,
Path(path): Path<BlockHashTxIndex>,
State(state): State<AppState>| {
state.cached_text(&headers, CacheStrategy::Static, move |q| q.block_txid_at_index(&path.hash, path.index).map(|t| t.to_string())).await
state.cached_text(&headers, CacheStrategy::Static, &uri, move |q| q.block_txid_at_index(&path.hash, path.index).map(|t| t.to_string())).await
},
|op| {
op.id("get_block_txid")
@@ -202,10 +209,11 @@ impl BlockRoutes for ApiRouter<AppState> {
.api_route(
"/api/v1/mining/blocks/timestamp/{timestamp}",
get_with(
async |headers: HeaderMap,
async |uri: Uri,
headers: HeaderMap,
Path(path): Path<TimestampParam>,
State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_by_timestamp(path.timestamp)).await
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.block_by_timestamp(path.timestamp)).await
},
|op| {
op.id("get_block_by_timestamp")
@@ -223,10 +231,11 @@ impl BlockRoutes for ApiRouter<AppState> {
.api_route(
"/api/block/{hash}/raw",
get_with(
async |headers: HeaderMap,
async |uri: Uri,
headers: HeaderMap,
Path(path): Path<BlockHashParam>,
State(state): State<AppState>| {
state.cached_bytes(&headers, CacheStrategy::Static, move |q| q.block_raw(&path.hash)).await
state.cached_bytes(&headers, CacheStrategy::Static, &uri, move |q| q.block_raw(&path.hash)).await
},
|op| {
op.id("get_block_raw")
+31 -11
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 axum::{extract::State, http::{HeaderMap, Uri}, response::Redirect, routing::get};
use brk_types::{Dollars, MempoolBlock, MempoolInfo, RecommendedFees, Txid};
use crate::{CacheStrategy, extended::TransformResponseExtended};
use crate::extended::TransformResponseExtended;
use super::AppState;
@@ -17,8 +17,8 @@ impl MempoolRoutes for ApiRouter<AppState> {
.api_route(
"/api/mempool/info",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.mempool_info()).await
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, state.mempool_cache(), &uri, |q| q.mempool_info()).await
},
|op| {
op.id("get_mempool")
@@ -33,8 +33,8 @@ impl MempoolRoutes for ApiRouter<AppState> {
.api_route(
"/api/mempool/txids",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::MaxAge(5), |q| q.mempool_txids()).await
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, state.mempool_cache(), &uri, |q| q.mempool_txids()).await
},
|op| {
op.id("get_mempool_txids")
@@ -49,8 +49,8 @@ impl MempoolRoutes for ApiRouter<AppState> {
.api_route(
"/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
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, state.mempool_cache(), &uri, |q| q.recommended_fees()).await
},
|op| {
op.id("get_recommended_fees")
@@ -62,11 +62,31 @@ impl MempoolRoutes for ApiRouter<AppState> {
},
),
)
.api_route(
"/api/mempool/price",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, state.mempool_cache(), &uri, |q| q.live_price()).await
},
|op| {
op.id("get_live_price")
.mempool_tag()
.summary("Live BTC/USD price")
.description(
"Returns the current BTC/USD price in dollars, derived from \
on-chain round-dollar output patterns in the last 12 blocks \
plus mempool.",
)
.ok_response::<Dollars>()
.server_error()
},
),
)
.api_route(
"/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
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, state.mempool_cache(), &uri, |q| q.mempool_blocks()).await
},
|op| {
op.id("get_mempool_blocks")
+23 -39
View File
@@ -1,14 +1,13 @@
use std::{net::SocketAddr, time::Duration};
use std::net::SocketAddr;
use axum::{
Extension,
body::Body,
body::{Body, Bytes},
extract::{Query, State},
http::{HeaderMap, StatusCode, Uri},
response::{IntoResponse, Response},
};
use brk_types::{Format, MetricSelection, Output};
use quick_cache::sync::GuardResult;
use crate::{
Result,
@@ -23,56 +22,41 @@ pub async fn handler(
headers: HeaderMap,
Extension(addr): Extension<SocketAddr>,
Query(params): Query<MetricSelection>,
State(AppState { query, cache, .. }): State<AppState>,
State(state): State<AppState>,
) -> Result<Response> {
// Phase 1: Search and resolve metadata (cheap)
let resolved = query.run(move |q| q.resolve(params, max_weight(&addr))).await?;
let resolved = state.run(move |q| q.resolve(params, max_weight(&addr))).await?;
let format = resolved.format();
let etag = resolved.etag();
// Check if client has fresh cache
if headers.has_etag(etag.as_str()) {
let response = (StatusCode::NOT_MODIFIED, "").into_response();
return Ok(response);
return Ok((StatusCode::NOT_MODIFIED, "").into_response());
}
// Check server-side cache
// Phase 2: Format (expensive, server-side cached)
let cache_key = format!("bulk-{}{}{}", uri.path(), uri.query().unwrap_or(""), etag);
let guard_res = cache.get_value_or_guard(&cache_key, Some(Duration::from_millis(50)));
let mut response = if let GuardResult::Value(v) = guard_res {
Response::new(Body::from(v))
} else {
// Phase 2: Format (expensive, only on cache miss)
let metric_output = query.run(move |q| q.format(resolved)).await?;
match metric_output.output {
Output::CSV(s) => {
if let GuardResult::Guard(g) = guard_res {
let _ = g.insert(s.clone().into());
}
s.into_response()
}
Output::Json(v) => {
if let GuardResult::Guard(g) = guard_res {
let _ = g.insert(v.clone().into());
}
Response::new(Body::from(v))
}
}
};
let headers = response.headers_mut();
headers.insert_etag(etag.as_str());
headers.insert_cache_control(CACHE_CONTROL);
let query = &state;
let bytes = state
.get_or_insert(&cache_key, async move {
let out = query.run(move |q| q.format(resolved)).await?;
Ok(match out.output {
Output::CSV(s) => Bytes::from(s),
Output::Json(v) => Bytes::from(v),
})
})
.await?;
let mut response = Response::new(Body::from(bytes));
let h = response.headers_mut();
h.insert_etag(etag.as_str());
h.insert_cache_control(CACHE_CONTROL);
match format {
Format::CSV => {
headers.insert_content_disposition_attachment();
headers.insert_content_type_text_csv()
h.insert_content_disposition_attachment();
h.insert_content_type_text_csv()
}
Format::JSON => headers.insert_content_type_application_json(),
Format::JSON => h.insert_content_type_application_json(),
}
Ok(response)
+23 -39
View File
@@ -1,14 +1,13 @@
use std::{net::SocketAddr, time::Duration};
use std::net::SocketAddr;
use axum::{
Extension,
body::Body,
body::{Body, Bytes},
extract::{Query, State},
http::{HeaderMap, StatusCode, Uri},
response::{IntoResponse, Response},
};
use brk_types::{Format, MetricSelection, Output};
use quick_cache::sync::GuardResult;
use crate::{
Result,
@@ -23,56 +22,41 @@ pub async fn handler(
headers: HeaderMap,
Extension(addr): Extension<SocketAddr>,
Query(params): Query<MetricSelection>,
State(AppState { query, cache, .. }): State<AppState>,
State(state): State<AppState>,
) -> Result<Response> {
// Phase 1: Search and resolve metadata (cheap)
let resolved = query.run(move |q| q.resolve(params, max_weight(&addr))).await?;
let resolved = state.run(move |q| q.resolve(params, max_weight(&addr))).await?;
let format = resolved.format();
let etag = resolved.etag();
// Check if client has fresh cache
if headers.has_etag(etag.as_str()) {
let response = (StatusCode::NOT_MODIFIED, "").into_response();
return Ok(response);
return Ok((StatusCode::NOT_MODIFIED, "").into_response());
}
// Check server-side cache
// Phase 2: Format (expensive, server-side cached)
let cache_key = format!("single-{}{}{}", uri.path(), uri.query().unwrap_or(""), etag);
let guard_res = cache.get_value_or_guard(&cache_key, Some(Duration::from_millis(50)));
let mut response = if let GuardResult::Value(v) = guard_res {
Response::new(Body::from(v))
} else {
// Phase 2: Format (expensive, only on cache miss)
let metric_output = query.run(move |q| q.format(resolved)).await?;
match metric_output.output {
Output::CSV(s) => {
if let GuardResult::Guard(g) = guard_res {
let _ = g.insert(s.clone().into());
}
s.into_response()
}
Output::Json(v) => {
if let GuardResult::Guard(g) = guard_res {
let _ = g.insert(v.clone().into());
}
Response::new(Body::from(v))
}
}
};
let headers = response.headers_mut();
headers.insert_etag(etag.as_str());
headers.insert_cache_control(CACHE_CONTROL);
let query = &state;
let bytes = state
.get_or_insert(&cache_key, async move {
let out = query.run(move |q| q.format(resolved)).await?;
Ok(match out.output {
Output::CSV(s) => Bytes::from(s),
Output::Json(v) => Bytes::from(v),
})
})
.await?;
let mut response = Response::new(Body::from(bytes));
let h = response.headers_mut();
h.insert_etag(etag.as_str());
h.insert_cache_control(CACHE_CONTROL);
match format {
Format::CSV => {
headers.insert_content_disposition_attachment();
headers.insert_content_type_text_csv()
h.insert_content_disposition_attachment();
h.insert_content_type_text_csv()
}
Format::JSON => headers.insert_content_type_application_json(),
Format::JSON => h.insert_content_type_application_json(),
}
Ok(response)
+23 -40
View File
@@ -1,14 +1,13 @@
use std::{net::SocketAddr, time::Duration};
use std::net::SocketAddr;
use axum::{
Extension,
body::Body,
body::{Body, Bytes},
extract::{Query, State},
http::{HeaderMap, StatusCode, Uri},
response::{IntoResponse, Response},
};
use brk_types::{Format, MetricSelection, OutputLegacy};
use quick_cache::sync::GuardResult;
use crate::{
Result,
@@ -23,57 +22,41 @@ pub async fn handler(
headers: HeaderMap,
Extension(addr): Extension<SocketAddr>,
Query(params): Query<MetricSelection>,
State(AppState { query, cache, .. }): State<AppState>,
State(state): State<AppState>,
) -> Result<Response> {
// Phase 1: Search and resolve metadata (cheap)
let resolved = query.run(move |q| q.resolve(params, max_weight(&addr))).await?;
let resolved = state.run(move |q| q.resolve(params, max_weight(&addr))).await?;
let format = resolved.format();
let etag = resolved.etag();
// Check if client has fresh cache
if headers.has_etag(etag.as_str()) {
let response = (StatusCode::NOT_MODIFIED, "").into_response();
return Ok(response);
return Ok((StatusCode::NOT_MODIFIED, "").into_response());
}
// Check server-side cache
// Phase 2: Format (expensive, server-side cached)
let cache_key = format!("legacy-{}{}{}", uri.path(), uri.query().unwrap_or(""), etag);
let guard_res = cache.get_value_or_guard(&cache_key, Some(Duration::from_millis(50)));
let mut response = if let GuardResult::Value(v) = guard_res {
Response::new(Body::from(v))
} else {
// Phase 2: Format (expensive, only on cache miss)
let metric_output = query.run(move |q| q.format_legacy(resolved)).await?;
match metric_output.output {
OutputLegacy::CSV(s) => {
if let GuardResult::Guard(g) = guard_res {
let _ = g.insert(s.clone().into());
}
s.into_response()
}
OutputLegacy::Json(v) => {
let json = v.to_vec();
if let GuardResult::Guard(g) = guard_res {
let _ = g.insert(json.clone().into());
}
json.into_response()
}
}
};
let headers = response.headers_mut();
headers.insert_etag(etag.as_str());
headers.insert_cache_control(CACHE_CONTROL);
let query = &state;
let bytes = state
.get_or_insert(&cache_key, async move {
let out = query.run(move |q| q.format_legacy(resolved)).await?;
Ok(match out.output {
OutputLegacy::CSV(s) => Bytes::from(s),
OutputLegacy::Json(v) => Bytes::from(v.to_vec()),
})
})
.await?;
let mut response = Response::new(Body::from(bytes));
let h = response.headers_mut();
h.insert_etag(etag.as_str());
h.insert_cache_control(CACHE_CONTROL);
match format {
Format::CSV => {
headers.insert_content_disposition_attachment();
headers.insert_content_type_text_csv()
h.insert_content_disposition_attachment();
h.insert_content_type_text_csv()
}
Format::JSON => headers.insert_content_type_application_json(),
Format::JSON => h.insert_content_type_application_json(),
}
Ok(response)
+92 -8
View File
@@ -9,7 +9,8 @@ use axum::{
};
use brk_traversable::TreeNode;
use brk_types::{
DataRangeFormat, Index, IndexInfo, LimitParam, Metric, MetricCount, MetricData, MetricParam,
CostBasisCohortParam, CostBasisFormatted, CostBasisParams, CostBasisQuery, DataRangeFormat,
Date, Index, IndexInfo, LimitParam, Metric, MetricCount, MetricData, MetricParam,
MetricSelection, MetricSelectionLegacy, MetricWithIndex, Metrics, PaginatedMetrics, Pagination,
};
@@ -47,8 +48,8 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
self.api_route(
"/api/metrics",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Static, |q| Ok(q.metrics_catalog().clone())).await
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.metrics_catalog().clone())).await
},
|op| op
.id("get_metrics_tree")
@@ -66,10 +67,11 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
"/api/metrics/count",
get_with(
async |
uri: Uri,
headers: HeaderMap,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Static, |q| Ok(q.metric_count())).await
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.metric_count())).await
},
|op| op
.id("get_metrics_count")
@@ -84,10 +86,11 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
"/api/metrics/indexes",
get_with(
async |
uri: Uri,
headers: HeaderMap,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Static, |q| Ok(q.indexes().to_vec())).await
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.indexes().to_vec())).await
},
|op| op
.id("get_indexes")
@@ -104,11 +107,12 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
"/api/metrics/list",
get_with(
async |
uri: Uri,
headers: HeaderMap,
State(state): State<AppState>,
Query(pagination): Query<Pagination>
| {
state.cached_json(&headers, CacheStrategy::Static, move |q| Ok(q.metrics(pagination))).await
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| Ok(q.metrics(pagination))).await
},
|op| op
.id("list_metrics")
@@ -123,12 +127,13 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
"/api/metrics/search/{metric}",
get_with(
async |
uri: Uri,
headers: HeaderMap,
State(state): State<AppState>,
Path(path): Path<MetricParam>,
Query(query): Query<LimitParam>
| {
state.cached_json(&headers, CacheStrategy::Static, move |q| Ok(q.match_metric(&path.metric, query.limit))).await
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| Ok(q.match_metric(&path.metric, query.limit))).await
},
|op| op
.id("search_metrics")
@@ -144,11 +149,12 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
"/api/metric/{metric}",
get_with(
async |
uri: Uri,
headers: HeaderMap,
State(state): State<AppState>,
Path(path): Path<MetricParam>
| {
state.cached_json(&headers, CacheStrategy::Static, move |q| {
state.cached_json(&headers, CacheStrategy::Static, &uri, move |q| {
if let Some(indexes) = q.metric_to_indexes(path.metric.clone()) {
return Ok(indexes.clone())
}
@@ -291,5 +297,83 @@ impl ApiMetricsRoutes for ApiRouter<AppState> {
.not_modified(),
),
)
// Cost basis distribution endpoints
.api_route(
"/api/metrics/cost-basis",
get_with(
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, &uri, |q| q.cost_basis_cohorts())
.await
},
|op| {
op.id("get_cost_basis_cohorts")
.metrics_tag()
.summary("Available cost basis cohorts")
.description("List available cohorts for cost basis distribution.")
.ok_response::<Vec<String>>()
.server_error()
},
),
)
.api_route(
"/api/metrics/cost-basis/{cohort}/dates",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<CostBasisCohortParam>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Height, &uri, move |q| {
q.cost_basis_dates(&params.cohort)
})
.await
},
|op| {
op.id("get_cost_basis_dates")
.metrics_tag()
.summary("Available cost basis dates")
.description("List available dates for a cohort's cost basis distribution.")
.ok_response::<Vec<Date>>()
.not_found()
.server_error()
},
),
)
.api_route(
"/api/metrics/cost-basis/{cohort}/{date}",
get_with(
async |uri: Uri,
headers: HeaderMap,
Path(params): Path<CostBasisParams>,
Query(query): Query<CostBasisQuery>,
State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, &uri, move |q| {
q.cost_basis_formatted(
&params.cohort,
params.date,
query.bucket,
query.value,
)
})
.await
},
|op| {
op.id("get_cost_basis")
.metrics_tag()
.summary("Cost basis distribution")
.description(
"Get the cost basis distribution for a cohort on a specific date.\n\n\
Query params:\n\
- `bucket`: raw (default), lin200, lin500, lin1000, log10, log50, log100\n\
- `value`: supply (default, in BTC), realized (USD), unrealized (USD)",
)
.ok_response::<CostBasisFormatted>()
.not_found()
.server_error()
},
),
)
}
}
+25 -25
View File
@@ -1,7 +1,7 @@
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::{Path, State},
http::HeaderMap,
http::{HeaderMap, Uri},
response::Redirect,
routing::get,
};
@@ -28,8 +28,8 @@ impl MiningRoutes for ApiRouter<AppState> {
.api_route(
"/api/v1/difficulty-adjustment",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, |q| q.difficulty_adjustment()).await
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, &uri, |q| q.difficulty_adjustment()).await
},
|op| {
op.id("get_difficulty_adjustment")
@@ -45,9 +45,9 @@ impl MiningRoutes for ApiRouter<AppState> {
.api_route(
"/api/v1/mining/pools",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
// Pool list is static, only changes on code update
state.cached_json(&headers, CacheStrategy::Static, |q| Ok(q.all_pools())).await
state.cached_json(&headers, CacheStrategy::Static, &uri, |q| Ok(q.all_pools())).await
},
|op| {
op.id("get_pools")
@@ -63,8 +63,8 @@ impl MiningRoutes for ApiRouter<AppState> {
.api_route(
"/api/v1/mining/pools/{time_period}",
get_with(
async |headers: HeaderMap, Path(path): Path<TimePeriodParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.mining_pools(path.time_period)).await
async |uri: Uri, headers: HeaderMap, Path(path): Path<TimePeriodParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.mining_pools(path.time_period)).await
},
|op| {
op.id("get_pool_stats")
@@ -80,8 +80,8 @@ impl MiningRoutes for ApiRouter<AppState> {
.api_route(
"/api/v1/mining/pool/{slug}",
get_with(
async |headers: HeaderMap, Path(path): Path<PoolSlugParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.pool_detail(path.slug)).await
async |uri: Uri, headers: HeaderMap, Path(path): Path<PoolSlugParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.pool_detail(path.slug)).await
},
|op| {
op.id("get_pool")
@@ -98,8 +98,8 @@ impl MiningRoutes for ApiRouter<AppState> {
.api_route(
"/api/v1/mining/hashrate",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, |q| q.hashrate(None)).await
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, &uri, |q| q.hashrate(None)).await
},
|op| {
op.id("get_hashrate")
@@ -115,8 +115,8 @@ impl MiningRoutes for ApiRouter<AppState> {
.api_route(
"/api/v1/mining/hashrate/{time_period}",
get_with(
async |headers: HeaderMap, Path(path): Path<TimePeriodParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.hashrate(Some(path.time_period))).await
async |uri: Uri, headers: HeaderMap, Path(path): Path<TimePeriodParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.hashrate(Some(path.time_period))).await
},
|op| {
op.id("get_hashrate_by_period")
@@ -132,8 +132,8 @@ impl MiningRoutes for ApiRouter<AppState> {
.api_route(
"/api/v1/mining/difficulty-adjustments",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, |q| q.difficulty_adjustments(None)).await
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, &uri, |q| q.difficulty_adjustments(None)).await
},
|op| {
op.id("get_difficulty_adjustments")
@@ -149,8 +149,8 @@ impl MiningRoutes for ApiRouter<AppState> {
.api_route(
"/api/v1/mining/difficulty-adjustments/{time_period}",
get_with(
async |headers: HeaderMap, Path(path): Path<TimePeriodParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.difficulty_adjustments(Some(path.time_period))).await
async |uri: Uri, headers: HeaderMap, Path(path): Path<TimePeriodParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.difficulty_adjustments(Some(path.time_period))).await
},
|op| {
op.id("get_difficulty_adjustments_by_period")
@@ -166,8 +166,8 @@ impl MiningRoutes for ApiRouter<AppState> {
.api_route(
"/api/v1/mining/blocks/fees/{time_period}",
get_with(
async |headers: HeaderMap, Path(path): Path<TimePeriodParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_fees(path.time_period)).await
async |uri: Uri, headers: HeaderMap, Path(path): Path<TimePeriodParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.block_fees(path.time_period)).await
},
|op| {
op.id("get_block_fees")
@@ -183,8 +183,8 @@ impl MiningRoutes for ApiRouter<AppState> {
.api_route(
"/api/v1/mining/blocks/rewards/{time_period}",
get_with(
async |headers: HeaderMap, Path(path): Path<TimePeriodParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_rewards(path.time_period)).await
async |uri: Uri, headers: HeaderMap, Path(path): Path<TimePeriodParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.block_rewards(path.time_period)).await
},
|op| {
op.id("get_block_rewards")
@@ -218,8 +218,8 @@ impl MiningRoutes for ApiRouter<AppState> {
.api_route(
"/api/v1/mining/blocks/sizes-weights/{time_period}",
get_with(
async |headers: HeaderMap, Path(path): Path<TimePeriodParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.block_sizes_weights(path.time_period)).await
async |uri: Uri, headers: HeaderMap, Path(path): Path<TimePeriodParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.block_sizes_weights(path.time_period)).await
},
|op| {
op.id("get_block_sizes_weights")
@@ -235,8 +235,8 @@ impl MiningRoutes for ApiRouter<AppState> {
.api_route(
"/api/v1/mining/reward-stats/{block_count}",
get_with(
async |headers: HeaderMap, Path(path): Path<BlockCountParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.reward_stats(path.block_count)).await
async |uri: Uri, headers: HeaderMap, Path(path): Path<BlockCountParam>, State(state): State<AppState>| {
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.reward_stats(path.block_count)).await
},
|op| {
op.id("get_reward_stats")
+7 -7
View File
@@ -1,7 +1,7 @@
use std::{borrow::Cow, fs, path};
use aide::axum::{ApiRouter, routing::get_with};
use axum::{extract::State, http::HeaderMap};
use axum::{extract::State, http::{HeaderMap, Uri}};
use brk_types::{DiskUsage, Health, Height, SyncStatus};
use vecdb::GenericStoredVec;
@@ -18,11 +18,11 @@ impl ServerRoutes for ApiRouter<AppState> {
self.api_route(
"/api/server/sync",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
let tip_height = state.client.get_last_height();
state
.cached_json(&headers, CacheStrategy::Height, move |q| {
.cached_json(&headers, CacheStrategy::Height, &uri, move |q| {
let indexed_height = q.height();
let tip_height = tip_height?;
let blocks_behind = Height::from(tip_height.saturating_sub(*indexed_height));
@@ -59,10 +59,10 @@ impl ServerRoutes for ApiRouter<AppState> {
.api_route(
"/api/server/disk",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
let brk_path = state.data_path.clone();
state
.cached_json(&headers, CacheStrategy::Height, move |q| {
.cached_json(&headers, CacheStrategy::Height, &uri, move |q| {
let brk_bytes = dir_size(&brk_path)?;
let bitcoin_bytes = dir_size(q.blocks_dir())?;
Ok(DiskUsage::new(brk_bytes, bitcoin_bytes))
@@ -106,9 +106,9 @@ impl ServerRoutes for ApiRouter<AppState> {
.api_route(
"/version",
get_with(
async |headers: HeaderMap, State(state): State<AppState>| {
async |uri: Uri, headers: HeaderMap, State(state): State<AppState>| {
state
.cached_json(&headers, CacheStrategy::Static, |_| {
.cached_json(&headers, CacheStrategy::Static, &uri, |_| {
Ok(env!("CARGO_PKG_VERSION"))
})
.await
+11 -6
View File
@@ -1,7 +1,7 @@
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
extract::{Path, State},
http::HeaderMap,
http::{HeaderMap, Uri},
response::Redirect,
routing::get,
};
@@ -24,11 +24,12 @@ impl TxRoutes for ApiRouter<AppState> {
"/api/tx/{txid}",
get_with(
async |
uri: Uri,
headers: HeaderMap,
Path(txid): Path<TxidParam>,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.transaction(txid)).await
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.transaction(txid)).await
},
|op| op
.id("get_tx")
@@ -48,11 +49,12 @@ impl TxRoutes for ApiRouter<AppState> {
"/api/tx/{txid}/status",
get_with(
async |
uri: Uri,
headers: HeaderMap,
Path(txid): Path<TxidParam>,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.transaction_status(txid)).await
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.transaction_status(txid)).await
},
|op| op
.id("get_tx_status")
@@ -72,11 +74,12 @@ impl TxRoutes for ApiRouter<AppState> {
"/api/tx/{txid}/hex",
get_with(
async |
uri: Uri,
headers: HeaderMap,
Path(txid): Path<TxidParam>,
State(state): State<AppState>
| {
state.cached_text(&headers, CacheStrategy::Height, move |q| q.transaction_hex(txid)).await
state.cached_text(&headers, CacheStrategy::Height, &uri, move |q| q.transaction_hex(txid)).await
},
|op| op
.id("get_tx_hex")
@@ -96,12 +99,13 @@ impl TxRoutes for ApiRouter<AppState> {
"/api/tx/{txid}/outspend/{vout}",
get_with(
async |
uri: Uri,
headers: HeaderMap,
Path(path): Path<TxidVout>,
State(state): State<AppState>
| {
let txid = TxidParam { txid: path.txid };
state.cached_json(&headers, CacheStrategy::Height, move |q| q.outspend(txid, path.vout)).await
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.outspend(txid, path.vout)).await
},
|op| op
.id("get_tx_outspend")
@@ -121,11 +125,12 @@ impl TxRoutes for ApiRouter<AppState> {
"/api/tx/{txid}/outspends",
get_with(
async |
uri: Uri,
headers: HeaderMap,
Path(txid): Path<TxidParam>,
State(state): State<AppState>
| {
state.cached_json(&headers, CacheStrategy::Height, move |q| q.outspends(txid)).await
state.cached_json(&headers, CacheStrategy::Height, &uri, move |q| q.outspends(txid)).await
},
|op| op
.id("get_tx_outspends")
+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(),
},
}
}
@@ -27,10 +27,8 @@ where
T: Serialize;
fn new_text(value: &str, etag: &str) -> Self;
fn new_text_with(status: StatusCode, value: &str, etag: &str) -> Self;
fn new_text_cached(value: &str, params: &CacheParams) -> Self;
fn new_bytes(value: Vec<u8>, etag: &str) -> Self;
fn new_bytes_with(status: StatusCode, value: Vec<u8>, etag: &str) -> Self;
fn new_bytes_cached(value: Vec<u8>, params: &CacheParams) -> Self;
}
impl ResponseExtended for Response<Body> {
@@ -114,26 +112,4 @@ impl ResponseExtended for Response<Body> {
}
Self::new_json_cached(value, &params)
}
fn new_text_cached(value: &str, params: &CacheParams) -> Self {
let mut response = Response::builder().body(value.to_string().into()).unwrap();
let headers = response.headers_mut();
headers.insert_content_type_text_plain();
headers.insert_cache_control(&params.cache_control);
if let Some(etag) = &params.etag {
headers.insert_etag(etag);
}
response
}
fn new_bytes_cached(value: Vec<u8>, params: &CacheParams) -> Self {
let mut response = Response::builder().body(value.into()).unwrap();
let headers = response.headers_mut();
headers.insert_content_type_octet_stream();
headers.insert_cache_control(&params.cache_control);
if let Some(etag) = &params.etag {
headers.insert_etag(etag);
}
response
}
}
+12 -1
View File
@@ -71,7 +71,18 @@ impl Server {
mut request: Request<Body>,
next: Next|
-> Response<Body> {
request.extensions_mut().insert(connect_info.0);
let mut addr = connect_info.0;
// When behind a reverse proxy (e.g. cloudflared), the direct
// connection comes from loopback but the request is external.
// Mark it as non-loopback so it gets the stricter limit.
if addr.ip().is_loopback()
&& request.headers().contains_key("CF-Connecting-IP")
{
addr.set_ip(std::net::Ipv4Addr::UNSPECIFIED.into());
}
request.extensions_mut().insert(addr);
next.run(request).await
},
);
+98 -13
View File
@@ -1,20 +1,20 @@
use std::{path::PathBuf, sync::Arc, time::Instant};
use std::{future::Future, path::PathBuf, sync::Arc, time::{Duration, Instant}};
use derive_more::Deref;
use axum::{
body::{Body, Bytes},
http::{HeaderMap, Response},
http::{HeaderMap, Response, Uri},
};
use brk_query::AsyncQuery;
use brk_rpc::Client;
use jiff::Timestamp;
use quick_cache::sync::Cache;
use quick_cache::sync::{Cache, GuardResult};
use serde::Serialize;
use crate::{
CacheParams, CacheStrategy, Website,
extended::{ResponseExtended, ResultExtended},
extended::{HeaderMapExtended, ResponseExtended, ResultExtended},
};
#[derive(Clone, Deref)]
@@ -30,11 +30,17 @@ pub struct AppState {
}
impl AppState {
/// JSON response with caching
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 HTTP + server-side caching
pub async fn cached_json<T, F>(
&self,
headers: &HeaderMap,
strategy: CacheStrategy,
uri: &Uri,
f: F,
) -> Response<Body>
where
@@ -45,17 +51,36 @@ impl AppState {
if params.matches_etag(headers) {
return ResponseExtended::new_not_modified();
}
match self.run(f).await {
Ok(value) => ResponseExtended::new_json_cached(&value, &params),
let full_key = format!("{}-{}", uri, params.etag_str());
let result = self
.get_or_insert(&full_key, async move {
let value = self.run(f).await?;
Ok(serde_json::to_vec(&value).unwrap().into())
})
.await;
match result {
Ok(bytes) => {
let mut response = Response::new(Body::from(bytes));
let h = response.headers_mut();
h.insert_content_type_application_json();
h.insert_cache_control(&params.cache_control);
if let Some(etag) = &params.etag {
h.insert_etag(etag);
}
response
}
Err(e) => ResultExtended::<T>::to_json_response(Err(e), params.etag_str()),
}
}
/// Text response with caching
/// Text response with HTTP + server-side caching
pub async fn cached_text<T, F>(
&self,
headers: &HeaderMap,
strategy: CacheStrategy,
uri: &Uri,
f: F,
) -> Response<Body>
where
@@ -66,17 +91,36 @@ impl AppState {
if params.matches_etag(headers) {
return ResponseExtended::new_not_modified();
}
match self.run(f).await {
Ok(value) => ResponseExtended::new_text_cached(value.as_ref(), &params),
let full_key = format!("{}-{}", uri, params.etag_str());
let result = self
.get_or_insert(&full_key, async move {
let value = self.run(f).await?;
Ok(Bytes::from(value.as_ref().to_owned()))
})
.await;
match result {
Ok(bytes) => {
let mut response = Response::new(Body::from(bytes));
let h = response.headers_mut();
h.insert_content_type_text_plain();
h.insert_cache_control(&params.cache_control);
if let Some(etag) = &params.etag {
h.insert_etag(etag);
}
response
}
Err(e) => ResultExtended::<T>::to_text_response(Err(e), params.etag_str()),
}
}
/// Binary response with caching
/// Binary response with HTTP + server-side caching
pub async fn cached_bytes<T, F>(
&self,
headers: &HeaderMap,
strategy: CacheStrategy,
uri: &Uri,
f: F,
) -> Response<Body>
where
@@ -87,9 +131,50 @@ impl AppState {
if params.matches_etag(headers) {
return ResponseExtended::new_not_modified();
}
match self.run(f).await {
Ok(value) => ResponseExtended::new_bytes_cached(value.into(), &params),
let full_key = format!("{}-{}", uri, params.etag_str());
let result = self
.get_or_insert(&full_key, async move {
let value = self.run(f).await?;
Ok(Bytes::from(value.into()))
})
.await;
match result {
Ok(bytes) => {
let mut response = Response::new(Body::from(bytes));
let h = response.headers_mut();
h.insert_content_type_octet_stream();
h.insert_cache_control(&params.cache_control);
if let Some(etag) = &params.etag {
h.insert_etag(etag);
}
response
}
Err(e) => ResultExtended::<T>::to_bytes_response(Err(e), params.etag_str()),
}
}
/// Check server-side cache, compute on miss
pub async fn get_or_insert(
&self,
cache_key: &str,
compute: impl Future<Output = brk_error::Result<Bytes>>,
) -> brk_error::Result<Bytes> {
let guard_res = self
.cache
.get_value_or_guard(cache_key, Some(Duration::from_millis(50)));
if let GuardResult::Value(bytes) = guard_res {
return Ok(bytes);
}
let bytes = compute.await?;
if let GuardResult::Guard(g) = guard_res {
let _ = g.insert(bytes.clone());
}
Ok(bytes)
}
}
+5 -3
View File
@@ -9,14 +9,16 @@ repository.workspace = true
[dependencies]
bitcoin = { workspace = true }
brk_error = { workspace = true, features = ["bitcoin", "jiff", "serde_json", "vecdb"] }
brk_error = { workspace = true, features = ["bitcoin", "jiff", "pco", "serde_json", "vecdb"] }
byteview = { workspace = true }
derive_more = { workspace = true }
indexmap = { workspace = true }
itoa = "1.0.17"
jiff = { workspace = true }
rapidhash = "4.2.1"
ryu = "1.0.22"
pco = { workspace = true }
rapidhash = "4.3.0"
rustc-hash = { workspace = true }
ryu = "1.0.23"
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+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)]
+18
View File
@@ -180,6 +180,24 @@ impl Div<usize> for CentsSigned {
}
}
impl From<CentsSigned> for i128 {
#[inline]
fn from(value: CentsSigned) -> Self {
value.0 as i128
}
}
impl From<i128> for CentsSigned {
#[inline]
fn from(value: i128) -> Self {
debug_assert!(
value >= i64::MIN as i128 && value <= i64::MAX as i128,
"i128 overflow to CentsSigned"
);
Self(value as i64)
}
}
impl From<u128> for CentsSigned {
#[inline]
fn from(value: u128) -> Self {
+36
View File
@@ -17,6 +17,7 @@ use super::{CentsSats, Dollars, Sats};
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,
Pco,
@@ -61,6 +62,41 @@ impl CentsUnsigned {
pub fn to_dollars(self) -> Dollars {
Dollars::from(self.0 as f64 / 100.0)
}
/// Round to N significant digits.
/// E.g., 12345 (= $123.45) with round_to(4) → 12350 (= $123.50)
/// E.g., 12345 (= $123.45) with round_to(3) → 12300 (= $123.00)
pub fn round_to(self, digits: i32) -> Self {
let v = self.0;
let ilog10 = v.checked_ilog10().unwrap_or(0) as i32;
if ilog10 >= digits {
let log_diff = ilog10 - digits + 1;
let pow = 10u64.pow(log_diff as u32);
// Add half for rounding
Self(((v + pow / 2) / pow) * pow)
} else {
self
}
}
/// Round to nearest dollar, then apply N significant digits.
/// E.g., 12345 (= $123.45) → 12300 (= $123.00) with 5 digits
/// E.g., 1234567 (= $12345.67) → 1234600 (= $12346.00) with 5 digits
#[inline]
pub fn round_to_dollar(self, digits: i32) -> Self {
// Round to nearest dollar (nearest 100 cents)
let dollars = (self.0 + 50) / 100;
// Apply significant digit rounding to dollars, then convert back to cents
let ilog10 = dollars.checked_ilog10().unwrap_or(0) as i32;
let rounded_dollars = if ilog10 >= digits {
let log_diff = ilog10 - digits + 1;
let pow = 10u64.pow(log_diff as u32);
((dollars + pow / 2) / pow) * pow
} else {
dollars
};
Self(rounded_dollars * 100)
}
}
impl From<Dollars> for CentsUnsigned {
+38 -1
View File
@@ -1,12 +1,13 @@
use std::ops::Sub;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::{CentsUnsigned, Dollars};
/// Compact unsigned cents (u32) - memory-efficient for map keys.
/// Supports values from $0.00 to $42,949,672.95 (u32::MAX / 100).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)]
pub struct CentsUnsignedCompact(u32);
impl CentsUnsignedCompact {
@@ -42,6 +43,42 @@ impl CentsUnsignedCompact {
pub fn saturating_sub(self, rhs: Self) -> Self {
Self(self.0.saturating_sub(rhs.0))
}
/// Round to N significant digits.
/// E.g., 12345 (= $123.45) with round_to(4) → 12350 (= $123.50)
/// E.g., 12345 (= $123.45) with round_to(3) → 12300 (= $123.00)
#[inline]
pub fn round_to(self, digits: i32) -> Self {
let v = self.0;
let ilog10 = v.checked_ilog10().unwrap_or(0) as i32;
if ilog10 >= digits {
let log_diff = ilog10 - digits + 1;
let pow = 10u32.pow(log_diff as u32);
// Add half for rounding
Self(((v + pow / 2) / pow) * pow)
} else {
self
}
}
/// Round to nearest dollar, then apply N significant digits.
/// E.g., 12345 (= $123.45) → 12300 (= $123.00) with 5 digits
/// E.g., 1234567 (= $12345.67) → 1234600 (= $12346.00) with 5 digits
#[inline]
pub fn round_to_dollar(self, digits: i32) -> Self {
// Round to nearest dollar (nearest 100 cents)
let dollars = (self.0 + 50) / 100;
// Apply significant digit rounding to dollars, then convert back to cents
let ilog10 = dollars.checked_ilog10().unwrap_or(0) as i32;
let rounded_dollars = if ilog10 >= digits {
let log_diff = ilog10 - digits + 1;
let pow = 10u32.pow(log_diff as u32);
((dollars + pow / 2) / pow) * pow
} else {
dollars
};
Self(rounded_dollars * 100)
}
}
impl From<Dollars> for CentsUnsignedCompact {
+72
View File
@@ -0,0 +1,72 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use strum::Display;
use crate::CentsUnsigned;
/// Bucket type for cost basis aggregation.
/// Options: raw (no aggregation), lin200/lin500/lin1000 (linear $200/$500/$1000),
/// log10/log50/log100/log200 (logarithmic with 10/50/100/200 buckets per decade).
#[derive(
Debug, Display, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum CostBasisBucket {
#[default]
Raw,
Lin200,
Lin500,
Lin1000,
Log10,
Log50,
Log100,
Log200,
}
impl CostBasisBucket {
/// Returns the linear bucket size in cents, if this is a linear bucket type.
fn linear_size_cents(&self) -> Option<u64> {
match self {
Self::Lin200 => Some(20_000),
Self::Lin500 => Some(50_000),
Self::Lin1000 => Some(100_000),
_ => None,
}
}
/// Returns the number of buckets per decade, if this is a log bucket type.
fn log_buckets_per_decade(&self) -> Option<u32> {
match self {
Self::Log10 => Some(10),
Self::Log50 => Some(50),
Self::Log100 => Some(100),
Self::Log200 => Some(200),
_ => None,
}
}
/// Compute bucket floor for a given price in cents.
/// Returns None for Raw (no bucketing).
pub fn bucket_floor(&self, price_cents: CentsUnsigned) -> Option<CentsUnsigned> {
match self {
Self::Raw => None,
Self::Lin200 | Self::Lin500 | Self::Lin1000 => {
let size = self.linear_size_cents().unwrap();
Some((price_cents / size) * size)
}
Self::Log10 | Self::Log50 | Self::Log100 | Self::Log200 => {
if price_cents == CentsUnsigned::ZERO {
return Some(CentsUnsigned::ZERO);
}
let n = self.log_buckets_per_decade().unwrap();
// Bucket index = floor(n * log10(price))
// Floor = 10^(bucket_index / n)
let log_price = f64::from(price_cents).log10();
let bucket_idx = (n as f64 * log_price).floor() as i32;
let floor = 10_f64.powf(bucket_idx as f64 / n as f64);
Some(CentsUnsigned::from(floor.round() as u64))
}
}
}
}
@@ -0,0 +1,126 @@
use std::collections::BTreeMap;
use rustc_hash::FxHashMap;
use brk_error::Result;
use pco::{ChunkConfig, standalone::{simple_compress, simple_decompress}};
use schemars::JsonSchema;
use serde::Serialize;
use vecdb::Bytes;
use crate::{Bitcoin, CentsUnsigned, CentsUnsignedCompact, CostBasisBucket, CostBasisValue, Dollars, Sats};
/// Cost basis distribution: a map of price (cents) to sats.
#[derive(Debug, Clone, Default, Serialize, JsonSchema)]
pub struct CostBasisDistribution {
pub map: BTreeMap<CentsUnsignedCompact, Sats>,
}
/// Formatted cost basis output.
/// Key: price floor in USD (dollars).
/// Value: BTC (for supply) or USD (for realized/unrealized).
pub type CostBasisFormatted = BTreeMap<Dollars, f64>;
impl CostBasisDistribution {
/// Deserialize from the pco-compressed format, returning remaining bytes.
pub fn deserialize_with_rest(data: &[u8]) -> Result<(Self, &[u8])> {
let entry_count = usize::from_bytes(&data[0..8])?;
let keys_len = usize::from_bytes(&data[8..16])?;
let values_len = usize::from_bytes(&data[16..24])?;
let keys_start = 24;
let values_start = keys_start + keys_len;
let rest_start = values_start + values_len;
let keys: Vec<u32> = simple_decompress(&data[keys_start..values_start])?;
let values: Vec<u64> = simple_decompress(&data[values_start..rest_start])?;
let map: BTreeMap<CentsUnsignedCompact, Sats> = keys
.into_iter()
.zip(values)
.map(|(k, v)| (CentsUnsignedCompact::new(k), Sats::from(v)))
.collect();
assert_eq!(map.len(), entry_count);
Ok((Self { map }, &data[rest_start..]))
}
/// Deserialize from the pco-compressed format.
pub fn deserialize(data: &[u8]) -> Result<Self> {
Self::deserialize_with_rest(data).map(|(s, _)| s)
}
/// Serialize to the pco-compressed format.
pub fn serialize(&self) -> Result<Vec<u8>> {
Self::serialize_iter(self.map.iter().map(|(&k, &v)| (k, v)))
}
/// Serialize from a sorted iterator of (price, sats) pairs.
pub fn serialize_iter(iter: impl Iterator<Item = (CentsUnsignedCompact, Sats)>) -> Result<Vec<u8>> {
let entries: Vec<_> = iter.collect();
let keys: Vec<u32> = entries.iter().map(|(k, _)| k.inner()).collect();
let values: Vec<u64> = entries.iter().map(|(_, v)| u64::from(*v)).collect();
let config = ChunkConfig::default();
let compressed_keys = simple_compress(&keys, &config)?;
let compressed_values = simple_compress(&values, &config)?;
let mut buffer = Vec::new();
buffer.extend(keys.len().to_bytes());
buffer.extend(compressed_keys.len().to_bytes());
buffer.extend(compressed_values.len().to_bytes());
buffer.extend(compressed_keys);
buffer.extend(compressed_values);
Ok(buffer)
}
/// Format the distribution with optional bucketing and value transformation.
///
/// - `bucket`: How to aggregate prices (raw, linear, or logarithmic)
/// - `value`: What value to compute (supply, realized, or unrealized)
/// - `spot_cents`: Current spot price in cents (required for unrealized)
pub fn format(
&self,
bucket: CostBasisBucket,
value: CostBasisValue,
spot_cents: CentsUnsigned,
) -> CostBasisFormatted {
let spot = Dollars::from(spot_cents);
let needs_realized = value == CostBasisValue::Realized;
let mut result: FxHashMap<CentsUnsigned, (Sats, Dollars)> =
FxHashMap::with_capacity_and_hasher(self.map.len(), Default::default());
// Aggregate into buckets
for (&price_cents, &sats) in &self.map {
let price_cents_u = CentsUnsigned::from(price_cents);
let bucket_key = match bucket {
CostBasisBucket::Raw => price_cents_u,
_ => bucket.bucket_floor(price_cents_u).unwrap_or(price_cents_u),
};
let entry = result.entry(bucket_key).or_insert((Sats::ZERO, Dollars::ZERO));
entry.0 += sats;
// Only compute realized value if needed
if needs_realized {
entry.1 += Dollars::from(price_cents_u) * sats;
}
}
// Convert to final output based on value type
result
.into_iter()
.map(|(cents, (sats, realized))| {
let k = Dollars::from(cents);
let v = match value {
CostBasisValue::Supply => f64::from(Bitcoin::from(sats)),
CostBasisValue::Realized => f64::from(realized),
CostBasisValue::Unrealized => f64::from((spot - k) * sats),
};
(k, v)
})
.collect()
}
}
+55
View File
@@ -0,0 +1,55 @@
use std::{fmt, ops::Deref};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::{CostBasisBucket, CostBasisValue, Date};
/// Cohort identifier for cost basis distribution.
#[derive(Deserialize, JsonSchema)]
#[schemars(example = &"all", example = &"sth", example = &"lth")]
pub struct Cohort(String);
impl fmt::Display for Cohort {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<T: Into<String>> From<T> for Cohort {
fn from(s: T) -> Self {
Self(s.into())
}
}
impl Deref for Cohort {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
/// Path parameters for cost basis distribution endpoint.
#[derive(Deserialize, JsonSchema)]
pub struct CostBasisParams {
pub cohort: Cohort,
#[schemars(with = "String", example = &"2024-01-01")]
pub date: Date,
}
/// Path parameters for cost basis dates endpoint.
#[derive(Deserialize, JsonSchema)]
pub struct CostBasisCohortParam {
pub cohort: Cohort,
}
/// Query parameters for cost basis distribution endpoint.
#[derive(Deserialize, JsonSchema)]
pub struct CostBasisQuery {
/// Bucket type for aggregation. Default: raw (no aggregation).
#[serde(default)]
pub bucket: CostBasisBucket,
/// Value type to return. Default: supply.
#[serde(default)]
pub value: CostBasisValue,
}
+15
View File
@@ -0,0 +1,15 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use strum::Display;
/// Value type for cost basis distribution.
/// Options: supply (BTC), realized (USD, price × supply), unrealized (USD, spot × supply).
#[derive(Debug, Display, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum CostBasisValue {
#[default]
Supply,
Realized,
Unrealized,
}
+16 -1
View File
@@ -1,4 +1,4 @@
use std::fmt;
use std::{fmt, str::FromStr};
use jiff::{Span, Zoned, civil::Date as Date_, tz::TimeZone};
use schemars::JsonSchema;
@@ -250,6 +250,21 @@ impl fmt::Display for Date {
}
}
impl FromStr for Date {
type Err = &'static str;
/// Parse a date from YYYY-MM-DD format.
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.len() != 10 || s.as_bytes()[4] != b'-' || s.as_bytes()[7] != b'-' {
return Err("expected YYYY-MM-DD format");
}
let year: u16 = s[0..4].parse().map_err(|_| "invalid year")?;
let month: u8 = s[5..7].parse().map_err(|_| "invalid month")?;
let day: u8 = s[8..10].parse().map_err(|_| "invalid day")?;
Ok(Self::new(year, month, day))
}
}
impl Formattable for Date {
#[inline(always)]
fn may_need_escaping() -> bool {
+3 -3
View File
@@ -270,9 +270,9 @@ impl Mul<Sats> for Dollars {
if self.is_nan() {
self
} else {
Self::from(CentsSigned::from(
u128::from(rhs) * u128::from(CentsSigned::from(self)) / Sats::ONE_BTC_U128,
))
let cents = i128::from(CentsSigned::from(self));
let sats = rhs.as_u128() as i128;
Self::from(CentsSigned::from(sats * cents / Sats::ONE_BTC_U128 as i128))
}
}
}
+8
View File
@@ -40,6 +40,10 @@ mod cents_signed;
mod cents_squared_sats;
mod cents_unsigned;
mod cents_unsigned_compact;
mod cost_basis_bucket;
mod cost_basis_distribution;
mod cost_basis_params;
mod cost_basis_value;
mod datarange;
mod datarangeformat;
mod date;
@@ -216,6 +220,10 @@ pub use cents_signed::*;
pub use cents_squared_sats::*;
pub use cents_unsigned::*;
pub use cents_unsigned_compact::*;
pub use cost_basis_bucket::*;
pub use cost_basis_distribution::*;
pub use cost_basis_params::*;
pub use cost_basis_value::*;
pub use datarange::*;
pub use datarangeformat::*;
pub use date::*;
+13 -31
View File
@@ -26,6 +26,7 @@ use super::{Bitcoin, CentsUnsigned, Dollars, Height};
Default,
Serialize,
Deserialize,
Hash,
Pco,
JsonSchema,
)]
@@ -76,38 +77,19 @@ 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 mag = 10u64.pow(self.0.ilog10());
let leading = (self.0 + mag / 2) / mag;
if !matches!(leading, 1 | 2 | 3 | 5 | 6 | 10) {
return false;
}
let round_val = leading * mag;
self.0.abs_diff(round_val) * 1000 <= round_val
}
}
+35 -11
View File
@@ -1,34 +1,58 @@
use std::path::Path;
use axum::{body::Body, extract::State, http::Response};
use axum::{
body::Body,
extract::State,
http::{HeaderMap, Response, StatusCode},
};
use crate::{HeaderMapExtended, Result, Website};
pub async fn file_handler(
State(website): State<Website>,
headers: HeaderMap,
path: axum::extract::Path<String>,
) -> Result<Response<Body>> {
serve(&website, &path.0)
serve(&website, &path.0, &headers)
}
pub async fn index_handler(State(website): State<Website>) -> Result<Response<Body>> {
serve(&website, "")
pub async fn index_handler(
State(website): State<Website>,
headers: HeaderMap,
) -> Result<Response<Body>> {
serve(&website, "", &headers)
}
fn serve(website: &Website, path: &str) -> Result<Response<Body>> {
fn serve(website: &Website, path: &str, request_headers: &HeaderMap) -> Result<Response<Body>> {
let path = sanitize(path);
let content = website.get_file(&path)?;
let is_html =
path.is_empty() || Path::new(&path).extension().is_none() || path.ends_with(".html");
// Etag 304 check (release mode, HTML only)
if is_html
&& let Some(etag) = website.index_etag()
&& request_headers.has_etag(etag)
{
let mut response = Response::builder()
.status(StatusCode::NOT_MODIFIED)
.body(Body::empty())
.unwrap();
let headers = response.headers_mut();
headers.insert_etag(etag);
headers.insert_cache_control_must_revalidate();
return Ok(response);
}
let content = website.get_file(&path)?;
let mut response = Response::new(Body::from(content));
let headers = response.headers_mut();
// Empty path or no extension = index.html (SPA fallback)
let is_html = path.is_empty()
|| Path::new(&path).extension().is_none()
|| path.ends_with(".html");
if is_html {
headers.insert_content_type_text_html();
if let Some(etag) = website.index_etag() {
headers.insert_etag(etag);
}
} else {
headers.insert_content_type(Path::new(&path));
}
+11
View File
@@ -3,6 +3,8 @@ use std::path::Path;
use axum::http::{HeaderMap, header};
pub trait HeaderMapExtended {
fn has_etag(&self, etag: &str) -> bool;
fn insert_etag(&mut self, etag: &str);
fn insert_cache_control_must_revalidate(&mut self);
fn insert_cache_control_immutable(&mut self);
fn insert_content_type(&mut self, path: &Path);
@@ -10,6 +12,15 @@ pub trait HeaderMapExtended {
}
impl HeaderMapExtended for HeaderMap {
fn has_etag(&self, etag: &str) -> bool {
self.get(header::IF_NONE_MATCH)
.is_some_and(|v| v == etag)
}
fn insert_etag(&mut self, etag: &str) {
self.insert(header::ETAG, etag.parse().unwrap());
}
fn insert_cache_control_must_revalidate(&mut self) {
self.insert(
header::CACHE_CONTROL,
+47 -22
View File
@@ -1,5 +1,6 @@
use std::{
fs,
hash::{DefaultHasher, Hash, Hasher},
path::{Path, PathBuf},
str::FromStr,
sync::OnceLock,
@@ -15,8 +16,13 @@ use crate::{Error, Result};
/// Embedded website assets
pub static EMBEDDED_WEBSITE: Dir = include_dir!("$CARGO_MANIFEST_DIR/website");
struct CachedIndex {
html: Vec<u8>,
etag: String,
}
/// Cached index.html with importmap injected
static INDEX_HTML: OnceLock<String> = OnceLock::new();
static INDEX_HTML: OnceLock<CachedIndex> = OnceLock::new();
/// Website configuration:
/// - `true` or omitted: serve embedded website
@@ -35,6 +41,14 @@ impl Website {
!matches!(self, Self::Disabled)
}
/// Returns the cached index.html etag (None in debug mode or before first request)
pub fn index_etag(&self) -> Option<&str> {
if cfg!(debug_assertions) {
return None;
}
INDEX_HTML.get().map(|cached| cached.etag.as_str())
}
/// Returns the filesystem path if available, None means use embedded
pub fn filesystem_path(&self) -> Option<PathBuf> {
match self {
@@ -96,35 +110,46 @@ impl Website {
}
// Release mode: cache with importmap
let html = INDEX_HTML.get_or_init(|| match self.filesystem_path() {
None => {
let file = EMBEDDED_WEBSITE
.get_file("index.html")
.expect("index.html must exist in embedded website");
let cached = INDEX_HTML.get_or_init(|| {
let html = match self.filesystem_path() {
None => {
let file = EMBEDDED_WEBSITE
.get_file("index.html")
.expect("index.html must exist in embedded website");
let html =
std::str::from_utf8(file.contents()).expect("index.html must be valid UTF-8");
let html =
std::str::from_utf8(file.contents()).expect("index.html must be valid UTF-8");
let importmap = ImportMap::scan_embedded(&EMBEDDED_WEBSITE, "");
importmap
.transform_html(html)
.unwrap_or_else(|| html.to_string())
}
Some(base) => {
let html =
fs::read_to_string(base.join("index.html")).expect("index.html must exist");
let importmap = ImportMap::scan_embedded(&EMBEDDED_WEBSITE, "");
importmap
.transform_html(html)
.unwrap_or_else(|| html.to_string())
}
Some(base) => {
let html =
fs::read_to_string(base.join("index.html")).expect("index.html must exist");
match ImportMap::scan(&base, "") {
Ok(importmap) => importmap.transform_html(&html).unwrap_or(html),
Err(e) => {
error!("Failed to scan for importmap: {e}");
html
match ImportMap::scan(&base, "") {
Ok(importmap) => importmap.transform_html(&html).unwrap_or(html),
Err(e) => {
error!("Failed to scan for importmap: {e}");
html
}
}
}
};
let mut hasher = DefaultHasher::new();
html.hash(&mut hasher);
let etag = format!("\"{}\"", hasher.finish());
CachedIndex {
html: html.into_bytes(),
etag,
}
});
Ok(html.as_bytes().to_vec())
Ok(cached.html.clone())
}
fn get_embedded(&self, path: &str) -> Result<Vec<u8>> {

Some files were not shown because too many files have changed in this diff Show More