Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
babaa0e788 | ||
|
|
9ecdf7836b | ||
|
|
75ffb50a4a | ||
|
|
aeb194cf86 | ||
|
|
1b0907baab | ||
|
|
f588823abe | ||
|
|
720b8fe117 | ||
|
|
e012597059 | ||
|
|
f4643de354 |
No files matched your search
@@ -84,12 +84,15 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: sudo apt-get update && sudo apt-get install -y libxkbcommon-x11-0 libxcb-xkb1 mesa-vulkan-drivers xvfb
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo build --release --workspace --all-targets
|
||||
- run: cargo test --release --workspace
|
||||
- run: cargo clippy --release --workspace --all-targets -- -D warnings
|
||||
- name: The protocol crate builds for the browser
|
||||
run: cargo build -p tr-proto --target wasm32-unknown-unknown
|
||||
- name: Run the app (smoke test)
|
||||
env:
|
||||
TR_PROVIDER: sample
|
||||
|
||||
@@ -225,6 +225,63 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"sha1 0.10.7",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
@@ -453,7 +510,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -550,6 +607,15 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.13.1"
|
||||
@@ -1120,6 +1186,18 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
@@ -1470,7 +1548,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -1719,6 +1797,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
@@ -1740,6 +1824,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -1768,7 +1853,7 @@ version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
@@ -2200,7 +2285,7 @@ dependencies = [
|
||||
"log",
|
||||
"md-5",
|
||||
"nom",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"rangemap",
|
||||
"sha2",
|
||||
"stringprep",
|
||||
@@ -2235,6 +2320,12 @@ dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
@@ -2260,6 +2351,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3059,6 +3156,18 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||
dependencies = [
|
||||
"cobs",
|
||||
"embedded-io 0.4.0",
|
||||
"embedded-io 0.6.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postscript"
|
||||
version = "0.14.1"
|
||||
@@ -3080,6 +3189,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -3193,7 +3311,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash 2.1.3",
|
||||
@@ -3241,6 +3359,16 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
@@ -3249,7 +3377,26 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3264,7 +3411,7 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3396,7 +3543,7 @@ version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
@@ -3754,6 +3901,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
@@ -3784,6 +3942,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
@@ -3821,6 +3990,16 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
@@ -4101,6 +4280,7 @@ name = "terminal"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4257,6 +4437,7 @@ dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -4283,6 +4464,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite 0.29.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.30.0"
|
||||
@@ -4295,7 +4488,7 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"tungstenite 0.30.0",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
@@ -4427,7 +4620,7 @@ name = "tr-broker"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"base64 0.23.1",
|
||||
"chrono",
|
||||
"futures",
|
||||
"hmac",
|
||||
@@ -4503,8 +4696,9 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-tungstenite 0.30.0",
|
||||
"tr-core",
|
||||
"tr-proto",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
@@ -4512,6 +4706,27 @@ dependencies = [
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tr-gateway"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"futures",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.30.0",
|
||||
"tr-core",
|
||||
"tr-data",
|
||||
"tr-proto",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tr-macos"
|
||||
version = "0.1.1"
|
||||
@@ -4521,6 +4736,16 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tr-proto"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"postcard",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tr-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
@@ -4595,6 +4820,22 @@ version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"sha1 0.10.7",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.30.0"
|
||||
@@ -4606,10 +4847,10 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"sha1 0.11.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/core", "crates/broker", "crates/congress", "crates/data", "crates/chart", "crates/macos", "app"]
|
||||
members = ["crates/core", "crates/broker", "crates/congress", "crates/data", "crates/chart", "crates/macos", "crates/proto", "app", "server"]
|
||||
default-members = ["app"]
|
||||
|
||||
[workspace.package]
|
||||
@@ -15,6 +15,7 @@ tr-congress = { path = "crates/congress" }
|
||||
tr-data = { path = "crates/data" }
|
||||
tr-chart = { path = "crates/chart" }
|
||||
tr-macos = { path = "crates/macos" }
|
||||
tr-proto = { path = "crates/proto" }
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] }
|
||||
|
||||
@@ -60,6 +60,10 @@ web-sys = { version = "0.3", features = [
|
||||
"Response",
|
||||
"Storage",
|
||||
"Url",
|
||||
"UrlSearchParams",
|
||||
"Window",
|
||||
"console",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1"
|
||||
@@ -957,6 +957,7 @@ impl TerminalApp {
|
||||
prepended,
|
||||
from_cache,
|
||||
exhausted,
|
||||
full,
|
||||
live,
|
||||
} => {
|
||||
let worker = &self.worker;
|
||||
@@ -964,7 +965,7 @@ impl TerminalApp {
|
||||
&& c.symbol == symbol
|
||||
&& c.tf == tf
|
||||
{
|
||||
c.on_history(generation, bars, gpu, prepended, from_cache, exhausted, live, worker);
|
||||
c.on_history(generation, bars, gpu, prepended, from_cache, exhausted, full, live, worker);
|
||||
}
|
||||
}
|
||||
Response::HistoryError { pane, generation, error } => {
|
||||
|
||||
@@ -108,7 +108,7 @@ impl TerminalApp {
|
||||
p.rect_filled(chip, 9.0, theme::control());
|
||||
p.circle_filled(pos2(chip.left() + 8.0, cy), 3.0, if live { theme::up() } else { amber() });
|
||||
p.galley(pos2(chip.left() + 14.0, cy - chip_text.size().y / 2.0), chip_text, muted());
|
||||
ui.interact(chip, Id::new("source-chip"), Sense::hover()).on_hover_text(self.provider_label());
|
||||
ui.interact(chip, Id::new("source-chip"), Sense::hover()).on_hover_text(provider_label(&self.provider));
|
||||
x = chip.right() + 8.0;
|
||||
|
||||
// Which account trades: DEMO (paper) or LIVE (real money, in red). Click to switch.
|
||||
@@ -294,18 +294,6 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_label(&self) -> String {
|
||||
let n = self.provider.to_lowercase();
|
||||
if n == "sample" {
|
||||
"Offline · sample data".into()
|
||||
} else if n.contains("sample") {
|
||||
// The browser build (tr_data::web::WebMarkets).
|
||||
"Live crypto from Binance and Coinbase; every other market is sample data in the browser".into()
|
||||
} else {
|
||||
format!("Live · {}", self.provider)
|
||||
}
|
||||
}
|
||||
|
||||
/// The Bloomberg command line, drawn as a rounded macOS search field.
|
||||
fn command_field(&mut self, ui: &mut Ui, field: Rect) -> bool {
|
||||
let suggestions = self.suggestions();
|
||||
@@ -988,3 +976,41 @@ fn nav_row(ui: &mut Ui, icon: Icon, label: &str, trailing: Option<&str>, selecte
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// What the data-source chip says on hover, from the provider's name.
|
||||
fn provider_label(provider: &str) -> String {
|
||||
let n = provider.to_lowercase();
|
||||
if n == "sample" {
|
||||
"Offline · sample data".into()
|
||||
} else if let Some(rest) = provider.strip_prefix("server+") {
|
||||
// A Terminal server (tr_data::remote::RemoteMarkets), in front of the browser's sources
|
||||
// (tr_data::web::WebMarkets) or the desktop's.
|
||||
if rest.contains("sample") {
|
||||
"Live crypto from Hyperliquid perps through the Terminal server, or from Binance and Coinbase while \
|
||||
it is unreachable; every other market is sample data in the browser"
|
||||
.into()
|
||||
} else {
|
||||
format!("Live · crypto from Hyperliquid perps through the Terminal server; other markets from {rest}")
|
||||
}
|
||||
} else if n.contains("sample") {
|
||||
// The browser build (tr_data::web::WebMarkets).
|
||||
"Live crypto from Binance and Coinbase; every other market is sample data in the browser".into()
|
||||
} else {
|
||||
format!("Live · {provider}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::provider_label;
|
||||
|
||||
#[test]
|
||||
fn the_source_tooltip_names_the_terminal_server() {
|
||||
assert_eq!(provider_label("sample"), "Offline · sample data");
|
||||
assert!(provider_label("binance+sample").starts_with("Live crypto from Binance"));
|
||||
let web = provider_label("server+binance+sample");
|
||||
assert!(web.contains("Terminal server") && web.contains("sample data in the browser"), "{web}");
|
||||
let desktop = provider_label("server+markets");
|
||||
assert!(desktop.contains("Terminal server") && desktop.ends_with("other markets from markets"), "{desktop}");
|
||||
}
|
||||
}
|
||||
@@ -397,6 +397,7 @@ impl ChartState {
|
||||
prepended: usize,
|
||||
from_cache: bool,
|
||||
exhausted: bool,
|
||||
full: bool,
|
||||
live: bool,
|
||||
w: &Worker,
|
||||
) {
|
||||
@@ -421,7 +422,7 @@ impl ChartState {
|
||||
self.bars = bars;
|
||||
self.gpu = Some(gpu);
|
||||
self.paging = false;
|
||||
self.exhausted = exhausted || self.exhausted && prepended == 0;
|
||||
self.exhausted = end_of_history(self.exhausted, exhausted, prepended, full);
|
||||
if !from_cache {
|
||||
self.loading = false;
|
||||
}
|
||||
@@ -439,7 +440,7 @@ impl ChartState {
|
||||
}
|
||||
self.apply_range();
|
||||
// Live ticks only move the last bar: refresh indicators at most every 1.5 s and
|
||||
// leave the compare series alone.
|
||||
// leave the compare series alone. (A live feed filling a gap arrives as a refresh.)
|
||||
let due = self.last_indicator_req.is_none_or(|t| t.elapsed().as_millis() >= 1500);
|
||||
if !live || due {
|
||||
self.request_indicators(w);
|
||||
@@ -1435,10 +1436,26 @@ fn hover_card(ui: &Ui, at: Pos2, t: &Trade) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the chart has reached the start of its history after a response. A full load decides
|
||||
/// it, since it may come from another source than the bars before it. A page decides it too. Live
|
||||
/// updates and refreshes keep what the chart knew.
|
||||
fn end_of_history(was: bool, exhausted: bool, prepended: usize, full: bool) -> bool {
|
||||
if full { exhausted } else { exhausted || was && prepended == 0 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_full_load_from_a_new_source_can_page_again() {
|
||||
assert!(!end_of_history(true, false, 0, true), "a reload from a source with more history");
|
||||
assert!(end_of_history(false, true, 0, true));
|
||||
assert!(end_of_history(true, false, 0, false), "a live update keeps the end");
|
||||
assert!(!end_of_history(true, false, 5, false), "a page that added bars");
|
||||
assert!(end_of_history(false, true, 0, false), "an empty page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glide_pans_linearly_and_zooms_evenly() {
|
||||
let from = Viewport { right: 100.0, bar_px: 2.0, width_px: 800.0 };
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
//!
|
||||
//! Query flags: `?sample` serves offline sample data only (like `TR_PROVIDER=sample`), and
|
||||
//! `?fps` shows the frame-time readout and repaints continuously (like `TR_FPS`).
|
||||
//!
|
||||
//! Crypto markets come from the Terminal server that `TR_SERVER_URL` named when the page was built
|
||||
//! (scripts/build-web.sh). `?server=off` turns it off, and `?server=ws://localhost:8787/ws` uses a
|
||||
//! server on this machine instead (see `tr_data::web::server_url`).
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
@@ -19,6 +23,13 @@ pub fn start() {
|
||||
let query = web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default();
|
||||
let offline = has_flag(&query, "sample");
|
||||
let perf = has_flag(&query, "fps");
|
||||
let requested = web_sys::UrlSearchParams::new_with_str(&query).ok().and_then(|p| p.get("server"));
|
||||
let server = tr_data::web::server_url(option_env!("TR_SERVER_URL"), requested.as_deref());
|
||||
if let Some(url) = server.as_deref().filter(|_| !offline) {
|
||||
tracing::info!(
|
||||
"crypto markets come from the Terminal server at {url}, or Binance and Coinbase while it's down"
|
||||
);
|
||||
}
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(canvas) = canvas() else { return fail("the page has no #terminal canvas") };
|
||||
let started = eframe::WebRunner::new()
|
||||
@@ -29,7 +40,7 @@ pub fn start() {
|
||||
// The first frame needs fonts; set them before it (set_fonts applies from the next pass).
|
||||
cc.egui_ctx.set_fonts(crate::theme::base_fonts());
|
||||
wasm_bindgen_futures::spawn_local(load_emoji_fonts(cc.egui_ctx.clone()));
|
||||
let mut app = crate::create_app(cc, tr_data::for_browser(offline), 1, t0)?;
|
||||
let mut app = crate::create_app(cc, tr_data::for_browser(offline, server.as_deref()), 1, t0)?;
|
||||
if perf {
|
||||
app.show_perf();
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ pub type PaneId = u64;
|
||||
/// Minutes between Congress disclosure checks (`TR_CONGRESS_REFRESH_MIN`; 0 disables).
|
||||
pub const CONGRESS_REFRESH_MIN: u64 = 30;
|
||||
|
||||
/// The least time between a chart's reloads after its provider ended the live feed.
|
||||
const RELOAD_AFTER_FEED_ENDS: Duration = Duration::from_secs(5);
|
||||
|
||||
pub enum Request {
|
||||
/// Load the brokerage portfolio through SnapTrade (`None` = the simulated sample).
|
||||
Portfolio(Option<tr_broker::Credentials>),
|
||||
@@ -238,6 +241,9 @@ pub enum Response {
|
||||
prepended: usize,
|
||||
from_cache: bool,
|
||||
exhausted: bool,
|
||||
/// A full load from the provider. Its `exhausted` replaces the chart's, which may have
|
||||
/// come from the source the chart showed before.
|
||||
full: bool,
|
||||
/// A live-feed update of the latest bar.
|
||||
live: bool,
|
||||
},
|
||||
@@ -305,6 +311,9 @@ struct Shared {
|
||||
/// Authoritative bars per chart pane (history, paging and live ticks all merge here),
|
||||
/// tagged with the pane's load generation.
|
||||
series: std::sync::Mutex<SeriesMap>,
|
||||
/// Counts each chart pane's reloads for a changed data source, up once as one starts and again
|
||||
/// when it's done (odd while it runs), so a page of older bars that spans one is dropped.
|
||||
sources: std::sync::Mutex<HashMap<PaneId, u64>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cache: std::sync::Mutex<Option<BarCache>>,
|
||||
db: Mutex<Db>,
|
||||
@@ -367,6 +376,7 @@ impl Worker {
|
||||
let shared = Arc::new(Shared {
|
||||
provider,
|
||||
series: Default::default(),
|
||||
sources: Default::default(),
|
||||
cache: std::sync::Mutex::new(cache),
|
||||
db: Mutex::new(db),
|
||||
out,
|
||||
@@ -407,8 +417,14 @@ impl Worker {
|
||||
pub fn spawn(ctx: egui::Context, provider: Provider) -> anyhow::Result<Self> {
|
||||
let (tx, mut req_rx) = tokio::sync::mpsc::unbounded_channel::<Request>();
|
||||
let (out, rx) = mpsc::channel();
|
||||
let shared =
|
||||
Arc::new(Shared { provider, series: Default::default(), db: Mutex::new(Db::open_in_memory()?), out, ctx });
|
||||
let shared = Arc::new(Shared {
|
||||
provider,
|
||||
series: Default::default(),
|
||||
sources: Default::default(),
|
||||
db: Mutex::new(Db::open_in_memory()?),
|
||||
out,
|
||||
ctx,
|
||||
});
|
||||
rt::spawn(async move {
|
||||
while let Some(req) = req_rx.recv().await {
|
||||
let s = shared.clone();
|
||||
@@ -571,6 +587,7 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
prepended: 0,
|
||||
from_cache: true,
|
||||
exhausted: false,
|
||||
full: false,
|
||||
live: false,
|
||||
});
|
||||
}
|
||||
@@ -593,9 +610,10 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
prepended: 0,
|
||||
from_cache: false,
|
||||
exhausted,
|
||||
full: true,
|
||||
live: false,
|
||||
});
|
||||
start_live(s.clone(), pane, generation, symbol, tf);
|
||||
start_live(s.clone(), pane, generation, symbol, tf, limit);
|
||||
}
|
||||
Ok(_) => s.send(Response::HistoryError { pane, generation, error: format!("no data for {symbol}") }),
|
||||
Err(e) => s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") }),
|
||||
@@ -605,6 +623,12 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
// Page from the worker's copy (it may hold newer live bars than the UI's).
|
||||
let base = current_series(s, pane, generation).filter(|b| !b.is_empty()).unwrap_or(existing);
|
||||
let Some(first) = base.first().copied() else { return };
|
||||
// A page that overlaps a reload for another source is dropped. The reload answers the
|
||||
// pane, and the chart pages again from the new source.
|
||||
let source = source_switches(s, pane);
|
||||
if source % 2 == 1 {
|
||||
return;
|
||||
}
|
||||
match s.provider.history(&symbol, tf, Some(first.ts), limit).await {
|
||||
Ok(older) => {
|
||||
let older: Vec<Bar> = older.into_iter().filter(|b| b.ts < first.ts).collect();
|
||||
@@ -613,6 +637,9 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
let mut map = s.series.lock().expect("series lock");
|
||||
let Some((g, cur)) = map.get_mut(&pane).filter(|(g, _)| *g == generation) else { return };
|
||||
let _ = g;
|
||||
if source_switches(s, pane) != source {
|
||||
return;
|
||||
}
|
||||
let mut merged = older;
|
||||
merged.extend_from_slice(cur);
|
||||
*cur = Arc::new(merged);
|
||||
@@ -629,10 +656,14 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
prepended,
|
||||
from_cache: false,
|
||||
exhausted: prepended == 0,
|
||||
full: false,
|
||||
live: false,
|
||||
});
|
||||
}
|
||||
Err(e) => s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") }),
|
||||
Err(e) if source_switches(s, pane) == source => {
|
||||
s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") })
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
Request::Indicators { pane, generation, bars, tf, specs, palette } => {
|
||||
@@ -828,24 +859,53 @@ fn store_series(s: &Shared, pane: PaneId, generation: u64, bars: Arc<Vec<Bar>>)
|
||||
}
|
||||
}
|
||||
|
||||
fn source_switches(s: &Shared, pane: PaneId) -> u64 {
|
||||
s.sources.lock().expect("sources lock").get(&pane).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn current_series(s: &Shared, pane: PaneId, generation: u64) -> Option<Arc<Vec<Bar>>> {
|
||||
s.series.lock().expect("series lock").get(&pane).filter(|(g, _)| *g == generation).map(|(_, b)| b.clone())
|
||||
}
|
||||
|
||||
/// Merge the provider's live feed (if any) into the pane's series, publishing at most
|
||||
/// four updates a second. Stops when the pane reloads, closes or the feed ends.
|
||||
fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe) {
|
||||
/// What one live bar did to a series.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
struct Applied {
|
||||
changed: bool,
|
||||
/// It went in before the latest bar (a feed filling a gap after an outage), so every
|
||||
/// series aligned to the bars by index (compare lines) has to be rebuilt.
|
||||
inserted: bool,
|
||||
}
|
||||
|
||||
fn apply_live_bar(bars: &mut Vec<Bar>, bar: Bar) -> Applied {
|
||||
let (len, last) = (bars.len(), bars.last().map(|b| b.ts));
|
||||
let changed = Series::apply_live(bars, bar);
|
||||
Applied { changed, inserted: bars.len() > len && bars.last().map(|b| b.ts) == last }
|
||||
}
|
||||
|
||||
fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe, limit: usize) {
|
||||
let Some(mut feed) = s.provider.live(&symbol, tf) else { return };
|
||||
rt::spawn(async move {
|
||||
let started = web_time::Instant::now();
|
||||
let mut tick = rt::interval(Duration::from_millis(250));
|
||||
let mut dirty = false;
|
||||
let mut inserted = false;
|
||||
let mut ended = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
bar = feed.recv() => {
|
||||
let Some(bar) = bar else { break };
|
||||
let Some(bar) = bar else {
|
||||
ended = true;
|
||||
break;
|
||||
};
|
||||
let mut map = s.series.lock().expect("series lock");
|
||||
match map.get_mut(&pane) {
|
||||
Some((g, bars)) if *g == generation => dirty |= Series::apply_live(Arc::make_mut(bars), bar),
|
||||
Some((g, bars)) if *g == generation => {
|
||||
let a = apply_live_bar(Arc::make_mut(bars), bar);
|
||||
dirty |= a.changed;
|
||||
inserted |= a.inserted;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
@@ -869,12 +929,26 @@ fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf:
|
||||
prepended: 0,
|
||||
from_cache: false,
|
||||
exhausted: false,
|
||||
live: true,
|
||||
full: false,
|
||||
// A bar slotted into a gap shifts everything after it: send it as a
|
||||
// refresh so the chart rebuilds its compare line and indicators.
|
||||
live: !std::mem::take(&mut inserted),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::debug!("live feed for pane {pane} ({symbol}) stopped");
|
||||
// A provider ends a feed the pane still shows when the chart's source changes (the Terminal
|
||||
// server went away or came back): reload the chart from wherever its data comes from now.
|
||||
if ended {
|
||||
rt::sleep(RELOAD_AFTER_FEED_ENDS.saturating_sub(started.elapsed())).await;
|
||||
if current_series(&s, pane, generation).is_some() {
|
||||
let switch = || *s.sources.lock().expect("sources lock").entry(pane).or_default() += 1;
|
||||
switch();
|
||||
handle(&s, Request::History { pane, generation, symbol, tf, limit }).await;
|
||||
switch();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -950,6 +1024,20 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bars_filling_a_gap_are_reported_as_inserted() {
|
||||
let b = |ts: i64| Bar::new(ts, 1.0, 1.0, 1.0, 1.0, 1.0);
|
||||
let mut bars = vec![b(0), b(60), b(240)];
|
||||
assert_eq!(apply_live_bar(&mut bars, b(300)), Applied { changed: true, inserted: false });
|
||||
assert_eq!(apply_live_bar(&mut bars, b(300)), Applied { changed: false, inserted: false });
|
||||
assert_eq!(apply_live_bar(&mut bars, b(120)), Applied { changed: true, inserted: true });
|
||||
assert_eq!(
|
||||
apply_live_bar(&mut bars, Bar::new(60, 1.0, 2.0, 1.0, 2.0, 1.0)),
|
||||
Applied { changed: true, inserted: false }
|
||||
);
|
||||
assert_eq!(bars.iter().map(|x| x.ts).collect::<Vec<_>>(), [0, 60, 120, 240, 300]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_alignment_carries_last_close() {
|
||||
let base: Vec<Bar> = (0..5).map(|i| Bar::new(i * 10, 100.0, 100.0, 100.0, 100.0 + i as f64, 1.0)).collect();
|
||||
@@ -986,6 +1074,7 @@ mod tests {
|
||||
let s = Shared {
|
||||
provider: std::sync::Arc::new(tr_data::sample::SampleProvider::at(0, 10)),
|
||||
series: Default::default(),
|
||||
sources: Default::default(),
|
||||
cache: std::sync::Mutex::new(None),
|
||||
db: Mutex::new(Db::open_in_memory().unwrap()),
|
||||
out,
|
||||
@@ -1000,6 +1089,120 @@ mod tests {
|
||||
assert!(current_series(&s, 1, 1).is_none());
|
||||
}
|
||||
|
||||
/// Bars whose close counts the loads. Reloads wait for `reload`, and older pages for `page`.
|
||||
/// The first chart's live feed ends at once, the way `RemoteMarkets` ends one when the chart's
|
||||
/// source changes.
|
||||
#[derive(Default)]
|
||||
struct SourceChanges {
|
||||
loads: std::sync::atomic::AtomicUsize,
|
||||
open: std::sync::Mutex<Vec<tokio::sync::mpsc::Sender<Bar>>>,
|
||||
reload: tokio::sync::Notify,
|
||||
page: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl tr_data::MarketData for SourceChanges {
|
||||
fn name(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
|
||||
async fn history(&self, _: &str, _: Timeframe, end: Option<i64>, _: usize) -> anyhow::Result<Vec<Bar>> {
|
||||
if end.is_some() {
|
||||
self.page.notified().await;
|
||||
return Ok(vec![Bar::new(-60, 1.0, 1.0, 1.0, 1.0, 1.0)]);
|
||||
}
|
||||
let n = self.loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
|
||||
if n > 1 {
|
||||
self.reload.notified().await;
|
||||
}
|
||||
Ok(vec![Bar::new(0, 1.0, 1.0, 1.0, n as f64, 1.0)])
|
||||
}
|
||||
|
||||
fn live(&self, _: &str, _: Timeframe) -> Option<tr_data::LiveFeed> {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(1);
|
||||
if self.loads.load(std::sync::atomic::Ordering::SeqCst) > 1 {
|
||||
self.open.lock().unwrap().push(tx);
|
||||
}
|
||||
Some(rx)
|
||||
}
|
||||
}
|
||||
|
||||
/// A worker on `SourceChanges` with pane 1 loading `BTCUSD Curncy`, and its responses.
|
||||
async fn switching_chart() -> (Arc<Shared>, mpsc::Receiver<Response>, Arc<SourceChanges>) {
|
||||
let (out, rx) = mpsc::channel();
|
||||
let provider = Arc::new(SourceChanges::default());
|
||||
let s = Arc::new(Shared {
|
||||
provider: provider.clone(),
|
||||
series: Default::default(),
|
||||
sources: Default::default(),
|
||||
cache: std::sync::Mutex::new(None),
|
||||
db: Mutex::new(Db::open_in_memory().unwrap()),
|
||||
out,
|
||||
ctx: egui::Context::default(),
|
||||
});
|
||||
let symbol = "BTCUSD Curncy".to_string();
|
||||
handle(&s, Request::History { pane: 1, generation: 1, symbol, tf: Timeframe::D1, limit: 10 }).await;
|
||||
(s, rx, provider)
|
||||
}
|
||||
|
||||
/// The last close of each chart update, until there are `n`.
|
||||
async fn closes(rx: &mpsc::Receiver<Response>, n: usize) -> Vec<f64> {
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut closes = Vec::new();
|
||||
while closes.len() < n {
|
||||
assert!(t0.elapsed() < Duration::from_secs(15), "no reload: {closes:?}");
|
||||
match rx.try_recv() {
|
||||
Ok(Response::History { generation: 1, bars, .. }) => closes.push(bars.last().unwrap().close),
|
||||
Ok(_) => {}
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(50)).await,
|
||||
}
|
||||
}
|
||||
closes
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn charts_reload_when_the_provider_ends_their_live_feed() {
|
||||
let t0 = std::time::Instant::now();
|
||||
let (_s, rx, provider) = switching_chart().await;
|
||||
provider.reload.notify_one();
|
||||
assert_eq!(closes(&rx, 2).await, [1.0, 2.0], "the reload replaced the chart");
|
||||
assert!(t0.elapsed() >= RELOAD_AFTER_FEED_ENDS, "not before the pause");
|
||||
assert_eq!(provider.loads.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pages_that_overlap_a_source_switch_are_dropped() {
|
||||
let (s, rx, provider) = switching_chart().await;
|
||||
let more = || Request::MoreHistory {
|
||||
pane: 1,
|
||||
generation: 1,
|
||||
symbol: "BTCUSD Curncy".into(),
|
||||
tf: Timeframe::D1,
|
||||
existing: Arc::new(Vec::new()),
|
||||
limit: 10,
|
||||
};
|
||||
// One page starts before the switch, and its bars arrive after it.
|
||||
let before = tokio::spawn({
|
||||
let (s, req) = (s.clone(), more());
|
||||
async move { handle(&s, req).await }
|
||||
});
|
||||
let t0 = std::time::Instant::now();
|
||||
while source_switches(&s, 1) != 1 {
|
||||
assert!(t0.elapsed() < Duration::from_secs(15), "no reload");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
// Another starts while the chart reloads, and gives up at once.
|
||||
let during = tokio::time::timeout(Duration::from_secs(1), handle(&s, more())).await;
|
||||
assert!(during.is_ok(), "a page started during the reload went ahead");
|
||||
provider.reload.notify_one();
|
||||
assert_eq!(closes(&rx, 2).await, [1.0, 2.0]);
|
||||
provider.page.notify_one();
|
||||
before.await.unwrap();
|
||||
let ts: Vec<i64> = current_series(&s, 1, 1).unwrap().iter().map(|b| b.ts).collect();
|
||||
assert_eq!(ts, [0], "the old source's page joined the new source's bars");
|
||||
assert!(rx.try_iter().all(|r| !matches!(r, Response::History { prepended: 1.., .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_all_runs_everything() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread().enable_time().build().unwrap();
|
||||
|
||||
@@ -89,18 +89,24 @@ impl Series {
|
||||
i.checked_sub(1)
|
||||
}
|
||||
|
||||
/// Apply one live bar: absorb it into the last bar if it is the same period, append it
|
||||
/// if newer, ignore it if older. Returns true if the series changed.
|
||||
/// Apply one live bar by its `ts`: absorb it into the bar of the same period, append it if
|
||||
/// newer, or insert it where a period is missing (a feed backfilling a gap after an outage).
|
||||
/// Bars older than the whole series are ignored; paging loads those. Returns true if the
|
||||
/// series changed.
|
||||
pub fn apply_live(bars: &mut Vec<Bar>, live: Bar) -> bool {
|
||||
match bars.last_mut() {
|
||||
Some(last) if last.ts == live.ts => {
|
||||
let before = *last;
|
||||
last.absorb(&live);
|
||||
*last != before
|
||||
if bars.last().is_none_or(|last| last.ts < live.ts) {
|
||||
bars.push(live);
|
||||
return true;
|
||||
}
|
||||
match bars.binary_search_by_key(&live.ts, |b| b.ts) {
|
||||
Ok(i) => {
|
||||
let before = bars[i];
|
||||
bars[i].absorb(&live);
|
||||
bars[i] != before
|
||||
}
|
||||
Some(last) if last.ts > live.ts => false,
|
||||
_ => {
|
||||
bars.push(live);
|
||||
Err(0) => false,
|
||||
Err(i) => {
|
||||
bars.insert(i, live);
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -148,6 +154,14 @@ mod tests {
|
||||
assert!(!Series::apply_live(&mut v, Bar::new(0, 1.0, 1.0, 1.0, 1.0, 1.0)));
|
||||
assert!(Series::apply_live(&mut v, Bar::new(120, 11.5, 11.6, 11.4, 11.6, 5.0)));
|
||||
assert_eq!(v.len(), 2);
|
||||
// A feed backfilling after an outage: a missing period is inserted, an older bar it
|
||||
// still holds is completed, and nothing older than the series is added.
|
||||
assert!(Series::apply_live(&mut v, Bar::new(240, 12.0, 12.0, 12.0, 12.0, 1.0)));
|
||||
assert!(Series::apply_live(&mut v, Bar::new(180, 11.6, 11.9, 11.5, 11.8, 7.0)));
|
||||
assert!(Series::apply_live(&mut v, Bar::new(120, 11.5, 11.7, 11.4, 11.65, 6.0)));
|
||||
assert_eq!(v.iter().map(|b| b.ts).collect::<Vec<_>>(), [60, 120, 180, 240]);
|
||||
assert_eq!((v[1].high, v[1].close, v[1].volume), (11.7, 11.65, 6.0));
|
||||
assert!(!Series::apply_live(&mut v, Bar::new(180, 11.6, 11.9, 11.5, 11.8, 7.0)), "no change");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,6 +6,7 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tr-core.workspace = true
|
||||
tr-proto.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -12,9 +12,14 @@
|
||||
//! The router is wrapped in [`Fallback`], which serves sample data if a source is
|
||||
//! unreachable, so the terminal always has something to draw.
|
||||
//!
|
||||
//! `TR_SERVER_URL` (`ws://` or `wss://`) opts in to the Terminal data server
|
||||
//! ([`remote::RemoteMarkets`]): crypto markets then come from Hyperliquid perps through the
|
||||
//! server, and everything else, or anything the server can't serve, from the sources above.
|
||||
//!
|
||||
//! The browser build (`wasm32`) can only reach sources that allow cross-origin requests:
|
||||
//! [`for_browser`] serves live crypto through [`web::WebMarkets`] and sample data for the
|
||||
//! rest. It has no SQLite bar cache.
|
||||
//! rest. It has no SQLite bar cache. When the page was built with a Terminal server URL
|
||||
//! ([`web::server_url`]), crypto comes through the server first, as on the desktop.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod alpaca;
|
||||
@@ -23,6 +28,7 @@ pub mod cache;
|
||||
pub mod crypto;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod markets;
|
||||
pub mod remote;
|
||||
pub mod rss;
|
||||
pub mod rt;
|
||||
pub mod sample;
|
||||
@@ -196,7 +202,9 @@ pub struct OptionChain {
|
||||
}
|
||||
|
||||
/// Live bar updates for one symbol and timeframe: each message is the latest state of
|
||||
/// the current (possibly still forming) bar. The feed stops when the receiver is dropped.
|
||||
/// the current (possibly still forming) bar, or, after an outage, an older bar that fills the
|
||||
/// gap ([`tr_core::Series::apply_live`] slots it in). The feed stops when the receiver is
|
||||
/// dropped.
|
||||
pub type LiveFeed = mpsc::Receiver<Bar>;
|
||||
|
||||
/// Pick curve snapshots from per-date rows: latest, nearest at or before -30d and -365d.
|
||||
@@ -412,18 +420,33 @@ pub fn from_env() -> Provider {
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match markets::Markets::new(alpaca) {
|
||||
Ok(m) => Arc::new(Fallback::new(m)),
|
||||
Err(_) => Arc::new(sample::SampleProvider::default().with_live_ticks()),
|
||||
let markets = match markets::Markets::new(alpaca) {
|
||||
Ok(m) => Fallback::new(m),
|
||||
Err(_) => return Arc::new(sample::SampleProvider::default().with_live_ticks()),
|
||||
};
|
||||
match var("TR_SERVER_URL") {
|
||||
Some(url) => {
|
||||
// The URL itself stays out of the logs.
|
||||
tracing::info!("TR_SERVER_URL is set: crypto markets come through the Terminal server");
|
||||
Arc::new(remote::RemoteMarkets::new(url.trim(), markets))
|
||||
}
|
||||
None => Arc::new(markets),
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser build's provider: sample data only when `offline`, otherwise live crypto
|
||||
/// with sample data for everything else.
|
||||
pub fn for_browser(offline: bool) -> Provider {
|
||||
/// with sample data for everything else. With a Terminal `server` URL, crypto comes through
|
||||
/// that server, and from Binance and Coinbase whenever the server can't serve it.
|
||||
pub fn for_browser(offline: bool, server: Option<&str>) -> Provider {
|
||||
if !offline {
|
||||
match web::WebMarkets::new() {
|
||||
Ok(m) => return Arc::new(Fallback::new(m)),
|
||||
Ok(m) => {
|
||||
let m = Fallback::new(m);
|
||||
return match server {
|
||||
Some(url) => Arc::new(remote::RemoteMarkets::new(url, m)),
|
||||
None => Arc::new(m),
|
||||
};
|
||||
}
|
||||
Err(e) => tracing::warn!("crypto sources unavailable, using sample data: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,16 +37,51 @@ pub async fn sleep(d: Duration) {
|
||||
tokio::time::sleep(d).await;
|
||||
}
|
||||
|
||||
/// Resolves after `d`, through `setTimeout`.
|
||||
/// Resolves after `d`, through a `setTimeout` set on the first poll and cleared if the sleep is
|
||||
/// dropped before it fires.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn sleep(d: Duration) {
|
||||
let ms = d.as_millis().min(i32::MAX as u128) as i32;
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
pub fn sleep(d: Duration) -> impl Future<Output = ()> + Send {
|
||||
struct Timer(Option<i32>);
|
||||
impl Drop for Timer {
|
||||
fn drop(&mut self) {
|
||||
if let (Some(id), Some(w)) = (self.0, web_sys::window()) {
|
||||
w.clear_timeout_with_handle(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
|
||||
}
|
||||
let ms = d.as_millis().min(i32::MAX as u128) as i32;
|
||||
// A promise isn't `Send`. The web build has one thread, so wrapping it is sound, and the provider
|
||||
// traits' `Send` futures can then wait on it.
|
||||
send_wrapper::SendWrapper::new(async move {
|
||||
let mut timer = Timer(None);
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
timer.0 = web_sys::window()
|
||||
.and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms).ok());
|
||||
});
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
|
||||
timer.0 = None; // it fired
|
||||
})
|
||||
}
|
||||
|
||||
/// `fut` ran past its deadline.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Elapsed;
|
||||
|
||||
impl std::fmt::Display for Elapsed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("deadline has elapsed")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Elapsed {}
|
||||
|
||||
/// Run `fut` for at most `d`.
|
||||
pub async fn timeout<F: Future>(d: Duration, fut: F) -> Result<F::Output, Elapsed> {
|
||||
let (fut, deadline) = (std::pin::pin!(fut), std::pin::pin!(sleep(d)));
|
||||
match futures::future::select(fut, deadline).await {
|
||||
futures::future::Either::Left((out, _)) => Ok(out),
|
||||
futures::future::Either::Right(_) => Err(Elapsed),
|
||||
}
|
||||
}
|
||||
|
||||
/// A periodic timer: the first tick completes at once, later ones every `period`. A late
|
||||
@@ -86,4 +121,12 @@ mod tests {
|
||||
assert!(t0.elapsed() >= Duration::from_millis(40));
|
||||
assert_eq!(blocking(|| 6 * 7).await, Some(42));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_stops_at_the_deadline() {
|
||||
assert_eq!(timeout(Duration::from_secs(5), async { 7 }).await, Ok(7));
|
||||
let t0 = Instant::now();
|
||||
assert_eq!(timeout(Duration::from_millis(20), std::future::pending::<()>()).await, Err(Elapsed));
|
||||
assert!(t0.elapsed() >= Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@
|
||||
//! Treasury.gov, Alpaca and CoinDesk do not, so every other asset class comes from the
|
||||
//! sample generator and there are no headlines or network search.
|
||||
//!
|
||||
//! When the page has a Terminal server ([`server_url`]), [`crate::for_browser`] puts
|
||||
//! [`crate::remote::RemoteMarkets`] in front of these, so crypto comes from the server and
|
||||
//! from Binance and Coinbase only while the server can't serve it.
|
||||
//!
|
||||
//! TODO(web proxy): route equities, FX, indices, futures, Treasuries and news through a
|
||||
//! same-origin proxy to those sources, then use [`crate::markets::Markets`] here instead.
|
||||
|
||||
@@ -27,6 +31,38 @@ impl WebMarkets {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Terminal server a page uses for crypto markets: `built`, the URL set when the page was
|
||||
/// built, unless the page's `?server=` parameter (`requested`) turns it off (`off`) or names a
|
||||
/// server on the same machine, such as `ws://localhost:8787/ws`. Other servers are refused, so a
|
||||
/// link can't point someone's page at a server that isn't ours.
|
||||
pub fn server_url(built: Option<&str>, requested: Option<&str>) -> Option<String> {
|
||||
let built = built.map(str::trim).filter(|u| !u.is_empty()).map(str::to_string);
|
||||
let Some(requested) = requested.map(str::trim) else { return built };
|
||||
if requested.eq_ignore_ascii_case("off") {
|
||||
return None;
|
||||
}
|
||||
if is_local(requested) {
|
||||
return Some(requested.to_string());
|
||||
}
|
||||
tracing::warn!("?server= takes a server on this machine (ws://localhost:8787/ws) or off; ignoring it");
|
||||
built
|
||||
}
|
||||
|
||||
/// A `ws://` or `wss://` URL whose host is this machine. Anything unusual (user info, other
|
||||
/// spellings of the address) counts as not local.
|
||||
fn is_local(url: &str) -> bool {
|
||||
let Some(rest) = url.strip_prefix("ws://").or_else(|| url.strip_prefix("wss://")) else { return false };
|
||||
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
|
||||
if authority.contains('@') {
|
||||
return false;
|
||||
}
|
||||
let host = match authority.strip_prefix('[') {
|
||||
Some(v6) => v6.split(']').next().unwrap_or_default(),
|
||||
None => authority.split(':').next().unwrap_or_default(),
|
||||
};
|
||||
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
fn is_crypto(symbol: &str) -> bool {
|
||||
security(symbol).class == AssetClass::Crypto
|
||||
}
|
||||
@@ -93,4 +129,35 @@ mod tests {
|
||||
assert_eq!(m.yield_curves().await.unwrap().current.points.len(), 11);
|
||||
assert!(is_crypto("BTCUSD Curncy") && !is_crypto("US10Y Govt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_url_from_the_build_or_a_local_override() {
|
||||
let prod = Some("wss://terminal-gateway.style.dev/ws");
|
||||
assert_eq!(server_url(prod, None).as_deref(), prod);
|
||||
assert_eq!(server_url(Some(" "), None), None, "an empty build setting means no server");
|
||||
assert_eq!(server_url(None, None), None);
|
||||
assert_eq!(server_url(prod, Some("off")), None);
|
||||
for local in ["ws://localhost:8787/ws", "ws://127.0.0.1:9", "wss://[::1]:8787/ws", "ws://LOCALHOST/ws"] {
|
||||
assert_eq!(server_url(prod, Some(local)).as_deref(), Some(local));
|
||||
assert_eq!(server_url(None, Some(local)).as_deref(), Some(local));
|
||||
}
|
||||
for refused in [
|
||||
"wss://evil.example/ws",
|
||||
"ws://localhost.evil.example/ws",
|
||||
"ws://[email protected]/ws",
|
||||
"ws://evil.example:80@localhost/ws",
|
||||
"http://localhost:8787/ws",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(server_url(prod, Some(refused)).as_deref(), prod, "{refused}");
|
||||
assert_eq!(server_url(None, Some(refused)), None, "{refused}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_server_goes_in_front_of_the_browser_sources() {
|
||||
assert_eq!(crate::for_browser(false, None).name(), "binance+sample");
|
||||
assert_eq!(crate::for_browser(false, Some("ws://127.0.0.1:9/ws")).name(), "server+binance+sample");
|
||||
assert_eq!(crate::for_browser(true, Some("ws://127.0.0.1:9/ws")).name(), "sample");
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,45 @@
|
||||
//! A text-message websocket client: tokio-tungstenite on the desktop, the browser's
|
||||
//! `WebSocket` on the web.
|
||||
//! A websocket client: tokio-tungstenite on the desktop, the browser's `WebSocket` on the web.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use native::Socket;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::Socket;
|
||||
|
||||
/// A data message. Control frames stay inside the socket.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Message {
|
||||
Text(String),
|
||||
Binary(Vec<u8>),
|
||||
}
|
||||
|
||||
impl Socket {
|
||||
pub async fn send(&mut self, text: String) -> Result<()> {
|
||||
self.send_message(Message::Text(text)).await
|
||||
}
|
||||
|
||||
/// The next text message, skipping binary ones; `None` once the server closes the connection.
|
||||
pub async fn next(&mut self) -> Option<Result<String>> {
|
||||
loop {
|
||||
match self.next_message().await? {
|
||||
Ok(Message::Text(t)) => return Some(Ok(t)),
|
||||
Ok(Message::Binary(_)) => {}
|
||||
Err(e) => return Some(Err(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native {
|
||||
use anyhow::{Context, Result};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||
|
||||
use super::Message;
|
||||
|
||||
pub struct Socket(WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>);
|
||||
|
||||
impl Socket {
|
||||
@@ -21,22 +48,32 @@ mod native {
|
||||
Ok(Self(ws))
|
||||
}
|
||||
|
||||
pub async fn send(&mut self, text: String) -> Result<()> {
|
||||
Ok(self.0.send(Message::Text(text.into())).await?)
|
||||
pub async fn send_message(&mut self, m: Message) -> Result<()> {
|
||||
let m = match m {
|
||||
Message::Text(t) => WsMessage::Text(t.into()),
|
||||
Message::Binary(b) => WsMessage::Binary(b.into()),
|
||||
};
|
||||
Ok(self.0.send(m).await?)
|
||||
}
|
||||
|
||||
/// The next text message; `None` once the server closes the connection.
|
||||
pub async fn next(&mut self) -> Option<Result<String>> {
|
||||
/// The next data message; `None` once the server closes the connection.
|
||||
pub async fn next_message(&mut self) -> Option<Result<Message>> {
|
||||
while let Some(msg) = self.0.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(t)) => return Some(Ok(t.as_str().to_owned())),
|
||||
Ok(Message::Close(_)) => return None,
|
||||
Ok(WsMessage::Text(t)) => return Some(Ok(Message::Text(t.as_str().to_owned()))),
|
||||
Ok(WsMessage::Binary(b)) => return Some(Ok(Message::Binary(b.into()))),
|
||||
Ok(WsMessage::Close(_)) => return None,
|
||||
Ok(_) => {}
|
||||
Err(e) => return Some(Err(e.into())),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Send a close frame.
|
||||
pub async fn close(&mut self) {
|
||||
let _ = self.0.close(None).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +83,13 @@ mod web {
|
||||
use tokio::sync::mpsc;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::{CloseEvent, MessageEvent, WebSocket};
|
||||
use web_sys::{BinaryType, CloseEvent, MessageEvent, WebSocket};
|
||||
|
||||
use super::Message;
|
||||
|
||||
enum Event {
|
||||
Open,
|
||||
Text(String),
|
||||
Message(Message),
|
||||
Error,
|
||||
Closed,
|
||||
}
|
||||
@@ -68,6 +107,8 @@ mod web {
|
||||
impl Socket {
|
||||
pub async fn connect(url: &str) -> Result<Self> {
|
||||
let ws = WebSocket::new(url).map_err(|e| anyhow!("websocket {url}: {e:?}"))?;
|
||||
// Binary frames arrive as ArrayBuffers rather than Blobs, which can only be read asynchronously.
|
||||
ws.set_binary_type(BinaryType::Arraybuffer);
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let t = tx.clone();
|
||||
let on_open = Closure::<dyn FnMut()>::new(move || {
|
||||
@@ -75,9 +116,15 @@ mod web {
|
||||
});
|
||||
let t = tx.clone();
|
||||
let on_message = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
|
||||
if let Some(text) = e.data().as_string() {
|
||||
let _ = t.send(Event::Text(text));
|
||||
}
|
||||
let data = e.data();
|
||||
let msg = if let Some(text) = data.as_string() {
|
||||
Message::Text(text)
|
||||
} else if let Some(buf) = data.dyn_ref::<js_sys::ArrayBuffer>() {
|
||||
Message::Binary(js_sys::Uint8Array::new(buf).to_vec())
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let _ = t.send(Event::Message(msg));
|
||||
});
|
||||
let t = tx.clone();
|
||||
let on_error = Closure::<dyn FnMut()>::new(move || {
|
||||
@@ -98,18 +145,27 @@ mod web {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send(&mut self, text: String) -> Result<()> {
|
||||
self.ws.send_with_str(&text).map_err(|e| anyhow!("websocket send: {e:?}"))
|
||||
pub async fn send_message(&mut self, m: Message) -> Result<()> {
|
||||
match m {
|
||||
Message::Text(t) => self.ws.send_with_str(&t),
|
||||
Message::Binary(b) => self.ws.send_with_u8_array(&b),
|
||||
}
|
||||
.map_err(|e| anyhow!("websocket send: {e:?}"))
|
||||
}
|
||||
|
||||
/// The next text message; `None` once the server closes the connection.
|
||||
pub async fn next(&mut self) -> Option<Result<String>> {
|
||||
/// The next data message; `None` once the server closes the connection.
|
||||
pub async fn next_message(&mut self) -> Option<Result<Message>> {
|
||||
match self.rx.recv().await? {
|
||||
Event::Text(t) => Some(Ok(t)),
|
||||
Event::Message(m) => Some(Ok(m)),
|
||||
Event::Error => Some(Err(anyhow!("websocket error"))),
|
||||
Event::Open | Event::Closed => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a close frame.
|
||||
pub async fn close(&mut self) {
|
||||
let _ = self.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Socket {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "tr-proto"
|
||||
description = "Client protocol between Terminal clients and the Terminal data server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
# Shared by the server, the desktop app and the browser build, so it must build for
|
||||
# wasm32-unknown-unknown: serde, tr-core and the two codecs only, nothing async or networked.
|
||||
[dependencies]
|
||||
tr-core.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
|
||||
@@ -0,0 +1,629 @@
|
||||
//! The protocol between Terminal clients (desktop, web, CLI) and the Terminal data server
|
||||
//! (`tr-gateway`), as designed in `docs/architecture/server.md` §4.
|
||||
//!
|
||||
//! Each message is one websocket frame. postcard over binary frames is the default encoding;
|
||||
//! JSON over text frames is a debug encoding a client asks for in [`ClientMsg::Hello`]. The
|
||||
//! server reads binary frames as postcard and text frames as JSON, and answers in the encoding
|
||||
//! the client's `Hello` named.
|
||||
//!
|
||||
//! # Evolving the protocol
|
||||
//!
|
||||
//! postcard is not self-describing: an enum variant travels as its index and a struct as its
|
||||
//! fields in order. So:
|
||||
//! - enum variants are only ever appended, never reordered or removed;
|
||||
//! - a struct never gains or loses a field (a new field means a new message variant);
|
||||
//! - any other change bumps [`PROTOCOL_VERSION`], and the server keeps serving N and N-1.
|
||||
//!
|
||||
//! The `wire_tags_are_stable` test pins every variant's index. The design's later variants
|
||||
//! (`DocOp`, `DocAck`/`DocConflict`, and the `Ctx`, Congress, Portfolio, Docs and Alerts
|
||||
//! topics) are not here yet; each is appended after the variants below when a server serves it.
|
||||
//! [`Bar`] and [`Timeframe`] come from `tr-core`, so their field and variant order is part of
|
||||
//! the wire format too.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use tr_core::{Bar, Timeframe};
|
||||
|
||||
/// Bumped on any change the evolution rules above don't allow.
|
||||
pub const PROTOCOL_VERSION: u16 = 1;
|
||||
|
||||
/// Client-chosen subscription id, unique per connection.
|
||||
pub type SubId = u32;
|
||||
/// Client-chosen request id, unique per connection.
|
||||
pub type ReqId = u32;
|
||||
|
||||
/// A market as a client names it: a venue-native key (`BTC`, `xyz:TSLA`, `@107`, `PURR/USDC`)
|
||||
/// or a market key the server maps to one (`BTCUSD Curncy`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct MarketKey(pub String);
|
||||
|
||||
impl MarketKey {
|
||||
pub fn new(key: impl Into<String>) -> Self {
|
||||
Self(key.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MarketKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Encoding {
|
||||
Postcard,
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ClientInfo {
|
||||
/// For example `terminal-desktop` or `tr-cli`.
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Reserved for session resume (§4.3). Servers ignore it for now.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ResumeToken(pub String);
|
||||
|
||||
/// Client to server.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ClientMsg {
|
||||
/// The first message on every connection. `token` is reserved for sign-in (§8) and is
|
||||
/// ignored for now; `resume` likewise.
|
||||
Hello {
|
||||
version: u16,
|
||||
encoding: Encoding,
|
||||
client: ClientInfo,
|
||||
token: Option<String>,
|
||||
resume: Option<ResumeToken>,
|
||||
},
|
||||
Subscribe {
|
||||
id: SubId,
|
||||
topic: TopicReq,
|
||||
},
|
||||
Unsubscribe {
|
||||
id: SubId,
|
||||
},
|
||||
/// The client saw a gap in `seq`; the server answers with a fresh `Snapshot`.
|
||||
Resync {
|
||||
id: SubId,
|
||||
},
|
||||
Request {
|
||||
id: ReqId,
|
||||
req: Rpc,
|
||||
},
|
||||
Ping {
|
||||
t: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum TopicReq {
|
||||
/// Prices for many markets in one subscription, at most `max_hz` deltas a second.
|
||||
Quotes { keys: Vec<MarketKey>, max_hz: u8 },
|
||||
/// Snapshot: the last `history` bars, the forming bar last. Deltas: [`BarDelta`].
|
||||
Bars { key: MarketKey, tf: Timeframe, history: u32 },
|
||||
/// The top `depth` levels a side, bucketed to multiples of `group` when set.
|
||||
Book { key: MarketKey, depth: u8, group: Option<f64> },
|
||||
/// Snapshot: recent trades. Deltas: new trades, oldest first.
|
||||
Trades { key: MarketKey },
|
||||
}
|
||||
|
||||
/// One-shot requests.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Rpc {
|
||||
/// Up to `limit` bars before `end` (Unix seconds; `None` = now), oldest first. The same
|
||||
/// contract as `MarketData::history` in `tr-data`.
|
||||
History { key: MarketKey, tf: Timeframe, end: Option<i64>, limit: u32 },
|
||||
/// Every market the server can stream right now.
|
||||
Markets,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum RpcResult {
|
||||
History(Vec<Bar>),
|
||||
Markets(Vec<MarketInfo>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MarketInfo {
|
||||
pub key: MarketKey,
|
||||
/// Where it trades: a Hyperliquid perp dex (`""` is the core dex, `xyz`, ...) or `spot`.
|
||||
pub venue: String,
|
||||
pub kind: InstrumentKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum InstrumentKind {
|
||||
Perp,
|
||||
Spot,
|
||||
}
|
||||
|
||||
/// Opaque per-connection id, for logs and support.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct SessionId(pub u64);
|
||||
|
||||
/// Reserved for sign-in (§8). Always `None` for now.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UserInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// What the server accepts on this connection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Limits {
|
||||
pub max_subscriptions: u32,
|
||||
pub max_quote_keys: u32,
|
||||
pub max_book_depth: u8,
|
||||
pub max_history: u32,
|
||||
}
|
||||
|
||||
/// Server to client.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ServerMsg {
|
||||
Welcome {
|
||||
version: u16,
|
||||
session: SessionId,
|
||||
user: Option<UserInfo>,
|
||||
server_time_ms: i64,
|
||||
limits: Limits,
|
||||
},
|
||||
/// Full state. Resets the subscription's `seq`.
|
||||
Snapshot {
|
||||
id: SubId,
|
||||
seq: u64,
|
||||
data: TopicData,
|
||||
},
|
||||
/// `seq` is the previous message's `seq` + 1.
|
||||
Delta {
|
||||
id: SubId,
|
||||
seq: u64,
|
||||
data: TopicDelta,
|
||||
},
|
||||
/// The server dropped deltas for this subscription; a `Snapshot` follows.
|
||||
Resync {
|
||||
id: SubId,
|
||||
reason: ResyncReason,
|
||||
},
|
||||
Reply {
|
||||
id: ReqId,
|
||||
result: Result<RpcResult, ApiError>,
|
||||
},
|
||||
Status {
|
||||
id: SubId,
|
||||
status: TopicStatus,
|
||||
},
|
||||
Pong {
|
||||
t: u64,
|
||||
server_time_ms: i64,
|
||||
},
|
||||
Error {
|
||||
code: ErrorCode,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum TopicData {
|
||||
Quotes(Vec<Quote>),
|
||||
Bars(Vec<Bar>),
|
||||
Book(Book),
|
||||
Trades(Vec<Trade>),
|
||||
}
|
||||
|
||||
/// For state topics (quotes, book) a delta is the new value, so a lost one is harmless.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum TopicDelta {
|
||||
/// The quotes that changed since the last message.
|
||||
Quotes(Vec<Quote>),
|
||||
Bar(BarDelta),
|
||||
Book(Book),
|
||||
Trades(Vec<Trade>),
|
||||
}
|
||||
|
||||
/// Bars stream as the forming bar's latest state, and a `Closed` bar with its final values
|
||||
/// before the first `Forming` of the next bar. The server may drop a `Forming` that a later
|
||||
/// message for the same `ts` supersedes; it never drops a `Closed`. Clients apply `Closed` by
|
||||
/// replacing the bar with that `ts` (§4.3).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum BarDelta {
|
||||
Forming(Bar),
|
||||
Closed(Bar),
|
||||
}
|
||||
|
||||
impl BarDelta {
|
||||
pub fn bar(&self) -> &Bar {
|
||||
match self {
|
||||
BarDelta::Forming(b) | BarDelta::Closed(b) => b,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Quote {
|
||||
/// The key as the client sent it.
|
||||
pub key: MarketKey,
|
||||
/// The venue market that backs it (`BTC` for `BTCUSD Curncy`).
|
||||
pub source: MarketKey,
|
||||
pub px: f64,
|
||||
pub basis: PriceBasis,
|
||||
/// The price 24 hours earlier, when the venue publishes one.
|
||||
pub prev_day: Option<f64>,
|
||||
/// When the server received this price (Unix milliseconds).
|
||||
pub ts_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum PriceBasis {
|
||||
Mid,
|
||||
Mark,
|
||||
Oracle,
|
||||
Last,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Book {
|
||||
/// Best first: bids descending, asks ascending.
|
||||
pub bids: Vec<Level>,
|
||||
pub asks: Vec<Level>,
|
||||
/// The venue's time for this book (Unix milliseconds).
|
||||
pub time_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Level {
|
||||
pub px: f64,
|
||||
pub sz: f64,
|
||||
/// Orders at this level (0 when grouped levels were merged and the venue gave none).
|
||||
pub n: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Trade {
|
||||
pub px: f64,
|
||||
pub sz: f64,
|
||||
/// The aggressor's side.
|
||||
pub side: Side,
|
||||
/// The venue's trade time (Unix milliseconds).
|
||||
pub time_ms: i64,
|
||||
/// Venue trade id; unique together with `time_ms`.
|
||||
pub tid: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Side {
|
||||
Buy,
|
||||
Sell,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ResyncReason {
|
||||
/// The client fell too far behind an event topic.
|
||||
Overflow,
|
||||
/// The server's own upstream reconnected, so its state may have jumped.
|
||||
Upstream,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TopicStatus {
|
||||
Live,
|
||||
/// No fresh data since `since_ms` (Unix milliseconds); the last values may be old.
|
||||
Stale {
|
||||
since_ms: i64,
|
||||
},
|
||||
/// The server can't serve this subscription. It stays open but gets no data.
|
||||
Unavailable {
|
||||
reason: UnavailableReason,
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum UnavailableReason {
|
||||
UnknownMarket,
|
||||
/// The server is at its upstream subscription budget.
|
||||
Capacity,
|
||||
Unsupported,
|
||||
Upstream,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ErrorCode {
|
||||
BadMessage,
|
||||
UnsupportedVersion,
|
||||
HelloRequired,
|
||||
DuplicateId,
|
||||
UnknownId,
|
||||
TooManySubscriptions,
|
||||
SlowConsumer,
|
||||
UnknownMarket,
|
||||
Upstream,
|
||||
Internal,
|
||||
/// The client sent messages faster than `Limits` allows; the server ignored this one.
|
||||
RateLimited,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ApiError {
|
||||
pub code: ErrorCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for ApiError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{:?}: {}", self.code, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ApiError {}
|
||||
|
||||
/// One encoded websocket frame.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Frame {
|
||||
Binary(Vec<u8>),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
pub fn len(&self) -> usize {
|
||||
match self {
|
||||
Frame::Binary(b) => b.len(),
|
||||
Frame::Text(t) => t.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodecError(pub String);
|
||||
|
||||
impl fmt::Display for CodecError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "protocol codec: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CodecError {}
|
||||
|
||||
/// Encode a message as one frame: postcard in a binary frame, or JSON in a text frame.
|
||||
pub fn encode<T: Serialize>(msg: &T, encoding: Encoding) -> Result<Frame, CodecError> {
|
||||
match encoding {
|
||||
Encoding::Postcard => postcard::to_stdvec(msg).map(Frame::Binary).map_err(|e| CodecError(e.to_string())),
|
||||
Encoding::Json => serde_json::to_string(msg).map(Frame::Text).map_err(|e| CodecError(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a binary (postcard) frame.
|
||||
pub fn decode_binary<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, CodecError> {
|
||||
postcard::from_bytes(bytes).map_err(|e| CodecError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Decode a text (JSON) frame.
|
||||
pub fn decode_text<T: DeserializeOwned>(text: &str) -> Result<T, CodecError> {
|
||||
serde_json::from_str(text).map_err(|e| CodecError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Decode either kind of frame.
|
||||
pub fn decode<T: DeserializeOwned>(frame: &Frame) -> Result<T, CodecError> {
|
||||
match frame {
|
||||
Frame::Binary(b) => decode_binary(b),
|
||||
Frame::Text(t) => decode_text(t),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(s: &str) -> MarketKey {
|
||||
MarketKey::new(s)
|
||||
}
|
||||
|
||||
fn bar(ts: i64) -> Bar {
|
||||
Bar::new(ts, 76_612.0, 76_640.5, 76_443.0, 76_521.0, 74.46557)
|
||||
}
|
||||
|
||||
fn book(levels: usize) -> Book {
|
||||
let side = |sign: f64| {
|
||||
(0..levels)
|
||||
.map(|i| Level { px: 76_530.0 + sign * i as f64, sz: 1.5 + i as f64 * 0.1, n: 3 + i as u32 })
|
||||
.collect()
|
||||
};
|
||||
Book { bids: side(-1.0), asks: side(1.0), time_ms: 1_793_325_942_459 }
|
||||
}
|
||||
|
||||
fn quote(k: &str, src: &str) -> Quote {
|
||||
Quote {
|
||||
key: key(k),
|
||||
source: key(src),
|
||||
px: 76_521.5,
|
||||
basis: PriceBasis::Mid,
|
||||
prev_day: Some(75_000.0),
|
||||
ts_ms: 1_793_325_942_459,
|
||||
}
|
||||
}
|
||||
|
||||
fn trade(i: u64) -> Trade {
|
||||
Trade { px: 76_528.0, sz: 0.41745, side: Side::Buy, time_ms: 1_793_325_941_395, tid: 1_005_251_635_753_713 + i }
|
||||
}
|
||||
|
||||
fn client_msgs() -> Vec<ClientMsg> {
|
||||
vec![
|
||||
ClientMsg::Hello {
|
||||
version: PROTOCOL_VERSION,
|
||||
encoding: Encoding::Postcard,
|
||||
client: ClientInfo { name: "tr-cli".into(), version: "0.1.1".into() },
|
||||
token: None,
|
||||
resume: None,
|
||||
},
|
||||
ClientMsg::Subscribe {
|
||||
id: 1,
|
||||
topic: TopicReq::Quotes { keys: vec![key("BTC"), key("xyz:TSLA")], max_hz: 4 },
|
||||
},
|
||||
ClientMsg::Unsubscribe { id: 1 },
|
||||
ClientMsg::Resync { id: 2 },
|
||||
ClientMsg::Request {
|
||||
id: 7,
|
||||
req: Rpc::History { key: key("BTCUSD Curncy"), tf: Timeframe::M1, end: None, limit: 500 },
|
||||
},
|
||||
ClientMsg::Ping { t: 42 },
|
||||
]
|
||||
}
|
||||
|
||||
fn server_msgs() -> Vec<ServerMsg> {
|
||||
let limits = Limits { max_subscriptions: 64, max_quote_keys: 512, max_book_depth: 20, max_history: 5000 };
|
||||
vec![
|
||||
ServerMsg::Welcome {
|
||||
version: PROTOCOL_VERSION,
|
||||
session: SessionId(9),
|
||||
user: None,
|
||||
server_time_ms: 1_793_325_942_000,
|
||||
limits,
|
||||
},
|
||||
ServerMsg::Snapshot { id: 1, seq: 0, data: TopicData::Quotes(vec![quote("BTCUSD Curncy", "BTC")]) },
|
||||
ServerMsg::Delta { id: 1, seq: 1, data: TopicDelta::Bar(BarDelta::Closed(bar(60))) },
|
||||
ServerMsg::Resync { id: 1, reason: ResyncReason::Overflow },
|
||||
ServerMsg::Reply { id: 7, result: Ok(RpcResult::History(vec![bar(0), bar(60)])) },
|
||||
ServerMsg::Status {
|
||||
id: 3,
|
||||
status: TopicStatus::Unavailable { reason: UnavailableReason::Capacity, detail: "budget".into() },
|
||||
},
|
||||
ServerMsg::Pong { t: 42, server_time_ms: 1_793_325_942_000 },
|
||||
ServerMsg::Error { code: ErrorCode::BadMessage, message: "nope".into() },
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_in_both_encodings() {
|
||||
for enc in [Encoding::Postcard, Encoding::Json] {
|
||||
for m in client_msgs() {
|
||||
let f = encode(&m, enc).unwrap();
|
||||
assert_eq!(decode::<ClientMsg>(&f).unwrap(), m, "{enc:?}");
|
||||
}
|
||||
for m in server_msgs() {
|
||||
let f = encode(&m, enc).unwrap();
|
||||
assert_eq!(decode::<ServerMsg>(&f).unwrap(), m, "{enc:?}");
|
||||
}
|
||||
let extra = [
|
||||
ServerMsg::Snapshot { id: 4, seq: 10, data: TopicData::Book(book(20)) },
|
||||
ServerMsg::Snapshot { id: 5, seq: 0, data: TopicData::Trades(vec![trade(0), trade(1)]) },
|
||||
ServerMsg::Snapshot { id: 6, seq: 0, data: TopicData::Bars(vec![bar(0)]) },
|
||||
ServerMsg::Delta { id: 4, seq: 11, data: TopicDelta::Book(book(5)) },
|
||||
ServerMsg::Delta { id: 5, seq: 1, data: TopicDelta::Trades(vec![trade(2)]) },
|
||||
ServerMsg::Delta { id: 1, seq: 2, data: TopicDelta::Quotes(vec![quote("BTC", "BTC")]) },
|
||||
ServerMsg::Reply {
|
||||
id: 8,
|
||||
result: Ok(RpcResult::Markets(vec![MarketInfo {
|
||||
key: key("xyz:TSLA"),
|
||||
venue: "xyz".into(),
|
||||
kind: InstrumentKind::Perp,
|
||||
}])),
|
||||
},
|
||||
ServerMsg::Reply {
|
||||
id: 9,
|
||||
result: Err(ApiError { code: ErrorCode::UnknownMarket, message: "x".into() }),
|
||||
},
|
||||
ServerMsg::Status { id: 1, status: TopicStatus::Stale { since_ms: 5 } },
|
||||
];
|
||||
for m in extra {
|
||||
assert_eq!(decode::<ServerMsg>(&encode(&m, enc).unwrap()).unwrap(), m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodings_pick_the_frame_kind() {
|
||||
let m = ClientMsg::Ping { t: 1 };
|
||||
assert!(matches!(encode(&m, Encoding::Postcard).unwrap(), Frame::Binary(_)));
|
||||
assert_eq!(encode(&m, Encoding::Json).unwrap(), Frame::Text(r#"{"Ping":{"t":1}}"#.into()));
|
||||
assert!(decode_binary::<ClientMsg>(&[0xff, 0xff]).is_err());
|
||||
assert!(decode_text::<ClientMsg>("{}").is_err());
|
||||
}
|
||||
|
||||
/// postcard writes an enum variant as its index. Appending variants keeps these; reordering
|
||||
/// or removing one changes them and breaks every deployed client, so this test pins them.
|
||||
#[test]
|
||||
fn wire_tags_are_stable() {
|
||||
let tag = |f: Frame| match f {
|
||||
Frame::Binary(b) => b[0],
|
||||
Frame::Text(_) => unreachable!(),
|
||||
};
|
||||
let client: Vec<u8> = client_msgs().iter().map(|m| tag(encode(m, Encoding::Postcard).unwrap())).collect();
|
||||
assert_eq!(client, [0, 1, 2, 3, 4, 5]);
|
||||
let server: Vec<u8> = server_msgs().iter().map(|m| tag(encode(m, Encoding::Postcard).unwrap())).collect();
|
||||
assert_eq!(server, [0, 1, 2, 3, 4, 5, 6, 7]);
|
||||
let t = |v: Vec<u8>| v[0];
|
||||
let topics = [
|
||||
TopicReq::Quotes { keys: vec![], max_hz: 1 },
|
||||
TopicReq::Bars { key: key("BTC"), tf: Timeframe::M1, history: 1 },
|
||||
TopicReq::Book { key: key("BTC"), depth: 1, group: None },
|
||||
TopicReq::Trades { key: key("BTC") },
|
||||
];
|
||||
assert_eq!(topics.map(|x| t(postcard::to_stdvec(&x).unwrap())), [0, 1, 2, 3]);
|
||||
let data =
|
||||
[TopicData::Quotes(vec![]), TopicData::Bars(vec![]), TopicData::Book(book(0)), TopicData::Trades(vec![])];
|
||||
assert_eq!(data.map(|x| t(postcard::to_stdvec(&x).unwrap())), [0, 1, 2, 3]);
|
||||
let deltas = [
|
||||
TopicDelta::Quotes(vec![]),
|
||||
TopicDelta::Bar(BarDelta::Forming(bar(0))),
|
||||
TopicDelta::Book(book(0)),
|
||||
TopicDelta::Trades(vec![]),
|
||||
];
|
||||
assert_eq!(deltas.map(|x| t(postcard::to_stdvec(&x).unwrap())), [0, 1, 2, 3]);
|
||||
assert_eq!(
|
||||
[BarDelta::Forming(bar(0)), BarDelta::Closed(bar(0))].map(|x| t(postcard::to_stdvec(&x).unwrap())),
|
||||
[0, 1]
|
||||
);
|
||||
assert_eq!(
|
||||
[Rpc::History { key: key("B"), tf: Timeframe::D1, end: None, limit: 1 }, Rpc::Markets]
|
||||
.map(|x| t(postcard::to_stdvec(&x).unwrap())),
|
||||
[0, 1]
|
||||
);
|
||||
let status = [
|
||||
TopicStatus::Live,
|
||||
TopicStatus::Stale { since_ms: 0 },
|
||||
TopicStatus::Unavailable { reason: UnavailableReason::Upstream, detail: String::new() },
|
||||
];
|
||||
assert_eq!(status.map(|x| t(postcard::to_stdvec(&x).unwrap())), [0, 1, 2]);
|
||||
// tr-core types are part of the wire format: Timeframe's variant order and Bar's fields.
|
||||
let tfs: Vec<u8> = Timeframe::ALL.iter().map(|tf| t(postcard::to_stdvec(tf).unwrap())).collect();
|
||||
assert_eq!(tfs, [0, 1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
let b = postcard::to_stdvec(&Bar::new(1, 2.0, 3.0, 4.0, 5.0, 6.0)).unwrap();
|
||||
assert_eq!(b.len(), 1 + 5 * 8, "ts as a varint, then five f64 in field order");
|
||||
assert_eq!(&b[1..9], &2.0f64.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Frame sizes for the messages that dominate traffic (§11 estimates ~30 bytes a quote).
|
||||
#[test]
|
||||
fn frame_sizes() {
|
||||
let size = |m: &ServerMsg, e| encode(m, e).unwrap().len();
|
||||
let one_quote = ServerMsg::Delta { id: 1, seq: 1234, data: TopicDelta::Quotes(vec![quote("BTC", "BTC")]) };
|
||||
let book20 = ServerMsg::Delta { id: 2, seq: 1234, data: TopicDelta::Book(book(20)) };
|
||||
let one_trade = ServerMsg::Delta { id: 3, seq: 1234, data: TopicDelta::Trades(vec![trade(0)]) };
|
||||
let forming = ServerMsg::Delta { id: 4, seq: 1234, data: TopicDelta::Bar(BarDelta::Forming(bar(60))) };
|
||||
let rows = [("quote", &one_quote), ("book 20x2", &book20), ("trade", &one_trade), ("bar", &forming)];
|
||||
for (name, m) in rows {
|
||||
let (p, j) = (size(m, Encoding::Postcard), size(m, Encoding::Json));
|
||||
println!("{name:>9}: postcard {p:>4} B, json {j:>5} B ({:.1}x)", j as f64 / p as f64);
|
||||
assert!(p < j, "{name}: postcard should be smaller");
|
||||
}
|
||||
assert!(size(&one_quote, Encoding::Postcard) <= 40);
|
||||
assert!(size(&one_trade, Encoding::Postcard) <= 40);
|
||||
assert!(size(&forming, Encoding::Postcard) <= 50);
|
||||
assert!(size(&book20, Encoding::Postcard) <= 40 * 18 + 16);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# tr-gateway on Freestyle
|
||||
|
||||
The Terminal data server runs on a Freestyle VM at **https://terminal-gateway.style.dev**. Clients connect to `wss://terminal-gateway.style.dev/ws`, for example the desktop app:
|
||||
|
||||
```sh
|
||||
TR_SERVER_URL=wss://terminal-gateway.style.dev cargo run --release
|
||||
```
|
||||
|
||||
## What runs where
|
||||
|
||||
- One VM, slug `terminal-gateway`, from the `freestyle/ubuntu-sm` snapshot: 2 vCPU, 4 GiB of memory, 16 GB of disk.
|
||||
- `tr-gateway` runs under systemd (`tr-gateway.service`) as the unprivileged user `trgw`, bound to `0.0.0.0:8080`. systemd restarts it whenever it exits.
|
||||
- Freestyle's edge terminates TLS on 443 and forwards to port 8080, so the gateway itself serves plain HTTP and websockets.
|
||||
- The source lives in `/opt/terminal/src`. `/opt/terminal/DEPLOYED_COMMIT` holds the commit that's running, and the CLI example is installed as `tr-cli`.
|
||||
|
||||
## Why this size
|
||||
|
||||
The VM builds the gateway from source on every deploy, so the 16 GB disk holds the Rust toolchain and the build cache. On 2 vCPU a first build takes about 4 minutes, and later ones are incremental. The gateway itself is one process with a few upstream websockets: on a Mac against the live feed, with one client watching BTC's quotes, book, trades and 1m bars, its resident memory was about 15 MiB. A bigger VM buys nothing until there are many more clients.
|
||||
|
||||
## Deploy or update
|
||||
|
||||
From a machine with Node (for `npx`), `jq`, `curl` and a Freestyle API key:
|
||||
|
||||
```sh
|
||||
export FREESTYLE_API_KEY=... # never commit or paste it
|
||||
deploy/freestyle/deploy.sh master # or a branch, tag or full commit
|
||||
```
|
||||
|
||||
`deploy.sh` creates the VM if it's missing and publishes the domain with a TLS rule if that's missing. It then copies `vm-setup.sh` to the VM and runs it at the ref you give. The VM builds that ref from GitHub, so push it first. `vm-setup.sh` installs the build tools and Rust (keeping Rust on the latest stable, like CI), builds `tr-gateway` and the CLI with `--locked`, installs them, writes the systemd unit and restarts the service. It waits for `/readyz` on the VM, and `deploy.sh` then waits for `/readyz` through the public URL.
|
||||
|
||||
A deploy restarts the gateway, which drops every client connection. Clients reconnect and resubscribe on their own. A subscription made while the gateway is still starting gets `Stale`, then `Live` and its snapshot once the gateway has its instrument list and mids (`server/README.md`).
|
||||
|
||||
## Check health
|
||||
|
||||
```sh
|
||||
curl -fsS https://terminal-gateway.style.dev/healthz # the process is up
|
||||
curl -fsS https://terminal-gateway.style.dev/readyz # upstream connected, every venue's mids in
|
||||
cli() { cargo run -q --release -p tr-gateway --example cli -- --url wss://terminal-gateway.style.dev "$@"; }
|
||||
cli stats
|
||||
cli watch --quotes BTC --book BTC --trades BTC --bars BTC:1m --secs 30
|
||||
```
|
||||
|
||||
On the VM, `systemctl status tr-gateway` and `journalctl -u tr-gateway` show the service and its logs, and `cat /opt/terminal/DEPLOYED_COMMIT` shows what's deployed. The gateway's logs never carry addresses.
|
||||
|
||||
## Cost
|
||||
|
||||
2 vCPU and 4 GiB running around the clock come to about $80 a month of Freestyle usage above the free allowance.
|
||||
|
||||
## `/stats` and `/metrics` are public
|
||||
|
||||
Anyone can read `/stats` (JSON) and `/metrics` (Prometheus text) through the public URL right now. They show counts, budgets, latency and the upstream subscriptions, which reveal the markets clients are watching. They carry no client addresses or user data. Micky hasn't decided whether to restrict them, so they stay as they are for now. Restricting them later means serving them on a second port the edge doesn't publish, or checking a token.
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy or update tr-gateway on a Freestyle VM, published at https://$DOMAIN (wss://$DOMAIN/ws).
|
||||
# The VM builds the ref from GitHub, so push it first.
|
||||
# Needs the Freestyle CLI auth in the environment (FREESTYLE_API_KEY); nothing is printed from it.
|
||||
# Usage: deploy/freestyle/deploy.sh [git-ref] (default: master)
|
||||
# Env: TR_VM_SLUG (default terminal-gateway), TR_DOMAIN (default terminal-gateway.style.dev),
|
||||
# TR_VM_SNAPSHOT (default freestyle/ubuntu-sm: 2 vCPU / 4 GiB / 16 GB),
|
||||
# TR_FREESTYLE_CLI (default [email protected], the npm package that runs the CLI)
|
||||
set -euo pipefail
|
||||
REF="${1:-master}"
|
||||
SLUG="${TR_VM_SLUG:-terminal-gateway}"
|
||||
DOMAIN="${TR_DOMAIN:-terminal-gateway.style.dev}"
|
||||
SNAP="${TR_VM_SNAPSHOT:-freestyle/ubuntu-sm}"
|
||||
CLI="${TR_FREESTYLE_CLI:-freestyle@0.2.21}"
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
[ -n "${FREESTYLE_API_KEY:-}" ] || { echo "set FREESTYLE_API_KEY" >&2; exit 1; }
|
||||
for tool in npx jq curl; do
|
||||
command -v "$tool" >/dev/null || { echo "$tool is required" >&2; exit 1; }
|
||||
done
|
||||
fs() { npx -y "$CLI" "$@"; }
|
||||
|
||||
if ! fs --output json vm get "$SLUG" >/dev/null 2>&1; then
|
||||
echo "creating VM $SLUG from $SNAP"
|
||||
fs --output json vm create --snapshot-id "$SNAP" --slug "$SLUG" --display-name "Terminal data server (tr-gateway)" \
|
||||
--metadata app=terminal --metadata role=tr-gateway --no-ssh >/dev/null
|
||||
fi
|
||||
VM_ID="$(fs --output json vm get "$SLUG" | jq -r .id)"
|
||||
|
||||
if ! fs --output json tls list | jq -e --arg d "$DOMAIN" '.rules[]? | select(.domain==$d)' >/dev/null; then
|
||||
echo "publishing https://$DOMAIN -> $SLUG:8080"
|
||||
fs --output json tls create --domain "$DOMAIN" --from public --to "vm=$VM_ID,port=8080" >/dev/null
|
||||
fi
|
||||
|
||||
fs vm scp "$HERE/vm-setup.sh" "$SLUG:/root/vm-setup.sh" >/dev/null
|
||||
# The first build takes about 4 minutes on 2 vCPU; later ones are incremental.
|
||||
fs vm exec "$SLUG" --linux-user root --timeout-ms 1800000 -- bash /root/vm-setup.sh "$REF"
|
||||
|
||||
# The VM said it's ready; check the public path through Freestyle's edge too.
|
||||
deadline=$((SECONDS + 60))
|
||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||
if curl -fsS --connect-timeout 5 --max-time 10 "https://$DOMAIN/readyz" >/dev/null 2>&1; then
|
||||
echo "ready: wss://$DOMAIN/ws"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "deployed, but https://$DOMAIN/readyz is not answering 200 yet" >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs as root inside a Freestyle Ubuntu VM. Builds tr-gateway from a git ref and
|
||||
# runs it under systemd on 0.0.0.0:8080; Freestyle's edge terminates TLS on 443.
|
||||
# Usage: vm-setup.sh [git-ref] (a branch, tag or full commit; default: master)
|
||||
set -euo pipefail
|
||||
REF="${1:-master}"
|
||||
REPO="https://github.com/michaelshimeles/terminal.git"
|
||||
SRC=/opt/terminal/src
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq build-essential pkg-config libssl-dev git curl ca-certificates >/dev/null
|
||||
|
||||
if ! id trgw >/dev/null 2>&1; then useradd --system --create-home --home-dir /var/lib/trgw --shell /usr/sbin/nologin trgw; fi
|
||||
|
||||
# CI builds with the latest stable Rust, so the VM keeps up with it.
|
||||
export RUSTUP_HOME=/opt/rust/rustup CARGO_HOME=/opt/rust/cargo PATH=/opt/rust/cargo/bin:$PATH
|
||||
if command -v cargo >/dev/null; then
|
||||
rustup update --no-self-update stable >/dev/null
|
||||
else
|
||||
curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable --no-modify-path
|
||||
fi
|
||||
|
||||
mkdir -p /opt/terminal
|
||||
if [ ! -d "$SRC/.git" ]; then
|
||||
git clone --quiet "$REPO" "$SRC"
|
||||
fi
|
||||
git -C "$SRC" fetch --quiet origin "$REF"
|
||||
git -C "$SRC" checkout --quiet --detach FETCH_HEAD
|
||||
cd "$SRC"
|
||||
echo "building $(git rev-parse HEAD)"
|
||||
cargo build --release --locked -p tr-gateway --bin tr-gateway --example cli
|
||||
install -m 0755 target/release/tr-gateway /usr/local/bin/tr-gateway.new
|
||||
mv /usr/local/bin/tr-gateway.new /usr/local/bin/tr-gateway
|
||||
install -m 0755 target/release/examples/cli /usr/local/bin/tr-cli
|
||||
git rev-parse HEAD > /opt/terminal/DEPLOYED_COMMIT
|
||||
|
||||
cat > /etc/systemd/system/tr-gateway.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=Terminal data server (tr-gateway)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
# Keep restarting however often it fails; systemd would otherwise give up after 5 quick tries.
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
User=trgw
|
||||
Environment=TR_GATEWAY_BIND=0.0.0.0:8080
|
||||
Environment=RUST_LOG=info
|
||||
ExecStart=/usr/local/bin/tr-gateway
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
LimitNOFILE=65536
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl enable tr-gateway >/dev/null 2>&1
|
||||
systemctl restart tr-gateway
|
||||
|
||||
# Wait until the process is up (/healthz), then until it has the instrument list and
|
||||
# every venue's mids (/readyz).
|
||||
wait_for() {
|
||||
local deadline=$((SECONDS + $2))
|
||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||
if curl -fsS --connect-timeout 2 --max-time 5 "http://127.0.0.1:8080/$1" >/dev/null 2>&1; then return 0; fi
|
||||
sleep 1
|
||||
done
|
||||
echo "/$1 did not answer 200 within $2 s" >&2
|
||||
systemctl --no-pager status tr-gateway >&2 || true
|
||||
journalctl --no-pager -u tr-gateway -n 30 >&2 || true
|
||||
return 1
|
||||
}
|
||||
wait_for healthz 30
|
||||
wait_for readyz 90
|
||||
echo "deployed $(cat /opt/terminal/DEPLOYED_COMMIT); ready"
|
||||
@@ -1,12 +1,26 @@
|
||||
# Terminal data server: design
|
||||
|
||||
Status: **draft for review**. This PR adds no server code.
|
||||
Status: **draft for review**. The first server code runs on an interim public feed (see [Interim: public feed](#interim-public-feed)).
|
||||
Author: Grok Bot, for Micky. Research date: 2026-10-29. Times are UTC unless marked.
|
||||
|
||||
Terminal today fetches all of its own data: Yahoo, Binance and Coinbase websockets, Treasury.gov, Senate eFD and the House Clerk, and SnapTrade. Each desktop process opens its own connections. The browser build serves sample data wherever a source blocks cross-origin requests (`TODO(web proxy)` in `crates/data/src/web.rs` and `crates/congress/src/db_web.rs`). This doc proposes the replacement Micky asked for: **one Rust server that every client (desktop, web, later mobile) connects to, so market data and user state sync in real time**. Hyperliquid is the first market-data source, and the design leaves a clean provider interface for licensed equities and options later.
|
||||
|
||||
**Decision (Micky, 2026-10-29): Terminal runs on our own Hyperliquid non-validator node(s) and our own systems from day one.** Live data comes only from the node's output files and its local info server: books, trades, candles, mids, marks, oracle prices and funding. The public Hyperliquid API is used for nothing live. At most, it serves a one-time history backfill (§5.6).
|
||||
|
||||
## Interim: public feed
|
||||
|
||||
On 2026-10-29 Micky approved starting on Hyperliquid's public API, because a node box (about $735 a month, §6.3) costs too much for now. Until the node is up, this is what runs:
|
||||
|
||||
- `tr-gateway` (`server/`) with `HlPublicProvider`, which implements the `Provider` trait (§3.1) over `wss://api.hyperliquid.xyz/ws` and the public `info` endpoint. Clients speak `tr-proto` (§4) and can't tell providers apart, so `HlNodeProvider` replaces it later with no client or protocol change.
|
||||
- One set of upstream connections serves every client. Mids for the core dex and for every HIP-3 dex with listed markets stay subscribed. The gateway subscribes a market's book, trades and candles only while a client watches it, and drops them 20 s after the last one leaves.
|
||||
- The gateway keeps its own budgets under Hyperliquid's per-IP limits [doc: RL]: at most 8 of 10 connections, 950 of 1000 subscriptions, 1800 of 2000 sent messages a minute and 1100 of 1200 REST weight a minute (defaults 3, 900, 1000 and 800). It refuses a client subscription past the budget, and it never rotates IPs or uses proxies [doc: TOU §3.1.8].
|
||||
- Hyperliquid closes the whole websocket when asked for a coin it doesn't list, with no error message [observed 2026-10-29]. The gateway only subscribes coins that have a live mid, and keeps the permanent mids on a connection of their own.
|
||||
- History comes from `candleSnapshot`, so it stops at the most recent 5000 bars per interval [doc: INFO]. For 1m bars that is about 3.5 days. Node-built candles (§5.3) lift the cap.
|
||||
- It makes read-only public calls only. No `exchange` endpoint, no user subscriptions, no orders.
|
||||
- The desktop opts in with `TR_SERVER_URL` (§4.6). Crypto keys such as `BTCUSD Curncy` then come from Hyperliquid core perps, and the order book pane names the source. Everything else is unchanged.
|
||||
- The web build uses the server for the same crypto markets. `scripts/build-web.sh` builds in `wss://terminal-gateway.style.dev/ws` unless `TR_SERVER_URL` says otherwise. While the server is unreachable, the page falls back to Binance and Coinbase as before.
|
||||
- It runs on one Freestyle VM (2 vCPU, 4 GiB) at `wss://terminal-gateway.style.dev/ws`, behind Freestyle's TLS edge. `deploy/freestyle/README.md` covers deploys, health checks and cost, which is about $80 a month above the free allowance.
|
||||
|
||||
How facts are marked:
|
||||
|
||||
- **[doc: …]** is a fact from official documentation. Sources are listed in [References](#references).
|
||||
@@ -250,7 +264,7 @@ For comparison, the public `l2Book` feed is pushed "on each block that is at lea
|
||||
|
||||
- **`tr-data::remote::RemoteMarkets`** implements `MarketData` over one shared connection. `history` maps to an RPC, `quotes` to a short-lived or shared `Quotes` subscription, and `live` to a `Bars` subscription bridged into the existing `mpsc::Receiver<Bar>`.
|
||||
- **Selection:** `TR_SERVER_URL` (or a settings toggle) picks `RemoteMarkets`. The direct providers stay as the offline and dev fallback (`TR_PROVIDER=sample` keeps working).
|
||||
- **Web build:** `for_browser()` switches to `RemoteMarkets`. This closes the `TODO(web proxy)` stubs in `crates/data/src/web.rs` and `crates/congress/src/db_web.rs`.
|
||||
- **Web build:** `for_browser()` puts `RemoteMarkets` in front of the browser's own sources when the page was built with a server URL (`TR_SERVER_URL`, read by `scripts/build-web.sh`; production uses `wss://terminal-gateway.style.dev/ws`). The client runs on the page's event loop over the browser's `WebSocket`, with postcard in binary frames like the desktop. `?server=ws://localhost:8787/ws` points a page at a server on the same machine, and `?server=off` turns the server off. Crypto is the only asset class the server serves so far, so the `TODO(web proxy)` stubs in `crates/data/src/web.rs` and `crates/congress/src/db_web.rs` stay until it serves the rest.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -12,6 +12,11 @@
|
||||
# cache everything under immutable/ for good (site/vercel.json), and a page can't pair one
|
||||
# build's JS with another build's wasm.
|
||||
#
|
||||
# The app gets crypto markets from the Terminal data server at TR_SERVER_URL, which is built
|
||||
# in. It defaults to the production server; set it to another ws:// or wss:// URL, or to
|
||||
# nothing (TR_SERVER_URL=) for no server. A page can also ask for a server on the same machine
|
||||
# with ?server=ws://localhost:8787/ws, or for none with ?server=off.
|
||||
#
|
||||
# Needs the wasm32-unknown-unknown target (rustup target add wasm32-unknown-unknown),
|
||||
# wasm-bindgen-cli at the version pinned in Cargo.lock, and wasm-opt from binaryen.
|
||||
set -euo pipefail
|
||||
@@ -19,7 +24,17 @@ cd "$(dirname "$0")/.."
|
||||
|
||||
out="${1:-site/static/app}"
|
||||
target=wasm32-unknown-unknown
|
||||
wasm="target/$target/web/terminal.wasm"
|
||||
wasm="${CARGO_TARGET_DIR:-target}/$target/web/terminal.wasm"
|
||||
|
||||
export TR_SERVER_URL="${TR_SERVER_URL-wss://terminal-gateway.style.dev/ws}"
|
||||
case "$TR_SERVER_URL" in
|
||||
"") echo "no Terminal server: crypto markets come from Binance and Coinbase" ;;
|
||||
ws://* | wss://*) echo "Terminal server: $TR_SERVER_URL" ;;
|
||||
*)
|
||||
echo "TR_SERVER_URL must be a ws:// or wss:// URL, or empty for no server" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
want=$(awk '/^name = "wasm-bindgen"$/ { getline; gsub(/version = |"/, ""); print; exit }' Cargo.lock)
|
||||
have=$(wasm-bindgen --version 2>/dev/null | awk '{ print $2 }' || true)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "tr-gateway"
|
||||
description = "Terminal data server: one upstream feed fanned out to every Terminal client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tr-core.workspace = true
|
||||
tr-proto.workspace = true
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
reqwest.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
tokio = { workspace = true, features = ["net", "signal"] }
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "ws"] }
|
||||
tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# The CLI example and the integration tests talk to the gateway through the desktop's client.
|
||||
tr-data.workspace = true
|
||||
tokio = { workspace = true, features = ["net", "signal", "test-util"] }
|
||||
@@ -0,0 +1,62 @@
|
||||
# tr-gateway
|
||||
|
||||
The Terminal data server. For now it carries Hyperliquid's public API (see "Interim: public feed" in `docs/architecture/server.md`). One set of upstream websocket connections serves every client, fanned out over the protocol in `crates/proto`.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
cargo run --release -p tr-gateway
|
||||
```
|
||||
|
||||
It listens on `localhost:8787`. Clients connect to `ws://localhost:8787/ws`. `/healthz` says the process is up, `/readyz` says the upstream is connected and every venue's mids have arrived, `/metrics` is Prometheus text and `/stats` is JSON.
|
||||
|
||||
The desktop app uses it for crypto markets when `TR_SERVER_URL` is set:
|
||||
|
||||
```sh
|
||||
TR_SERVER_URL=ws://localhost:8787 cargo run --release
|
||||
```
|
||||
|
||||
The browser build uses it too. `scripts/build-web.sh` builds in `wss://terminal-gateway.style.dev/ws`, or `TR_SERVER_URL` when it is set (empty for none). To try a local gateway with a built page, open the page with `?server=ws://localhost:8787/ws`.
|
||||
|
||||
The gateway serves plain HTTP and websockets. For `wss://` on 443, put a TLS-terminating edge in front of it and bind to an address the edge can reach, for example `TR_GATEWAY_BIND=0.0.0.0:8080`. Clients accept `wss://` URLs. The gateway pings each client every 20 s so idle connections survive proxies. The hosted one runs on Freestyle; see `deploy/freestyle/README.md`.
|
||||
|
||||
## Restarts
|
||||
|
||||
A restarted gateway listens before it knows which markets exist. It learns that from the instrument list and the first mids from every venue, which usually takes a few seconds. Clients that reconnect in that window keep their subscriptions. Each one gets `Stale`, then `Live` and its snapshot once the gateway is ready. Requests wait up to 10 s and then fail with `Upstream` rather than `UnknownMarket`, so clients can retry them. If one venue is slower than the rest, the gateway gets ready without it after 10 s. Subscriptions to that venue's markets keep waiting for it, and requests for them fail with `Upstream` until it reports. The client in `crates/data/src/remote.rs` subscribes again, with backoff from 1 s to 30 s, to anything a server ended with `Unavailable { reason: Upstream }`, so it also recovers from older gateways that gave up on such subscriptions.
|
||||
|
||||
## Try it
|
||||
|
||||
```sh
|
||||
cli() { cargo run -q --release -p tr-gateway --example cli -- "$@"; }
|
||||
cli markets xyz:
|
||||
cli watch --quotes BTC,xyz:TSLA --book BTC --trades BTC --bars BTC:1m --secs 30
|
||||
cli load --clients 5 --book BTC --trades BTC --secs 30
|
||||
cli stats
|
||||
```
|
||||
|
||||
`watch` prints about one line a second per topic, then latency. Quote latency counts from the moment the server received the price, book and trade latency from Hyperliquid's own timestamps. `/stats` reports the part the server adds, from an upstream frame arriving to the client frame it causes being written.
|
||||
|
||||
## Settings
|
||||
|
||||
Environment variables, also listed by `tr-gateway --help`:
|
||||
|
||||
| Variable | Default | |
|
||||
|---|---|---|
|
||||
| `TR_GATEWAY_BIND` | `localhost:8787` | Listen address, `host:port` |
|
||||
| `TR_HL_DEXES` | `all` | HIP-3 dexes besides the core dex, for example `xyz,flx` |
|
||||
| `TR_UPSTREAM_CONNECTIONS` | 3 | Upstream websocket connections, 2 to 8 |
|
||||
| `TR_UPSTREAM_SUBSCRIPTIONS` | 900 | Upstream subscriptions, at most 950 |
|
||||
| `TR_UPSTREAM_MESSAGES_PER_MIN` | 1000 | Messages sent upstream a minute, at most 1800 |
|
||||
| `TR_REST_WEIGHT_PER_MIN` | 800 | `info` weight a minute, at most 1100 |
|
||||
| `TR_LINGER_SECS` | 20 | How long an unwatched market keeps its upstream streams |
|
||||
| `TR_MAX_CLIENTS` | 1000 | Client connections |
|
||||
|
||||
## Hyperliquid's limits
|
||||
|
||||
Per IP, Hyperliquid allows 10 websocket connections, 30 new connections a minute, 1000 subscriptions, 2000 sent messages a minute and 1200 REST weight a minute. The gateway keeps its own budget below each one (the caps in the table, and 20 new connections a minute). A subscription past the budget gets `Unavailable { reason: Capacity }`. The gateway never rotates IPs, uses proxies or otherwise works around a limit (Hyperliquid's terms, §3.1.8).
|
||||
|
||||
- The gateway subscribes every dex's mids for good, on a connection of their own.
|
||||
- It subscribes a market's book, trades and candles only while some client watches it, and drops them `TR_LINGER_SECS` after the last one leaves.
|
||||
- Hyperliquid closes the whole connection when asked for a coin it doesn't list, so the gateway only subscribes coins that have a live mid.
|
||||
- History comes from `candleSnapshot`, which serves only the most recent 5000 bars per interval.
|
||||
- It makes read-only public calls only: `info` requests and public websocket channels. No `exchange` endpoint, no user data, no orders.
|
||||
@@ -0,0 +1,353 @@
|
||||
//! A small client for trying and measuring a running gateway.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --release -p tr-gateway --example cli -- [--url ws://localhost:8787] [--json] COMMAND
|
||||
//!
|
||||
//! markets [FILTER] list the markets the server streams
|
||||
//! watch [TOPICS] [--secs N]
|
||||
//! stream topics, print about one line a second per topic, then latency stats
|
||||
//! load --clients N [TOPICS] [--secs N]
|
||||
//! N connections with the same subscriptions; prints totals and latency
|
||||
//! stats print the server's /stats
|
||||
//!
|
||||
//! TOPICS: --quotes K,K,.. --book K --trades K --bars K:TF (TF: 1m 5m 15m 30m 1h 4h 1d 1w 1M)
|
||||
//! ```
|
||||
//!
|
||||
//! Latency, measured at the client on the same clock as the server when both run on one host:
|
||||
//! quotes from the server's upstream receive time (`Quote::ts_ms`), books and trades from
|
||||
//! Hyperliquid's own timestamps.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use tr_data::remote::{Client, Event, Subscription, normalize_url};
|
||||
use tr_proto::{BarDelta, Encoding, MarketKey, Rpc, RpcResult, Timeframe, TopicData, TopicDelta, TopicReq};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Args {
|
||||
url: String,
|
||||
json: bool,
|
||||
command: String,
|
||||
filter: Option<String>,
|
||||
topics: Vec<TopicReq>,
|
||||
secs: u64,
|
||||
clients: usize,
|
||||
}
|
||||
|
||||
const USAGE: &str = "\
|
||||
usage: cli [--url ws://localhost:8787] [--json] COMMAND
|
||||
markets [FILTER] list the markets the server streams
|
||||
watch [TOPICS] [--secs N] stream topics, then print latency stats
|
||||
load --clients N [TOPICS] [--secs N]
|
||||
N connections with the same subscriptions
|
||||
stats print the server's /stats
|
||||
TOPICS: --quotes K,K,.. --book K --trades K --bars K:TF (1m 5m 15m 30m 1h 4h 1d 1w 1M)";
|
||||
|
||||
fn usage() -> ! {
|
||||
eprintln!("{USAGE}");
|
||||
std::process::exit(2)
|
||||
}
|
||||
|
||||
fn parse_tf(s: &str) -> Result<Timeframe> {
|
||||
Ok(match s {
|
||||
"1m" => Timeframe::M1,
|
||||
"5m" => Timeframe::M5,
|
||||
"15m" => Timeframe::M15,
|
||||
"30m" => Timeframe::M30,
|
||||
"1h" => Timeframe::H1,
|
||||
"4h" => Timeframe::H4,
|
||||
"1d" => Timeframe::D1,
|
||||
"1w" => Timeframe::W1,
|
||||
"1M" => Timeframe::MN1,
|
||||
_ => bail!("unknown timeframe {s}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse() -> Result<Args> {
|
||||
let mut a = Args { url: "ws://localhost:8787".into(), secs: 30, clients: 1, ..Default::default() };
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(arg) = it.next() {
|
||||
let mut val = || it.next().with_context(|| format!("{arg} needs a value"));
|
||||
match arg.as_str() {
|
||||
"--url" => a.url = val()?,
|
||||
"--json" => a.json = true,
|
||||
"--secs" => a.secs = val()?.parse()?,
|
||||
"--clients" => a.clients = val()?.parse()?,
|
||||
"--quotes" => {
|
||||
let keys = val()?.split(',').map(|k| MarketKey::new(k.trim())).collect();
|
||||
a.topics.push(TopicReq::Quotes { keys, max_hz: 10 });
|
||||
}
|
||||
"--book" => a.topics.push(TopicReq::Book { key: MarketKey::new(val()?), depth: 20, group: None }),
|
||||
"--trades" => a.topics.push(TopicReq::Trades { key: MarketKey::new(val()?) }),
|
||||
"--bars" => {
|
||||
let v = val()?;
|
||||
let (k, tf) = v.rsplit_once(':').context("--bars wants KEY:TF")?;
|
||||
a.topics.push(TopicReq::Bars { key: MarketKey::new(k), tf: parse_tf(tf)?, history: 5 });
|
||||
}
|
||||
"-h" | "--help" => usage(),
|
||||
c if a.command.is_empty() => a.command = c.into(),
|
||||
f if a.command == "markets" && a.filter.is_none() => a.filter = Some(f.into()),
|
||||
other => bail!("unexpected argument {other}"),
|
||||
}
|
||||
}
|
||||
if a.command.is_empty() {
|
||||
usage();
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
fn now_ms(t: SystemTime) -> i64 {
|
||||
t.duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn topic_name(t: &TopicReq) -> String {
|
||||
match t {
|
||||
TopicReq::Quotes { keys, .. } => {
|
||||
format!("quotes[{}]", keys.iter().map(|k| k.as_str()).collect::<Vec<_>>().join(","))
|
||||
}
|
||||
TopicReq::Book { key, .. } => format!("book {key}"),
|
||||
TopicReq::Trades { key } => format!("trades {key}"),
|
||||
TopicReq::Bars { key, tf, .. } => format!("bars {key} {tf}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Latency samples (ms) by measure, plus message counts by topic.
|
||||
#[derive(Default)]
|
||||
struct Tally {
|
||||
lat: BTreeMap<&'static str, Vec<i64>>,
|
||||
msgs: BTreeMap<String, u64>,
|
||||
resyncs: u64,
|
||||
statuses: Vec<String>,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
fn report(&self) {
|
||||
for (name, n) in &self.msgs {
|
||||
println!(" {name}: {n} messages");
|
||||
}
|
||||
for (what, v) in &self.lat {
|
||||
let mut v = v.clone();
|
||||
v.sort_unstable();
|
||||
let q = |p: f64| v[((v.len() - 1) as f64 * p).round() as usize];
|
||||
if !v.is_empty() {
|
||||
println!(
|
||||
" latency {what}: n={} p50={}ms p90={}ms p99={}ms max={}ms",
|
||||
v.len(),
|
||||
q(0.5),
|
||||
q(0.9),
|
||||
q(0.99),
|
||||
v[v.len() - 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
println!(" resyncs: {} statuses: {:?}", self.resyncs, self.statuses);
|
||||
}
|
||||
}
|
||||
|
||||
const QUOTE_LAT: &str = "quotes (server upstream receive -> client)";
|
||||
const BOOK_LAT: &str = "book (Hyperliquid time -> client)";
|
||||
const TRADE_LAT: &str = "trades (Hyperliquid time -> client)";
|
||||
|
||||
/// Read one subscription until `until`, tallying and (when `print`) describing what arrives.
|
||||
async fn drain(mut sub: Subscription, name: String, tally: Arc<Mutex<Tally>>, until: Instant, print: bool) {
|
||||
let mut last_print = Instant::now() - Duration::from_secs(5);
|
||||
// Hyperliquid opens a trades subscription with recent history; only later trades are live.
|
||||
let since = now_ms(SystemTime::now());
|
||||
loop {
|
||||
let Ok(Some(u)) = tokio::time::timeout_at(until.into(), sub.next()).await else { return };
|
||||
let at = now_ms(u.received);
|
||||
let mut line = None;
|
||||
{
|
||||
let mut t = tally.lock().unwrap();
|
||||
*t.msgs.entry(name.clone()).or_default() += 1;
|
||||
match &u.event {
|
||||
Event::Snapshot(TopicData::Quotes(q)) | Event::Delta(TopicDelta::Quotes(q)) => {
|
||||
// A snapshot's `ts_ms` is the price's age; a delta's is when it changed.
|
||||
for x in q.iter().filter(|_| matches!(u.event, Event::Delta(_))) {
|
||||
t.lat.entry(QUOTE_LAT).or_default().push(at - x.ts_ms);
|
||||
}
|
||||
let f = |x: &tr_proto::Quote| {
|
||||
format!("{}={} (from {}, prev day {:?})", x.key, x.px, x.source, x.prev_day)
|
||||
};
|
||||
line = Some(q.iter().map(f).collect::<Vec<_>>().join(" "));
|
||||
}
|
||||
Event::Snapshot(TopicData::Book(b)) | Event::Delta(TopicDelta::Book(b)) => {
|
||||
t.lat.entry(BOOK_LAT).or_default().push(at - b.time_ms);
|
||||
if let (Some(bid), Some(ask)) = (b.bids.first(), b.asks.first()) {
|
||||
line = Some(format!(
|
||||
"{}x{} levels, best {} x {} / {} x {}",
|
||||
b.bids.len(),
|
||||
b.asks.len(),
|
||||
bid.px,
|
||||
bid.sz,
|
||||
ask.px,
|
||||
ask.sz
|
||||
));
|
||||
}
|
||||
}
|
||||
Event::Snapshot(TopicData::Trades(tr)) | Event::Delta(TopicDelta::Trades(tr)) => {
|
||||
for x in tr.iter().filter(|x| x.time_ms >= since) {
|
||||
t.lat.entry(TRADE_LAT).or_default().push(at - x.time_ms);
|
||||
}
|
||||
if let Some(x) = tr.last() {
|
||||
line = Some(format!("{} trades, last {:?} {} @ {}", tr.len(), x.side, x.sz, x.px));
|
||||
}
|
||||
}
|
||||
Event::Snapshot(TopicData::Bars(b)) => {
|
||||
line = b.last().map(|x| format!("{} bars, last ts {} close {}", b.len(), x.ts, x.close));
|
||||
}
|
||||
Event::Delta(TopicDelta::Bar(d)) => {
|
||||
let (kind, b) = match d {
|
||||
BarDelta::Forming(b) => ("forming", b),
|
||||
BarDelta::Closed(b) => ("CLOSED", b),
|
||||
};
|
||||
line = Some(format!(
|
||||
"{kind} ts {} o {} h {} l {} c {} v {:.4}",
|
||||
b.ts, b.open, b.high, b.low, b.close, b.volume
|
||||
));
|
||||
}
|
||||
Event::Resync(r) => {
|
||||
t.resyncs += 1;
|
||||
line = Some(format!("resync ({r:?})"));
|
||||
}
|
||||
Event::Status(s) => {
|
||||
t.statuses.push(format!("{name}: {s:?}"));
|
||||
line = Some(format!("status {s:?}"));
|
||||
}
|
||||
Event::Reconnecting => line = Some("reconnecting".into()),
|
||||
Event::Resubscribing => line = Some("resubscribing".into()),
|
||||
}
|
||||
}
|
||||
let kind = match &u.event {
|
||||
Event::Snapshot(_) => "snapshot",
|
||||
Event::Delta(_) => "delta",
|
||||
_ => "event",
|
||||
};
|
||||
let important =
|
||||
!matches!(u.event, Event::Delta(TopicDelta::Book(_) | TopicDelta::Quotes(_) | TopicDelta::Trades(_)))
|
||||
|| last_print.elapsed() >= Duration::from_secs(1);
|
||||
if print
|
||||
&& important
|
||||
&& let Some(l) = line
|
||||
{
|
||||
println!("{name} {kind}: {l}");
|
||||
last_print = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn http_base(ws: &str) -> String {
|
||||
let u = normalize_url(ws);
|
||||
let u = u.replacen("wss://", "https://", 1).replacen("ws://", "http://", 1);
|
||||
u.strip_suffix("/ws").unwrap_or(&u).to_string()
|
||||
}
|
||||
|
||||
async fn print_stats(url: &str) -> Result<()> {
|
||||
let body = reqwest::get(format!("{}/stats", http_base(url))).await?.text().await?;
|
||||
let v: serde_json::Value = serde_json::from_str(&body)?;
|
||||
let u = &v["upstream"];
|
||||
println!(
|
||||
"server: upstream {}/{} connections up, {} of {} subscriptions {} by kind {}; clients {}, client subscriptions {}; topics {}",
|
||||
u["connections_up"],
|
||||
u["max_connections"],
|
||||
u["subscriptions"],
|
||||
u["max_subscriptions"],
|
||||
u["per_connection"],
|
||||
u["by_kind"],
|
||||
v["clients"],
|
||||
v["client_subscriptions"],
|
||||
v["topics"],
|
||||
);
|
||||
let keys: Vec<&str> = u["keys"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|k| k.as_str())
|
||||
.filter(|k| !k.starts_with("allMids"))
|
||||
.collect();
|
||||
println!("server: on-demand upstream subscriptions: {keys:?}");
|
||||
println!(
|
||||
"server: sent upstream {} messages in the last minute (budget {}), REST weight {} (budget {})",
|
||||
u["messages_sent_last_min"], u["messages_per_min"], u["rest_weight_last_min"], u["rest_weight_per_min"]
|
||||
);
|
||||
for (topic, q) in v["latency_us"].as_object().into_iter().flatten() {
|
||||
if q["window"].as_u64().unwrap_or(0) > 0 {
|
||||
println!(
|
||||
"server: added latency {topic}: n={} p50={}us p90={}us p99={}us max={}us",
|
||||
q["window"], q["p50_us"], q["p90_us"], q["p99_us"], q["max_us"]
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let a = parse()?;
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let enc = if a.json { Encoding::Json } else { Encoding::Postcard };
|
||||
match a.command.as_str() {
|
||||
"stats" => print_stats(&a.url).await,
|
||||
"markets" => {
|
||||
let c = Client::connect(&a.url, enc, "tr-cli");
|
||||
let Ok(RpcResult::Markets(m)) = c.request(Rpc::Markets).await.map_err(anyhow::Error::from) else {
|
||||
bail!("no market list")
|
||||
};
|
||||
let f = a.filter.unwrap_or_default().to_lowercase();
|
||||
let shown: Vec<_> = m.iter().filter(|x| x.key.as_str().to_lowercase().contains(&f)).collect();
|
||||
for x in &shown {
|
||||
println!("{:<8} {:?} {}", if x.venue.is_empty() { "core" } else { &x.venue }, x.kind, x.key);
|
||||
}
|
||||
println!("{} of {} markets", shown.len(), m.len());
|
||||
Ok(())
|
||||
}
|
||||
"watch" | "load" => {
|
||||
if a.topics.is_empty() {
|
||||
bail!("name at least one topic (--quotes, --book, --trades, --bars)");
|
||||
}
|
||||
let n = if a.command == "watch" { 1 } else { a.clients.max(1) };
|
||||
let tally = Arc::new(Mutex::new(Tally::default()));
|
||||
let mut clients = Vec::new();
|
||||
let t0 = Instant::now();
|
||||
for _ in 0..n {
|
||||
let c = Client::connect(&a.url, enc, "tr-cli");
|
||||
if !c.wait_up(Duration::from_secs(10)).await {
|
||||
bail!("could not reach the server: {:?}", c.state());
|
||||
}
|
||||
clients.push(c);
|
||||
}
|
||||
println!(
|
||||
"{n} client connection(s) up in {:?}; subscribing to {}",
|
||||
t0.elapsed(),
|
||||
a.topics.iter().map(topic_name).collect::<Vec<_>>().join(", ")
|
||||
);
|
||||
let until = Instant::now() + Duration::from_secs(a.secs);
|
||||
let mut tasks = Vec::new();
|
||||
for (i, c) in clients.iter().enumerate() {
|
||||
for t in &a.topics {
|
||||
let name = if n == 1 { topic_name(t) } else { format!("{} (all clients)", topic_name(t)) };
|
||||
tasks.push(tokio::spawn(drain(
|
||||
c.subscribe(t.clone()),
|
||||
name,
|
||||
tally.clone(),
|
||||
until,
|
||||
i == 0 && n == 1,
|
||||
)));
|
||||
}
|
||||
}
|
||||
if a.command == "load" {
|
||||
tokio::time::sleep(Duration::from_secs(a.secs.min(5))).await;
|
||||
let _ = print_stats(&a.url).await;
|
||||
}
|
||||
for t in tasks {
|
||||
let _ = t.await;
|
||||
}
|
||||
println!("after {}s across {n} client(s):", a.secs);
|
||||
tally.lock().unwrap().report();
|
||||
Ok(())
|
||||
}
|
||||
other => bail!("unknown command {other}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Gateway settings from environment variables. See `server/README.md` for the list.
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::hl::HlConfig;
|
||||
use crate::session::SessionConfig;
|
||||
use crate::store::HubConfig;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// Plain HTTP and websocket. TLS ends at the edge in front of the gateway.
|
||||
pub bind: SocketAddr,
|
||||
pub hl: HlConfig,
|
||||
pub hub: HubConfig,
|
||||
pub session: SessionConfig,
|
||||
pub max_clients: usize,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bind: SocketAddr::from((Ipv4Addr::LOCALHOST, 8787)),
|
||||
hl: HlConfig::default(),
|
||||
hub: HubConfig::default(),
|
||||
session: SessionConfig::default(),
|
||||
max_clients: 1000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn var(k: &str) -> Option<String> {
|
||||
std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn num<T: std::str::FromStr>(k: &str) -> Result<Option<T>>
|
||||
where
|
||||
T::Err: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
var(k).map(|v| v.parse::<T>().with_context(|| format!("{k}={v}"))).transpose()
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let mut c = Config::default();
|
||||
if let Some(b) = var("TR_GATEWAY_BIND") {
|
||||
c.bind = b
|
||||
.to_socket_addrs()
|
||||
.ok()
|
||||
.and_then(|mut a| a.next())
|
||||
.with_context(|| format!("TR_GATEWAY_BIND={b} (want host:port)"))?;
|
||||
}
|
||||
if let Some(u) = var("TR_HL_WS_URL") {
|
||||
c.hl.pool.ws_url = u;
|
||||
}
|
||||
if let Some(u) = var("TR_HL_INFO_URL") {
|
||||
c.hl.info_url = u;
|
||||
}
|
||||
if let Some(d) = var("TR_HL_DEXES").filter(|d| !d.eq_ignore_ascii_case("all")) {
|
||||
c.hl.dexes = Some(d.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect());
|
||||
}
|
||||
if let Some(n) = num("TR_UPSTREAM_CONNECTIONS")? {
|
||||
c.hl.pool.max_connections = n;
|
||||
}
|
||||
if let Some(n) = num("TR_UPSTREAM_SUBSCRIPTIONS")? {
|
||||
c.hl.pool.max_subscriptions = n;
|
||||
}
|
||||
if let Some(n) = num("TR_UPSTREAM_MESSAGES_PER_MIN")? {
|
||||
c.hl.pool.messages_per_min = n;
|
||||
}
|
||||
if let Some(n) = num("TR_REST_WEIGHT_PER_MIN")? {
|
||||
c.hl.rest_weight_per_min = n;
|
||||
}
|
||||
if let Some(s) = num::<f64>("TR_LINGER_SECS")? {
|
||||
c.hub.linger = Duration::from_secs_f64(s.max(0.0));
|
||||
}
|
||||
if let Some(n) = num("TR_MAX_CLIENTS")? {
|
||||
c.max_clients = n;
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
}
|
||||
|
||||
pub const HELP: &str = "\
|
||||
tr-gateway: the Terminal data server (interim: Hyperliquid's public feed)
|
||||
|
||||
Serves websocket clients at /ws, plus /healthz, /readyz, /metrics and /stats.
|
||||
Plain HTTP only: put a TLS-terminating edge in front of it for wss:// on 443.
|
||||
|
||||
Environment:
|
||||
TR_GATEWAY_BIND listen address, host:port (default localhost:8787)
|
||||
TR_HL_DEXES HIP-3 dexes besides the core dex: all (default) or a list like xyz,flx
|
||||
TR_UPSTREAM_CONNECTIONS upstream websocket connections, 2 to 8 (default 3)
|
||||
TR_UPSTREAM_SUBSCRIPTIONS upstream subscriptions, at most 950 (default 900)
|
||||
TR_UPSTREAM_MESSAGES_PER_MIN messages sent upstream a minute, at most 1800 (default 1000)
|
||||
TR_REST_WEIGHT_PER_MIN info endpoint weight a minute, at most 1100 (default 800)
|
||||
TR_LINGER_SECS keep an unwatched market's streams this long (default 20)
|
||||
TR_MAX_CLIENTS client connections (default 1000)
|
||||
TR_HL_WS_URL, TR_HL_INFO_URL upstream endpoints (default Hyperliquid mainnet)
|
||||
RUST_LOG log filter (default info)
|
||||
";
|
||||
@@ -0,0 +1,144 @@
|
||||
//! `HlPublicProvider`: Hyperliquid through its public API, the interim source until our own node
|
||||
//! is up (docs/architecture/server.md, "Interim: public feed"). Read-only public calls only: no
|
||||
//! `exchange` endpoint, no user-specific subscriptions, no orders. A node-backed provider
|
||||
//! replaces it behind the same [`Provider`] trait.
|
||||
|
||||
pub mod pool;
|
||||
pub mod rest;
|
||||
pub mod wire;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use tr_core::{Bar, Timeframe};
|
||||
use tr_proto::InstrumentKind;
|
||||
|
||||
use crate::metrics::Metrics;
|
||||
use crate::provider::{
|
||||
Capabilities, Channel, ChannelKind, DisplayPolicy, InstrumentDef, InstrumentId, Provider, ProviderId,
|
||||
SubscribeError, UpstreamHandle, UpstreamSink, UpstreamStats,
|
||||
};
|
||||
use pool::{Pool, PoolConfig};
|
||||
use rest::{CANDLE_CAP, Info};
|
||||
use wire::SubKey;
|
||||
|
||||
pub const MAINNET_WS: &str = "wss://api.hyperliquid.xyz/ws";
|
||||
pub const MAINNET_INFO: &str = "https://api.hyperliquid.xyz/info";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HlConfig {
|
||||
pub info_url: String,
|
||||
pub pool: PoolConfig,
|
||||
/// HIP-3 dexes to carry besides the core dex; `None` means every dex `perpDexs` lists.
|
||||
pub dexes: Option<Vec<String>>,
|
||||
/// REST weight per minute. Capped at 1100, below Hyperliquid's 1200 per IP.
|
||||
pub rest_weight_per_min: u32,
|
||||
}
|
||||
|
||||
impl Default for HlConfig {
|
||||
fn default() -> Self {
|
||||
Self { info_url: MAINNET_INFO.into(), pool: PoolConfig::new(MAINNET_WS), dexes: None, rest_weight_per_min: 800 }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HlPublicProvider {
|
||||
pool: Pool,
|
||||
info: Info,
|
||||
dexes: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl HlPublicProvider {
|
||||
pub fn new(cfg: HlConfig, metrics: Arc<Metrics>) -> Result<Self> {
|
||||
Ok(Self {
|
||||
pool: Pool::new(cfg.pool, metrics.clone()),
|
||||
info: Info::new(&cfg.info_url, cfg.rest_weight_per_min.clamp(20, 1100), metrics)?,
|
||||
dexes: cfg.dexes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn now_s() -> i64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for HlPublicProvider {
|
||||
fn id(&self) -> ProviderId {
|
||||
ProviderId("hyperliquid-public")
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
Capabilities {
|
||||
channels: vec![ChannelKind::Mids, ChannelKind::Book, ChannelKind::Trades, ChannelKind::Bars],
|
||||
kinds: vec![InstrumentKind::Perp, InstrumentKind::Spot],
|
||||
}
|
||||
}
|
||||
|
||||
fn display_policy(&self) -> DisplayPolicy {
|
||||
DisplayPolicy { public_display: true, entitlement: None, delay: None, attribution: "Data: Hyperliquid" }
|
||||
}
|
||||
|
||||
fn close(&self) {
|
||||
self.pool.close();
|
||||
}
|
||||
|
||||
fn stats(&self) -> UpstreamStats {
|
||||
let (used, budget) = self.info.weight();
|
||||
UpstreamStats { rest_weight_last_min: used, rest_weight_per_min: budget, ..self.pool.stats() }
|
||||
}
|
||||
|
||||
/// Perps on the core dex and the selected HIP-3 dexes, with their previous-day prices. Spot
|
||||
/// markets aren't listed here, but they stream: the core dex's mids carry them.
|
||||
async fn instruments(&self) -> Result<Vec<InstrumentDef>> {
|
||||
let mut dexes = self.info.perp_dexs().await?;
|
||||
if let Some(want) = &self.dexes {
|
||||
dexes.retain(|d| d.is_empty() || want.contains(d));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for dex in dexes {
|
||||
match self.info.perps(&dex).await {
|
||||
Ok(perps) => out.extend(perps.into_iter().filter(|p| !p.delisted).map(|p| InstrumentDef {
|
||||
id: InstrumentId(p.name),
|
||||
venue: dex.clone(),
|
||||
kind: InstrumentKind::Perp,
|
||||
prev_day: p.prev_day,
|
||||
})),
|
||||
// One dex failing shouldn't hide the others.
|
||||
Err(e) => tracing::warn!("dex {dex:?}: {e:#}"),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// From `candleSnapshot`, which only serves the most recent 5000 bars per interval: paging
|
||||
/// further back returns nothing (the interim feed's history cap, until node-built candles).
|
||||
async fn history(&self, inst: &InstrumentId, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
|
||||
let limit = limit.clamp(1, CANDLE_CAP);
|
||||
let end_s = end.unwrap_or_else(|| now_s() + 1);
|
||||
// Months are counted as 31 days so the window always covers `limit` bars.
|
||||
let span = if tf == Timeframe::MN1 { 31 * 86_400 } else { tf.seconds() };
|
||||
let start_s = end_s - span * (limit as i64 + 1);
|
||||
let bars =
|
||||
self.info.candles(inst.as_str(), wire::interval(tf), start_s * 1000, end_s * 1000 - 1, limit).await?;
|
||||
Ok(crate::clip(bars, end, limit))
|
||||
}
|
||||
|
||||
async fn subscribe(
|
||||
&self,
|
||||
ch: Channel,
|
||||
inst: &InstrumentId,
|
||||
sink: UpstreamSink,
|
||||
) -> Result<UpstreamHandle, SubscribeError> {
|
||||
let coin = inst.0.clone();
|
||||
let key = match ch {
|
||||
Channel::Mids => SubKey::AllMids { dex: coin },
|
||||
Channel::Book => SubKey::L2Book { coin },
|
||||
Channel::Trades => SubKey::Trades { coin },
|
||||
Channel::Bars(tf) => SubKey::Candle { coin, interval: wire::interval(tf) },
|
||||
};
|
||||
let guard = self.pool.subscribe(key, sink, ch == Channel::Mids)?;
|
||||
Ok(UpstreamHandle::new(guard))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
//! The server's upstream connections to Hyperliquid's public websocket, shared by every client.
|
||||
//!
|
||||
//! Hyperliquid allows 10 websocket connections, 30 new connections a minute, 1000 subscriptions
|
||||
//! and 2000 sent messages a minute per IP (RL in docs/architecture/server.md). The pool keeps a
|
||||
//! budget below each and refuses a subscription past it; it never works around a limit.
|
||||
//!
|
||||
//! Permanent subscriptions (every dex's mids) get a connection of their own. Hyperliquid closes
|
||||
//! a connection outright when it is asked for a coin it doesn't list, and that must not take
|
||||
//! the mids feed down with it.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet, VecDeque};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::wire::{self, Incoming, SubKey};
|
||||
use crate::limit::WindowLimiter;
|
||||
use crate::metrics::Metrics;
|
||||
use crate::provider::{SubscribeError, Upstream, UpstreamEvent, UpstreamSink, UpstreamStats};
|
||||
|
||||
/// Hyperliquid's own per-IP limits. Budgets are capped below these.
|
||||
pub const HL_MAX_CONNECTIONS: usize = 10;
|
||||
pub const HL_MAX_SUBSCRIPTIONS: usize = 1000;
|
||||
pub const HL_MAX_MESSAGES_PER_MIN: u32 = 2000;
|
||||
pub const HL_MAX_CONNECTS_PER_MIN: u32 = 30;
|
||||
|
||||
/// Open another on-demand connection once each one carries this many subscriptions.
|
||||
const SPREAD_AT: usize = 200;
|
||||
/// A subscription sent this recently and never acknowledged is blamed for a closed connection.
|
||||
const REJECT_WINDOW: Duration = Duration::from_secs(3);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PoolConfig {
|
||||
pub ws_url: String,
|
||||
/// Connections, the permanent one included. At least 2, at most 8.
|
||||
pub max_connections: usize,
|
||||
/// Subscriptions across all connections. At most 950.
|
||||
pub max_subscriptions: usize,
|
||||
/// Messages sent (subscribe, unsubscribe, ping) per minute across all connections.
|
||||
pub messages_per_min: u32,
|
||||
pub connects_per_min: u32,
|
||||
/// Hyperliquid closes a connection it hasn't sent anything to in 60 s.
|
||||
pub ping_every: Duration,
|
||||
/// Reconnect when nothing has arrived for this long.
|
||||
pub idle_timeout: Duration,
|
||||
}
|
||||
|
||||
impl PoolConfig {
|
||||
pub fn new(ws_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
ws_url: ws_url.into(),
|
||||
max_connections: 3,
|
||||
max_subscriptions: 900,
|
||||
messages_per_min: 1000,
|
||||
connects_per_min: 10,
|
||||
ping_every: Duration::from_secs(30),
|
||||
idle_timeout: Duration::from_secs(75),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep every budget inside Hyperliquid's limits, with room for reconnect overlap (a
|
||||
/// dropped socket may still count against the IP for a moment) and other tools on the IP.
|
||||
pub fn clamped(mut self) -> Self {
|
||||
self.max_connections = self.max_connections.clamp(2, HL_MAX_CONNECTIONS - 2);
|
||||
self.max_subscriptions = self.max_subscriptions.clamp(1, HL_MAX_SUBSCRIPTIONS - 50);
|
||||
self.messages_per_min = self.messages_per_min.clamp(10, HL_MAX_MESSAGES_PER_MIN - 200);
|
||||
self.connects_per_min = self.connects_per_min.clamp(1, HL_MAX_CONNECTS_PER_MIN - 10);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// What every connection task shares. It holds no connection handles, so the tasks end once
|
||||
/// the pool and every guard are gone.
|
||||
struct Shared {
|
||||
cfg: PoolConfig,
|
||||
sends: WindowLimiter,
|
||||
connects: WindowLimiter,
|
||||
metrics: Arc<Metrics>,
|
||||
}
|
||||
|
||||
struct Conn {
|
||||
tx: mpsc::UnboundedSender<Cmd>,
|
||||
permanent: bool,
|
||||
subs: usize,
|
||||
up: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
struct State {
|
||||
conns: Vec<Conn>,
|
||||
subs: BTreeMap<SubKey, usize>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
shared: Arc<Shared>,
|
||||
state: Mutex<State>,
|
||||
}
|
||||
|
||||
enum Cmd {
|
||||
Subscribe(SubKey, UpstreamSink),
|
||||
Unsubscribe(SubKey),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Pool(Arc<Inner>);
|
||||
|
||||
/// Holds one upstream subscription; dropping it unsubscribes.
|
||||
pub struct SubGuard {
|
||||
pool: Arc<Inner>,
|
||||
key: SubKey,
|
||||
}
|
||||
|
||||
impl Drop for SubGuard {
|
||||
fn drop(&mut self) {
|
||||
let mut st = self.pool.state.lock().expect("pool lock");
|
||||
if let Some(i) = st.subs.remove(&self.key)
|
||||
&& let Some(c) = st.conns.get_mut(i)
|
||||
{
|
||||
c.subs -= 1;
|
||||
let _ = c.tx.send(Cmd::Unsubscribe(self.key.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Pool {
|
||||
pub fn new(cfg: PoolConfig, metrics: Arc<Metrics>) -> Self {
|
||||
let cfg = cfg.clamped();
|
||||
let shared = Arc::new(Shared {
|
||||
sends: WindowLimiter::per_minute(cfg.messages_per_min),
|
||||
connects: WindowLimiter::per_minute(cfg.connects_per_min),
|
||||
cfg,
|
||||
metrics,
|
||||
});
|
||||
Self(Arc::new(Inner {
|
||||
shared,
|
||||
state: Mutex::new(State { conns: Vec::new(), subs: BTreeMap::new(), closed: false }),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Subscribe upstream, within budget. Must run inside a tokio runtime (it may open a
|
||||
/// connection).
|
||||
pub fn subscribe(&self, key: SubKey, sink: UpstreamSink, permanent: bool) -> Result<SubGuard, SubscribeError> {
|
||||
let cfg = &self.0.shared.cfg;
|
||||
let mut st = self.0.state.lock().expect("pool lock");
|
||||
if st.closed {
|
||||
return Err(SubscribeError::Unsupported("the upstream pool is closed".into()));
|
||||
}
|
||||
if st.subs.contains_key(&key) {
|
||||
return Err(SubscribeError::Unsupported(format!("{key} is already subscribed")));
|
||||
}
|
||||
if st.subs.len() >= cfg.max_subscriptions {
|
||||
return Err(SubscribeError::Capacity(format!(
|
||||
"{} of {} upstream subscriptions in use",
|
||||
st.subs.len(),
|
||||
cfg.max_subscriptions
|
||||
)));
|
||||
}
|
||||
let least = st
|
||||
.conns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| c.permanent == permanent)
|
||||
.min_by_key(|(_, c)| c.subs)
|
||||
.map(|(i, c)| (i, c.subs));
|
||||
let i = match least {
|
||||
Some((i, _)) if permanent => i,
|
||||
Some((i, n)) if n < SPREAD_AT || st.conns.len() >= cfg.max_connections => i,
|
||||
_ if st.conns.len() < cfg.max_connections => {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let up = Arc::new(AtomicBool::new(false));
|
||||
let id = st.conns.len();
|
||||
tokio::spawn(run_conn(self.0.shared.clone(), id, up.clone(), rx));
|
||||
st.conns.push(Conn { tx, permanent, subs: 0, up });
|
||||
id
|
||||
}
|
||||
_ => {
|
||||
return Err(SubscribeError::Capacity(format!(
|
||||
"all {} upstream connections are in use",
|
||||
cfg.max_connections
|
||||
)));
|
||||
}
|
||||
};
|
||||
st.conns[i].subs += 1;
|
||||
st.subs.insert(key.clone(), i);
|
||||
let _ = st.conns[i].tx.send(Cmd::Subscribe(key.clone(), sink));
|
||||
Ok(SubGuard { pool: self.0.clone(), key })
|
||||
}
|
||||
|
||||
/// Close every connection for good. Dropping a connection's sender ends its task, which
|
||||
/// closes the socket and never reconnects.
|
||||
pub fn close(&self) {
|
||||
let mut st = self.0.state.lock().expect("pool lock");
|
||||
st.closed = true;
|
||||
st.subs.clear();
|
||||
st.conns.clear();
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> UpstreamStats {
|
||||
let sh = &self.0.shared;
|
||||
let st = self.0.state.lock().expect("pool lock");
|
||||
let mut by_kind = BTreeMap::new();
|
||||
for k in st.subs.keys() {
|
||||
*by_kind.entry(k.kind()).or_insert(0) += 1;
|
||||
}
|
||||
UpstreamStats {
|
||||
connections_open: st.conns.len(),
|
||||
connections_up: st.conns.iter().filter(|c| c.up.load(Ordering::Relaxed)).count(),
|
||||
max_connections: sh.cfg.max_connections,
|
||||
subscriptions: st.subs.len(),
|
||||
max_subscriptions: sh.cfg.max_subscriptions,
|
||||
by_kind,
|
||||
per_connection: st.conns.iter().map(|c| c.subs).collect(),
|
||||
keys: st.subs.keys().map(ToString::to_string).collect(),
|
||||
messages_sent_last_min: sh.sends.used(),
|
||||
messages_per_min: sh.sends.budget(),
|
||||
connects_last_min: sh.connects.used(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
|
||||
}
|
||||
|
||||
struct Sub {
|
||||
sink: UpstreamSink,
|
||||
/// Hyperliquid acknowledged it on the current connection.
|
||||
acked: bool,
|
||||
/// When the subscribe went out on the current connection.
|
||||
sent: Option<Instant>,
|
||||
/// Sinks were told `Disconnected` and haven't heard `Reconnected` yet.
|
||||
stale: bool,
|
||||
}
|
||||
|
||||
impl Sub {
|
||||
fn event(&self, event: UpstreamEvent) {
|
||||
self.sink.send(Upstream { recv: Instant::now(), recv_ms: now_ms(), event });
|
||||
}
|
||||
}
|
||||
|
||||
enum Out {
|
||||
Subscribe(SubKey),
|
||||
Unsubscribe(SubKey),
|
||||
Ping,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum End {
|
||||
/// The connection dropped; reconnect.
|
||||
Dropped,
|
||||
/// The pool is gone; stop.
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
/// Apply a command whether or not the connection is up. Returns false once the pool is gone.
|
||||
fn apply(cmd: Option<Cmd>, subs: &mut BTreeMap<SubKey, Sub>, out: Option<&mut VecDeque<Out>>) -> bool {
|
||||
match cmd {
|
||||
None => false,
|
||||
Some(Cmd::Subscribe(key, sink)) => {
|
||||
subs.insert(key.clone(), Sub { sink, acked: false, sent: None, stale: false });
|
||||
if let Some(out) = out {
|
||||
out.push_back(Out::Subscribe(key));
|
||||
}
|
||||
true
|
||||
}
|
||||
Some(Cmd::Unsubscribe(key)) => {
|
||||
if subs.remove(&key).is_some()
|
||||
&& let Some(out) = out
|
||||
{
|
||||
out.push_back(Out::Unsubscribe(key));
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait out `fut` (a backoff or the connect limiter) while still taking commands.
|
||||
async fn wait<F: Future<Output = ()>>(
|
||||
fut: F,
|
||||
rx: &mut mpsc::UnboundedReceiver<Cmd>,
|
||||
subs: &mut BTreeMap<SubKey, Sub>,
|
||||
) -> bool {
|
||||
tokio::pin!(fut);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut fut => return true,
|
||||
cmd = rx.recv() => if !apply(cmd, subs, None) { return false },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_conn(sh: Arc<Shared>, id: usize, up: Arc<AtomicBool>, mut rx: mpsc::UnboundedReceiver<Cmd>) {
|
||||
let mut subs: BTreeMap<SubKey, Sub> = BTreeMap::new();
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
let mut connected_before = false;
|
||||
loop {
|
||||
if !wait(sh.connects.acquire(1), &mut rx, &mut subs).await {
|
||||
return;
|
||||
}
|
||||
match tokio_tungstenite::connect_async(sh.cfg.ws_url.as_str()).await {
|
||||
Ok((ws, _)) => {
|
||||
up.store(true, Ordering::Relaxed);
|
||||
if connected_before {
|
||||
Metrics::add(&sh.metrics.upstream_reconnects, 1);
|
||||
}
|
||||
connected_before = true;
|
||||
tracing::info!("upstream connection {id} up ({} subscriptions)", subs.len());
|
||||
let started = Instant::now();
|
||||
let end = session(&sh, ws, &mut subs, &mut rx).await;
|
||||
up.store(false, Ordering::Relaxed);
|
||||
blame_unacked(&sh, &mut subs);
|
||||
for s in subs.values_mut().filter(|s| !s.stale) {
|
||||
s.event(UpstreamEvent::Disconnected);
|
||||
s.stale = true;
|
||||
}
|
||||
if end == End::Shutdown {
|
||||
return;
|
||||
}
|
||||
tracing::warn!("upstream connection {id} dropped after {:?}", started.elapsed());
|
||||
if started.elapsed() > Duration::from_secs(60) {
|
||||
backoff = Duration::from_secs(1);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("upstream connection {id}: {e}"),
|
||||
}
|
||||
// Up to 25% jitter so connections don't reconnect in lockstep.
|
||||
let jitter = backoff.mul_f64(
|
||||
f64::from(SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos() % 250) / 1000.0,
|
||||
);
|
||||
if !wait(tokio::time::sleep(backoff + jitter), &mut rx, &mut subs).await {
|
||||
return;
|
||||
}
|
||||
backoff = (backoff * 2).min(Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
|
||||
/// Hyperliquid answers a subscription to a coin it doesn't list by closing the connection,
|
||||
/// with no error message. A subscription sent just before the close and never acknowledged
|
||||
/// is the likely cause: drop it rather than resubscribe it into a reconnect loop.
|
||||
fn blame_unacked(sh: &Shared, subs: &mut BTreeMap<SubKey, Sub>) {
|
||||
let suspects: Vec<SubKey> = subs
|
||||
.iter()
|
||||
.filter(|(_, s)| !s.acked && s.sent.is_some_and(|t| t.elapsed() < REJECT_WINDOW))
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
for key in suspects {
|
||||
tracing::warn!("upstream closed the connection right after `{key}`; not resubscribing it");
|
||||
Metrics::add(&sh.metrics.upstream_rejected, 1);
|
||||
if let Some(s) = subs.remove(&key) {
|
||||
s.event(UpstreamEvent::Rejected(format!("the upstream refused {key}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Ws = tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
async fn session(sh: &Shared, ws: Ws, subs: &mut BTreeMap<SubKey, Sub>, rx: &mut mpsc::UnboundedReceiver<Cmd>) -> End {
|
||||
let (mut tx, mut stream) = ws.split();
|
||||
let mut out: VecDeque<Out> = subs.keys().cloned().map(Out::Subscribe).collect();
|
||||
for s in subs.values_mut() {
|
||||
s.acked = false;
|
||||
s.sent = None;
|
||||
}
|
||||
// Keys whose subscribe went out on this connection, so an unsubscribe is only ever sent
|
||||
// for something actually subscribed.
|
||||
let mut active: HashSet<SubKey> = HashSet::new();
|
||||
let mut ping = tokio::time::interval(sh.cfg.ping_every);
|
||||
ping.tick().await;
|
||||
let mut last_rx = Instant::now();
|
||||
loop {
|
||||
// Drop queued messages that no longer apply before spending send budget on them.
|
||||
while let Some(o) = out.front() {
|
||||
let stale = match o {
|
||||
Out::Subscribe(k) => !subs.contains_key(k) || active.contains(k),
|
||||
Out::Unsubscribe(k) => !active.contains(k) || subs.contains_key(k),
|
||||
Out::Ping => false,
|
||||
};
|
||||
if !stale {
|
||||
break;
|
||||
}
|
||||
out.pop_front();
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
msg = stream.next() => match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
let recv = Instant::now();
|
||||
last_rx = recv;
|
||||
Metrics::add(&sh.metrics.upstream_messages, 1);
|
||||
Metrics::add(&sh.metrics.upstream_bytes, text.len() as u64);
|
||||
handle(text.as_str(), recv, subs);
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => return End::Dropped,
|
||||
Some(Ok(_)) => last_rx = Instant::now(),
|
||||
Some(Err(e)) => {
|
||||
tracing::debug!("upstream read: {e}");
|
||||
return End::Dropped;
|
||||
}
|
||||
},
|
||||
cmd = rx.recv() => if !apply(cmd, subs, Some(&mut out)) {
|
||||
let _ = tx.close().await;
|
||||
return End::Shutdown;
|
||||
},
|
||||
_ = sh.sends.acquire(1), if !out.is_empty() => {
|
||||
let text = match out.pop_front().expect("checked non-empty") {
|
||||
Out::Subscribe(k) => {
|
||||
if let Some(s) = subs.get_mut(&k) {
|
||||
s.sent = Some(Instant::now());
|
||||
}
|
||||
let m = k.message("subscribe");
|
||||
active.insert(k);
|
||||
m
|
||||
}
|
||||
Out::Unsubscribe(k) => {
|
||||
active.remove(&k);
|
||||
k.message("unsubscribe")
|
||||
}
|
||||
Out::Ping => wire::PING.to_string(),
|
||||
};
|
||||
if let Err(e) = tx.send(Message::Text(text.into())).await {
|
||||
tracing::debug!("upstream write: {e}");
|
||||
return End::Dropped;
|
||||
}
|
||||
}
|
||||
_ = ping.tick() => {
|
||||
if last_rx.elapsed() > sh.cfg.idle_timeout {
|
||||
tracing::warn!("upstream silent for {:?}; reconnecting", last_rx.elapsed());
|
||||
return End::Dropped;
|
||||
}
|
||||
out.push_back(Out::Ping);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(text: &str, recv: Instant, subs: &mut BTreeMap<SubKey, Sub>) {
|
||||
let recv_ms = now_ms();
|
||||
match wire::parse(text) {
|
||||
Ok(Incoming::Data(key, event)) => {
|
||||
if let Some(s) = subs.get_mut(&key) {
|
||||
if s.stale {
|
||||
s.stale = false;
|
||||
s.sink.send(Upstream { recv, recv_ms, event: UpstreamEvent::Reconnected });
|
||||
}
|
||||
s.acked = true;
|
||||
s.sink.send(Upstream { recv, recv_ms, event });
|
||||
}
|
||||
}
|
||||
Ok(Incoming::Subscribed(key)) => {
|
||||
if let Some(s) = subs.get_mut(&key) {
|
||||
s.acked = true;
|
||||
if s.stale {
|
||||
s.stale = false;
|
||||
s.sink.send(Upstream { recv, recv_ms, event: UpstreamEvent::Reconnected });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Incoming::Error(e)) => tracing::warn!("upstream error: {e}"),
|
||||
Ok(Incoming::Unsubscribed(_) | Incoming::Pong | Incoming::Ignored) => {}
|
||||
Err(e) => tracing::debug!("upstream message skipped: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sink() -> UpstreamSink {
|
||||
UpstreamSink::new(|_| {})
|
||||
}
|
||||
|
||||
fn book(c: &str) -> SubKey {
|
||||
SubKey::L2Book { coin: c.into() }
|
||||
}
|
||||
|
||||
fn pool(max_connections: usize, max_subscriptions: usize) -> Pool {
|
||||
// Nothing listens here: the connection tasks retry in the background, which these
|
||||
// tests don't need. They only check the pool's accounting.
|
||||
let mut cfg = PoolConfig::new("ws://localhost:9/ws");
|
||||
cfg.max_connections = max_connections;
|
||||
cfg.max_subscriptions = max_subscriptions;
|
||||
Pool::new(cfg, Arc::new(Metrics::default()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budgets_are_capped_below_hyperliquid_limits() {
|
||||
let mut cfg = PoolConfig::new("x");
|
||||
cfg.max_connections = 50;
|
||||
cfg.max_subscriptions = 5000;
|
||||
cfg.messages_per_min = 9999;
|
||||
cfg.connects_per_min = 100;
|
||||
let c = cfg.clamped();
|
||||
assert!(c.max_connections < HL_MAX_CONNECTIONS && c.max_subscriptions < HL_MAX_SUBSCRIPTIONS);
|
||||
assert!(c.messages_per_min < HL_MAX_MESSAGES_PER_MIN && c.connects_per_min < HL_MAX_CONNECTS_PER_MIN);
|
||||
assert_eq!(PoolConfig { max_connections: 1, ..PoolConfig::new("x") }.clamped().max_connections, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_subscriptions_past_the_budget() {
|
||||
let p = pool(3, 4);
|
||||
let mids = p.subscribe(SubKey::AllMids { dex: String::new() }, sink(), true).unwrap();
|
||||
let a = p.subscribe(book("BTC"), sink(), false).unwrap();
|
||||
let _b = p.subscribe(book("ETH"), sink(), false).unwrap();
|
||||
let _c = p.subscribe(SubKey::Trades { coin: "BTC".into() }, sink(), false).unwrap();
|
||||
assert!(matches!(p.subscribe(book("SOL"), sink(), false), Err(SubscribeError::Capacity(_))));
|
||||
assert!(matches!(p.subscribe(book("ETH"), sink(), false), Err(SubscribeError::Unsupported(_))));
|
||||
let s = p.stats();
|
||||
assert_eq!((s.subscriptions, s.connections_open), (4, 2), "mids alone on one, the rest on another");
|
||||
assert_eq!(s.per_connection, vec![1, 3]);
|
||||
assert_eq!(s.by_kind.get("l2Book"), Some(&2));
|
||||
// Releasing one frees a slot.
|
||||
drop(a);
|
||||
assert_eq!(p.stats().subscriptions, 3);
|
||||
let _d = p.subscribe(book("SOL"), sink(), false).unwrap();
|
||||
drop(mids);
|
||||
assert_eq!(p.stats().per_connection, vec![0, 3]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn never_opens_more_connections_than_the_budget() {
|
||||
let p = pool(2, 900);
|
||||
let _m = p.subscribe(SubKey::AllMids { dex: "xyz".into() }, sink(), true).unwrap();
|
||||
let guards: Vec<_> =
|
||||
(0..SPREAD_AT + 20).map(|i| p.subscribe(book(&format!("C{i}")), sink(), false).unwrap()).collect();
|
||||
let s = p.stats();
|
||||
assert_eq!(s.connections_open, 2);
|
||||
assert_eq!(s.per_connection, vec![1, SPREAD_AT + 20], "past SPREAD_AT the last connection keeps filling");
|
||||
drop(guards);
|
||||
let p = pool(3, 900);
|
||||
let _m = p.subscribe(SubKey::AllMids { dex: String::new() }, sink(), true).unwrap();
|
||||
let _g: Vec<_> =
|
||||
(0..SPREAD_AT + 1).map(|i| p.subscribe(book(&format!("C{i}")), sink(), false).unwrap()).collect();
|
||||
assert_eq!(p.stats().per_connection, vec![1, SPREAD_AT, 1], "a third connection opens once the second is full");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Read-only calls to Hyperliquid's public `info` endpoint, under a weight self-limit that stays
|
||||
//! below Hyperliquid's 1200 per minute per IP (RL in docs/architecture/server.md).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
use tr_core::Bar;
|
||||
|
||||
use crate::limit::WindowLimiter;
|
||||
use crate::metrics::Metrics;
|
||||
|
||||
/// `candleSnapshot` serves at most this many of the most recent bars per interval.
|
||||
pub const CANDLE_CAP: usize = 5000;
|
||||
|
||||
/// Weight Hyperliquid charges for a `candleSnapshot` returning `n` bars: 20, plus 1 per 60.
|
||||
pub fn candle_weight(n: usize) -> u32 {
|
||||
20 + n.div_ceil(60) as u32
|
||||
}
|
||||
|
||||
pub struct Info {
|
||||
http: reqwest::Client,
|
||||
url: String,
|
||||
limiter: WindowLimiter,
|
||||
metrics: Arc<Metrics>,
|
||||
}
|
||||
|
||||
/// One perp from `metaAndAssetCtxs`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PerpCtx {
|
||||
pub name: String,
|
||||
pub prev_day: Option<f64>,
|
||||
pub delisted: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Universe {
|
||||
universe: Vec<UniverseEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UniverseEntry {
|
||||
name: String,
|
||||
#[serde(default, rename = "isDelisted")]
|
||||
delisted: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Ctx {
|
||||
#[serde(rename = "prevDayPx")]
|
||||
prev_day: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Candle {
|
||||
t: i64,
|
||||
o: String,
|
||||
h: String,
|
||||
l: String,
|
||||
c: String,
|
||||
v: String,
|
||||
}
|
||||
|
||||
impl Info {
|
||||
pub fn new(url: &str, weight_per_min: u32, metrics: Arc<Metrics>) -> Result<Self> {
|
||||
let http = reqwest::Client::builder()
|
||||
.user_agent(concat!("terminal-gateway/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()?;
|
||||
Ok(Self { http, url: url.into(), limiter: WindowLimiter::per_minute(weight_per_min), metrics })
|
||||
}
|
||||
|
||||
/// Weight spent in the last minute, and the self-limit.
|
||||
pub fn weight(&self) -> (u32, u32) {
|
||||
(self.limiter.used(), self.limiter.budget())
|
||||
}
|
||||
|
||||
async fn post<T: DeserializeOwned>(&self, body: Value, weight: u32) -> Result<T> {
|
||||
self.limiter.acquire(weight).await;
|
||||
Metrics::add(&self.metrics.rest_requests, 1);
|
||||
let kind = body["type"].as_str().unwrap_or("?").to_string();
|
||||
let result = async {
|
||||
let resp = self.http.post(&self.url).json(&body).send().await?;
|
||||
let status = resp.status();
|
||||
if status.as_u16() == 429 {
|
||||
// Keep every caller off the endpoint for a full window.
|
||||
self.limiter.charge(self.limiter.budget());
|
||||
bail!("rate limited (HTTP 429)");
|
||||
}
|
||||
if !status.is_success() {
|
||||
bail!("HTTP {status}");
|
||||
}
|
||||
Ok(resp.json::<T>().await?)
|
||||
}
|
||||
.await;
|
||||
if result.is_err() {
|
||||
Metrics::add(&self.metrics.rest_errors, 1);
|
||||
}
|
||||
result.with_context(|| format!("info {kind}"))
|
||||
}
|
||||
|
||||
/// Perp dex names; `""` is the core dex.
|
||||
pub async fn perp_dexs(&self) -> Result<Vec<String>> {
|
||||
let v: Vec<Option<Value>> = self.post(json!({ "type": "perpDexs" }), 20).await?;
|
||||
Ok(v.into_iter().map(|d| d.and_then(|d| d["name"].as_str().map(str::to_string)).unwrap_or_default()).collect())
|
||||
}
|
||||
|
||||
pub async fn perps(&self, dex: &str) -> Result<Vec<PerpCtx>> {
|
||||
let body = if dex.is_empty() {
|
||||
json!({ "type": "metaAndAssetCtxs" })
|
||||
} else {
|
||||
json!({ "type": "metaAndAssetCtxs", "dex": dex })
|
||||
};
|
||||
let (meta, ctxs): (Universe, Vec<Ctx>) = self.post(body, 20).await?;
|
||||
Ok(meta
|
||||
.universe
|
||||
.into_iter()
|
||||
.zip(ctxs)
|
||||
.map(|(u, c)| PerpCtx {
|
||||
name: u.name,
|
||||
prev_day: c.prev_day.and_then(|p| p.parse().ok()),
|
||||
delisted: u.delisted,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Bars opening in `[start_ms, end_ms]`, oldest first. `expect` sizes the weight up front;
|
||||
/// any extra weight the response turns out to cost is charged afterwards.
|
||||
pub async fn candles(
|
||||
&self,
|
||||
coin: &str,
|
||||
interval: &str,
|
||||
start_ms: i64,
|
||||
end_ms: i64,
|
||||
expect: usize,
|
||||
) -> Result<Vec<Bar>> {
|
||||
let body = json!({
|
||||
"type": "candleSnapshot",
|
||||
"req": { "coin": coin, "interval": interval, "startTime": start_ms, "endTime": end_ms },
|
||||
});
|
||||
let charged = candle_weight(expect.min(CANDLE_CAP));
|
||||
let rows: Vec<Candle> = self.post(body, charged).await?;
|
||||
let actual = candle_weight(rows.len());
|
||||
if actual > charged {
|
||||
self.limiter.charge(actual - charged);
|
||||
}
|
||||
let p = |s: &str| s.parse::<f64>().with_context(|| format!("candle number {s:?}"));
|
||||
let mut bars = rows
|
||||
.iter()
|
||||
.map(|r| Ok(Bar::new(r.t.div_euclid(1000), p(&r.o)?, p(&r.h)?, p(&r.l)?, p(&r.c)?, p(&r.v)?)))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
bars.sort_by_key(|b| b.ts);
|
||||
bars.dedup_by_key(|b| b.ts);
|
||||
Ok(bars)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn candle_weights() {
|
||||
assert_eq!(candle_weight(0), 20);
|
||||
assert_eq!(candle_weight(60), 21);
|
||||
assert_eq!(candle_weight(61), 22);
|
||||
assert_eq!(candle_weight(CANDLE_CAP), 104);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Hyperliquid's public websocket messages: what we send and how we read what comes back.
|
||||
//! Shapes follow the docs (WS-SUB in docs/architecture/server.md) and recorded live messages
|
||||
//! in `tests/fixtures/hl`. Prices and sizes arrive as decimal strings (candles too, although
|
||||
//! the docs type them as numbers), so every number accepts either.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::de::{self, Deserializer, Visitor};
|
||||
use serde_json::json;
|
||||
use serde_json::value::RawValue;
|
||||
use tr_core::{Bar, Timeframe};
|
||||
use tr_proto::{Level, Side, Trade};
|
||||
|
||||
use crate::provider::UpstreamEvent;
|
||||
|
||||
/// One upstream subscription, as Hyperliquid identifies it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum SubKey {
|
||||
/// `dex` is `""` for the core perp dex (whose mids also carry spot).
|
||||
AllMids {
|
||||
dex: String,
|
||||
},
|
||||
L2Book {
|
||||
coin: String,
|
||||
},
|
||||
Trades {
|
||||
coin: String,
|
||||
},
|
||||
Candle {
|
||||
coin: String,
|
||||
interval: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
impl SubKey {
|
||||
pub fn subscription(&self) -> serde_json::Value {
|
||||
match self {
|
||||
SubKey::AllMids { dex } if dex.is_empty() => json!({ "type": "allMids" }),
|
||||
SubKey::AllMids { dex } => json!({ "type": "allMids", "dex": dex }),
|
||||
SubKey::L2Book { coin } => json!({ "type": "l2Book", "coin": coin }),
|
||||
SubKey::Trades { coin } => json!({ "type": "trades", "coin": coin }),
|
||||
SubKey::Candle { coin, interval } => json!({ "type": "candle", "coin": coin, "interval": interval }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `subscribe` or `unsubscribe` message for this subscription.
|
||||
pub fn message(&self, method: &str) -> String {
|
||||
json!({ "method": method, "subscription": self.subscription() }).to_string()
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
SubKey::AllMids { .. } => "allMids",
|
||||
SubKey::L2Book { .. } => "l2Book",
|
||||
SubKey::Trades { .. } => "trades",
|
||||
SubKey::Candle { .. } => "candle",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SubKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SubKey::AllMids { dex } if dex.is_empty() => f.write_str("allMids"),
|
||||
SubKey::AllMids { dex } => write!(f, "allMids:{dex}"),
|
||||
SubKey::L2Book { coin } => write!(f, "l2Book:{coin}"),
|
||||
SubKey::Trades { coin } => write!(f, "trades:{coin}"),
|
||||
SubKey::Candle { coin, interval } => write!(f, "candle:{coin}:{interval}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const PING: &str = r#"{"method":"ping"}"#;
|
||||
|
||||
/// Hyperliquid's name for a timeframe.
|
||||
pub fn interval(tf: Timeframe) -> &'static str {
|
||||
match tf {
|
||||
Timeframe::M1 => "1m",
|
||||
Timeframe::M5 => "5m",
|
||||
Timeframe::M15 => "15m",
|
||||
Timeframe::M30 => "30m",
|
||||
Timeframe::H1 => "1h",
|
||||
Timeframe::H4 => "4h",
|
||||
Timeframe::D1 => "1d",
|
||||
Timeframe::W1 => "1w",
|
||||
Timeframe::MN1 => "1M",
|
||||
}
|
||||
}
|
||||
|
||||
fn static_interval(s: &str) -> Option<&'static str> {
|
||||
Timeframe::ALL.into_iter().map(interval).find(|i| *i == s)
|
||||
}
|
||||
|
||||
/// What one upstream text frame means to us.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Incoming {
|
||||
Data(SubKey, UpstreamEvent),
|
||||
Subscribed(SubKey),
|
||||
Unsubscribed(SubKey),
|
||||
Pong,
|
||||
Error(String),
|
||||
/// A channel we don't use, or an empty batch.
|
||||
Ignored,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Envelope<'a> {
|
||||
#[serde(borrow)]
|
||||
channel: Cow<'a, str>,
|
||||
#[serde(borrow)]
|
||||
data: Option<&'a RawValue>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AllMids {
|
||||
mids: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
dex: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WsLevel {
|
||||
#[serde(deserialize_with = "num")]
|
||||
px: f64,
|
||||
#[serde(deserialize_with = "num")]
|
||||
sz: f64,
|
||||
n: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WsBook {
|
||||
coin: String,
|
||||
time: i64,
|
||||
levels: (Vec<WsLevel>, Vec<WsLevel>),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WsTrade {
|
||||
coin: String,
|
||||
side: String,
|
||||
#[serde(deserialize_with = "num")]
|
||||
px: f64,
|
||||
#[serde(deserialize_with = "num")]
|
||||
sz: f64,
|
||||
time: i64,
|
||||
tid: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WsCandle {
|
||||
t: i64,
|
||||
s: String,
|
||||
i: String,
|
||||
#[serde(deserialize_with = "num")]
|
||||
o: f64,
|
||||
#[serde(deserialize_with = "num")]
|
||||
h: f64,
|
||||
#[serde(deserialize_with = "num")]
|
||||
l: f64,
|
||||
#[serde(deserialize_with = "num")]
|
||||
c: f64,
|
||||
#[serde(deserialize_with = "num")]
|
||||
v: f64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SubAck {
|
||||
method: String,
|
||||
subscription: SubObj,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SubObj {
|
||||
#[serde(rename = "type")]
|
||||
ty: String,
|
||||
coin: Option<String>,
|
||||
dex: Option<String>,
|
||||
interval: Option<String>,
|
||||
}
|
||||
|
||||
impl SubObj {
|
||||
fn key(self) -> Option<SubKey> {
|
||||
Some(match self.ty.as_str() {
|
||||
"allMids" => SubKey::AllMids { dex: self.dex.unwrap_or_default() },
|
||||
"l2Book" => SubKey::L2Book { coin: self.coin? },
|
||||
"trades" => SubKey::Trades { coin: self.coin? },
|
||||
"candle" => SubKey::Candle { coin: self.coin?, interval: static_interval(self.interval.as_deref()?)? },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A price or size that may be a JSON string or number.
|
||||
fn num<'de, D: Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
|
||||
struct V;
|
||||
impl Visitor<'_> for V {
|
||||
type Value = f64;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("a number or a decimal string")
|
||||
}
|
||||
fn visit_f64<E: de::Error>(self, v: f64) -> Result<f64, E> {
|
||||
Ok(v)
|
||||
}
|
||||
fn visit_u64<E: de::Error>(self, v: u64) -> Result<f64, E> {
|
||||
Ok(v as f64)
|
||||
}
|
||||
fn visit_i64<E: de::Error>(self, v: i64) -> Result<f64, E> {
|
||||
Ok(v as f64)
|
||||
}
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> Result<f64, E> {
|
||||
v.parse().map_err(|_| E::custom(format!("bad number {v:?}")))
|
||||
}
|
||||
}
|
||||
d.deserialize_any(V)
|
||||
}
|
||||
|
||||
fn levels(v: Vec<WsLevel>) -> Vec<Level> {
|
||||
v.into_iter().map(|l| Level { px: l.px, sz: l.sz, n: l.n }).collect()
|
||||
}
|
||||
|
||||
/// Parse one text frame from the upstream websocket.
|
||||
pub fn parse(text: &str) -> Result<Incoming, String> {
|
||||
let env: Envelope = serde_json::from_str(text).map_err(|e| format!("envelope: {e}"))?;
|
||||
let data = || env.data.map(RawValue::get).ok_or_else(|| format!("{} without data", env.channel));
|
||||
let err = |e: serde_json::Error| format!("{}: {e}", env.channel);
|
||||
Ok(match env.channel.as_ref() {
|
||||
"allMids" => {
|
||||
let m: AllMids = serde_json::from_str(data()?).map_err(err)?;
|
||||
// `#…` keys are outcome (HIP-4) markets, which are out of scope (§5.7).
|
||||
let mids = m
|
||||
.mids
|
||||
.into_iter()
|
||||
.filter(|(k, _)| !k.starts_with('#'))
|
||||
.filter_map(|(k, v)| Some((k, v.parse::<f64>().ok()?)))
|
||||
.collect();
|
||||
Incoming::Data(SubKey::AllMids { dex: m.dex.unwrap_or_default() }, UpstreamEvent::Mids(mids))
|
||||
}
|
||||
"l2Book" => {
|
||||
let b: WsBook = serde_json::from_str(data()?).map_err(err)?;
|
||||
let ev = UpstreamEvent::Book { bids: levels(b.levels.0), asks: levels(b.levels.1), time_ms: b.time };
|
||||
Incoming::Data(SubKey::L2Book { coin: b.coin }, ev)
|
||||
}
|
||||
"trades" => {
|
||||
let ts: Vec<WsTrade> = serde_json::from_str(data()?).map_err(err)?;
|
||||
let Some(coin) = ts.first().map(|t| t.coin.clone()) else { return Ok(Incoming::Ignored) };
|
||||
let trades = ts
|
||||
.into_iter()
|
||||
.map(|t| Trade {
|
||||
px: t.px,
|
||||
sz: t.sz,
|
||||
side: if t.side == "B" { Side::Buy } else { Side::Sell },
|
||||
time_ms: t.time,
|
||||
tid: t.tid,
|
||||
})
|
||||
.collect();
|
||||
Incoming::Data(SubKey::Trades { coin }, UpstreamEvent::Trades(trades))
|
||||
}
|
||||
"candle" => {
|
||||
let c: WsCandle = serde_json::from_str(data()?).map_err(err)?;
|
||||
let Some(interval) = static_interval(&c.i) else { return Ok(Incoming::Ignored) };
|
||||
let bar = Bar::new(c.t.div_euclid(1000), c.o, c.h, c.l, c.c, c.v);
|
||||
Incoming::Data(SubKey::Candle { coin: c.s, interval }, UpstreamEvent::Bar(bar))
|
||||
}
|
||||
"subscriptionResponse" => {
|
||||
let ack: SubAck = serde_json::from_str(data()?).map_err(err)?;
|
||||
match (ack.subscription.key(), ack.method.as_str()) {
|
||||
(Some(k), "subscribe") => Incoming::Subscribed(k),
|
||||
(Some(k), "unsubscribe") => Incoming::Unsubscribed(k),
|
||||
_ => Incoming::Ignored,
|
||||
}
|
||||
}
|
||||
"pong" => Incoming::Pong,
|
||||
"error" => Incoming::Error(match env.data {
|
||||
Some(d) => serde_json::from_str::<String>(d.get()).unwrap_or_else(|_| d.get().to_string()),
|
||||
None => String::new(),
|
||||
}),
|
||||
_ => Incoming::Ignored,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
std::fs::read_to_string(format!("{}/tests/fixtures/hl/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscription_messages() {
|
||||
let k = SubKey::Candle { coin: "BTC".into(), interval: interval(Timeframe::M1) };
|
||||
assert_eq!(
|
||||
k.message("subscribe"),
|
||||
r#"{"method":"subscribe","subscription":{"coin":"BTC","interval":"1m","type":"candle"}}"#
|
||||
);
|
||||
assert_eq!(SubKey::AllMids { dex: String::new() }.subscription(), json!({"type": "allMids"}));
|
||||
assert_eq!(SubKey::AllMids { dex: "xyz".into() }.to_string(), "allMids:xyz");
|
||||
assert_eq!(k.to_string(), "candle:BTC:1m");
|
||||
assert_eq!(Timeframe::ALL.map(interval), ["1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w", "1M"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_messages() {
|
||||
let Incoming::Data(SubKey::AllMids { dex }, UpstreamEvent::Mids(m)) =
|
||||
parse(&fixture("all_mids_xyz.json")).unwrap()
|
||||
else {
|
||||
panic!("allMids")
|
||||
};
|
||||
assert_eq!(dex, "xyz");
|
||||
assert!(m.iter().any(|(k, px)| k == "xyz:TSLA" && *px > 0.0));
|
||||
let Incoming::Data(SubKey::AllMids { dex }, UpstreamEvent::Mids(m)) =
|
||||
parse(&fixture("all_mids_core.json")).unwrap()
|
||||
else {
|
||||
panic!("core allMids")
|
||||
};
|
||||
assert_eq!(dex, "");
|
||||
assert!(m.iter().any(|(k, _)| k == "BTC") && m.iter().any(|(k, _)| k.starts_with('@')));
|
||||
assert!(!m.iter().any(|(k, _)| k.starts_with('#')), "outcome markets are dropped");
|
||||
|
||||
let Incoming::Data(SubKey::L2Book { coin }, UpstreamEvent::Book { bids, asks, time_ms }) =
|
||||
parse(&fixture("l2book_btc.json")).unwrap()
|
||||
else {
|
||||
panic!("l2Book")
|
||||
};
|
||||
assert_eq!((coin.as_str(), bids.len(), asks.len()), ("BTC", 20, 20));
|
||||
assert!(bids[0].px < asks[0].px && bids.windows(2).all(|w| w[0].px > w[1].px) && time_ms > 1_700_000_000_000);
|
||||
|
||||
let Incoming::Data(SubKey::Trades { coin }, UpstreamEvent::Trades(t)) =
|
||||
parse(&fixture("trades_btc.json")).unwrap()
|
||||
else {
|
||||
panic!("trades")
|
||||
};
|
||||
assert_eq!(coin, "BTC");
|
||||
assert!(!t.is_empty() && t.iter().all(|x| x.px > 0.0 && x.sz > 0.0 && x.tid > 0));
|
||||
|
||||
let Incoming::Data(SubKey::Candle { coin, interval }, UpstreamEvent::Bar(b)) =
|
||||
parse(&fixture("candle_btc_1m.json")).unwrap()
|
||||
else {
|
||||
panic!("candle")
|
||||
};
|
||||
assert_eq!((coin.as_str(), interval), ("BTC", "1m"));
|
||||
assert_eq!(b.ts % 60, 0);
|
||||
assert!(b.low <= b.open && b.open <= b.high && b.volume > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_messages() {
|
||||
let ack = r#"{"channel":"subscriptionResponse","data":{"method":"subscribe","subscription":{"type":"l2Book","coin":"BTC","nSigFigs":null,"mantissa":null,"fast":false}}}"#;
|
||||
assert_eq!(parse(ack).unwrap(), Incoming::Subscribed(SubKey::L2Book { coin: "BTC".into() }));
|
||||
let ack = r#"{"channel":"subscriptionResponse","data":{"method":"unsubscribe","subscription":{"type":"candle","interval":"1h","coin":"xyz:TSLA"}}}"#;
|
||||
assert_eq!(
|
||||
parse(ack).unwrap(),
|
||||
Incoming::Unsubscribed(SubKey::Candle { coin: "xyz:TSLA".into(), interval: "1h" })
|
||||
);
|
||||
let ack =
|
||||
r#"{"channel":"subscriptionResponse","data":{"method":"subscribe","subscription":{"type":"allMids"}}}"#;
|
||||
assert_eq!(parse(ack).unwrap(), Incoming::Subscribed(SubKey::AllMids { dex: String::new() }));
|
||||
assert_eq!(parse(r#"{"channel":"pong"}"#).unwrap(), Incoming::Pong);
|
||||
assert_eq!(
|
||||
parse(r#"{"channel":"error","data":"Invalid subscription"}"#).unwrap(),
|
||||
Incoming::Error("Invalid subscription".into())
|
||||
);
|
||||
assert_eq!(parse(r#"{"channel":"trades","data":[]}"#).unwrap(), Incoming::Ignored);
|
||||
assert_eq!(parse(r#"{"channel":"webData3","data":{}}"#).unwrap(), Incoming::Ignored);
|
||||
assert!(parse("not json").is_err());
|
||||
// Numbers may come as JSON numbers, as the docs type candles.
|
||||
let c = r#"{"channel":"candle","data":{"t":60000,"T":119999,"s":"ETH","i":"5m","o":1,"c":2.5,"h":3,"l":0.5,"v":10,"n":4}}"#;
|
||||
assert_eq!(
|
||||
parse(c).unwrap(),
|
||||
Incoming::Data(
|
||||
SubKey::Candle { coin: "ETH".into(), interval: "5m" },
|
||||
UpstreamEvent::Bar(Bar::new(60, 1.0, 3.0, 0.5, 2.5, 10.0))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! The gateway's HTTP side: the client websocket at `/ws`, and `/healthz`, `/readyz`,
|
||||
//! `/metrics` (Prometheus text) and `/stats` (JSON) for operators.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::{State, WebSocketUpgrade};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::metrics::{Metrics, Quantiles, TopicKind};
|
||||
use crate::provider::UpstreamStats;
|
||||
use crate::session::{self, SessionConfig};
|
||||
use crate::store::{Hub, TopicCounts};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub hub: Arc<Hub>,
|
||||
pub sessions: Arc<SessionConfig>,
|
||||
pub max_clients: usize,
|
||||
/// Becomes true when the gateway shuts down; every session closes.
|
||||
closing: watch::Receiver<bool>,
|
||||
next_session: Arc<AtomicU64>,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(hub: Arc<Hub>, sessions: SessionConfig, max_clients: usize, closing: watch::Receiver<bool>) -> Self {
|
||||
Self {
|
||||
hub,
|
||||
sessions: Arc::new(sessions),
|
||||
max_clients,
|
||||
closing,
|
||||
next_session: Arc::new(AtomicU64::new(1)),
|
||||
started: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/ws", get(ws))
|
||||
.route("/healthz", get(|| async { "ok\n" }))
|
||||
.route("/readyz", get(readyz))
|
||||
.route("/metrics", get(metrics))
|
||||
.route("/stats", get(stats_json))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn ws(State(st): State<AppState>, upgrade: WebSocketUpgrade) -> Response {
|
||||
if st.hub.metrics.clients.load(Ordering::Relaxed) >= st.max_clients as i64 {
|
||||
return (StatusCode::SERVICE_UNAVAILABLE, "too many clients\n").into_response();
|
||||
}
|
||||
let id = st.next_session.fetch_add(1, Ordering::Relaxed);
|
||||
upgrade
|
||||
.max_message_size(64 * 1024)
|
||||
.on_upgrade(move |socket| session::run(socket, st.hub, st.sessions, id, st.closing))
|
||||
}
|
||||
|
||||
async fn readyz(State(st): State<AppState>) -> Response {
|
||||
let up = st.hub.provider().stats().connections_up;
|
||||
if up > 0 && st.hub.is_ready() {
|
||||
(StatusCode::OK, "ready\n").into_response()
|
||||
} else {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
format!("not ready: {up} upstream connections up, {} mids\n", st.hub.mids.len()),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Stats {
|
||||
pub uptime_s: u64,
|
||||
pub provider: &'static str,
|
||||
pub attribution: &'static str,
|
||||
pub upstream: UpstreamStats,
|
||||
pub upstream_messages: u64,
|
||||
pub upstream_bytes: u64,
|
||||
pub upstream_reconnects: u64,
|
||||
pub upstream_rejected: u64,
|
||||
pub rest_requests: u64,
|
||||
pub rest_errors: u64,
|
||||
pub topics: TopicCounts,
|
||||
pub clients: i64,
|
||||
pub clients_total: u64,
|
||||
pub client_subscriptions: i64,
|
||||
pub frames_sent: u64,
|
||||
pub bytes_sent: u64,
|
||||
pub resyncs: u64,
|
||||
pub slow_consumer_closes: u64,
|
||||
/// Upstream frame received → client frame written, per topic, in microseconds.
|
||||
pub latency_us: BTreeMap<&'static str, Quantiles>,
|
||||
}
|
||||
|
||||
pub fn stats(st: &AppState) -> Stats {
|
||||
let m = &st.hub.metrics;
|
||||
let p = st.hub.provider();
|
||||
Stats {
|
||||
uptime_s: st.started.elapsed().as_secs(),
|
||||
provider: p.id().0,
|
||||
attribution: p.display_policy().attribution,
|
||||
upstream: p.stats(),
|
||||
upstream_messages: Metrics::get(&m.upstream_messages),
|
||||
upstream_bytes: Metrics::get(&m.upstream_bytes),
|
||||
upstream_reconnects: Metrics::get(&m.upstream_reconnects),
|
||||
upstream_rejected: Metrics::get(&m.upstream_rejected),
|
||||
rest_requests: Metrics::get(&m.rest_requests),
|
||||
rest_errors: Metrics::get(&m.rest_errors),
|
||||
topics: st.hub.topic_counts(),
|
||||
clients: m.clients.load(Ordering::Relaxed),
|
||||
clients_total: Metrics::get(&m.clients_total),
|
||||
client_subscriptions: m.client_subscriptions.load(Ordering::Relaxed),
|
||||
frames_sent: Metrics::get(&m.frames_sent),
|
||||
bytes_sent: Metrics::get(&m.bytes_sent),
|
||||
resyncs: Metrics::get(&m.resyncs),
|
||||
slow_consumer_closes: Metrics::get(&m.slow_consumer_closes),
|
||||
latency_us: TopicKind::ALL.iter().map(|k| (k.label(), m.latency_for(*k).quantiles())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn stats_json(State(st): State<AppState>) -> Response {
|
||||
axum::Json(stats(&st)).into_response()
|
||||
}
|
||||
|
||||
async fn metrics(State(st): State<AppState>) -> Response {
|
||||
let s = stats(&st);
|
||||
let mut o = String::new();
|
||||
let mut put = |name: &str, kind: &str, help: &str, rows: &[(String, f64)]| {
|
||||
let _ = writeln!(o, "# HELP tr_{name} {help}\n# TYPE tr_{name} {kind}");
|
||||
for (labels, v) in rows {
|
||||
let _ = writeln!(o, "tr_{name}{labels} {v}");
|
||||
}
|
||||
};
|
||||
let one = |v: f64| vec![(String::new(), v)];
|
||||
let u = &s.upstream;
|
||||
put(
|
||||
"upstream_connections",
|
||||
"gauge",
|
||||
"Upstream websocket connections",
|
||||
&[
|
||||
("{state=\"open\"}".into(), u.connections_open as f64),
|
||||
("{state=\"up\"}".into(), u.connections_up as f64),
|
||||
("{state=\"budget\"}".into(), u.max_connections as f64),
|
||||
],
|
||||
);
|
||||
let mut subs: Vec<(String, f64)> =
|
||||
u.by_kind.iter().map(|(k, n)| (format!("{{kind=\"{k}\"}}"), *n as f64)).collect();
|
||||
subs.push(("{kind=\"budget\"}".into(), u.max_subscriptions as f64));
|
||||
put("upstream_subscriptions", "gauge", "Upstream subscriptions by kind", &subs);
|
||||
put(
|
||||
"upstream_messages_sent_last_min",
|
||||
"gauge",
|
||||
"Messages sent upstream in the last minute",
|
||||
&one(u.messages_sent_last_min as f64),
|
||||
);
|
||||
put(
|
||||
"rest_weight_last_min",
|
||||
"gauge",
|
||||
"Info endpoint weight spent in the last minute",
|
||||
&one(u.rest_weight_last_min as f64),
|
||||
);
|
||||
put("upstream_messages_total", "counter", "Upstream messages received", &one(s.upstream_messages as f64));
|
||||
put("upstream_bytes_total", "counter", "Upstream bytes received", &one(s.upstream_bytes as f64));
|
||||
put("upstream_reconnects_total", "counter", "Upstream reconnects", &one(s.upstream_reconnects as f64));
|
||||
put("upstream_rejected_total", "counter", "Upstream subscriptions refused", &one(s.upstream_rejected as f64));
|
||||
put("rest_requests_total", "counter", "Info endpoint requests", &one(s.rest_requests as f64));
|
||||
put("rest_errors_total", "counter", "Info endpoint errors", &one(s.rest_errors as f64));
|
||||
let t = &s.topics;
|
||||
put(
|
||||
"topics",
|
||||
"gauge",
|
||||
"Hot-store topics",
|
||||
&[
|
||||
("{kind=\"book\"}".into(), t.book as f64),
|
||||
("{kind=\"trades\"}".into(), t.trades as f64),
|
||||
("{kind=\"bars\"}".into(), t.bars as f64),
|
||||
("{kind=\"lingering\"}".into(), t.lingering as f64),
|
||||
("{kind=\"mids\"}".into(), t.mids as f64),
|
||||
],
|
||||
);
|
||||
put("clients", "gauge", "Connected clients", &one(s.clients as f64));
|
||||
put("client_subscriptions", "gauge", "Client subscriptions", &one(s.client_subscriptions as f64));
|
||||
put("frames_sent_total", "counter", "Frames sent to clients", &one(s.frames_sent as f64));
|
||||
put("bytes_sent_total", "counter", "Bytes sent to clients", &one(s.bytes_sent as f64));
|
||||
put("resyncs_total", "counter", "Resyncs sent to clients", &one(s.resyncs as f64));
|
||||
put(
|
||||
"slow_consumer_closes_total",
|
||||
"counter",
|
||||
"Clients closed for falling behind",
|
||||
&one(s.slow_consumer_closes as f64),
|
||||
);
|
||||
let mut lat = Vec::new();
|
||||
for (topic, q) in &s.latency_us {
|
||||
for (quantile, v) in [("0.5", q.p50_us), ("0.9", q.p90_us), ("0.99", q.p99_us)] {
|
||||
lat.push((format!("{{topic=\"{topic}\",quantile=\"{quantile}\"}}"), f64::from(v)));
|
||||
}
|
||||
}
|
||||
put("server_latency_us", "summary", "Upstream frame received to client frame written, microseconds", &lat);
|
||||
([(header::CONTENT_TYPE, "text/plain; version=0.0.4")], o).into_response()
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! `tr-gateway`, the Terminal data server (`docs/architecture/server.md`).
|
||||
//!
|
||||
//! For now it carries Hyperliquid's public feed ([`hl::HlPublicProvider`]): one shared set of
|
||||
//! upstream connections, fanned out to every client over the protocol in `tr-proto`.
|
||||
|
||||
pub mod config;
|
||||
pub mod hl;
|
||||
pub mod http;
|
||||
pub mod limit;
|
||||
pub mod metrics;
|
||||
pub mod provider;
|
||||
pub mod session;
|
||||
pub mod store;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::sync::{oneshot, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
use tr_core::Bar;
|
||||
|
||||
pub use config::Config;
|
||||
|
||||
use crate::hl::HlPublicProvider;
|
||||
use crate::metrics::Metrics;
|
||||
use crate::store::Hub;
|
||||
|
||||
/// Keep at most `limit` bars opening before `end`.
|
||||
pub fn clip(mut bars: Vec<Bar>, end: Option<i64>, limit: usize) -> Vec<Bar> {
|
||||
if let Some(end) = end {
|
||||
bars.retain(|b| b.ts < end);
|
||||
}
|
||||
if bars.len() > limit {
|
||||
bars.drain(..bars.len() - limit);
|
||||
}
|
||||
bars
|
||||
}
|
||||
|
||||
/// A running gateway.
|
||||
pub struct Gateway {
|
||||
pub addr: SocketAddr,
|
||||
pub state: http::AppState,
|
||||
stop: Option<oneshot::Sender<()>>,
|
||||
closing: watch::Sender<bool>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Gateway {
|
||||
pub fn hub(&self) -> &Arc<Hub> {
|
||||
&self.state.hub
|
||||
}
|
||||
|
||||
/// Stop accepting, close every client session and upstream connection, and wait for the
|
||||
/// server to finish. Nothing keeps running afterwards.
|
||||
pub async fn shutdown(mut self) {
|
||||
self.closing.send_replace(true);
|
||||
if let Some(s) = self.stop.take() {
|
||||
let _ = s.send(());
|
||||
}
|
||||
self.state.hub.stop();
|
||||
let _ = (&mut self.task).await;
|
||||
}
|
||||
|
||||
/// Run until the process is asked to stop.
|
||||
pub async fn wait(self) {
|
||||
let _ = self.task.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind, connect upstream and serve in the background.
|
||||
pub async fn start(cfg: Config) -> Result<Gateway> {
|
||||
let metrics = Arc::new(Metrics::default());
|
||||
let provider = Arc::new(HlPublicProvider::new(cfg.hl.clone(), metrics.clone())?);
|
||||
let hub = Hub::new(provider, cfg.hub.clone(), metrics);
|
||||
hub.start();
|
||||
let (closing, closing_rx) = watch::channel(false);
|
||||
let state = http::AppState::new(hub, cfg.session.clone(), cfg.max_clients, closing_rx);
|
||||
let listener = tokio::net::TcpListener::bind(cfg.bind).await.context("binding the listen address")?;
|
||||
let addr = listener.local_addr()?;
|
||||
let (stop, stopped) = oneshot::channel::<()>();
|
||||
let app = http::router(state.clone());
|
||||
let task = tokio::spawn(async move {
|
||||
let shutdown = async {
|
||||
let _ = stopped.await;
|
||||
};
|
||||
if let Err(e) = axum::serve(listener, app).with_graceful_shutdown(shutdown).await {
|
||||
tracing::error!("server: {e}");
|
||||
}
|
||||
});
|
||||
Ok(Gateway { addr, state, stop: Some(stop), closing, task })
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! A sliding-window limiter: at most `budget` units in any `window`. The server uses one for
|
||||
//! each Hyperliquid per-IP limit it has to respect (REST weight, websocket messages sent, new
|
||||
//! websocket connections), always with a budget below Hyperliquid's own.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct WindowLimiter {
|
||||
window: Duration,
|
||||
budget: u32,
|
||||
log: Mutex<VecDeque<(Instant, u32)>>,
|
||||
}
|
||||
|
||||
impl WindowLimiter {
|
||||
pub fn new(budget: u32, window: Duration) -> Self {
|
||||
Self { window, budget: budget.max(1), log: Mutex::new(VecDeque::new()) }
|
||||
}
|
||||
|
||||
pub fn per_minute(budget: u32) -> Self {
|
||||
Self::new(budget, Duration::from_secs(60))
|
||||
}
|
||||
|
||||
pub fn budget(&self) -> u32 {
|
||||
self.budget
|
||||
}
|
||||
|
||||
/// Units spent in the current window.
|
||||
pub fn used(&self) -> u32 {
|
||||
let mut log = self.log.lock().expect("limiter lock");
|
||||
Self::expire(&mut log, Instant::now(), self.window);
|
||||
log.iter().map(|(_, n)| n).sum()
|
||||
}
|
||||
|
||||
fn expire(log: &mut VecDeque<(Instant, u32)>, now: Instant, window: Duration) {
|
||||
while log.front().is_some_and(|(t, _)| now.duration_since(*t) >= window) {
|
||||
log.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Spend `n` now if the window has room, or say how long until it will. A request larger
|
||||
/// than the whole budget waits for an empty window, then goes through alone.
|
||||
pub fn try_acquire(&self, n: u32) -> Result<(), Duration> {
|
||||
let now = Instant::now();
|
||||
let mut log = self.log.lock().expect("limiter lock");
|
||||
Self::expire(&mut log, now, self.window);
|
||||
let mut used: u32 = log.iter().map(|(_, k)| k).sum();
|
||||
if used + n <= self.budget || log.is_empty() {
|
||||
log.push_back((now, n));
|
||||
return Ok(());
|
||||
}
|
||||
// Wait until enough of the oldest entries expire.
|
||||
for (t, k) in log.iter() {
|
||||
used -= k;
|
||||
if used + n <= self.budget {
|
||||
return Err((*t + self.window).saturating_duration_since(now) + Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
Err(self.window)
|
||||
}
|
||||
|
||||
/// Wait for room, then spend `n`. Dropping the future before it completes spends nothing.
|
||||
pub async fn acquire(&self, n: u32) {
|
||||
while let Err(wait) = self.try_acquire(n) {
|
||||
tokio::time::sleep(wait).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Record units already spent (a response that cost more than estimated, or a 429 that
|
||||
/// should keep everyone off the endpoint for a window).
|
||||
pub fn charge(&self, n: u32) {
|
||||
self.log.lock().expect("limiter lock").push_back((Instant::now(), n));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn budget_holds_within_a_window() {
|
||||
let l = WindowLimiter::new(100, Duration::from_millis(200));
|
||||
assert!(l.try_acquire(60).is_ok());
|
||||
assert!(l.try_acquire(40).is_ok());
|
||||
let wait = l.try_acquire(1).unwrap_err();
|
||||
assert!(wait > Duration::from_millis(150) && wait <= Duration::from_millis(201), "{wait:?}");
|
||||
assert_eq!(l.used(), 100);
|
||||
std::thread::sleep(Duration::from_millis(210));
|
||||
assert_eq!(l.used(), 0);
|
||||
assert!(l.try_acquire(100).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_requests_go_alone() {
|
||||
let l = WindowLimiter::new(10, Duration::from_millis(100));
|
||||
assert!(l.try_acquire(25).is_ok(), "an empty window admits one oversized request");
|
||||
assert!(l.try_acquire(1).is_err());
|
||||
l.charge(5);
|
||||
assert_eq!(l.used(), 30);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acquire_waits_for_room() {
|
||||
let l = WindowLimiter::new(2, Duration::from_millis(150));
|
||||
let t0 = Instant::now();
|
||||
l.acquire(1).await;
|
||||
l.acquire(1).await;
|
||||
assert!(t0.elapsed() < Duration::from_millis(50));
|
||||
l.acquire(1).await;
|
||||
assert!(t0.elapsed() >= Duration::from_millis(150), "the third waited for the window to roll");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! `tr-gateway`: see `server/README.md`.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
if std::env::args().skip(1).any(|a| a == "-h" || a == "--help") {
|
||||
print!("{}", tr_gateway::config::HELP);
|
||||
return Ok(());
|
||||
}
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||
.init();
|
||||
// reqwest and tungstenite both use rustls; pin one crypto provider, like the desktop app.
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
let cfg = tr_gateway::Config::from_env()?;
|
||||
let gw = tr_gateway::start(cfg).await?;
|
||||
// The port only: logs may end up on screen, so they never carry addresses.
|
||||
tracing::info!("listening on port {} (websocket at /ws)", gw.addr.port());
|
||||
// Ctrl-C, or SIGTERM from a service manager.
|
||||
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {}
|
||||
_ = term.recv() => {}
|
||||
}
|
||||
tracing::info!("shutting down");
|
||||
gw.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Counters and the server-added latency recorder behind `/metrics` and `/stats`.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Metrics {
|
||||
pub upstream_messages: AtomicU64,
|
||||
pub upstream_bytes: AtomicU64,
|
||||
pub upstream_reconnects: AtomicU64,
|
||||
pub upstream_rejected: AtomicU64,
|
||||
pub clients: AtomicI64,
|
||||
pub clients_total: AtomicU64,
|
||||
pub client_subscriptions: AtomicI64,
|
||||
pub frames_sent: AtomicU64,
|
||||
pub bytes_sent: AtomicU64,
|
||||
pub resyncs: AtomicU64,
|
||||
pub slow_consumer_closes: AtomicU64,
|
||||
pub rest_requests: AtomicU64,
|
||||
pub rest_errors: AtomicU64,
|
||||
/// Upstream frame received → the client frame it caused written to the socket, per topic.
|
||||
pub latency_quotes: LatencyRecorder,
|
||||
pub latency_book: LatencyRecorder,
|
||||
pub latency_trades: LatencyRecorder,
|
||||
pub latency_bars: LatencyRecorder,
|
||||
}
|
||||
|
||||
/// Which latency histogram a client frame counts towards.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TopicKind {
|
||||
Quotes,
|
||||
Book,
|
||||
Trades,
|
||||
Bars,
|
||||
}
|
||||
|
||||
impl TopicKind {
|
||||
pub const ALL: [TopicKind; 4] = [TopicKind::Quotes, TopicKind::Book, TopicKind::Trades, TopicKind::Bars];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TopicKind::Quotes => "quotes",
|
||||
TopicKind::Book => "book",
|
||||
TopicKind::Trades => "trades",
|
||||
TopicKind::Bars => "bars",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
pub fn latency_for(&self, kind: TopicKind) -> &LatencyRecorder {
|
||||
match kind {
|
||||
TopicKind::Quotes => &self.latency_quotes,
|
||||
TopicKind::Book => &self.latency_book,
|
||||
TopicKind::Trades => &self.latency_trades,
|
||||
TopicKind::Bars => &self.latency_bars,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(counter: &AtomicU64, n: u64) {
|
||||
counter.fetch_add(n, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn get(counter: &AtomicU64) -> u64 {
|
||||
counter.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the most recent samples so quantiles reflect current behaviour.
|
||||
pub struct LatencyRecorder {
|
||||
samples: Mutex<VecDeque<u32>>,
|
||||
count: AtomicU64,
|
||||
cap: usize,
|
||||
}
|
||||
|
||||
impl Default for LatencyRecorder {
|
||||
fn default() -> Self {
|
||||
Self::with_capacity(50_000)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
|
||||
pub struct Quantiles {
|
||||
/// Samples in the window the quantiles come from.
|
||||
pub window: usize,
|
||||
/// Samples since start.
|
||||
pub total: u64,
|
||||
pub p50_us: u32,
|
||||
pub p90_us: u32,
|
||||
pub p99_us: u32,
|
||||
pub max_us: u32,
|
||||
}
|
||||
|
||||
impl LatencyRecorder {
|
||||
pub fn with_capacity(cap: usize) -> Self {
|
||||
Self { samples: Mutex::new(VecDeque::with_capacity(cap.min(4096))), count: AtomicU64::new(0), cap }
|
||||
}
|
||||
|
||||
pub fn record(&self, d: Duration) {
|
||||
let us = d.as_micros().min(u32::MAX as u128) as u32;
|
||||
let mut s = self.samples.lock().expect("latency lock");
|
||||
if s.len() == self.cap {
|
||||
s.pop_front();
|
||||
}
|
||||
s.push_back(us);
|
||||
self.count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn quantiles(&self) -> Quantiles {
|
||||
let mut v: Vec<u32> = self.samples.lock().expect("latency lock").iter().copied().collect();
|
||||
v.sort_unstable();
|
||||
let q = |p: f64| if v.is_empty() { 0 } else { v[((v.len() - 1) as f64 * p).round() as usize] };
|
||||
Quantiles {
|
||||
window: v.len(),
|
||||
total: self.count.load(Ordering::Relaxed),
|
||||
p50_us: q(0.50),
|
||||
p90_us: q(0.90),
|
||||
p99_us: q(0.99),
|
||||
max_us: v.last().copied().unwrap_or(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quantiles_over_recent_samples() {
|
||||
let r = LatencyRecorder::with_capacity(100);
|
||||
for us in 1..=200u64 {
|
||||
r.record(Duration::from_micros(us));
|
||||
}
|
||||
let q = r.quantiles();
|
||||
assert_eq!((q.window, q.total), (100, 200));
|
||||
assert_eq!((q.p50_us, q.p99_us, q.max_us), (151, 199, 200));
|
||||
assert_eq!(LatencyRecorder::default().quantiles().p99_us, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! The server-side source of market data (`docs/architecture/server.md` §3.1). One provider
|
||||
//! per upstream. The gateway only talks to this trait, so a provider can be replaced (the
|
||||
//! public-feed `HlPublicProvider` today, a node-backed `HlNodeProvider` later) without any
|
||||
//! client or protocol change.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use tr_core::{Bar, Timeframe};
|
||||
use tr_proto::{InstrumentKind, Level, Trade};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ProviderId(pub &'static str);
|
||||
|
||||
/// A provider's own name for an instrument. For Hyperliquid, the coin: `BTC`, `xyz:TSLA`,
|
||||
/// `@107`. For [`Channel::Mids`] it names a venue instead (a perp dex; `""` is the core dex).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct InstrumentId(pub String);
|
||||
|
||||
impl InstrumentId {
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for InstrumentId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Channel {
|
||||
/// Every mid price on one venue.
|
||||
Mids,
|
||||
Book,
|
||||
Trades,
|
||||
Bars(Timeframe),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ChannelKind {
|
||||
Mids,
|
||||
Book,
|
||||
Trades,
|
||||
Bars,
|
||||
}
|
||||
|
||||
/// What a provider can serve: channels × instrument kinds.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Capabilities {
|
||||
pub channels: Vec<ChannelKind>,
|
||||
pub kinds: Vec<InstrumentKind>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Entitlement {
|
||||
Staff,
|
||||
}
|
||||
|
||||
/// Display rights for a provider's data. The gateway enforces these, never the client.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DisplayPolicy {
|
||||
/// May the general public (any signed-in or anonymous user) see it?
|
||||
pub public_display: bool,
|
||||
/// If not public: the entitlement that unlocks it.
|
||||
pub entitlement: Option<Entitlement>,
|
||||
/// Required delay before showing it to users without the entitlement.
|
||||
pub delay: Option<Duration>,
|
||||
/// Attribution the UI must show.
|
||||
pub attribution: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct InstrumentDef {
|
||||
pub id: InstrumentId,
|
||||
/// A perp dex (`""` is the core dex) or `spot`.
|
||||
pub venue: String,
|
||||
pub kind: InstrumentKind,
|
||||
/// The price 24 hours earlier, for change figures, when the provider has one.
|
||||
pub prev_day: Option<f64>,
|
||||
}
|
||||
|
||||
/// One upstream event and when it arrived.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Upstream {
|
||||
/// When the frame carrying it arrived, for the server-added latency histogram.
|
||||
pub recv: Instant,
|
||||
/// The same moment as Unix milliseconds, sent to clients as the time the server saw it.
|
||||
pub recv_ms: i64,
|
||||
pub event: UpstreamEvent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum UpstreamEvent {
|
||||
/// `(instrument, mid)` pairs that changed. The first message after (re)subscribing has all.
|
||||
Mids(Vec<(String, f64)>),
|
||||
Book {
|
||||
bids: Vec<Level>,
|
||||
asks: Vec<Level>,
|
||||
time_ms: i64,
|
||||
},
|
||||
/// New trades, oldest first. Right after (re)subscribing this holds recent history.
|
||||
Trades(Vec<Trade>),
|
||||
/// The latest state of the bar that `Bar::ts` opens.
|
||||
Bar(Bar),
|
||||
/// The upstream connection dropped. Data is stale until [`UpstreamEvent::Reconnected`].
|
||||
Disconnected,
|
||||
/// The upstream reconnected and resubscribed. The next data starts fresh, and anything sent
|
||||
/// in between was missed.
|
||||
Reconnected,
|
||||
/// The upstream refused this subscription; it gets no more data.
|
||||
Rejected(String),
|
||||
}
|
||||
|
||||
/// Where a subscription's events go. Called on the provider's I/O task, so it must not block.
|
||||
#[derive(Clone)]
|
||||
pub struct UpstreamSink(Arc<dyn Fn(Upstream) + Send + Sync>);
|
||||
|
||||
impl UpstreamSink {
|
||||
pub fn new(f: impl Fn(Upstream) + Send + Sync + 'static) -> Self {
|
||||
Self(Arc::new(f))
|
||||
}
|
||||
|
||||
pub fn send(&self, ev: Upstream) {
|
||||
(self.0)(ev)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps an upstream subscription alive; dropping it unsubscribes.
|
||||
pub struct UpstreamHandle(#[allow(dead_code)] Box<dyn Send + Sync>);
|
||||
|
||||
impl UpstreamHandle {
|
||||
pub fn new(guard: impl Send + Sync + 'static) -> Self {
|
||||
Self(Box::new(guard))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SubscribeError {
|
||||
/// At the provider's upstream subscription or connection budget.
|
||||
Capacity(String),
|
||||
UnknownInstrument(String),
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for SubscribeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SubscribeError::Capacity(m) => write!(f, "upstream budget: {m}"),
|
||||
SubscribeError::UnknownInstrument(m) => write!(f, "unknown instrument {m}"),
|
||||
SubscribeError::Unsupported(m) => write!(f, "unsupported: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SubscribeError {}
|
||||
|
||||
/// A provider's upstream usage against its budgets, for `/stats` and `/metrics`.
|
||||
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
|
||||
pub struct UpstreamStats {
|
||||
pub connections_open: usize,
|
||||
pub connections_up: usize,
|
||||
pub max_connections: usize,
|
||||
pub subscriptions: usize,
|
||||
pub max_subscriptions: usize,
|
||||
pub by_kind: BTreeMap<&'static str, usize>,
|
||||
pub per_connection: Vec<usize>,
|
||||
/// Every upstream subscription, for example `l2Book:BTC` or `candle:xyz:TSLA:1m`.
|
||||
pub keys: Vec<String>,
|
||||
pub messages_sent_last_min: u32,
|
||||
pub messages_per_min: u32,
|
||||
pub connects_last_min: u32,
|
||||
pub rest_weight_last_min: u32,
|
||||
pub rest_weight_per_min: u32,
|
||||
}
|
||||
|
||||
/// Server-side source of market data. The gateway asks it what it serves, fetches history from
|
||||
/// it and opens streams on it; everything else (fan-out, sequencing, backpressure) is the
|
||||
/// gateway's job.
|
||||
#[async_trait]
|
||||
pub trait Provider: Send + Sync + 'static {
|
||||
fn id(&self) -> ProviderId;
|
||||
fn capabilities(&self) -> Capabilities;
|
||||
fn display_policy(&self) -> DisplayPolicy;
|
||||
fn stats(&self) -> UpstreamStats {
|
||||
UpstreamStats::default()
|
||||
}
|
||||
/// Close every upstream connection for good (gateway shutdown).
|
||||
fn close(&self) {}
|
||||
/// Instruments listed right now. Called periodically; delistings show up here.
|
||||
async fn instruments(&self) -> Result<Vec<InstrumentDef>>;
|
||||
/// Up to `limit` bars before `end` (Unix seconds; `None` = now), oldest first: the same
|
||||
/// contract as `MarketData::history`.
|
||||
async fn history(&self, inst: &InstrumentId, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>>;
|
||||
/// Start an upstream stream. Events go to `sink` until the handle is dropped.
|
||||
async fn subscribe(
|
||||
&self,
|
||||
ch: Channel,
|
||||
inst: &InstrumentId,
|
||||
sink: UpstreamSink,
|
||||
) -> Result<UpstreamHandle, SubscribeError>;
|
||||
}
|
||||
@@ -0,0 +1,970 @@
|
||||
//! One client connection (§3.5, §5.8): `Hello`/`Welcome`, then one forwarding task per
|
||||
//! subscription feeding a bounded outbound queue that a writer task drains to the socket.
|
||||
//!
|
||||
//! - Every subscription has its own `seq`: a `Snapshot` sets it and each `Delta` adds one.
|
||||
//! - State topics (quotes, book) conflate: a slow reader gets the latest value, never a backlog.
|
||||
//! - Event topics (trades, bars) read a bounded ring. A reader that falls behind it gets
|
||||
//! `Resync` and a fresh `Snapshot`; a `Forming` bar may be dropped for a newer one with the
|
||||
//! same `ts`, a `Closed` bar never is.
|
||||
//! - If the queue stays full for `slow_consumer`, the connection closes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::extract::ws::{CloseFrame, Message, WebSocket};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio::sync::{Notify, broadcast, mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
use tr_proto::{
|
||||
ApiError, BarDelta, Book, ClientMsg, Encoding, ErrorCode, Frame, Level, Limits, MarketKey, PROTOCOL_VERSION,
|
||||
PriceBasis, Quote, ResyncReason, Rpc, RpcResult, ServerMsg, SessionId, SubId, TopicData, TopicDelta, TopicReq,
|
||||
TopicStatus, UnavailableReason,
|
||||
};
|
||||
|
||||
use crate::metrics::{Metrics, TopicKind as Kind};
|
||||
use crate::store::{
|
||||
BarsTopic, BookTopic, Event, EventKind, Hub, Mid, Topic, TopicKey, TradesTopic, VenueState, merge_bars,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionConfig {
|
||||
pub max_subscriptions: usize,
|
||||
pub max_quote_keys: usize,
|
||||
pub max_book_depth: u8,
|
||||
pub max_history: u32,
|
||||
pub max_quote_hz: u8,
|
||||
/// One-shot requests a connection may have running at once.
|
||||
pub max_requests_in_flight: usize,
|
||||
/// A request still running after this gets an error reply.
|
||||
pub request_timeout: Duration,
|
||||
/// Client messages per 10 s. Subscribes past this would churn the shared upstream.
|
||||
pub max_messages_per_10s: u32,
|
||||
/// Outbound frames a client may have queued.
|
||||
pub queue: usize,
|
||||
pub hello_timeout: Duration,
|
||||
pub slow_consumer: Duration,
|
||||
/// Websocket pings keep idle connections open through proxies and TLS edges.
|
||||
pub ping_every: Duration,
|
||||
/// Close a client we've heard nothing from (not even a pong) for this long.
|
||||
pub idle_timeout: Duration,
|
||||
/// How long a request waits for the upstream after a restart before it fails. Subscriptions
|
||||
/// wait as long as it takes.
|
||||
pub ready_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for SessionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_subscriptions: 64,
|
||||
max_quote_keys: 512,
|
||||
max_book_depth: 20,
|
||||
max_history: 5000,
|
||||
max_quote_hz: 10,
|
||||
max_requests_in_flight: 8,
|
||||
request_timeout: Duration::from_secs(30),
|
||||
max_messages_per_10s: 200,
|
||||
queue: 256,
|
||||
hello_timeout: Duration::from_secs(10),
|
||||
slow_consumer: Duration::from_secs(10),
|
||||
ping_every: Duration::from_secs(20),
|
||||
idle_timeout: Duration::from_secs(90),
|
||||
ready_timeout: Duration::from_secs(10),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionConfig {
|
||||
pub fn limits(&self) -> Limits {
|
||||
Limits {
|
||||
max_subscriptions: self.max_subscriptions as u32,
|
||||
max_quote_keys: self.max_quote_keys as u32,
|
||||
max_book_depth: self.max_book_depth,
|
||||
max_history: self.max_history,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn now_ms() -> i64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
|
||||
}
|
||||
|
||||
struct Out {
|
||||
msg: Message,
|
||||
/// When the upstream data in this frame arrived, if it carries any.
|
||||
recv: Option<(Instant, Kind)>,
|
||||
}
|
||||
|
||||
fn to_message(f: Frame) -> Message {
|
||||
match f {
|
||||
Frame::Binary(b) => Message::Binary(b.into()),
|
||||
Frame::Text(t) => Message::Text(t.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What every forwarding task of one session shares.
|
||||
#[derive(Clone)]
|
||||
struct Link {
|
||||
enc: Encoding,
|
||||
out: mpsc::Sender<Out>,
|
||||
kill: Arc<watch::Sender<bool>>,
|
||||
slow: Duration,
|
||||
metrics: Arc<Metrics>,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
/// Queue a message. False once the session is gone or too slow to keep.
|
||||
async fn send(&self, msg: &ServerMsg, recv: Option<(Instant, Kind)>) -> bool {
|
||||
let frame = match tr_proto::encode(msg, self.enc) {
|
||||
Ok(f) => to_message(f),
|
||||
Err(e) => {
|
||||
tracing::error!("encode: {e}");
|
||||
return true;
|
||||
}
|
||||
};
|
||||
match tokio::time::timeout(self.slow, self.out.send(Out { msg: frame, recv })).await {
|
||||
Ok(Ok(())) => true,
|
||||
Ok(Err(_)) => false,
|
||||
Err(_) => {
|
||||
self.kill.send_replace(true);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One subscription's forwarding state.
|
||||
struct Fwd {
|
||||
id: SubId,
|
||||
kind: Kind,
|
||||
link: Link,
|
||||
seq: u64,
|
||||
resync: Arc<Notify>,
|
||||
}
|
||||
|
||||
impl Fwd {
|
||||
/// Snapshots carry state of any age, so they don't count towards the latency histograms.
|
||||
async fn snapshot(&mut self, data: TopicData) -> bool {
|
||||
self.seq += 1;
|
||||
let m = ServerMsg::Snapshot { id: self.id, seq: self.seq, data };
|
||||
self.link.send(&m, None).await
|
||||
}
|
||||
|
||||
async fn delta(&mut self, data: TopicDelta, recv: Instant) -> bool {
|
||||
self.seq += 1;
|
||||
let m = ServerMsg::Delta { id: self.id, seq: self.seq, data };
|
||||
self.link.send(&m, Some((recv, self.kind))).await
|
||||
}
|
||||
|
||||
async fn status(&self, status: TopicStatus) -> bool {
|
||||
self.link.send(&ServerMsg::Status { id: self.id, status }, None).await
|
||||
}
|
||||
|
||||
async fn resync(&self, reason: ResyncReason) -> bool {
|
||||
Metrics::add(&self.link.metrics.resyncs, 1);
|
||||
self.link.send(&ServerMsg::Resync { id: self.id, reason }, None).await
|
||||
}
|
||||
|
||||
async fn unavailable(&self, reason: UnavailableReason, detail: String) -> bool {
|
||||
self.status(TopicStatus::Unavailable { reason, detail }).await
|
||||
}
|
||||
}
|
||||
|
||||
struct SubCtl {
|
||||
task: JoinHandle<()>,
|
||||
resync: Arc<Notify>,
|
||||
}
|
||||
|
||||
/// Serve one client until it leaves, misbehaves or can't keep up.
|
||||
pub async fn run(
|
||||
socket: WebSocket,
|
||||
hub: Arc<Hub>,
|
||||
cfg: Arc<SessionConfig>,
|
||||
session: u64,
|
||||
closing: watch::Receiver<bool>,
|
||||
) {
|
||||
let metrics = hub.metrics.clone();
|
||||
metrics.clients.fetch_add(1, Ordering::Relaxed);
|
||||
Metrics::add(&metrics.clients_total, 1);
|
||||
serve(socket, hub, cfg, session, closing).await;
|
||||
metrics.clients.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn decode(msg: &Message) -> Option<Result<ClientMsg, tr_proto::CodecError>> {
|
||||
match msg {
|
||||
Message::Binary(b) => Some(tr_proto::decode_binary(b)),
|
||||
Message::Text(t) => Some(tr_proto::decode_text(t.as_str())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn reject(mut socket: WebSocket, enc: Encoding, code: ErrorCode, message: String) {
|
||||
if let Ok(f) = tr_proto::encode(&ServerMsg::Error { code, message: message.clone() }, enc) {
|
||||
let _ = socket.send(to_message(f)).await;
|
||||
}
|
||||
let reason = message.chars().take(100).collect::<String>();
|
||||
let _ = socket.send(Message::Close(Some(CloseFrame { code: 1008, reason: reason.into() }))).await;
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
mut socket: WebSocket,
|
||||
hub: Arc<Hub>,
|
||||
cfg: Arc<SessionConfig>,
|
||||
session: u64,
|
||||
mut closing: watch::Receiver<bool>,
|
||||
) {
|
||||
if *closing.borrow_and_update() {
|
||||
return;
|
||||
}
|
||||
// Hello first, in either encoding.
|
||||
let first = tokio::time::timeout(cfg.hello_timeout, async {
|
||||
while let Some(Ok(m)) = socket.recv().await {
|
||||
if let Some(r) = decode(&m) {
|
||||
return Some((r, matches!(m, Message::Text(_))));
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.await;
|
||||
let (enc, client) = match first {
|
||||
Ok(Some((Ok(ClientMsg::Hello { version, encoding, client, .. }), _))) if version == PROTOCOL_VERSION => {
|
||||
(encoding, client)
|
||||
}
|
||||
Ok(Some((Ok(ClientMsg::Hello { version, encoding, .. }), _))) => {
|
||||
let msg = format!("protocol {version} is not supported; this server speaks {PROTOCOL_VERSION}");
|
||||
return reject(socket, encoding, ErrorCode::UnsupportedVersion, msg).await;
|
||||
}
|
||||
Ok(Some((_, text))) => {
|
||||
let enc = if text { Encoding::Json } else { Encoding::Postcard };
|
||||
return reject(socket, enc, ErrorCode::HelloRequired, "send Hello first".into()).await;
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
tracing::debug!("session {session}: {} {} ({enc:?})", client.name, client.version);
|
||||
let welcome = ServerMsg::Welcome {
|
||||
version: PROTOCOL_VERSION,
|
||||
session: SessionId(session),
|
||||
user: None,
|
||||
server_time_ms: now_ms(),
|
||||
limits: cfg.limits(),
|
||||
};
|
||||
let Ok(f) = tr_proto::encode(&welcome, enc) else { return };
|
||||
if socket.send(to_message(f)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let (ws_tx, mut ws_rx) = socket.split();
|
||||
let (out_tx, out_rx) = mpsc::channel::<Out>(cfg.queue.max(1));
|
||||
let (kill, mut killed) = watch::channel(false);
|
||||
let link = Link { enc, out: out_tx, kill: Arc::new(kill), slow: cfg.slow_consumer, metrics: hub.metrics.clone() };
|
||||
let mut writer = tokio::spawn(write(ws_tx, out_rx, hub.metrics.clone(), cfg.ping_every));
|
||||
let mut subs: HashMap<SubId, SubCtl> = HashMap::new();
|
||||
let mut idle = tokio::time::interval(cfg.idle_timeout / 3);
|
||||
let mut last_heard = Instant::now();
|
||||
let mut writer_done = false;
|
||||
let mut rpcs = tokio::task::JoinSet::new();
|
||||
let rate = crate::limit::WindowLimiter::new(cfg.max_messages_per_10s, Duration::from_secs(10));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = ws_rx.next() => {
|
||||
let Some(Ok(msg)) = msg else { break };
|
||||
last_heard = Instant::now();
|
||||
let Some(decoded) = decode(&msg) else {
|
||||
if matches!(msg, Message::Close(_)) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if rate.try_acquire(1).is_err() {
|
||||
let m = format!("more than {} messages in 10 s; this one was ignored", cfg.max_messages_per_10s);
|
||||
link.send(&ServerMsg::Error { code: ErrorCode::RateLimited, message: m }, None).await;
|
||||
continue;
|
||||
}
|
||||
match decoded {
|
||||
Ok(m) => handle(m, &hub, &cfg, &link, &mut subs, &mut rpcs).await,
|
||||
Err(e) => {
|
||||
let m = ServerMsg::Error { code: ErrorCode::BadMessage, message: e.to_string() };
|
||||
link.send(&m, None).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = killed.changed() => {
|
||||
Metrics::add(&hub.metrics.slow_consumer_closes, 1);
|
||||
let m = ServerMsg::Error { code: ErrorCode::SlowConsumer, message: "too slow to keep up; reconnect".into() };
|
||||
// The queue is full; the close goes out on a fresh channel slot or not at all.
|
||||
let _ = link.out.try_send(Out { msg: to_message(tr_proto::encode(&m, enc).expect("encodes")), recv: None });
|
||||
break;
|
||||
}
|
||||
_ = idle.tick() => if last_heard.elapsed() > cfg.idle_timeout {
|
||||
tracing::debug!("session {session}: idle; closing");
|
||||
break;
|
||||
},
|
||||
_ = &mut writer => {
|
||||
writer_done = true;
|
||||
break;
|
||||
}
|
||||
// Only ever set to true, or dropped with the gateway.
|
||||
_ = closing.changed() => break,
|
||||
}
|
||||
}
|
||||
for (_, s) in subs.drain() {
|
||||
s.task.abort();
|
||||
}
|
||||
rpcs.abort_all();
|
||||
drop(link);
|
||||
// Let the writer flush what's queued (a final error, the close frame), briefly.
|
||||
if !writer_done && tokio::time::timeout(Duration::from_secs(1), &mut writer).await.is_err() {
|
||||
writer.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn write(
|
||||
mut ws: futures::stream::SplitSink<WebSocket, Message>,
|
||||
mut rx: mpsc::Receiver<Out>,
|
||||
metrics: Arc<Metrics>,
|
||||
ping_every: Duration,
|
||||
) {
|
||||
let mut ping = tokio::time::interval(ping_every);
|
||||
ping.tick().await;
|
||||
loop {
|
||||
tokio::select! {
|
||||
out = rx.recv() => {
|
||||
let Some(out) = out else { break };
|
||||
let len = match &out.msg {
|
||||
Message::Binary(b) => b.len(),
|
||||
Message::Text(t) => t.len(),
|
||||
_ => 0,
|
||||
};
|
||||
if ws.send(out.msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
Metrics::add(&metrics.frames_sent, 1);
|
||||
Metrics::add(&metrics.bytes_sent, len as u64);
|
||||
if let Some((recv, kind)) = out.recv {
|
||||
metrics.latency_for(kind).record(recv.elapsed());
|
||||
}
|
||||
}
|
||||
_ = ping.tick() => if ws.send(Message::Ping(Default::default())).await.is_err() {
|
||||
break;
|
||||
},
|
||||
}
|
||||
}
|
||||
let _ = ws.close().await;
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
msg: ClientMsg,
|
||||
hub: &Arc<Hub>,
|
||||
cfg: &Arc<SessionConfig>,
|
||||
link: &Link,
|
||||
subs: &mut HashMap<SubId, SubCtl>,
|
||||
rpcs: &mut tokio::task::JoinSet<()>,
|
||||
) {
|
||||
let error = |code, message: String| ServerMsg::Error { code, message };
|
||||
match msg {
|
||||
ClientMsg::Hello { .. } => {
|
||||
link.send(&error(ErrorCode::BadMessage, "Hello was already sent".into()), None).await;
|
||||
}
|
||||
ClientMsg::Subscribe { id, topic } => {
|
||||
if subs.contains_key(&id) {
|
||||
link.send(&error(ErrorCode::DuplicateId, format!("subscription {id} exists")), None).await;
|
||||
return;
|
||||
}
|
||||
if subs.len() >= cfg.max_subscriptions {
|
||||
let m = format!("at most {} subscriptions per connection", cfg.max_subscriptions);
|
||||
link.send(&error(ErrorCode::TooManySubscriptions, m), None).await;
|
||||
return;
|
||||
}
|
||||
let resync = Arc::new(Notify::new());
|
||||
let kind = match topic {
|
||||
TopicReq::Quotes { .. } => Kind::Quotes,
|
||||
TopicReq::Book { .. } => Kind::Book,
|
||||
TopicReq::Trades { .. } => Kind::Trades,
|
||||
TopicReq::Bars { .. } => Kind::Bars,
|
||||
};
|
||||
let fwd = Fwd { id, kind, link: link.clone(), seq: 0, resync: resync.clone() };
|
||||
let (hub, cfg) = (hub.clone(), cfg.clone());
|
||||
let metrics = hub.metrics.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
metrics.client_subscriptions.fetch_add(1, Ordering::Relaxed);
|
||||
let _count = Decrement(metrics);
|
||||
forward(fwd, hub, cfg, topic).await;
|
||||
});
|
||||
subs.insert(id, SubCtl { task, resync });
|
||||
}
|
||||
ClientMsg::Unsubscribe { id } => match subs.remove(&id) {
|
||||
Some(s) => s.task.abort(),
|
||||
None => {
|
||||
link.send(&error(ErrorCode::UnknownId, format!("no subscription {id}")), None).await;
|
||||
}
|
||||
},
|
||||
ClientMsg::Resync { id } => match subs.get(&id) {
|
||||
Some(s) => s.resync.notify_one(),
|
||||
None => {
|
||||
link.send(&error(ErrorCode::UnknownId, format!("no subscription {id}")), None).await;
|
||||
}
|
||||
},
|
||||
ClientMsg::Request { id, req } => {
|
||||
while rpcs.try_join_next().is_some() {}
|
||||
if rpcs.len() >= cfg.max_requests_in_flight {
|
||||
let message = format!("at most {} requests in flight", cfg.max_requests_in_flight);
|
||||
let result = Err(ApiError { code: ErrorCode::RateLimited, message });
|
||||
link.send(&ServerMsg::Reply { id, result }, None).await;
|
||||
return;
|
||||
}
|
||||
let (hub, cfg, link) = (hub.clone(), cfg.clone(), link.clone());
|
||||
// The set aborts whatever is still running when the session ends.
|
||||
rpcs.spawn(async move {
|
||||
let result = match tokio::time::timeout(cfg.request_timeout, rpc(&hub, &cfg, req)).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(ApiError { code: ErrorCode::Upstream, message: "timed out".into() }),
|
||||
};
|
||||
link.send(&ServerMsg::Reply { id, result }, None).await;
|
||||
});
|
||||
}
|
||||
ClientMsg::Ping { t } => {
|
||||
link.send(&ServerMsg::Pong { t, server_time_ms: now_ms() }, None).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts a subscription for as long as its task lives, however it ends.
|
||||
struct Decrement(Arc<Metrics>);
|
||||
|
||||
impl Drop for Decrement {
|
||||
fn drop(&mut self) {
|
||||
self.0.client_subscriptions.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
async fn rpc(hub: &Arc<Hub>, cfg: &SessionConfig, req: Rpc) -> Result<RpcResult, ApiError> {
|
||||
let err = |code, message: String| ApiError { code, message };
|
||||
// Not `UnknownMarket`, since clients stop asking about a market the server called unknown.
|
||||
if !hub.ready(cfg.ready_timeout).await {
|
||||
return Err(err(ErrorCode::Upstream, "the server is starting; no upstream data yet".into()));
|
||||
}
|
||||
match req {
|
||||
Rpc::History { key, tf, end, limit } => {
|
||||
let inst = hub.resolve(&key).ok_or_else(|| match hub.unknown(&key) {
|
||||
(UnavailableReason::Upstream, m) => err(ErrorCode::Upstream, m),
|
||||
(_, m) => err(ErrorCode::UnknownMarket, m),
|
||||
})?;
|
||||
let limit = (limit.clamp(1, cfg.max_history)) as usize;
|
||||
// With a live stream for this market, cached history must be newer than the stream's
|
||||
// last (re)start, or bars that closed while it was down could be missing or partial.
|
||||
let live = hub.bars_topic(&inst, tf).filter(|_| end.is_none());
|
||||
let fresh_after = live.as_ref().map(|t| t.overlay().live_since.unwrap_or_else(Instant::now));
|
||||
let bars = hub
|
||||
.history(&inst, tf, end, limit, fresh_after)
|
||||
.await
|
||||
.map_err(|e| err(ErrorCode::Upstream, format!("{e:#}")))?;
|
||||
// Recent history also gets whatever the live stream has seen since.
|
||||
let bars = match live {
|
||||
Some(t) => merge_bars(bars.to_vec(), &t.overlay().bars, limit),
|
||||
None => bars.to_vec(),
|
||||
};
|
||||
Ok(RpcResult::History(bars))
|
||||
}
|
||||
Rpc::Markets => Ok(RpcResult::Markets(hub.markets())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the hub knows the market (for quotes, any of them).
|
||||
fn known(hub: &Hub, topic: &TopicReq) -> bool {
|
||||
match topic {
|
||||
TopicReq::Quotes { keys, .. } => keys.iter().any(|k| hub.resolve(k).is_some()),
|
||||
TopicReq::Book { key, .. } | TopicReq::Trades { key } | TopicReq::Bars { key, .. } => {
|
||||
hub.resolve(key).is_some()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Right after a restart the gateway doesn't know yet which markets exist, and one venue can
|
||||
/// be later than the rest. A subscription waits for its market, marked stale, rather than end.
|
||||
/// False once the session is gone.
|
||||
async fn wait_until_known(f: &Fwd, hub: &Hub, topic: &TopicReq) -> bool {
|
||||
let mut version = hub.mids.subscribe();
|
||||
let mut waited = false;
|
||||
loop {
|
||||
version.borrow_and_update();
|
||||
if hub.is_ready() && (!hub.loading() || known(hub, topic)) {
|
||||
return !waited || f.status(TopicStatus::Live).await;
|
||||
}
|
||||
if !waited && !f.status(TopicStatus::Stale { since_ms: now_ms() }).await {
|
||||
return false;
|
||||
}
|
||||
waited = true;
|
||||
// Mids, a venue's first report and readiness all bump the version.
|
||||
if version.changed().await.is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn forward(f: Fwd, hub: Arc<Hub>, cfg: Arc<SessionConfig>, topic: TopicReq) {
|
||||
if let TopicReq::Quotes { keys, .. } = &topic
|
||||
&& keys.len() > cfg.max_quote_keys
|
||||
{
|
||||
let m = format!("at most {} keys per quotes subscription", cfg.max_quote_keys);
|
||||
f.unavailable(UnavailableReason::Unsupported, m).await;
|
||||
return;
|
||||
}
|
||||
if !wait_until_known(&f, &hub, &topic).await {
|
||||
return;
|
||||
}
|
||||
let unknown = |k: &MarketKey| hub.unknown(k);
|
||||
let (tkey, extra) = match &topic {
|
||||
TopicReq::Quotes { keys, max_hz } => {
|
||||
let hz = (*max_hz).clamp(1, cfg.max_quote_hz);
|
||||
return quotes(f, hub, keys.clone(), hz).await;
|
||||
}
|
||||
TopicReq::Book { key, .. } => (hub.resolve(key).map(TopicKey::Book).ok_or_else(|| unknown(key)), 0),
|
||||
TopicReq::Trades { key } => (hub.resolve(key).map(TopicKey::Trades).ok_or_else(|| unknown(key)), 0),
|
||||
TopicReq::Bars { key, tf, history } => (
|
||||
hub.resolve(key).map(|i| TopicKey::Bars(i, *tf)).ok_or_else(|| unknown(key)),
|
||||
(*history).min(cfg.max_history),
|
||||
),
|
||||
};
|
||||
let held = match tkey {
|
||||
Ok(k) => hub.acquire(k).await,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let held = match held {
|
||||
Ok(h) => h,
|
||||
Err((reason, detail)) => {
|
||||
f.unavailable(reason, detail).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
match (&held.topic, topic) {
|
||||
(Topic::Book(t), TopicReq::Book { depth, group, .. }) => {
|
||||
book(f, t.clone(), depth.clamp(1, cfg.max_book_depth) as usize, group).await
|
||||
}
|
||||
(Topic::Trades(t), TopicReq::Trades { .. }) => trades(f, t.clone()).await,
|
||||
(Topic::Bars(t), TopicReq::Bars { .. }) => bars(f, &hub, t.clone(), extra as usize).await,
|
||||
_ => unreachable!("the topic key matches the request"),
|
||||
}
|
||||
drop(held);
|
||||
}
|
||||
|
||||
fn quote_for(hub: &Hub, key: &MarketKey, coin: &str, m: &Mid) -> Quote {
|
||||
Quote {
|
||||
key: key.clone(),
|
||||
source: MarketKey::new(coin),
|
||||
px: m.px,
|
||||
basis: PriceBasis::Mid,
|
||||
prev_day: hub.prev_day(coin),
|
||||
ts_ms: m.ts_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// The worst state among a subscription's venues: unavailable, else stale (since the earliest
|
||||
/// outage), else live.
|
||||
fn combined(states: &[VenueState]) -> TopicStatus {
|
||||
let mut out = TopicStatus::Live;
|
||||
for st in states {
|
||||
match (&st.status, &out) {
|
||||
(TopicStatus::Unavailable { .. }, _) => return st.status.clone(),
|
||||
(TopicStatus::Stale { since_ms }, TopicStatus::Stale { since_ms: s }) if since_ms >= s => {}
|
||||
(TopicStatus::Stale { .. }, _) => out = st.status.clone(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The feeds these quotes come from, to report their outages.
|
||||
fn venues_of(hub: &Hub, resolved: &[(MarketKey, String)]) -> Vec<String> {
|
||||
let mut venues: Vec<String> =
|
||||
resolved.iter().filter_map(|(_, c)| hub.mids.get(c).map(|m| m.venue.to_string())).collect();
|
||||
venues.sort();
|
||||
venues.dedup();
|
||||
venues
|
||||
}
|
||||
|
||||
async fn quotes(mut f: Fwd, hub: Arc<Hub>, keys: Vec<MarketKey>, hz: u8) {
|
||||
let (mut resolved, mut late) = (Vec::new(), Vec::new());
|
||||
for k in keys {
|
||||
match hub.resolve(&k) {
|
||||
Some(i) => resolved.push((k, i.0)),
|
||||
None => late.push(k),
|
||||
}
|
||||
}
|
||||
if resolved.is_empty() {
|
||||
f.unavailable(UnavailableReason::UnknownMarket, "none of these markets are known".into()).await;
|
||||
return;
|
||||
}
|
||||
// Keys on a venue that is still loading join with their first delta once it's in. After
|
||||
// that, whatever still doesn't resolve is unknown.
|
||||
let mut look_again = hub.loading();
|
||||
let mut venues = venues_of(&hub, &resolved);
|
||||
let venue_states = |venues: &[String]| venues.iter().map(|v| hub.mids.venue(v)).collect::<Vec<_>>();
|
||||
let mut epochs: Vec<u64> = venue_states(&venues).iter().map(|v| v.epoch).collect();
|
||||
let mut status = TopicStatus::Live;
|
||||
let mut version = hub.mids.subscribe();
|
||||
// What each requested key last carried: two keys can share a coin (`BTC`, `BTCUSD Curncy`).
|
||||
let mut stamps: Vec<u64> = vec![0; resolved.len()];
|
||||
let mut send_snapshot = true;
|
||||
let period = Duration::from_secs(1) / u32::from(hz);
|
||||
loop {
|
||||
version.borrow_and_update();
|
||||
if look_again && !late.is_empty() {
|
||||
look_again = hub.loading();
|
||||
let before = resolved.len();
|
||||
late.retain(|k| match hub.resolve(k) {
|
||||
Some(i) => {
|
||||
resolved.push((k.clone(), i.0));
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
});
|
||||
if resolved.len() > before {
|
||||
stamps.resize(resolved.len(), 0);
|
||||
venues = venues_of(&hub, &resolved);
|
||||
epochs = venue_states(&venues).iter().map(|v| v.epoch).collect();
|
||||
}
|
||||
}
|
||||
let states = venue_states(&venues);
|
||||
let now = combined(&states);
|
||||
if now != status {
|
||||
status = now;
|
||||
if !f.status(status.clone()).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// A feed that reconnected may have skipped prices: once it's live again, start over.
|
||||
let now_epochs: Vec<u64> = states.iter().map(|v| v.epoch).collect();
|
||||
if status == TopicStatus::Live && now_epochs != epochs {
|
||||
epochs = now_epochs;
|
||||
if !send_snapshot && !f.resync(ResyncReason::Upstream).await {
|
||||
return;
|
||||
}
|
||||
send_snapshot = true;
|
||||
}
|
||||
if send_snapshot {
|
||||
send_snapshot = false;
|
||||
let snap: Vec<Quote> = resolved
|
||||
.iter()
|
||||
.zip(stamps.iter_mut())
|
||||
.filter_map(|((k, c), stamp)| {
|
||||
let m = hub.mids.get(c)?;
|
||||
*stamp = m.stamp;
|
||||
Some(quote_for(&hub, k, c, &m))
|
||||
})
|
||||
.collect();
|
||||
if !f.snapshot(TopicData::Quotes(snap)).await {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
let mut newest: Option<Instant> = None;
|
||||
let changed: Vec<Quote> = resolved
|
||||
.iter()
|
||||
.zip(stamps.iter_mut())
|
||||
.filter_map(|((k, c), stamp)| {
|
||||
let m = hub.mids.get(c).filter(|m| m.stamp > *stamp)?;
|
||||
*stamp = m.stamp;
|
||||
newest = newest.max(Some(m.recv));
|
||||
Some(quote_for(&hub, k, c, &m))
|
||||
})
|
||||
.collect();
|
||||
if let Some(recv) = newest
|
||||
&& !f.delta(TopicDelta::Quotes(changed), recv).await
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
let sent = Instant::now();
|
||||
tokio::select! {
|
||||
r = version.changed() => if r.is_err() { return },
|
||||
_ = f.resync.notified() => send_snapshot = true,
|
||||
}
|
||||
// Conflate: at most `hz` messages a second.
|
||||
let next = sent + period;
|
||||
if next > Instant::now() {
|
||||
tokio::time::sleep_until(next.into()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Group levels into `tick` buckets (bids down, asks up), then keep `depth` a side.
|
||||
fn shape(levels: &[Level], depth: usize, group: Option<f64>, up: bool) -> Vec<Level> {
|
||||
let Some(tick) = group.filter(|t| *t > 0.0 && t.is_finite()) else {
|
||||
return levels.iter().take(depth).copied().collect();
|
||||
};
|
||||
let mut out: Vec<Level> = Vec::with_capacity(depth);
|
||||
for l in levels {
|
||||
let b = if up { (l.px / tick).ceil() } else { (l.px / tick).floor() } * tick;
|
||||
if let Some(last) = out.last_mut().filter(|last| (last.px - b).abs() < tick * 1e-6) {
|
||||
last.sz += l.sz;
|
||||
last.n += l.n;
|
||||
} else if out.len() == depth {
|
||||
break;
|
||||
} else {
|
||||
out.push(Level { px: b, sz: l.sz, n: l.n });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn book(mut f: Fwd, topic: Arc<BookTopic>, depth: usize, group: Option<f64>) {
|
||||
let mut rx = topic.state.subscribe();
|
||||
let mut epoch: Option<u64> = None;
|
||||
let mut status = TopicStatus::Live;
|
||||
let mut last: Option<Arc<crate::store::BookSnap>> = None;
|
||||
let mut need_snapshot = true;
|
||||
loop {
|
||||
let st = rx.borrow_and_update().clone();
|
||||
if st.status != status {
|
||||
status = st.status.clone();
|
||||
if !f.status(status.clone()).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if epoch.is_some_and(|e| e != st.epoch) && !need_snapshot {
|
||||
if !f.resync(ResyncReason::Upstream).await {
|
||||
return;
|
||||
}
|
||||
need_snapshot = true;
|
||||
}
|
||||
epoch = Some(st.epoch);
|
||||
if let Some(b) = st.book.filter(|b| last.as_ref().is_none_or(|l| !Arc::ptr_eq(l, b))) {
|
||||
let data = Book {
|
||||
bids: shape(&b.bids, depth, group, false),
|
||||
asks: shape(&b.asks, depth, group, true),
|
||||
time_ms: b.time_ms,
|
||||
};
|
||||
let ok = if need_snapshot {
|
||||
need_snapshot = false;
|
||||
f.snapshot(TopicData::Book(data)).await
|
||||
} else {
|
||||
f.delta(TopicDelta::Book(data), b.recv).await
|
||||
};
|
||||
if !ok {
|
||||
return;
|
||||
}
|
||||
last = Some(b);
|
||||
}
|
||||
tokio::select! {
|
||||
r = rx.changed() => if r.is_err() { return },
|
||||
_ = f.resync.notified() => {
|
||||
need_snapshot = true;
|
||||
last = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn trades(mut f: Fwd, topic: Arc<TradesTopic>) {
|
||||
let mut rx = topic.subscribe();
|
||||
let mut seen = 0;
|
||||
let mut snapshot = true;
|
||||
loop {
|
||||
if std::mem::take(&mut snapshot) {
|
||||
let (list, seq, status) = topic.snapshot();
|
||||
seen = seq;
|
||||
if !f.snapshot(TopicData::Trades(list)).await {
|
||||
return;
|
||||
}
|
||||
if status != TopicStatus::Live && !f.status(status).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let ev = tokio::select! {
|
||||
ev = rx.recv() => ev,
|
||||
_ = f.resync.notified() => {
|
||||
snapshot = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match ev {
|
||||
Ok(ev) if ev.seq <= seen => {}
|
||||
Ok(ev) => {
|
||||
seen = ev.seq;
|
||||
let ok = match &ev.kind {
|
||||
EventKind::Data(t) => f.delta(TopicDelta::Trades(t.clone()), ev.recv).await,
|
||||
EventKind::Status(s) => f.status(s.clone()).await,
|
||||
EventKind::Resync => {
|
||||
snapshot = true;
|
||||
f.resync(ResyncReason::Upstream).await
|
||||
}
|
||||
};
|
||||
if !ok {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
snapshot = true;
|
||||
if !f.resync(ResyncReason::Overflow).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop each `Forming` bar that a later event in the batch supersedes (same `ts`, or a resync).
|
||||
/// `Closed` bars and statuses always stay, in order.
|
||||
pub fn coalesce(batch: Vec<Arc<Event<BarDelta>>>) -> Vec<Arc<Event<BarDelta>>> {
|
||||
let keep = |i: usize, e: &Event<BarDelta>| match &e.kind {
|
||||
EventKind::Data(BarDelta::Forming(b)) => !batch[i + 1..].iter().any(|later| match &later.kind {
|
||||
EventKind::Data(d) => d.bar().ts == b.ts,
|
||||
EventKind::Resync => true,
|
||||
EventKind::Status(_) => false,
|
||||
}),
|
||||
_ => true,
|
||||
};
|
||||
batch.iter().enumerate().filter(|(i, e)| keep(*i, e)).map(|(_, e)| e.clone()).collect()
|
||||
}
|
||||
|
||||
async fn bars_snapshot(f: &mut Fwd, hub: &Hub, topic: &BarsTopic, history: usize) -> Option<u64> {
|
||||
let since = topic.overlay().live_since;
|
||||
let hist = if history == 0 {
|
||||
Arc::new(Vec::new())
|
||||
} else {
|
||||
match hub.history(&topic.coin, topic.tf, None, history, since.or(Some(Instant::now()))).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::warn!("history {} {}: {e:#}", topic.coin, topic.tf);
|
||||
Arc::new(Vec::new())
|
||||
}
|
||||
}
|
||||
};
|
||||
let ov = topic.overlay();
|
||||
let bars = merge_bars(hist.to_vec(), &ov.bars, history.max(ov.bars.len()));
|
||||
if !f.snapshot(TopicData::Bars(bars)).await {
|
||||
return None;
|
||||
}
|
||||
if ov.status != TopicStatus::Live && !f.status(ov.status).await {
|
||||
return None;
|
||||
}
|
||||
Some(ov.seq)
|
||||
}
|
||||
|
||||
async fn bars(mut f: Fwd, hub: &Hub, topic: Arc<BarsTopic>, history: usize) {
|
||||
let mut rx = topic.subscribe();
|
||||
let Some(mut seen) = bars_snapshot(&mut f, hub, &topic, history).await else { return };
|
||||
loop {
|
||||
let first = tokio::select! {
|
||||
ev = rx.recv() => ev,
|
||||
_ = f.resync.notified() => {
|
||||
match bars_snapshot(&mut f, hub, &topic, history).await {
|
||||
Some(s) => seen = s,
|
||||
None => return,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut batch = Vec::new();
|
||||
let mut lagged = false;
|
||||
match first {
|
||||
Ok(e) => batch.push(e),
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => lagged = true,
|
||||
Err(broadcast::error::RecvError::Closed) => return,
|
||||
}
|
||||
// Whatever else is already waiting goes out together, superseded bars dropped.
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(e) => batch.push(e),
|
||||
Err(broadcast::error::TryRecvError::Lagged(_)) => lagged = true,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if lagged {
|
||||
if !f.resync(ResyncReason::Overflow).await {
|
||||
return;
|
||||
}
|
||||
match bars_snapshot(&mut f, hub, &topic, history).await {
|
||||
Some(s) => seen = s,
|
||||
None => return,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
batch.retain(|e| e.seq > seen);
|
||||
let Some(last) = batch.last() else { continue };
|
||||
seen = last.seq;
|
||||
for e in coalesce(batch) {
|
||||
let ok = match &e.kind {
|
||||
EventKind::Data(d) => f.delta(TopicDelta::Bar(*d), e.recv).await,
|
||||
EventKind::Status(s) => f.status(s.clone()).await,
|
||||
EventKind::Resync => {
|
||||
if !f.resync(ResyncReason::Upstream).await {
|
||||
return;
|
||||
}
|
||||
// The fresh snapshot covers the rest of this batch.
|
||||
match bars_snapshot(&mut f, hub, &topic, history).await {
|
||||
Some(s) => seen = seen.max(s),
|
||||
None => return,
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
if !ok {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tr_core::Bar;
|
||||
|
||||
fn ev(seq: u64, kind: EventKind<BarDelta>) -> Arc<Event<BarDelta>> {
|
||||
Arc::new(Event { seq, recv: Instant::now(), kind })
|
||||
}
|
||||
|
||||
fn b(ts: i64, c: f64) -> Bar {
|
||||
Bar::new(ts, c, c, c, c, 1.0)
|
||||
}
|
||||
|
||||
fn kinds(v: &[Arc<Event<BarDelta>>]) -> Vec<String> {
|
||||
v.iter()
|
||||
.map(|e| match &e.kind {
|
||||
EventKind::Data(BarDelta::Forming(x)) => format!("F{}:{}", x.ts, x.close),
|
||||
EventKind::Data(BarDelta::Closed(x)) => format!("C{}:{}", x.ts, x.close),
|
||||
EventKind::Resync => "R".into(),
|
||||
EventKind::Status(_) => "S".into(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A slow reader that wakes up across a bar boundary still gets the closed bar's final
|
||||
/// values before the next bar, and only the latest state of each forming bar.
|
||||
#[test]
|
||||
fn slow_reader_across_a_bar_boundary() {
|
||||
let batch = vec![
|
||||
ev(1, EventKind::Data(BarDelta::Forming(b(0, 1.0)))),
|
||||
ev(2, EventKind::Data(BarDelta::Forming(b(0, 2.0)))),
|
||||
ev(3, EventKind::Data(BarDelta::Closed(b(0, 2.5)))),
|
||||
ev(4, EventKind::Data(BarDelta::Forming(b(60, 3.0)))),
|
||||
ev(5, EventKind::Status(TopicStatus::Live)),
|
||||
ev(6, EventKind::Data(BarDelta::Forming(b(60, 4.0)))),
|
||||
];
|
||||
assert_eq!(kinds(&coalesce(batch)), ["C0:2.5", "S", "F60:4"]);
|
||||
let batch = vec![ev(1, EventKind::Data(BarDelta::Forming(b(0, 1.0)))), ev(2, EventKind::Resync)];
|
||||
assert_eq!(kinds(&coalesce(batch)), ["R"]);
|
||||
let batch = vec![ev(1, EventKind::Data(BarDelta::Forming(b(0, 1.0))))];
|
||||
assert_eq!(kinds(&coalesce(batch)), ["F0:1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn book_grouping_and_depth() {
|
||||
let l = |px: f64, sz: f64| Level { px, sz, n: 1 };
|
||||
let bids = [l(100.4, 1.0), l(100.1, 2.0), l(99.7, 1.0), l(99.2, 1.0)];
|
||||
let asks = [l(100.6, 1.0), l(100.9, 3.0), l(101.2, 1.0)];
|
||||
assert_eq!(shape(&bids, 2, None, false), bids[..2]);
|
||||
let g = shape(&bids, 10, Some(0.5), false);
|
||||
assert_eq!(
|
||||
g.iter().map(|x| (x.px, x.sz, x.n)).collect::<Vec<_>>(),
|
||||
[(100.0, 3.0, 2), (99.5, 1.0, 1), (99.0, 1.0, 1)]
|
||||
);
|
||||
assert_eq!(shape(&bids, 1, Some(0.5), false).len(), 1);
|
||||
let g = shape(&asks, 10, Some(0.5), true);
|
||||
assert_eq!(g.iter().map(|x| (x.px, x.sz)).collect::<Vec<_>>(), [(101.0, 4.0), (101.5, 1.0)]);
|
||||
assert_eq!(shape(&asks, 5, Some(f64::NAN), true).len(), 3, "bad ticks mean no grouping");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel":"allMids","data":{"mids":{"#114910":"0.5","#114911":"0.5","#114920":"0.17488","@1":"12.372","@107":"89.972","BTC":"77074.5","ETH":"2212.45","HYPE":"90.161","PURR/USDC":"0.11949","SOL":"94.6065"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel":"allMids","data":{"dex":"xyz","mids":{"xyz:AAOI":"73.2525","xyz:AAPL":"349.85","xyz:ACN":"250.07","xyz:ALUMINIUM":"3080.0","xyz:AMAT":"401.52","xyz:AMD":"526.575","xyz:GOLD":"4013.15","xyz:TSLA":"394.6","xyz:XYZ100":"28743.5"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel":"candle","data":{"t":1793326680000,"T":1793326739999,"s":"BTC","i":"1m","o":"77032.0","c":"77074.0","h":"77093.0","l":"76991.0","v":"15.07216","n":220}}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel":"l2Book","data":{"coin":"BTC","time":1793326724865,"levels":[[{"px":"77074.0","sz":"4.32001","n":9},{"px":"77073.0","sz":"0.01622","n":1},{"px":"77072.0","sz":"1.51628","n":1},{"px":"77071.0","sz":"0.84713","n":2},{"px":"77070.0","sz":"2.92355","n":2},{"px":"77069.0","sz":"0.26275","n":3},{"px":"77068.0","sz":"0.99095","n":4},{"px":"77067.0","sz":"0.1098","n":2},{"px":"77066.0","sz":"0.106","n":2},{"px":"77065.0","sz":"1.27403","n":3},{"px":"77064.0","sz":"1.29282","n":3},{"px":"77063.0","sz":"8.97664","n":4},{"px":"77062.0","sz":"1.84681","n":4},{"px":"77061.0","sz":"6.08341","n":6},{"px":"77060.0","sz":"1.49667","n":5},{"px":"77059.0","sz":"3.77298","n":3},{"px":"77058.0","sz":"1.27048","n":5},{"px":"77057.0","sz":"1.34478","n":3},{"px":"77056.0","sz":"1.36837","n":5},{"px":"77055.0","sz":"8.21667","n":5}],[{"px":"77075.0","sz":"3.92943","n":10},{"px":"77076.0","sz":"0.03119","n":1},{"px":"77078.0","sz":"1.11631","n":1},{"px":"77079.0","sz":"0.22003","n":1},{"px":"77080.0","sz":"1.12873","n":3},{"px":"77081.0","sz":"1.83735","n":3},{"px":"77082.0","sz":"1.22913","n":2},{"px":"77083.0","sz":"3.64255","n":4},{"px":"77084.0","sz":"1.62902","n":2},{"px":"77085.0","sz":"7.96303","n":12},{"px":"77086.0","sz":"10.386","n":6},{"px":"77087.0","sz":"18.13093","n":8},{"px":"77088.0","sz":"4.2013","n":5},{"px":"77089.0","sz":"2.12388","n":7},{"px":"77090.0","sz":"0.2849","n":4},{"px":"77091.0","sz":"5.41136","n":6},{"px":"77092.0","sz":"1.05595","n":7},{"px":"77093.0","sz":"2.30917","n":10},{"px":"77094.0","sz":"4.87672","n":10},{"px":"77095.0","sz":"3.61234","n":5}]]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel":"trades","data":[{"coin":"BTC","side":"B","px":"77090.0","sz":"0.00064","time":1793326719367,"hash":"0x0000000000000000000000000000000000000000000000000000000000000000","tid":494268480360624,"users":["0x0000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000"]},{"coin":"BTC","side":"B","px":"77090.0","sz":"0.0022","time":1793326719367,"hash":"0x0000000000000000000000000000000000000000000000000000000000000000","tid":530242516244559,"users":["0x0000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000"]},{"coin":"BTC","side":"B","px":"77093.0","sz":"0.42668","time":1793326719367,"hash":"0x0000000000000000000000000000000000000000000000000000000000000000","tid":599896480530140,"users":["0x0000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000"]},{"coin":"BTC","side":"A","px":"77092.0","sz":"0.00397","time":1793326719427,"hash":"0x0000000000000000000000000000000000000000000000000000000000000000","tid":659301716729239,"users":["0x0000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000"]},{"coin":"BTC","side":"B","px":"77093.0","sz":"0.01627","time":1793326719856,"hash":"0x0000000000000000000000000000000000000000000000000000000000000000","tid":1030399580240574,"users":["0x0000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000"]}]}
|
||||
@@ -0,0 +1,870 @@
|
||||
//! The gateway end to end, against a mock of Hyperliquid's public API (websocket and `info` on
|
||||
//! one local port) and through the desktop's client (`tr_data::remote`).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use axum::extract::{State, WebSocketUpgrade};
|
||||
use axum::routing::{get, post};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::mpsc;
|
||||
use tr_data::MarketData;
|
||||
use tr_data::remote::{Client, Event, RemoteMarkets, Subscription};
|
||||
use tr_gateway::Config;
|
||||
use tr_proto::{
|
||||
BarDelta, Encoding, ErrorCode, InstrumentKind, MarketKey, ResyncReason, Rpc, RpcResult, Timeframe, TopicData,
|
||||
TopicDelta, TopicReq, TopicStatus, UnavailableReason,
|
||||
};
|
||||
|
||||
const WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
// ---- the mock upstream ----
|
||||
|
||||
/// A mock connection: where to send it frames (`None` closes it), and what it subscribed to.
|
||||
type MockConn = (mpsc::UnboundedSender<Option<String>>, Arc<Mutex<HashSet<String>>>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Mock {
|
||||
/// Every subscribe and unsubscribe received, as `subscribe l2Book:BTC`.
|
||||
log: Mutex<Vec<String>>,
|
||||
conns: Mutex<Vec<MockConn>>,
|
||||
/// Connections open right now.
|
||||
open: AtomicUsize,
|
||||
/// Answer this many `candleSnapshot` requests with HTTP 500...
|
||||
fail_candles: Mutex<usize>,
|
||||
/// ...after holding each this long.
|
||||
fail_hold: Mutex<Duration>,
|
||||
/// `candleSnapshot` requests being answered now, and the most there ever were at once.
|
||||
candles_inflight: AtomicUsize,
|
||||
candles_peak: AtomicUsize,
|
||||
}
|
||||
|
||||
/// Counts a `candleSnapshot` request as in flight until it's answered.
|
||||
struct InFlight(Arc<Mock>);
|
||||
|
||||
impl InFlight {
|
||||
fn new(m: &Arc<Mock>) -> Self {
|
||||
let n = m.candles_inflight.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
m.candles_peak.fetch_max(n, Ordering::SeqCst);
|
||||
Self(m.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InFlight {
|
||||
fn drop(&mut self) {
|
||||
self.0.candles_inflight.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts a mock connection as open until its task ends.
|
||||
struct Open(Arc<Mock>);
|
||||
|
||||
impl Drop for Open {
|
||||
fn drop(&mut self) {
|
||||
self.0.open.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn label(sub: &Value) -> String {
|
||||
let s = |k: &str| sub[k].as_str().unwrap_or_default().to_string();
|
||||
match s("type").as_str() {
|
||||
"allMids" if s("dex").is_empty() => "allMids".into(),
|
||||
"allMids" => format!("allMids:{}", s("dex")),
|
||||
"candle" => format!("candle:{}:{}", s("coin"), s("interval")),
|
||||
t => format!("{t}:{}", s("coin")),
|
||||
}
|
||||
}
|
||||
|
||||
impl Mock {
|
||||
fn subs(&self) -> HashSet<String> {
|
||||
self.conns.lock().unwrap().iter().flat_map(|(_, s)| s.lock().unwrap().clone()).collect()
|
||||
}
|
||||
|
||||
fn count(&self, entry: &str) -> usize {
|
||||
self.log.lock().unwrap().iter().filter(|e| *e == entry).count()
|
||||
}
|
||||
|
||||
/// Send `data` on `channel` to every connection subscribed to `sub`.
|
||||
fn push(&self, sub: &str, channel: &str, data: Value) {
|
||||
let text = json!({ "channel": channel, "data": data }).to_string();
|
||||
for (tx, subs) in self.conns.lock().unwrap().iter() {
|
||||
if subs.lock().unwrap().contains(sub) {
|
||||
let _ = tx.send(Some(text.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close every upstream connection, as Hyperliquid may at any time.
|
||||
fn drop_all(&self) {
|
||||
for (tx, _) in self.conns.lock().unwrap().drain(..) {
|
||||
let _ = tx.send(None);
|
||||
}
|
||||
}
|
||||
|
||||
async fn until(&self, what: &str, f: impl Fn(&Mock) -> bool) {
|
||||
let t0 = tokio::time::Instant::now();
|
||||
while !f(self) {
|
||||
assert!(t0.elapsed() < WAIT, "timed out waiting for {what}; upstream has {:?}", self.subs());
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn mock_ws(State(m): State<Arc<Mock>>, up: WebSocketUpgrade) -> axum::response::Response {
|
||||
up.on_upgrade(move |ws| mock_conn(ws, m))
|
||||
}
|
||||
|
||||
async fn mock_conn(ws: WebSocket, m: Arc<Mock>) {
|
||||
m.open.fetch_add(1, Ordering::SeqCst);
|
||||
let _open = Open(m.clone());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<Option<String>>();
|
||||
let subs = Arc::new(Mutex::new(HashSet::new()));
|
||||
m.conns.lock().unwrap().push((tx.clone(), subs.clone()));
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
loop {
|
||||
tokio::select! {
|
||||
out = rx.recv() => match out {
|
||||
Some(Some(text)) => if sink.send(Message::Text(text.into())).await.is_err() { return },
|
||||
_ => {
|
||||
let _ = sink.close().await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
msg = stream.next() => {
|
||||
let Some(Ok(Message::Text(t))) = msg else {
|
||||
if matches!(msg, None | Some(Err(_)) | Some(Ok(Message::Close(_)))) { return }
|
||||
continue;
|
||||
};
|
||||
let v: Value = serde_json::from_str(t.as_str()).unwrap();
|
||||
match v["method"].as_str() {
|
||||
Some("ping") => { let _ = tx.send(Some(json!({"channel": "pong"}).to_string())); }
|
||||
Some(method @ ("subscribe" | "unsubscribe")) => {
|
||||
let l = label(&v["subscription"]);
|
||||
m.log.lock().unwrap().push(format!("{method} {l}"));
|
||||
if method == "subscribe" { subs.lock().unwrap().insert(l); } else { subs.lock().unwrap().remove(&l); }
|
||||
let ack = json!({"channel": "subscriptionResponse", "data": {"method": method, "subscription": v["subscription"]}});
|
||||
let _ = tx.send(Some(ack.to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn interval_s(i: &str) -> i64 {
|
||||
match i {
|
||||
"1m" => 60,
|
||||
"5m" => 300,
|
||||
"1h" => 3600,
|
||||
"1d" => 86_400,
|
||||
_ => 60,
|
||||
}
|
||||
}
|
||||
|
||||
async fn mock_info(State(m): State<Arc<Mock>>, axum::Json(req): axum::Json<Value>) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
let _inflight = (req["type"] == "candleSnapshot").then(|| InFlight::new(&m));
|
||||
let fail = req["type"] == "candleSnapshot" && {
|
||||
let mut n = m.fail_candles.lock().unwrap();
|
||||
let fail = *n > 0;
|
||||
*n = n.saturating_sub(1);
|
||||
fail
|
||||
};
|
||||
if fail {
|
||||
let hold = *m.fail_hold.lock().unwrap();
|
||||
tokio::time::sleep(hold).await;
|
||||
return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
axum::Json(match req["type"].as_str().unwrap() {
|
||||
"perpDexs" => json!([null, {"name": "xyz"}]),
|
||||
"metaAndAssetCtxs" if req["dex"] == "xyz" => {
|
||||
json!([{"universe": [{"name": "xyz:TSLA"}]}, [{"prevDayPx": "400.0"}]])
|
||||
}
|
||||
"metaAndAssetCtxs" => json!([
|
||||
{"universe": [{"name": "BTC"}, {"name": "ETH"}, {"name": "OLD", "isDelisted": true}]},
|
||||
[{"prevDayPx": "100.0"}, {"prevDayPx": "10.0"}, {"prevDayPx": "1.0"}]
|
||||
]),
|
||||
"candleSnapshot" => {
|
||||
let r = &req["req"];
|
||||
if r["coin"] == "SLOW" {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
let iv = interval_s(r["interval"].as_str().unwrap()) * 1000;
|
||||
let (start, end) = (r["startTime"].as_i64().unwrap(), r["endTime"].as_i64().unwrap());
|
||||
let first = (start + iv - 1) / iv;
|
||||
let bars: Vec<Value> = (first..=end / iv)
|
||||
.map(|k| {
|
||||
let px = format!("{}.0", 100 + k % 10);
|
||||
json!({"t": k * iv, "T": k * iv + iv - 1, "s": r["coin"], "i": r["interval"], "o": px, "h": px, "l": px, "c": px, "v": "1.0", "n": 1})
|
||||
})
|
||||
.collect();
|
||||
Value::Array(bars[bars.len().saturating_sub(5000)..].to_vec())
|
||||
}
|
||||
other => panic!("unexpected info request {other}"),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn start_mock() -> (Arc<Mock>, SocketAddr) {
|
||||
let m = Arc::new(Mock::default());
|
||||
let app = Router::new().route("/ws", get(mock_ws)).route("/info", post(mock_info)).with_state(m.clone());
|
||||
let l = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
let addr = l.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(l, app).await.unwrap() });
|
||||
(m, addr)
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
struct Setup {
|
||||
mock: Arc<Mock>,
|
||||
gw: tr_gateway::Gateway,
|
||||
}
|
||||
|
||||
impl Setup {
|
||||
async fn new(tweak: impl FnOnce(&mut Config)) -> Setup {
|
||||
let s = Setup::starting(tweak).await;
|
||||
s.core_mids();
|
||||
s.xyz_mids();
|
||||
let t0 = tokio::time::Instant::now();
|
||||
while s.gw.hub().mids.len() < 4 {
|
||||
assert!(t0.elapsed() < WAIT, "mids never arrived");
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(s.gw.hub().ready(WAIT).await, "the hub never got ready");
|
||||
s
|
||||
}
|
||||
|
||||
/// A gateway that has subscribed every venue's mids upstream but heard none yet, as right
|
||||
/// after a restart.
|
||||
async fn starting(tweak: impl FnOnce(&mut Config)) -> Setup {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let (mock, addr) = start_mock().await;
|
||||
let mut cfg = Config { bind: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), ..Default::default() };
|
||||
cfg.hl.info_url = format!("http://{addr}/info");
|
||||
cfg.hl.pool.ws_url = format!("ws://{addr}/ws");
|
||||
cfg.hub.linger = Duration::from_millis(300);
|
||||
tweak(&mut cfg);
|
||||
let gw = tr_gateway::start(cfg).await.unwrap();
|
||||
mock.until("both venues' mids", |m| m.subs().is_superset(&set(&["allMids", "allMids:xyz"]))).await;
|
||||
Setup { mock, gw }
|
||||
}
|
||||
|
||||
fn core_mids(&self) {
|
||||
let mids = json!({"mids": {"BTC": "100.5", "ETH": "10.25", "@107": "30.0", "#5": "0.5"}});
|
||||
self.mock.push("allMids", "allMids", mids);
|
||||
}
|
||||
|
||||
fn xyz_mids(&self) {
|
||||
self.mock.push("allMids:xyz", "allMids", json!({"dex": "xyz", "mids": {"xyz:TSLA": "401.5"}}));
|
||||
}
|
||||
|
||||
fn client(&self, enc: Encoding) -> Client {
|
||||
Client::connect(&format!("ws://{}", self.gw.addr), enc, "gateway-test")
|
||||
}
|
||||
|
||||
fn upstream_keys(&self) -> Vec<String> {
|
||||
self.gw.hub().provider().stats().keys
|
||||
}
|
||||
}
|
||||
|
||||
fn set(v: &[&str]) -> HashSet<String> {
|
||||
v.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
async fn recv_bar(feed: &mut tr_data::LiveFeed) -> tr_core::Bar {
|
||||
tokio::time::timeout(WAIT, feed.recv()).await.expect("a bar in time").expect("feed open")
|
||||
}
|
||||
|
||||
async fn next(sub: &mut Subscription) -> Event {
|
||||
tokio::time::timeout(WAIT, sub.next()).await.expect("an update in time").expect("client alive").event
|
||||
}
|
||||
|
||||
/// The next event that isn't a status or reconnect notice.
|
||||
async fn next_data(sub: &mut Subscription) -> Event {
|
||||
loop {
|
||||
match next(sub).await {
|
||||
Event::Status(_) | Event::Reconnecting | Event::Resubscribing => {}
|
||||
e => return e,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a subscription sees up to its first snapshot, and the snapshot. Fails if the server
|
||||
/// gives up on it on the way.
|
||||
async fn until_snapshot(sub: &mut Subscription) -> (Vec<String>, TopicData) {
|
||||
let mut seen = Vec::new();
|
||||
loop {
|
||||
match next(sub).await {
|
||||
Event::Snapshot(data) => return (seen, data),
|
||||
Event::Status(s @ TopicStatus::Unavailable { .. }) => panic!("the server gave up: {s:?} after {seen:?}"),
|
||||
Event::Status(TopicStatus::Live) => seen.push("live".into()),
|
||||
Event::Status(TopicStatus::Stale { .. }) => seen.push("stale".into()),
|
||||
e => seen.push(format!("{e:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn book_msg(coin: &str, bid: &str) -> Value {
|
||||
json!({"coin": coin, "time": 1_793_325_942_459i64, "levels": [
|
||||
[{"px": bid, "sz": "1.5", "n": 3}, {"px": "99.0", "sz": "2", "n": 1}],
|
||||
[{"px": "101.0", "sz": "1", "n": 2}]
|
||||
]})
|
||||
}
|
||||
|
||||
fn trade(tid: u64) -> Value {
|
||||
json!({"coin": "BTC", "side": "B", "px": "100.5", "sz": "0.1", "time": tid as i64, "hash": "0x0", "tid": tid, "users": ["0x0", "0x0"]})
|
||||
}
|
||||
|
||||
// ---- tests ----
|
||||
|
||||
#[tokio::test]
|
||||
async fn clients_share_one_upstream_subscription_until_the_last_leaves() {
|
||||
let s = Setup::new(|_| {}).await;
|
||||
let (a, b) = (s.client(Encoding::Postcard), s.client(Encoding::Postcard));
|
||||
let topic = || TopicReq::Book { key: MarketKey::new("BTC"), depth: 10, group: None };
|
||||
let mut sa = a.subscribe(topic());
|
||||
let mut sb = b.subscribe(topic());
|
||||
s.mock.until("l2Book:BTC upstream", |m| m.subs().contains("l2Book:BTC")).await;
|
||||
s.mock.push("l2Book:BTC", "l2Book", book_msg("BTC", "100.0"));
|
||||
for sub in [&mut sa, &mut sb] {
|
||||
let Event::Snapshot(TopicData::Book(book)) = next_data(sub).await else { panic!("book snapshot") };
|
||||
assert_eq!((book.bids[0].px, book.bids[0].sz, book.bids[0].n, book.asks.len()), (100.0, 1.5, 3, 1));
|
||||
}
|
||||
// A third client on the same market adds nothing upstream.
|
||||
let c = s.client(Encoding::Postcard);
|
||||
let mut sc = c.subscribe(topic());
|
||||
let Event::Snapshot(TopicData::Book(_)) = next_data(&mut sc).await else { panic!("hot snapshot") };
|
||||
assert_eq!(s.mock.count("subscribe l2Book:BTC"), 1, "one upstream subscription for three clients");
|
||||
assert_eq!(s.upstream_keys(), ["allMids", "allMids:xyz", "l2Book:BTC"]);
|
||||
// Deltas reach every client, in sequence.
|
||||
s.mock.push("l2Book:BTC", "l2Book", book_msg("BTC", "100.25"));
|
||||
for sub in [&mut sa, &mut sb, &mut sc] {
|
||||
let Event::Delta(TopicDelta::Book(book)) = next_data(sub).await else { panic!("book delta") };
|
||||
assert_eq!(book.bids[0].px, 100.25);
|
||||
}
|
||||
drop((sa, sb));
|
||||
tokio::time::sleep(Duration::from_millis(700)).await;
|
||||
assert!(s.mock.subs().contains("l2Book:BTC"), "one client still watches");
|
||||
drop(sc);
|
||||
s.mock.until("the upstream unsubscribe after the linger", |m| m.count("unsubscribe l2Book:BTC") == 1).await;
|
||||
assert_eq!(s.upstream_keys(), ["allMids", "allMids:xyz"]);
|
||||
assert_eq!(s.gw.hub().topic_counts().book, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscriptions_past_the_upstream_budget_are_refused() {
|
||||
// Two permanent mids subscriptions plus one more.
|
||||
let s = Setup::new(|c| c.hl.pool.max_subscriptions = 3).await;
|
||||
let client = s.client(Encoding::Postcard);
|
||||
let book = client.subscribe(TopicReq::Book { key: MarketKey::new("BTC"), depth: 5, group: None });
|
||||
s.mock.until("l2Book:BTC upstream", |m| m.subs().contains("l2Book:BTC")).await;
|
||||
let mut trades = client.subscribe(TopicReq::Trades { key: MarketKey::new("ETH") });
|
||||
match next(&mut trades).await {
|
||||
Event::Status(TopicStatus::Unavailable { reason: UnavailableReason::Capacity, detail }) => {
|
||||
assert!(detail.contains("3 of 3"), "{detail}")
|
||||
}
|
||||
e => panic!("expected a capacity refusal, got {e:?}"),
|
||||
}
|
||||
assert_eq!(s.mock.count("subscribe trades:ETH"), 0, "nothing past the budget reaches upstream");
|
||||
// Freeing the slot lets the next subscription through.
|
||||
drop(book);
|
||||
s.mock.until("the book's unsubscribe", |m| m.count("unsubscribe l2Book:BTC") == 1).await;
|
||||
let _trades = client.subscribe(TopicReq::Trades { key: MarketKey::new("ETH") });
|
||||
s.mock.until("trades:ETH upstream", |m| m.subs().contains("trades:ETH")).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_markets_never_reach_upstream_and_keys_resolve() {
|
||||
let s = Setup::new(|_| {}).await;
|
||||
let client = s.client(Encoding::Postcard);
|
||||
let mut nope = client.subscribe(TopicReq::Book { key: MarketKey::new("NOTACOIN"), depth: 5, group: None });
|
||||
assert!(matches!(
|
||||
next(&mut nope).await,
|
||||
Event::Status(TopicStatus::Unavailable { reason: UnavailableReason::UnknownMarket, .. })
|
||||
));
|
||||
let keys =
|
||||
["BTCUSD Curncy", "BTC", "ETH-PERP", "xyz:TSLA", "@107", "#5", "AAPL US Equity"].map(MarketKey::new).to_vec();
|
||||
let mut q = client.subscribe(TopicReq::Quotes { keys, max_hz: 4 });
|
||||
let Event::Snapshot(TopicData::Quotes(quotes)) = next(&mut q).await else { panic!("quotes") };
|
||||
let got: HashMap<&str, (&str, f64, Option<f64>)> =
|
||||
quotes.iter().map(|q| (q.key.as_str(), (q.source.as_str(), q.px, q.prev_day))).collect();
|
||||
assert_eq!(got["BTCUSD Curncy"], ("BTC", 100.5, Some(100.0)));
|
||||
assert_eq!(got["ETH-PERP"], ("ETH", 10.25, Some(10.0)));
|
||||
assert_eq!(got["xyz:TSLA"], ("xyz:TSLA", 401.5, Some(400.0)));
|
||||
assert_eq!(got["@107"], ("@107", 30.0, None));
|
||||
assert!(!got.contains_key("#5"), "outcome markets are out of scope");
|
||||
assert!(!got.contains_key("AAPL US Equity"), "equities don't map to HIP-3 perps yet");
|
||||
// A changed mid arrives as a delta with just that market, under every key that asked for it.
|
||||
s.mock.push("allMids", "allMids", json!({"mids": {"BTC": "100.75", "ETH": "10.25", "@107": "30.0"}}));
|
||||
let Event::Delta(TopicDelta::Quotes(d)) = next(&mut q).await else { panic!("quote delta") };
|
||||
assert_eq!(
|
||||
d.iter().map(|q| (q.key.as_str(), q.px)).collect::<Vec<_>>(),
|
||||
[("BTCUSD Curncy", 100.75), ("BTC", 100.75)]
|
||||
);
|
||||
s.mock.push("allMids", "allMids", json!({"mids": {"BTC": "100.5", "ETH": "10.25", "@107": "30.0"}}));
|
||||
let Event::Delta(TopicDelta::Quotes(d)) = next(&mut q).await else { panic!("second quote delta") };
|
||||
assert_eq!(d.len(), 2, "neither key freezes: {d:?}");
|
||||
assert!(s.mock.log.lock().unwrap().iter().all(|e| !e.contains("NOTACOIN")));
|
||||
// The market list: perps by venue, spot, no outcomes.
|
||||
let Ok(RpcResult::Markets(m)) = client.request(Rpc::Markets).await else { panic!("markets") };
|
||||
let find = |k: &str| m.iter().find(|x| x.key.as_str() == k).map(|x| (x.venue.as_str(), x.kind));
|
||||
assert_eq!(find("BTC"), Some(("", InstrumentKind::Perp)));
|
||||
assert_eq!(find("xyz:TSLA"), Some(("xyz", InstrumentKind::Perp)));
|
||||
assert_eq!(find("@107"), Some(("spot", InstrumentKind::Spot)));
|
||||
assert_eq!(find("#5"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upstream_reconnects_resubscribe_and_clients_resync() {
|
||||
let s = Setup::new(|_| {}).await;
|
||||
let client = s.client(Encoding::Postcard);
|
||||
let mut sub = client.subscribe(TopicReq::Trades { key: MarketKey::new("BTC") });
|
||||
s.mock.until("trades:BTC upstream", |m| m.subs().contains("trades:BTC")).await;
|
||||
let Event::Snapshot(TopicData::Trades(t)) = next(&mut sub).await else { panic!("trades snapshot") };
|
||||
assert!(t.is_empty());
|
||||
s.mock.push("trades:BTC", "trades", json!([trade(1), trade(2)]));
|
||||
let Event::Delta(TopicDelta::Trades(t)) = next(&mut sub).await else { panic!("trades delta") };
|
||||
assert_eq!(t.iter().map(|t| t.tid).collect::<Vec<_>>(), [1, 2]);
|
||||
|
||||
s.mock.drop_all();
|
||||
assert!(matches!(next(&mut sub).await, Event::Status(TopicStatus::Stale { .. })));
|
||||
s.mock
|
||||
.until("resubscribed after reconnecting", |m| {
|
||||
m.count("subscribe trades:BTC") == 2 && m.count("subscribe allMids") == 2
|
||||
})
|
||||
.await;
|
||||
// Hyperliquid starts a (re)subscription with recent trades, overlapping what we have.
|
||||
s.mock.push("trades:BTC", "trades", json!([trade(2), trade(3)]));
|
||||
let mut seen = Vec::new();
|
||||
loop {
|
||||
match next(&mut sub).await {
|
||||
Event::Snapshot(TopicData::Trades(t)) => {
|
||||
assert_eq!(t.iter().map(|t| t.tid).collect::<Vec<_>>(), [1, 2, 3], "deduplicated");
|
||||
break;
|
||||
}
|
||||
Event::Status(TopicStatus::Live) => seen.push("live"),
|
||||
Event::Resync(ResyncReason::Upstream) => seen.push("resync"),
|
||||
e => panic!("unexpected {e:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(seen, ["live", "resync"]);
|
||||
assert_eq!(s.gw.hub().provider().stats().connections_up, 2);
|
||||
let stats = tr_gateway::http::stats(&s.gw.state);
|
||||
assert_eq!(stats.upstream_reconnects, 2, "both connections came back");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bars_stream_closed_before_the_next_bar_and_history_merges() {
|
||||
let s = Setup::new(|_| {}).await;
|
||||
let client = s.client(Encoding::Postcard);
|
||||
let Ok(RpcResult::History(h)) = client
|
||||
.request(Rpc::History { key: MarketKey::new("BTCUSD Curncy"), tf: Timeframe::H1, end: None, limit: 10 })
|
||||
.await
|
||||
else {
|
||||
panic!("history")
|
||||
};
|
||||
assert_eq!(h.len(), 10);
|
||||
assert!(h.windows(2).all(|w| w[1].ts - w[0].ts == 3600));
|
||||
let mut sub = client.subscribe(TopicReq::Bars { key: MarketKey::new("BTC"), tf: Timeframe::M1, history: 3 });
|
||||
s.mock.until("candle:BTC:1m upstream", |m| m.subs().contains("candle:BTC:1m")).await;
|
||||
let Event::Snapshot(TopicData::Bars(snap)) = next(&mut sub).await else { panic!("bars snapshot") };
|
||||
assert_eq!(snap.len(), 3);
|
||||
let t0 = snap.last().unwrap().ts + 60;
|
||||
let candle = |t: i64, c: &str| json!({"t": t * 1000, "T": t * 1000 + 59_999, "s": "BTC", "i": "1m", "o": "100", "h": "200", "l": "50", "c": c, "v": "3", "n": 9});
|
||||
for (t, c) in [(t0, "101"), (t0, "102"), (t0 + 60, "103")] {
|
||||
s.mock.push("candle:BTC:1m", "candle", candle(t, c));
|
||||
}
|
||||
let mut got = Vec::new();
|
||||
while got.last().is_none_or(|l| l != "F60:103") {
|
||||
if let Event::Delta(TopicDelta::Bar(d)) = next_data(&mut sub).await {
|
||||
got.push(match d {
|
||||
BarDelta::Forming(b) => format!("F{}:{}", b.ts - t0, b.close),
|
||||
BarDelta::Closed(b) => format!("C{}:{}", b.ts - t0, b.close),
|
||||
});
|
||||
}
|
||||
}
|
||||
// A reader may skip a forming bar a later message supersedes, but the closed bar's final
|
||||
// values always arrive, before the next bar.
|
||||
let closed = got.iter().position(|g| g == "C0:102").expect("the closed bar");
|
||||
assert_eq!(&got[closed..], ["C0:102", "F60:103"], "{got:?}");
|
||||
assert!(got[..closed].iter().all(|g| g.starts_with("F0:")), "{got:?}");
|
||||
// History now includes what the live stream has seen.
|
||||
let Ok(RpcResult::History(h)) =
|
||||
client.request(Rpc::History { key: MarketKey::new("BTC"), tf: Timeframe::M1, end: None, limit: 5 }).await
|
||||
else {
|
||||
panic!("history")
|
||||
};
|
||||
assert_eq!(h.last().map(|b| (b.ts, b.close)), Some((t0 + 60, 103.0)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn json_is_a_debug_encoding() {
|
||||
let s = Setup::new(|_| {}).await;
|
||||
let client = s.client(Encoding::Json);
|
||||
let mut q = client.subscribe(TopicReq::Quotes { keys: vec![MarketKey::new("xyz:TSLA")], max_hz: 1 });
|
||||
let Event::Snapshot(TopicData::Quotes(quotes)) = next(&mut q).await else { panic!("quotes over JSON") };
|
||||
assert_eq!(quotes[0].px, 401.5);
|
||||
let stats = tr_gateway::http::stats(&s.gw.state);
|
||||
assert_eq!((stats.clients, stats.upstream.subscriptions), (1, 2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clients_that_send_too_fast_are_throttled() {
|
||||
let s = Setup::new(|c| c.session.max_messages_per_10s = 3).await;
|
||||
let client = s.client(Encoding::Postcard);
|
||||
assert!(client.wait_up(WAIT).await);
|
||||
// The client's first ping and these five subscribes share a budget of three messages.
|
||||
let mut subs: Vec<_> =
|
||||
(0..5).map(|_| client.subscribe(TopicReq::Quotes { keys: vec![MarketKey::new("BTC")], max_hz: 1 })).collect();
|
||||
let mut answered = 0;
|
||||
for sub in &mut subs {
|
||||
if let Ok(Some(_)) = tokio::time::timeout(Duration::from_millis(500), sub.next()).await {
|
||||
answered += 1;
|
||||
}
|
||||
}
|
||||
assert!((2..=3).contains(&answered), "{answered} of 5 subscriptions were served");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quote_subscribers_see_feed_outages_then_resync() {
|
||||
let s = Setup::new(|_| {}).await;
|
||||
let client = s.client(Encoding::Postcard);
|
||||
let keys = ["BTC", "xyz:TSLA"].map(MarketKey::new).to_vec();
|
||||
let mut q = client.subscribe(TopicReq::Quotes { keys, max_hz: 10 });
|
||||
assert!(matches!(next(&mut q).await, Event::Snapshot(TopicData::Quotes(_))));
|
||||
s.mock.drop_all();
|
||||
assert!(matches!(next(&mut q).await, Event::Status(TopicStatus::Stale { .. })), "the outage is visible");
|
||||
s.mock
|
||||
.until("mids resubscribed", |m| m.count("subscribe allMids") == 2 && m.count("subscribe allMids:xyz") == 2)
|
||||
.await;
|
||||
s.mock.push("allMids", "allMids", json!({"mids": {"BTC": "101.0", "ETH": "10.25", "@107": "30.0"}}));
|
||||
s.mock.push("allMids:xyz", "allMids", json!({"dex": "xyz", "mids": {"xyz:TSLA": "401.5"}}));
|
||||
let mut seen = Vec::new();
|
||||
let quotes = loop {
|
||||
match next(&mut q).await {
|
||||
Event::Snapshot(TopicData::Quotes(q)) => break q,
|
||||
Event::Status(TopicStatus::Live) => seen.push("live"),
|
||||
Event::Status(TopicStatus::Stale { .. }) => seen.push("stale"),
|
||||
Event::Resync(ResyncReason::Upstream) => seen.push("resync"),
|
||||
e => panic!("unexpected {e:?}"),
|
||||
}
|
||||
};
|
||||
assert_eq!(seen.last_chunk::<2>(), Some(&["live", "resync"]), "{seen:?}");
|
||||
let btc = quotes.iter().find(|q| q.key.as_str() == "BTC").unwrap();
|
||||
assert_eq!(btc.px, 101.0, "the fresh snapshot carries prices from after the outage");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requests_are_bounded_and_time_out() {
|
||||
let s = Setup::new(|c| {
|
||||
c.session.max_requests_in_flight = 2;
|
||||
c.session.request_timeout = Duration::from_secs(1);
|
||||
})
|
||||
.await;
|
||||
// A market whose history the mock serves slowly.
|
||||
s.mock.push("allMids", "allMids", json!({"mids": {"BTC": "100.5", "ETH": "10.25", "@107": "30.0", "SLOW": "1.0"}}));
|
||||
let t0 = tokio::time::Instant::now();
|
||||
while !s.gw.hub().mids.contains("SLOW") {
|
||||
assert!(t0.elapsed() < WAIT);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let client = s.client(Encoding::Postcard);
|
||||
let req = || client.request(Rpc::History { key: MarketKey::new("SLOW"), tf: Timeframe::M1, end: None, limit: 5 });
|
||||
let t0 = tokio::time::Instant::now();
|
||||
let results = futures::future::join_all([req(), req(), req()]).await;
|
||||
let mut codes: Vec<String> = results.iter().map(|r| format!("{:?}", r.as_ref().unwrap_err().code)).collect();
|
||||
codes.sort();
|
||||
assert_eq!(codes, ["RateLimited", "Upstream", "Upstream"], "two time out, the third is refused: {results:?}");
|
||||
assert!(t0.elapsed() < Duration::from_secs(3), "nobody waited for the slow upstream");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_closes_upstream_for_good() {
|
||||
let s = Setup::new(|_| {}).await;
|
||||
let client = s.client(Encoding::Postcard);
|
||||
let mut book = client.subscribe(TopicReq::Book { key: MarketKey::new("BTC"), depth: 5, group: None });
|
||||
s.mock.until("l2Book:BTC upstream", |m| m.subs().contains("l2Book:BTC")).await;
|
||||
assert_eq!(s.mock.open.load(Ordering::SeqCst), 2);
|
||||
let Setup { mock, gw } = s;
|
||||
tokio::time::timeout(WAIT, gw.shutdown()).await.expect("shutdown finishes");
|
||||
mock.until("every upstream connection closed", |m| m.open.load(Ordering::SeqCst) == 0).await;
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
assert_eq!(mock.open.load(Ordering::SeqCst), 0, "nothing reconnects after shutdown");
|
||||
// The client's session was closed too.
|
||||
while !matches!(next(&mut book).await, Event::Reconnecting) {}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn order_books_fall_back_while_the_server_is_away() {
|
||||
let s = Setup::new(|_| {}).await;
|
||||
let m = RemoteMarkets::new(&format!("ws://{}", s.gw.addr), tr_data::sample::SampleProvider::default());
|
||||
let push = async {
|
||||
s.mock.until("l2Book:BTC upstream", |mk| mk.subs().contains("l2Book:BTC")).await;
|
||||
s.mock.push("l2Book:BTC", "l2Book", book_msg("BTC", "100.0"));
|
||||
};
|
||||
let (book, ()) = tokio::join!(m.order_book("BTCUSD Curncy", 10), push);
|
||||
let book = book.unwrap();
|
||||
assert_eq!((book.source.as_str(), book.bids[0].0), (tr_data::remote::BOOK_SOURCE, 100.0));
|
||||
// The server goes away: the pane gets the fallback's book, not the last one the server sent.
|
||||
let Setup { mock: _mock, gw } = s;
|
||||
tokio::time::timeout(WAIT, gw.shutdown()).await.expect("shutdown finishes");
|
||||
let t0 = tokio::time::Instant::now();
|
||||
loop {
|
||||
let book = m.order_book("BTCUSD Curncy", 10).await.unwrap();
|
||||
if book.source != tr_data::remote::BOOK_SOURCE {
|
||||
assert!(book.simulated, "the sample provider's book");
|
||||
break;
|
||||
}
|
||||
assert!(t0.elapsed() < WAIT, "still the server's last book {:?} after it went away", book.bids[0]);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn charts_hand_off_to_the_fallback_and_back_when_the_server_goes_and_returns() {
|
||||
let s = Setup::new(|_| {}).await;
|
||||
let addr = s.gw.addr;
|
||||
let m = RemoteMarkets::new(&format!("ws://{addr}"), tr_data::sample::SampleProvider::default().with_live_ticks());
|
||||
assert_eq!(m.history("BTCUSD Curncy", Timeframe::M1, None, 10).await.unwrap().len(), 10, "from the server");
|
||||
let mut feed = m.live("BTCUSD Curncy", Timeframe::M1).expect("the server's feed");
|
||||
s.mock.until("candle:BTC:1m upstream", |mk| mk.subs().contains("candle:BTC:1m")).await;
|
||||
// The server goes away and stays away: the feed ends, so the worker reloads the chart...
|
||||
let Setup { mock: _mock, gw } = s;
|
||||
tokio::time::timeout(WAIT, gw.shutdown()).await.expect("shutdown finishes");
|
||||
let gone = tokio::time::Instant::now();
|
||||
while tokio::time::timeout(Duration::from_secs(20), feed.recv()).await.expect("the feed ends").is_some() {}
|
||||
assert!(gone.elapsed() >= Duration::from_secs(9), "it gave the server a while to come back first");
|
||||
// ...from the fallback.
|
||||
let bars = m.history("BTCUSD Curncy", Timeframe::M1, None, 10).await.unwrap();
|
||||
assert_eq!(bars.len(), 10, "sample bars");
|
||||
let mut feed = m.live("BTCUSD Curncy", Timeframe::M1).expect("the fallback's feed");
|
||||
assert!(recv_bar(&mut feed).await.close > 0.0, "the fallback's live bars come through");
|
||||
// The server comes back on the same address: the fallback's feed ends, so the worker reloads
|
||||
// the chart from the server.
|
||||
let back = Setup::new(|c| c.bind = addr).await;
|
||||
while tokio::time::timeout(Duration::from_secs(30), feed.recv()).await.expect("the fallback feed ends").is_some() {}
|
||||
assert_eq!(m.history("BTCUSD Curncy", Timeframe::M1, None, 10).await.unwrap().len(), 10);
|
||||
let _feed = m.live("BTCUSD Curncy", Timeframe::M1).expect("the server's feed again");
|
||||
back.mock.until("candle:BTC:1m upstream again", |mk| mk.subs().contains("candle:BTC:1m")).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn charts_on_the_fallback_move_back_on_a_newer_connection_only() {
|
||||
// Nothing listens there yet, so the chart loads from the fallback.
|
||||
let addr = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap();
|
||||
let m = RemoteMarkets::new(&format!("ws://{addr}"), tr_data::sample::SampleProvider::default().with_live_ticks());
|
||||
assert_eq!(m.history("BTCUSD Curncy", Timeframe::M1, None, 10).await.unwrap().len(), 10, "sample bars");
|
||||
// The server comes up before the chart starts its live feed...
|
||||
let s = Setup::new(|c| c.bind = addr).await;
|
||||
let t0 = tokio::time::Instant::now();
|
||||
while m.quotes(&["BTCUSD Curncy".into()]).await.unwrap()[0].last != 100.5 {
|
||||
assert!(t0.elapsed() < Duration::from_secs(20), "the client never connected");
|
||||
}
|
||||
// ...so the fallback's feed ends at once, and the chart reloads from the server.
|
||||
let mut feed = m.live("BTCUSD Curncy", Timeframe::M1).expect("the fallback's feed");
|
||||
let ended = tokio::time::timeout(Duration::from_secs(2), async { while feed.recv().await.is_some() {} }).await;
|
||||
assert!(ended.is_ok(), "the fallback's feed kept going with the server up");
|
||||
// A chart that falls back while the server is up (it failed the request) stays on the fallback:
|
||||
// reloading would only fail again.
|
||||
*s.mock.fail_candles.lock().unwrap() = 1000;
|
||||
assert_eq!(m.history("BTCUSD Curncy", Timeframe::M1, None, 10).await.unwrap().len(), 10, "sample bars");
|
||||
assert!(*s.mock.fail_candles.lock().unwrap() < 1000, "the server was asked and failed");
|
||||
let mut feed = m.live("BTCUSD Curncy", Timeframe::M1).expect("the fallback's feed");
|
||||
let ended = tokio::time::timeout(Duration::from_secs(2), async { while feed.recv().await.is_some() {} }).await;
|
||||
assert!(ended.is_err(), "the fallback's feed ended with no new connection");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn desktop_charts_backfill_bars_missed_in_an_outage() {
|
||||
let s = Setup::new(|_| {}).await;
|
||||
let m = RemoteMarkets::new(&format!("ws://{}", s.gw.addr), tr_data::sample::SampleProvider::default());
|
||||
let hist = m.history("BTCUSD Curncy", Timeframe::M1, None, 10).await.unwrap();
|
||||
assert_eq!(hist.len(), 10, "history from the server");
|
||||
let now = hist.last().unwrap().ts;
|
||||
let mut feed = m.live("BTCUSD Curncy", Timeframe::M1).expect("a live feed");
|
||||
s.mock.until("candle:BTC:1m upstream", |mk| mk.subs().contains("candle:BTC:1m")).await;
|
||||
let candle = |t: i64| json!({"t": t * 1000, "T": t * 1000 + 59_999, "s": "BTC", "i": "1m", "o": "1", "h": "1", "l": "1", "c": "1", "v": "1", "n": 1});
|
||||
// The stream is on a bar three minutes back when the upstream drops...
|
||||
let before = now - 180;
|
||||
s.mock.push("candle:BTC:1m", "candle", candle(before));
|
||||
let got = tokio::time::timeout(WAIT, feed.recv()).await.unwrap().unwrap();
|
||||
assert_eq!(got.ts, before);
|
||||
s.mock.drop_all();
|
||||
s.mock.until("candle resubscribed", |mk| mk.count("subscribe candle:BTC:1m") == 2).await;
|
||||
// ...and back on the current bar after. The first history request for the gap fails: the
|
||||
// live bar goes on at once, and the two bars in between follow when the retry works.
|
||||
*s.mock.fail_candles.lock().unwrap() = 1;
|
||||
s.mock.push("candle:BTC:1m", "candle", candle(now));
|
||||
let mut chart = vec![got];
|
||||
let mut ts = Vec::new();
|
||||
while !(ts.contains(&(before + 60)) && ts.contains(&(before + 120))) {
|
||||
let b = tokio::time::timeout(WAIT, feed.recv()).await.expect("the gap fills").unwrap();
|
||||
ts.push(b.ts);
|
||||
tr_core::Series::apply_live(&mut chart, b);
|
||||
}
|
||||
assert_eq!(ts.first(), Some(&now), "the live bar didn't wait for history: {ts:?}");
|
||||
assert_eq!(*s.mock.fail_candles.lock().unwrap(), 0, "the first attempt failed");
|
||||
assert_eq!(chart.iter().map(|b| b.ts).collect::<Vec<_>>(), [before, before + 60, before + 120, now], "no hole");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn desktop_backfill_keeps_one_request_in_flight_across_outages() {
|
||||
let s = Setup::new(|_| {}).await;
|
||||
let m = RemoteMarkets::new(&format!("ws://{}", s.gw.addr), tr_data::sample::SampleProvider::default());
|
||||
let now = m.history("BTCUSD Curncy", Timeframe::M1, None, 10).await.unwrap().last().unwrap().ts;
|
||||
let mut feed = m.live("BTCUSD Curncy", Timeframe::M1).expect("a live feed");
|
||||
s.mock.until("candle:BTC:1m upstream", |mk| mk.subs().contains("candle:BTC:1m")).await;
|
||||
let candle = |t: i64| json!({"t": t * 1000, "T": t * 1000 + 59_999, "s": "BTC", "i": "1m", "o": "1", "h": "1", "l": "1", "c": "1", "v": "1", "n": 1});
|
||||
s.mock.push("candle:BTC:1m", "candle", candle(now - 300));
|
||||
let mut chart = vec![recv_bar(&mut feed).await];
|
||||
// History is down, and each failed request hangs for a while: two outages in a row must
|
||||
// not stack up two retry loops.
|
||||
*s.mock.fail_candles.lock().unwrap() = 1000;
|
||||
*s.mock.fail_hold.lock().unwrap() = Duration::from_secs(4);
|
||||
s.mock.candles_peak.store(0, Ordering::SeqCst);
|
||||
for (k, t) in [(2, now - 180), (3, now - 60)] {
|
||||
s.mock.drop_all();
|
||||
s.mock.until("candle resubscribed", |mk| mk.count("subscribe candle:BTC:1m") == k).await;
|
||||
s.mock.push("candle:BTC:1m", "candle", candle(t));
|
||||
let b = recv_bar(&mut feed).await;
|
||||
assert_eq!(b.ts, t, "the live bar doesn't wait for history");
|
||||
tr_core::Series::apply_live(&mut chart, b);
|
||||
}
|
||||
assert!(s.mock.candles_inflight.load(Ordering::SeqCst) >= 1, "a backfill request is in flight");
|
||||
assert_eq!(s.mock.candles_peak.load(Ordering::SeqCst), 1, "one history request at a time per feed");
|
||||
// History comes back: one request fills both gaps.
|
||||
*s.mock.fail_candles.lock().unwrap() = 0;
|
||||
let want: Vec<i64> = (0..5).map(|i| now - 300 + i * 60).collect();
|
||||
while chart.iter().map(|b| b.ts).collect::<Vec<_>>() != want {
|
||||
tr_core::Series::apply_live(&mut chart, recv_bar(&mut feed).await);
|
||||
}
|
||||
assert_eq!(s.mock.candles_peak.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
/// A client that reconnects while the gateway is still starting (no instrument list or mids yet)
|
||||
/// keeps its subscriptions: each is marked stale, then goes live with a snapshot once the
|
||||
/// upstream is in. The gateway used to wait a few seconds, then end them with `Unavailable`.
|
||||
#[tokio::test]
|
||||
async fn subscriptions_made_while_the_gateway_starts_wait_for_the_upstream() {
|
||||
let s = Setup::starting(|c| c.session.ready_timeout = Duration::from_millis(200)).await;
|
||||
let client = s.client(Encoding::Postcard);
|
||||
let keys = ["BTC", "xyz:TSLA"].map(MarketKey::new).to_vec();
|
||||
let mut quotes = client.subscribe(TopicReq::Quotes { keys, max_hz: 10 });
|
||||
let mut book = client.subscribe(TopicReq::Book { key: MarketKey::new("BTC"), depth: 5, group: None });
|
||||
let mut trades = client.subscribe(TopicReq::Trades { key: MarketKey::new("xyz:TSLA") });
|
||||
// Well past the wait after which the gateway used to give up.
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
s.core_mids();
|
||||
s.xyz_mids();
|
||||
let (seen, data) = until_snapshot(&mut quotes).await;
|
||||
assert_eq!(seen, ["stale", "live"], "told it's waiting, then that it's live");
|
||||
let TopicData::Quotes(q) = data else { panic!("quotes") };
|
||||
let mut got: Vec<(&str, f64)> = q.iter().map(|q| (q.key.as_str(), q.px)).collect();
|
||||
got.sort_by(|a, b| a.0.cmp(b.0));
|
||||
assert_eq!(got, [("BTC", 100.5), ("xyz:TSLA", 401.5)]);
|
||||
s.mock.until("l2Book:BTC upstream", |m| m.subs().contains("l2Book:BTC")).await;
|
||||
s.mock.push("l2Book:BTC", "l2Book", book_msg("BTC", "100.0"));
|
||||
let (seen, data) = until_snapshot(&mut book).await;
|
||||
assert_eq!(seen, ["stale", "live"]);
|
||||
assert!(matches!(data, TopicData::Book(b) if b.bids[0].px == 100.0));
|
||||
let (_, data) = until_snapshot(&mut trades).await;
|
||||
assert_eq!(data, TopicData::Trades(vec![]));
|
||||
assert!(s.mock.subs().contains("trades:xyz:TSLA"));
|
||||
}
|
||||
|
||||
/// The gateway is ready once every venue's mids are in, not just the first venue's: a HIP-3
|
||||
/// market asked for while only the core dex has reported waits for its own dex, where it used
|
||||
/// to come back as an unknown market.
|
||||
#[tokio::test]
|
||||
async fn markets_on_a_dex_still_loading_are_not_unknown() {
|
||||
let s = Setup::starting(|_| {}).await;
|
||||
s.core_mids();
|
||||
let t0 = tokio::time::Instant::now();
|
||||
while !s.gw.hub().mids.contains("BTC") {
|
||||
assert!(t0.elapsed() < WAIT, "core mids never arrived");
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let client = s.client(Encoding::Postcard);
|
||||
let mut q = client.subscribe(TopicReq::Quotes { keys: vec![MarketKey::new("xyz:TSLA")], max_hz: 10 });
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
s.xyz_mids();
|
||||
let (_, data) = until_snapshot(&mut q).await;
|
||||
let TopicData::Quotes(q) = data else { panic!("quotes") };
|
||||
assert_eq!(q.iter().map(|q| (q.key.as_str(), q.px)).collect::<Vec<_>>(), [("xyz:TSLA", 401.5)]);
|
||||
}
|
||||
|
||||
/// A request made while the gateway starts fails as an upstream error, which clients retry, not
|
||||
/// as an unknown market: the desktop stops asking the server about a market it called unknown.
|
||||
#[tokio::test]
|
||||
async fn requests_made_while_the_gateway_starts_fail_as_retryable() {
|
||||
let s = Setup::starting(|c| c.session.ready_timeout = Duration::from_millis(200)).await;
|
||||
let client = s.client(Encoding::Postcard);
|
||||
let history = || {
|
||||
client.request(Rpc::History { key: MarketKey::new("BTCUSD Curncy"), tf: Timeframe::H1, end: None, limit: 10 })
|
||||
};
|
||||
let r = history().await;
|
||||
assert_eq!(r.as_ref().map_err(|e| e.code), Err(ErrorCode::Upstream), "{r:?}");
|
||||
s.core_mids();
|
||||
s.xyz_mids();
|
||||
assert!(s.gw.hub().ready(WAIT).await);
|
||||
let Ok(RpcResult::History(h)) = history().await else { panic!("history once ready") };
|
||||
assert_eq!(h.len(), 10);
|
||||
}
|
||||
|
||||
/// A venue whose mids never come holds readiness back only for `venue_grace`: after that, the
|
||||
/// next mids from any venue make the gateway ready without it.
|
||||
#[tokio::test]
|
||||
async fn a_silent_venue_delays_readiness_only_for_the_grace() {
|
||||
let s = Setup::starting(|c| c.hub.venue_grace = Duration::from_secs(1)).await;
|
||||
let readyz = || async {
|
||||
reqwest::get(format!("http://{}/readyz", s.gw.addr)).await.expect("readyz answers").status().as_u16()
|
||||
};
|
||||
s.core_mids();
|
||||
assert!(!s.gw.hub().ready(Duration::from_millis(300)).await, "still waiting for the xyz dex");
|
||||
assert_eq!(readyz().await, 503);
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
s.mock.push("allMids", "allMids", json!({"mids": {"BTC": "100.75", "ETH": "10.25", "@107": "30.0"}}));
|
||||
assert!(s.gw.hub().ready(WAIT).await);
|
||||
assert_eq!(readyz().await, 200);
|
||||
assert!(!s.gw.hub().mids.contains("xyz:TSLA"));
|
||||
}
|
||||
|
||||
/// A venue later than `venue_grace` doesn't make its markets unknown. Until it reports, a
|
||||
/// request for one fails as `Upstream`, which clients retry, a subscription waits for it like
|
||||
/// one made during startup, and a quotes subscription takes it in when its mids arrive.
|
||||
#[tokio::test]
|
||||
async fn markets_on_a_venue_later_than_the_grace_come_back() {
|
||||
let s = Setup::starting(|c| c.hub.venue_grace = Duration::from_millis(300)).await;
|
||||
s.core_mids();
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
s.mock.push("allMids", "allMids", json!({"mids": {"BTC": "100.75", "ETH": "10.25", "@107": "30.0"}}));
|
||||
assert!(s.gw.hub().ready(WAIT).await, "ready without the xyz dex");
|
||||
let client = s.client(Encoding::Postcard);
|
||||
let tsla = || MarketKey::new("xyz:TSLA");
|
||||
let r = client.request(Rpc::History { key: tsla(), tf: Timeframe::H1, end: None, limit: 5 }).await;
|
||||
assert_eq!(r.as_ref().map_err(|e| e.code), Err(ErrorCode::Upstream), "{r:?}");
|
||||
let mut book = client.subscribe(TopicReq::Book { key: tsla(), depth: 5, group: None });
|
||||
let mut quotes = client.subscribe(TopicReq::Quotes { keys: vec![MarketKey::new("BTC"), tsla()], max_hz: 10 });
|
||||
let Event::Snapshot(TopicData::Quotes(q)) = next(&mut quotes).await else { panic!("quotes") };
|
||||
assert_eq!(q.iter().map(|q| q.key.as_str()).collect::<Vec<_>>(), ["BTC"]);
|
||||
let e = next(&mut book).await;
|
||||
assert!(matches!(e, Event::Status(TopicStatus::Stale { .. })), "the book waits for its venue: {e:?}");
|
||||
s.xyz_mids();
|
||||
// The quotes subscription takes the late market in...
|
||||
let Event::Delta(TopicDelta::Quotes(d)) = next(&mut quotes).await else { panic!("a quotes delta") };
|
||||
assert_eq!(d.iter().map(|q| (q.key.as_str(), q.px)).collect::<Vec<_>>(), [("xyz:TSLA", 401.5)]);
|
||||
// ...and the book subscription goes live, with no retry from the client.
|
||||
s.mock.until("l2Book:xyz:TSLA upstream", |m| m.subs().contains("l2Book:xyz:TSLA")).await;
|
||||
s.mock.push("l2Book:xyz:TSLA", "l2Book", book_msg("xyz:TSLA", "400.0"));
|
||||
let (seen, data) = until_snapshot(&mut book).await;
|
||||
assert_eq!(seen, ["live"]);
|
||||
assert!(matches!(data, TopicData::Book(b) if b.bids[0].px == 400.0));
|
||||
}
|
||||