feat(data): native Binance REST kline fetcher in 9 languages (#315)
Adds `BinanceRest::fetch_klines` to `wickra-data`: a blocking historical kline downloader (`GET /api/v3/klines`) and the historical counterpart to the F4 live `BinanceFeed`. It is the last native data-layer primitive needed to drop third-party HTTP/JSON download helpers (`jackson`, `jsonlite`, `urllib`, …) from the examples. ## What - **Core** (`wickra-data`): `fetch_klines(symbol, interval, limit, start?, end?)` built on `ureq` with native-tls — sharing the exact same TLS backend (native-tls 0.2 / SChannel) as the existing tokio-tungstenite live feed, so the two pull one TLS stack, not two. Parses Binance's 12-element array rows via the existing serde infrastructure into validated `Candle`s. Blocking by design (a one-shot request needs no async runtime; the FFI boundary is synchronous anyway). Nine mock-HTTP-server tests cover parse / empty / limit / transport / JSON / invariant-violation paths. - **C ABI**: `wickra_binance_fetch_klines(...)` (blocking drain into a caller buffer, `-1` on error) + regenerated cbindgen header and its vendored Go copy. - **Bindings**: native Node `fetchBinanceKlines` / Python `fetch_binance_klines`; generated Go `FetchBinanceKlines` / C# `BinanceFeed.FetchKlines` / Java `BinanceFeed.fetchKlines` / R `fetch_binance_klines`. C / C++ call the C ABI directly. **WASM is excluded** (browsers use the host `fetch`). The four C-ABI bindings are regenerated from the ScriptHelpers generators (not hand-edited); the regen diff is exactly the new wrapper in each. ## Verification All ten toolchains green locally: Rust (`cargo test`/`clippy`/`fmt`), Node, Python, Go, C#, Java, R (`R CMD INSTALL` + smoke), WASM (`cargo check`, confirmed `ureq` is not pulled). Each binding has an error-path smoke test; the parse/HTTP success path is covered by the Rust mock-server tests. No release in this PR — ships with the data-layer + numpy bundle later.
This commit is contained in:
@@ -14,6 +14,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
and map to the correct wire-format strings.
|
||||
|
||||
### Added
|
||||
- **Native historical Binance REST kline fetcher in 9 languages (data layer).**
|
||||
`fetchBinanceKlines` (Node.js / Python `fetch_binance_klines` / Go
|
||||
`FetchBinanceKlines` / C# `BinanceFeed.FetchKlines` / Java `BinanceFeed.fetchKlines`
|
||||
/ R `fetch_binance_klines`; C / C++ call `wickra_binance_fetch_klines`) downloads
|
||||
historical OHLCV candles straight from Binance's public REST endpoint — no
|
||||
third-party HTTP/JSON client (`jackson`, `jsonlite`, `urllib`, …) needed. Pass a
|
||||
symbol, interval, and limit (`1..=1000`) plus optional millisecond start/end
|
||||
bounds; it blocks until the response arrives and returns the parsed candles. It
|
||||
is built on `ureq` with native-tls, sharing the live feed's TLS stack, and is
|
||||
covered by mock-HTTP-server tests. The historical counterpart to the live
|
||||
`BinanceFeed`; WASM is excluded (browsers use the host `fetch`). Ships with the
|
||||
C ABI's default `live-binance` feature.
|
||||
- **Native live Binance kline feed in 9 languages (data layer).** `BinanceFeed`
|
||||
streams live OHLCV candles straight from Binance's public WebSocket — no
|
||||
third-party WebSocket client (`ws`, `websockets`, `gorilla/websocket`, …) in any
|
||||
|
||||
Generated
+23
-203
@@ -32,12 +32,6 @@ version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
version = "0.5.1"
|
||||
@@ -64,6 +58,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -398,12 +398,6 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
@@ -489,23 +483,10 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
@@ -517,15 +498,6 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
@@ -636,12 +608,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -670,9 +636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -712,12 +676,6 @@ dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
@@ -1103,16 +1061,6 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
@@ -1228,12 +1176,6 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
@@ -1260,7 +1202,7 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1557,7 +1499,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
@@ -1725,10 +1667,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"log",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
@@ -1791,16 +1740,7 @@ version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.51.0",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1897,40 +1837,6 @@ version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.98"
|
||||
@@ -1990,12 +1896,14 @@ dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
"futures-util",
|
||||
"native-tls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"ureq",
|
||||
"url",
|
||||
"wickra-core",
|
||||
]
|
||||
@@ -2102,100 +2010,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
|
||||
@@ -13773,6 +13773,15 @@ void wickra_binance_close(struct BinanceStream *handle);
|
||||
|
||||
void wickra_binance_free(struct BinanceStream *handle);
|
||||
|
||||
intptr_t wickra_binance_fetch_klines(const char *symbol,
|
||||
uint8_t interval,
|
||||
uint32_t limit,
|
||||
int64_t start_ms,
|
||||
int64_t end_ms,
|
||||
const char *base_url,
|
||||
struct WickraCandle *out,
|
||||
uintptr_t cap);
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif // __cplusplus
|
||||
|
||||
@@ -68572,6 +68572,71 @@ pub unsafe extern "C" fn wickra_binance_free(handle: *mut BinanceStream) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch historical klines from Binance's REST endpoint into `out` (up to `cap`
|
||||
/// candles). `symbol` is the trading pair (case-insensitive, e.g. `"BTCUSDT"`),
|
||||
/// `interval` the code `0..=15` (the `Interval` declaration order), and `limit`
|
||||
/// the number of candles to request (`1..=1000`). `start_ms`/`end_ms` are
|
||||
/// inclusive Unix-millisecond bounds; pass a negative value for "unset".
|
||||
/// `base_url` overrides the host (`NULL` = production `https://api.binance.com`;
|
||||
/// pass an `http://…` URL to target a test server). This blocks until the HTTP
|
||||
/// response arrives. Returns the number of candles written (`<= cap`), or `-1`
|
||||
/// on a null/invalid symbol, an unknown interval, an out-of-range limit, a
|
||||
/// transport/JSON error, or a `NULL` `out`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `symbol` must be a valid NUL-terminated UTF-8 C string; `base_url` must be
|
||||
/// `NULL` or a valid NUL-terminated UTF-8 C string; when non-`NULL`, `out` must
|
||||
/// cover `cap` `WickraCandle` elements.
|
||||
#[cfg(feature = "live-binance")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wickra_binance_fetch_klines(
|
||||
symbol: *const c_char,
|
||||
interval: u8,
|
||||
limit: u32,
|
||||
start_ms: i64,
|
||||
end_ms: i64,
|
||||
base_url: *const c_char,
|
||||
out: *mut WickraCandle,
|
||||
cap: usize,
|
||||
) -> isize {
|
||||
if symbol.is_null() || out.is_null() {
|
||||
return -1;
|
||||
}
|
||||
let Ok(symbol_str) = core::ffi::CStr::from_ptr(symbol).to_str() else {
|
||||
return -1;
|
||||
};
|
||||
let Some(interval) = binance_interval(interval) else {
|
||||
return -1;
|
||||
};
|
||||
let Ok(limit) = u16::try_from(limit) else {
|
||||
return -1;
|
||||
};
|
||||
let start = (start_ms >= 0).then_some(start_ms);
|
||||
let end = (end_ms >= 0).then_some(end_ms);
|
||||
let fetched = if base_url.is_null() {
|
||||
wickra_data::live::binance_rest::fetch_klines(symbol_str, interval, limit, start, end)
|
||||
} else {
|
||||
let Ok(url) = core::ffi::CStr::from_ptr(base_url).to_str() else {
|
||||
return -1;
|
||||
};
|
||||
let config = wickra_data::live::binance_rest::BinanceRestConfig {
|
||||
base_url: url.to_owned(),
|
||||
};
|
||||
wickra_data::live::binance_rest::fetch_klines_with_config(
|
||||
symbol_str, interval, limit, start, end, &config,
|
||||
)
|
||||
};
|
||||
let Ok(candles) = fetched else {
|
||||
return -1;
|
||||
};
|
||||
let count = candles.len().min(cap);
|
||||
let slots = slice::from_raw_parts_mut(out, count);
|
||||
for (slot, candle) in slots.iter_mut().zip(&candles[..count]) {
|
||||
*slot = candle_to_c(*candle);
|
||||
}
|
||||
isize::try_from(count).unwrap_or(isize::MAX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -68621,4 +68686,55 @@ mod tests {
|
||||
wickra_sma_free(ptr::null_mut());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-binance")]
|
||||
#[test]
|
||||
fn fetch_klines_rejects_invalid_arguments() {
|
||||
// Each of these short-circuits to -1 before any network access, so the
|
||||
// test is deterministic and offline.
|
||||
unsafe {
|
||||
let symbol = c"BTCUSDT".as_ptr();
|
||||
let mut out = [WickraCandle {
|
||||
open: 0.0,
|
||||
high: 0.0,
|
||||
low: 0.0,
|
||||
close: 0.0,
|
||||
volume: 0.0,
|
||||
timestamp: 0,
|
||||
}; 4];
|
||||
// Null symbol.
|
||||
assert_eq!(
|
||||
wickra_binance_fetch_klines(
|
||||
ptr::null(),
|
||||
6,
|
||||
1,
|
||||
-1,
|
||||
-1,
|
||||
ptr::null(),
|
||||
out.as_mut_ptr(),
|
||||
4
|
||||
),
|
||||
-1
|
||||
);
|
||||
// Null output buffer.
|
||||
assert_eq!(
|
||||
wickra_binance_fetch_klines(symbol, 6, 1, -1, -1, ptr::null(), ptr::null_mut(), 4),
|
||||
-1
|
||||
);
|
||||
// Unknown interval code.
|
||||
assert_eq!(
|
||||
wickra_binance_fetch_klines(
|
||||
symbol,
|
||||
99,
|
||||
1,
|
||||
-1,
|
||||
-1,
|
||||
ptr::null(),
|
||||
out.as_mut_ptr(),
|
||||
4
|
||||
),
|
||||
-1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,4 +27,27 @@ public class BinanceFeedTests
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new BinanceFeed("BTCUSDT", BinanceInterval.OneMinute, "ws://127.0.0.1:1"));
|
||||
}
|
||||
|
||||
// The REST fetcher's parse/HTTP success path is covered by the Rust
|
||||
// mock-HTTP-server tests; here we only assert the binding's error paths.
|
||||
[Fact]
|
||||
public void FetchKlinesRejectsUnknownInterval()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
BinanceFeed.FetchKlines("BTCUSDT", (BinanceInterval)99, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FetchKlinesRejectsZeroLimit()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
BinanceFeed.FetchKlines("BTCUSDT", BinanceInterval.OneHour, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FetchKlinesSurfacesUnreachableEndpoint()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
BinanceFeed.FetchKlines("BTCUSDT", BinanceInterval.OneHour, 1, baseUrl: "http://127.0.0.1:1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41589,6 +41589,43 @@ public sealed class BinanceFeed : IDisposable
|
||||
return new KlineEvent(symbol, ev.open, ev.high, ev.low, ev.close, ev.volume, ev.open_time, ev.is_closed != 0);
|
||||
}
|
||||
|
||||
/// <summary>Fetch historical klines from Binance's REST endpoint. <paramref name="symbol"/>
|
||||
/// is the trading pair (case-insensitive), <paramref name="limit"/> the number of
|
||||
/// candles (1..=1000). <paramref name="startMs"/>/<paramref name="endMs"/> are inclusive
|
||||
/// Unix-millisecond bounds (negative = unset); <paramref name="baseUrl"/> overrides the
|
||||
/// host (null = production). Blocks until the response arrives.</summary>
|
||||
public static Candle[] FetchKlines(string symbol, BinanceInterval interval, uint limit, long startMs = -1, long endMs = -1, string? baseUrl = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbol);
|
||||
if (limit == 0)
|
||||
{
|
||||
throw new ArgumentException("limit must be in 1..=1000");
|
||||
}
|
||||
var symBytes = System.Text.Encoding.UTF8.GetBytes(symbol + '\0');
|
||||
var urlBytes = baseUrl is null ? null : System.Text.Encoding.UTF8.GetBytes(baseUrl + '\0');
|
||||
var buffer = new WickraCandle[limit];
|
||||
long count;
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* sp = symBytes)
|
||||
fixed (byte* up = urlBytes)
|
||||
fixed (WickraCandle* ptr = buffer)
|
||||
{
|
||||
count = (long)NativeMethods.wickra_binance_fetch_klines(sp, (byte)interval, limit, startMs, endMs, up, ptr, (nuint)limit);
|
||||
}
|
||||
}
|
||||
if (count < 0)
|
||||
{
|
||||
throw new ArgumentException("invalid FetchKlines parameters or transport error");
|
||||
}
|
||||
var result = new Candle[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
result[i] = new Candle(buffer[i].open, buffer[i].high, buffer[i].low, buffer[i].close, buffer[i].volume, buffer[i].timestamp);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
unsafe
|
||||
|
||||
@@ -12454,6 +12454,9 @@ internal static partial class NativeMethods
|
||||
[LibraryImport(WickraNative.LibraryName)]
|
||||
internal static partial void wickra_binance_free(nint handle);
|
||||
|
||||
[LibraryImport(WickraNative.LibraryName)]
|
||||
internal static unsafe partial nint wickra_binance_fetch_klines(byte* symbol, byte interval, uint limit, long startMs, long endMs, byte* baseUrl, WickraCandle* @out, nuint cap);
|
||||
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
|
||||
@@ -18,3 +18,18 @@ func TestBinanceFeedRejectsBadParams(t *testing.T) {
|
||||
t.Fatal("expected an error connecting to an unreachable endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
// The REST fetcher's parse/HTTP success path is covered by the Rust
|
||||
// mock-HTTP-server tests in wickra-data; here we only assert the binding's
|
||||
// error paths, which need no reachable network.
|
||||
func TestFetchBinanceKlinesRejectsBadParams(t *testing.T) {
|
||||
if _, err := FetchBinanceKlines("BTCUSDT", BinanceInterval(99), 1, -1, -1, ""); err == nil {
|
||||
t.Fatal("expected an error for an unknown interval code")
|
||||
}
|
||||
if _, err := FetchBinanceKlines("BTCUSDT", OneHour, 0, -1, -1, ""); err == nil {
|
||||
t.Fatal("expected an error for a zero limit")
|
||||
}
|
||||
if _, err := FetchBinanceKlines("BTCUSDT", OneHour, 1, -1, -1, "http://127.0.0.1:1"); err == nil {
|
||||
t.Fatal("expected an error connecting to an unreachable endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13773,6 +13773,15 @@ void wickra_binance_close(struct BinanceStream *handle);
|
||||
|
||||
void wickra_binance_free(struct BinanceStream *handle);
|
||||
|
||||
intptr_t wickra_binance_fetch_klines(const char *symbol,
|
||||
uint8_t interval,
|
||||
uint32_t limit,
|
||||
int64_t start_ms,
|
||||
int64_t end_ms,
|
||||
const char *base_url,
|
||||
struct WickraCandle *out,
|
||||
uintptr_t cap);
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif // __cplusplus
|
||||
|
||||
@@ -43431,3 +43431,32 @@ func (f *BinanceFeed) Close() {
|
||||
runtime.SetFinalizer(f, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// FetchBinanceKlines fetches historical klines from Binance's REST endpoint.
|
||||
// symbol is the trading pair (case-insensitive), interval the kline interval,
|
||||
// and limit the number of candles to request (1..=1000). startMs/endMs are
|
||||
// inclusive Unix-millisecond bounds (negative = unset); baseURL overrides the
|
||||
// host ("" = production https://api.binance.com). It blocks until the response
|
||||
// arrives and returns ErrInvalidParams on a bad argument or transport error.
|
||||
func FetchBinanceKlines(symbol string, interval BinanceInterval, limit uint32, startMs, endMs int64, baseURL string) ([]Candle, error) {
|
||||
if limit == 0 {
|
||||
return nil, ErrInvalidParams
|
||||
}
|
||||
csym := C.CString(symbol)
|
||||
defer C.free(unsafe.Pointer(csym))
|
||||
var curl *C.char
|
||||
if baseURL != "" {
|
||||
curl = C.CString(baseURL)
|
||||
defer C.free(unsafe.Pointer(curl))
|
||||
}
|
||||
buf := make([]C.struct_WickraCandle, limit)
|
||||
n := int(C.wickra_binance_fetch_klines(csym, C.uint8_t(interval), C.uint32_t(limit), C.int64_t(startMs), C.int64_t(endMs), curl, &buf[0], C.uintptr_t(limit)))
|
||||
if n < 0 {
|
||||
return nil, ErrInvalidParams
|
||||
}
|
||||
out := make([]Candle, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = Candle{float64(buf[i].open), float64(buf[i].high), float64(buf[i].low), float64(buf[i].close), float64(buf[i].volume), int64(buf[i].timestamp)}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -81,6 +81,51 @@ public final class BinanceFeed implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch historical klines from Binance's REST endpoint. symbol is the
|
||||
* trading pair (case-insensitive), limit the number of candles (1..=1000).
|
||||
* startMs/endMs are inclusive Unix-millisecond bounds (negative = unset);
|
||||
* baseUrl overrides the host (null = production). Blocks until done. */
|
||||
public static Candle[] fetchKlines(String symbol, BinanceInterval interval, int limit,
|
||||
long startMs, long endMs, String baseUrl) {
|
||||
if (symbol == null) {
|
||||
throw new NullPointerException("symbol");
|
||||
}
|
||||
if (limit <= 0) {
|
||||
throw new IllegalArgumentException("limit must be in 1..=1000");
|
||||
}
|
||||
try (Arena a = Arena.ofConfined()) {
|
||||
byte[] sb = symbol.getBytes(StandardCharsets.UTF_8);
|
||||
MemorySegment sym = a.allocate(sb.length + 1L);
|
||||
MemorySegment.copy(sb, 0, sym, JAVA_BYTE, 0L, sb.length);
|
||||
MemorySegment url = MemorySegment.NULL;
|
||||
if (baseUrl != null) {
|
||||
byte[] ub = baseUrl.getBytes(StandardCharsets.UTF_8);
|
||||
url = a.allocate(ub.length + 1L);
|
||||
MemorySegment.copy(ub, 0, url, JAVA_BYTE, 0L, ub.length);
|
||||
}
|
||||
MemorySegment out = a.allocate(48L * limit);
|
||||
long n = (long) NativeMethods.WICKRA_BINANCE_FETCH_KLINES.invokeExact(
|
||||
sym, (byte) interval.ordinal(), limit, startMs, endMs, url, out, (long) limit);
|
||||
if (n < 0) {
|
||||
throw new IllegalArgumentException("invalid fetchKlines parameters or transport error");
|
||||
}
|
||||
Candle[] result = new Candle[(int) n];
|
||||
for (int i = 0; i < n; i++) {
|
||||
long b = (long) i * 48L;
|
||||
result[i] = new Candle(
|
||||
out.get(JAVA_DOUBLE, b + 0L),
|
||||
out.get(JAVA_DOUBLE, b + 8L),
|
||||
out.get(JAVA_DOUBLE, b + 16L),
|
||||
out.get(JAVA_DOUBLE, b + 24L),
|
||||
out.get(JAVA_DOUBLE, b + 32L),
|
||||
(double) out.get(JAVA_LONG, b + 40L));
|
||||
}
|
||||
return result;
|
||||
} catch (Throwable t) {
|
||||
throw WickraNative.rethrow(t);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
try {
|
||||
NativeMethods.WICKRA_BINANCE_CLOSE.invokeExact(handle);
|
||||
|
||||
@@ -3963,6 +3963,7 @@ public final class NativeMethods {
|
||||
public static MethodHandle WICKRA_BINANCE_NEXT;
|
||||
public static MethodHandle WICKRA_BINANCE_CLOSE;
|
||||
public static MethodHandle WICKRA_BINANCE_FREE;
|
||||
public static MethodHandle WICKRA_BINANCE_FETCH_KLINES;
|
||||
|
||||
static {
|
||||
init0();
|
||||
@@ -8047,6 +8048,7 @@ public final class NativeMethods {
|
||||
WICKRA_BINANCE_NEXT = h("wickra_binance_next", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG));
|
||||
WICKRA_BINANCE_CLOSE = h("wickra_binance_close", FunctionDescriptor.ofVoid(ADDRESS));
|
||||
WICKRA_BINANCE_FREE = h("wickra_binance_free", FunctionDescriptor.ofVoid(ADDRESS));
|
||||
WICKRA_BINANCE_FETCH_KLINES = h("wickra_binance_fetch_klines", FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_BYTE, JAVA_INT, JAVA_LONG, JAVA_LONG, ADDRESS, ADDRESS, JAVA_LONG));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -21,4 +21,18 @@ class BinanceFeedTest {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new BinanceFeed("BTCUSDT", BinanceInterval.ONE_MINUTE, "ws://127.0.0.1:1"));
|
||||
}
|
||||
|
||||
// The REST fetcher's parse/HTTP success path is covered by the Rust
|
||||
// mock-HTTP-server tests; here we only assert the binding's error paths.
|
||||
@Test
|
||||
void fetchKlinesRejectsZeroLimit() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> BinanceFeed.fetchKlines("BTCUSDT", BinanceInterval.ONE_HOUR, 0, -1L, -1L, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchKlinesSurfacesUnreachableEndpoint() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> BinanceFeed.fetchKlines("BTCUSDT", BinanceInterval.ONE_HOUR, 1, -1L, -1L, "http://127.0.0.1:1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { BinanceFeed } = require('..');
|
||||
const { BinanceFeed, fetchBinanceKlines } = require('..');
|
||||
|
||||
test('binance feed rejects an unknown interval', () => {
|
||||
assert.throws(() => new BinanceFeed('BTCUSDT', 99));
|
||||
@@ -17,3 +17,18 @@ test('binance feed rejects an empty symbol list', () => {
|
||||
test('binance feed rejects an unreachable endpoint', () => {
|
||||
assert.throws(() => new BinanceFeed('BTCUSDT', 1, 'ws://127.0.0.1:1'));
|
||||
});
|
||||
|
||||
// The REST fetcher's parse/HTTP success path is covered deterministically by the
|
||||
// Rust mock-HTTP-server tests in wickra-data; here we only assert the binding's
|
||||
// error paths, which short-circuit before (or fail fast at) the network.
|
||||
test('fetchBinanceKlines rejects an unknown interval', () => {
|
||||
assert.throws(() => fetchBinanceKlines('BTCUSDT', 99, 1));
|
||||
});
|
||||
|
||||
test('fetchBinanceKlines rejects an out-of-range limit', () => {
|
||||
assert.throws(() => fetchBinanceKlines('BTCUSDT', 6, 0));
|
||||
});
|
||||
|
||||
test('fetchBinanceKlines surfaces an unreachable endpoint', () => {
|
||||
assert.throws(() => fetchBinanceKlines('BTCUSDT', 6, 1, null, null, 'http://127.0.0.1:1'));
|
||||
});
|
||||
|
||||
Vendored
+9
@@ -614,6 +614,15 @@ export interface KlineEvent {
|
||||
openTime: number
|
||||
isClosed: boolean
|
||||
}
|
||||
/**
|
||||
* Fetch historical klines from Binance's REST endpoint. `symbol` is the trading
|
||||
* pair (case-insensitive, e.g. `"BTCUSDT"`), `interval` the code `0..=15` (the
|
||||
* `Interval` declaration order), and `limit` the number of candles to request
|
||||
* (`1..=1000`). `startMs`/`endMs` are optional inclusive Unix-millisecond
|
||||
* bounds; `baseUrl` overrides the host (omit for production). This is a blocking
|
||||
* call — run it on a Worker thread to keep the event loop responsive.
|
||||
*/
|
||||
export declare function fetchBinanceKlines(symbol: string, interval: number, limit: number, startMs?: number | undefined | null, endMs?: number | undefined | null, baseUrl?: string | undefined | null): Array<CandleValue>
|
||||
export type SmaNode = SMA
|
||||
export declare class SMA {
|
||||
constructor(period: number)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -22016,6 +22016,44 @@ impl BinanceFeedNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch historical klines from Binance's REST endpoint. `symbol` is the trading
|
||||
/// pair (case-insensitive, e.g. `"BTCUSDT"`), `interval` the code `0..=15` (the
|
||||
/// `Interval` declaration order), and `limit` the number of candles to request
|
||||
/// (`1..=1000`). `startMs`/`endMs` are optional inclusive Unix-millisecond
|
||||
/// bounds; `baseUrl` overrides the host (omit for production). This is a blocking
|
||||
/// call — run it on a Worker thread to keep the event loop responsive.
|
||||
#[napi(js_name = "fetchBinanceKlines")]
|
||||
pub fn fetch_binance_klines(
|
||||
symbol: String,
|
||||
interval: u8,
|
||||
limit: u32,
|
||||
start_ms: Option<f64>,
|
||||
end_ms: Option<f64>,
|
||||
base_url: Option<String>,
|
||||
) -> napi::Result<Vec<CandleValue>> {
|
||||
let iv = binance_interval(interval).ok_or_else(|| {
|
||||
NapiError::new(
|
||||
Status::InvalidArg,
|
||||
"unknown interval code (expected 0..=15)",
|
||||
)
|
||||
})?;
|
||||
let limit = u16::try_from(limit)
|
||||
.map_err(|_| NapiError::new(Status::InvalidArg, "limit must be in 1..=1000"))?;
|
||||
let start = start_ms.map(|v| v as i64);
|
||||
let end = end_ms.map(|v| v as i64);
|
||||
let candles = match base_url {
|
||||
Some(url) => {
|
||||
let config = wickra_data::live::binance_rest::BinanceRestConfig { base_url: url };
|
||||
wickra_data::live::binance_rest::fetch_klines_with_config(
|
||||
&symbol, iv, limit, start, end, &config,
|
||||
)
|
||||
}
|
||||
None => wickra_data::live::binance_rest::fetch_klines(&symbol, iv, limit, start, end),
|
||||
}
|
||||
.map_err(map_data_err)?;
|
||||
Ok(candles.into_iter().map(candle_to_value).collect())
|
||||
}
|
||||
|
||||
/// Roll trade ticks up into fixed-timeframe OHLCV candles.
|
||||
#[napi(js_name = "TickAggregator")]
|
||||
pub struct TickAggregatorNode {
|
||||
|
||||
@@ -363,6 +363,7 @@ from ._wickra import (
|
||||
Resampler,
|
||||
CandleReader,
|
||||
BinanceFeed,
|
||||
fetch_binance_klines,
|
||||
# Market Profile
|
||||
CompositeProfile,
|
||||
HighLowVolumeNodes,
|
||||
@@ -912,6 +913,7 @@ __all__ = [
|
||||
"Resampler",
|
||||
"CandleReader",
|
||||
"BinanceFeed",
|
||||
"fetch_binance_klines",
|
||||
# Market Profile
|
||||
"CompositeProfile",
|
||||
"HighLowVolumeNodes",
|
||||
|
||||
@@ -28290,6 +28290,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyResampler>()?;
|
||||
m.add_class::<PyCandleReader>()?;
|
||||
m.add_class::<PyBinanceFeed>()?;
|
||||
m.add_function(wrap_pyfunction!(fetch_binance_klines, m)?)?;
|
||||
// Candlestick patterns.
|
||||
m.add_class::<PyDoji>()?;
|
||||
m.add_class::<PyHammer>()?;
|
||||
@@ -28679,6 +28680,49 @@ impl PyBinanceFeed {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch historical klines from Binance's REST endpoint. `symbol` is the trading
|
||||
/// pair (case-insensitive, e.g. `"BTCUSDT"`), `interval` the code `0..=15` (the
|
||||
/// `Interval` declaration order), and `limit` the number of candles to request
|
||||
/// (`1..=1000`). `start_ms`/`end_ms` are optional inclusive Unix-millisecond
|
||||
/// bounds; `base_url` overrides the host (omit for production). Returns a list of
|
||||
/// `(open, high, low, close, volume, timestamp)` tuples. This blocks until the
|
||||
/// HTTP response arrives, releasing the GIL while it waits.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (symbol, interval, limit, start_ms = None, end_ms = None, base_url = None))]
|
||||
fn fetch_binance_klines(
|
||||
py: Python<'_>,
|
||||
symbol: &str,
|
||||
interval: u8,
|
||||
limit: u32,
|
||||
start_ms: Option<i64>,
|
||||
end_ms: Option<i64>,
|
||||
base_url: Option<&str>,
|
||||
) -> PyResult<Vec<CandleTuple>> {
|
||||
let iv = binance_interval(interval)
|
||||
.ok_or_else(|| PyValueError::new_err("unknown interval code (expected 0..=15)"))?;
|
||||
let limit =
|
||||
u16::try_from(limit).map_err(|_| PyValueError::new_err("limit must be in 1..=1000"))?;
|
||||
let symbol = symbol.to_owned();
|
||||
let base_url = base_url.map(str::to_owned);
|
||||
let candles = py
|
||||
.detach(move || match base_url {
|
||||
Some(url) => {
|
||||
let config = wickra_data::live::binance_rest::BinanceRestConfig { base_url: url };
|
||||
wickra_data::live::binance_rest::fetch_klines_with_config(
|
||||
&symbol, iv, limit, start_ms, end_ms, &config,
|
||||
)
|
||||
}
|
||||
None => {
|
||||
wickra_data::live::binance_rest::fetch_klines(&symbol, iv, limit, start_ms, end_ms)
|
||||
}
|
||||
})
|
||||
.map_err(map_data_err)?;
|
||||
Ok(candles
|
||||
.into_iter()
|
||||
.map(|c| (c.open, c.high, c.low, c.close, c.volume, c.timestamp))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Roll trade ticks up into fixed-timeframe OHLCV candles.
|
||||
#[pyclass(
|
||||
name = "TickAggregator",
|
||||
|
||||
@@ -13,3 +13,14 @@ def test_binance_feed_rejects_bad_params():
|
||||
ta.BinanceFeed("", 1) # empty symbol list
|
||||
with pytest.raises(ValueError):
|
||||
ta.BinanceFeed("BTCUSDT", 1, "ws://127.0.0.1:1") # unreachable endpoint
|
||||
|
||||
|
||||
def test_fetch_binance_klines_rejects_bad_params():
|
||||
# The parse/HTTP success path is covered by the Rust mock-HTTP-server tests;
|
||||
# here we only assert the binding's error paths.
|
||||
with pytest.raises(ValueError):
|
||||
ta.fetch_binance_klines("BTCUSDT", 99, 1) # unknown interval code
|
||||
with pytest.raises(ValueError):
|
||||
ta.fetch_binance_klines("BTCUSDT", 6, 0) # out-of-range limit
|
||||
with pytest.raises(ValueError):
|
||||
ta.fetch_binance_klines("BTCUSDT", 6, 1, base_url="http://127.0.0.1:1") # unreachable
|
||||
|
||||
@@ -530,6 +530,7 @@ export(Zlema)
|
||||
export(batch)
|
||||
export(binance_close)
|
||||
export(binance_next)
|
||||
export(fetch_binance_klines)
|
||||
export(is_ready)
|
||||
export(name)
|
||||
export(push)
|
||||
|
||||
@@ -4180,3 +4180,24 @@ binance_close <- function(feed) {
|
||||
invisible(NULL)
|
||||
}
|
||||
|
||||
#' Fetch historical Binance klines over REST
|
||||
#'
|
||||
#' Downloads up to `limit` (`1:1000`) historical klines for `symbol` at the given
|
||||
#' `interval` code (an integer `0:15`, the same order as the other bindings).
|
||||
#' `start_ms`/`end_ms` are optional inclusive Unix-millisecond bounds (a negative
|
||||
#' value means unset); `base_url` overrides the host (`NULL` = production). Returns
|
||||
#' an `n x 6` numeric matrix with columns `open`, `high`, `low`, `close`,
|
||||
#' `volume`, `timestamp`. Blocks until the response arrives. Not available in the
|
||||
#' wasm (r-universe/webR) build, which has no raw sockets.
|
||||
#'
|
||||
#' @keywords internal
|
||||
#' @export
|
||||
fetch_binance_klines <- function(symbol, interval, limit, start_ms = -1,
|
||||
end_ms = -1, base_url = NULL) {
|
||||
m <- .Call("wk_binance_fetch_klines", symbol, as.integer(interval),
|
||||
as.integer(limit), as.numeric(start_ms), as.numeric(end_ms),
|
||||
base_url, PACKAGE = "wickra")
|
||||
colnames(m) <- c("open", "high", "low", "close", "volume", "timestamp")
|
||||
m
|
||||
}
|
||||
|
||||
|
||||
@@ -22707,6 +22707,31 @@ SEXP wk_binance_close(SEXP e) {
|
||||
wickra_binance_close(h);
|
||||
return R_NilValue;
|
||||
}
|
||||
SEXP wk_binance_fetch_klines(SEXP symbol, SEXP interval, SEXP limit,
|
||||
SEXP start_ms, SEXP end_ms, SEXP base_url) {
|
||||
const char *url = (base_url == R_NilValue || Rf_xlength(base_url) == 0)
|
||||
? NULL : CHAR(STRING_ELT(base_url, 0));
|
||||
uint32_t lim = (uint32_t)Rf_asInteger(limit);
|
||||
if (lim == 0) Rf_error("limit must be in 1..=1000");
|
||||
struct WickraCandle *buf =
|
||||
(struct WickraCandle *)R_alloc(lim, sizeof(struct WickraCandle));
|
||||
intptr_t n = wickra_binance_fetch_klines(
|
||||
CHAR(STRING_ELT(symbol, 0)), (uint8_t)Rf_asInteger(interval), lim,
|
||||
(int64_t)Rf_asReal(start_ms), (int64_t)Rf_asReal(end_ms), url, buf,
|
||||
(uintptr_t)lim);
|
||||
if (n < 0) Rf_error("invalid fetch_binance_klines parameters or transport error");
|
||||
SEXP r = PROTECT(Rf_allocMatrix(REALSXP, (int)n, 6));
|
||||
for (intptr_t i = 0; i < n; i++) {
|
||||
REAL(r)[i + n * 0] = buf[i].open;
|
||||
REAL(r)[i + n * 1] = buf[i].high;
|
||||
REAL(r)[i + n * 2] = buf[i].low;
|
||||
REAL(r)[i + n * 3] = buf[i].close;
|
||||
REAL(r)[i + n * 4] = buf[i].volume;
|
||||
REAL(r)[i + n * 5] = (double)buf[i].timestamp;
|
||||
}
|
||||
UNPROTECT(1);
|
||||
return r;
|
||||
}
|
||||
#endif
|
||||
|
||||
static const R_CallMethodDef CallEntries[] = {
|
||||
@@ -26141,6 +26166,7 @@ static const R_CallMethodDef CallEntries[] = {
|
||||
{"wk_binance_connect", (DL_FUNC)&wk_binance_connect, 3},
|
||||
{"wk_binance_next", (DL_FUNC)&wk_binance_next, 2},
|
||||
{"wk_binance_close", (DL_FUNC)&wk_binance_close, 1},
|
||||
{"wk_binance_fetch_klines", (DL_FUNC)&wk_binance_fetch_klines, 6},
|
||||
#endif
|
||||
{NULL, NULL, 0}
|
||||
};
|
||||
|
||||
@@ -6,3 +6,13 @@ test_that("binance feed rejects bad parameters", {
|
||||
expect_error(BinanceFeed("", 1L), "BinanceFeed")
|
||||
expect_error(BinanceFeed("BTCUSDT", 1L, "ws://127.0.0.1:1"), "BinanceFeed")
|
||||
})
|
||||
|
||||
# The REST fetcher's parse/HTTP success path is covered by the Rust
|
||||
# mock-HTTP-server tests; here we only assert the binding's error paths.
|
||||
test_that("fetch_binance_klines rejects bad parameters", {
|
||||
expect_error(fetch_binance_klines("BTCUSDT", 6L, 0L), "limit")
|
||||
expect_error(
|
||||
fetch_binance_klines("BTCUSDT", 6L, 1L, base_url = "http://127.0.0.1:1"),
|
||||
"fetch_binance_klines"
|
||||
)
|
||||
})
|
||||
|
||||
@@ -38,11 +38,22 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "
|
||||
tokio-tungstenite = { version = "0.29", optional = true, features = ["native-tls"] }
|
||||
futures-util = { version = "0.3", optional = true }
|
||||
url = { version = "2", optional = true }
|
||||
# Blocking HTTP client for the historical REST kline fetcher. ureq 2.x with
|
||||
# native-tls verifies against the OS trust store (SChannel / Security.framework /
|
||||
# OpenSSL) — the same backend tokio-tungstenite uses above — so it ships no
|
||||
# bundled CA roots. (ureq 3.x hard-depends on `webpki-root-certs` regardless of
|
||||
# TLS backend, which is CDLA-Permissive-2.0 and dead weight under native-tls.)
|
||||
ureq = { version = "2", default-features = false, features = ["native-tls"], optional = true }
|
||||
# Direct dependency so the agent can be built with a native-tls connector (ureq
|
||||
# 2.x does not auto-configure native-tls). Already in the tree via tokio-tungstenite.
|
||||
native-tls = { version = "0.2", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Each exchange is gated so users only pay for the WS stack they actually want.
|
||||
live-binance = ["dep:tokio", "dep:tokio-tungstenite", "dep:futures-util", "dep:url"]
|
||||
# Each exchange is gated so users only pay for the WS/REST stack they actually
|
||||
# want. `live-binance` covers both the live WebSocket feed and the historical
|
||||
# REST kline fetcher.
|
||||
live-binance = ["dep:tokio", "dep:tokio-tungstenite", "dep:futures-util", "dep:url", "dep:ureq", "dep:native-tls"]
|
||||
|
||||
[dev-dependencies]
|
||||
approx = { workspace = true }
|
||||
|
||||
@@ -31,6 +31,13 @@ pub enum Error {
|
||||
#[cfg(feature = "live-binance")]
|
||||
#[error("JSON decode error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
/// Boxed because ureq 2.x's `Error::Status` carries a full `Response`, which
|
||||
/// would otherwise make this the dominant enum variant (clippy
|
||||
/// `large_enum_variant`).
|
||||
#[cfg(feature = "live-binance")]
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(#[from] Box<ureq::Error>),
|
||||
}
|
||||
|
||||
/// Convenience alias for `Result<T, wickra_data::Error>`.
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//! history in memory.
|
||||
//! - [`aggregator`]: roll trade ticks up into candles of arbitrary timeframes.
|
||||
//! - [`resample`]: convert a stream of candles from one timeframe to a coarser one.
|
||||
//! - [`live`] (feature `live-binance`): connect to exchange websockets and yield
|
||||
//! typed events compatible with the rest of the crate.
|
||||
//! - [`live`] (feature `live-binance`): connect to exchange websockets for live
|
||||
//! klines, or pull historical klines over REST — both yield typed candles
|
||||
//! compatible with the rest of the crate.
|
||||
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
// `tokio_tungstenite::Error` is large by itself (~200 B). Boxing every Err
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
//! lives behind the `live-binance` feature.
|
||||
|
||||
pub mod binance;
|
||||
pub mod binance_rest;
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
//! Binance spot REST historical kline fetcher.
|
||||
//!
|
||||
//! Where [`super::binance`] streams *live* klines over a WebSocket, this module
|
||||
//! pulls *historical* candles from Binance's public REST endpoint
|
||||
//! (`GET /api/v3/klines`) with a single blocking request. It is the native,
|
||||
//! dependency-free replacement for hand-rolled `urllib` / `jackson` / `jsonlite`
|
||||
//! download helpers in every binding.
|
||||
//!
|
||||
//! Example (requires the `live-binance` feature):
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use wickra_data::live::binance::Interval;
|
||||
//! use wickra_data::live::binance_rest::fetch_klines;
|
||||
//! # fn run() -> wickra_data::Result<()> {
|
||||
//! let candles = fetch_klines("BTCUSDT", Interval::OneHour, 500, None, None)?;
|
||||
//! println!("got {} candles", candles.len());
|
||||
//! # Ok(()) }
|
||||
//! ```
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::binance::Interval;
|
||||
use crate::error::{Error, Result};
|
||||
use wickra_core::Candle;
|
||||
|
||||
/// Binance allows at most 1000 klines per REST request.
|
||||
const MAX_LIMIT: u16 = 1000;
|
||||
|
||||
/// Endpoint configuration for [`fetch_klines_with_config`]. The default points
|
||||
/// at Binance's public production REST host; tests and Testnet users override
|
||||
/// `base_url` to aim the request elsewhere.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BinanceRestConfig {
|
||||
/// REST host **without** path, e.g. `"https://api.binance.com"`. The
|
||||
/// `/api/v3/klines` path and query string are appended internally.
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl Default for BinanceRestConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: "https://api.binance.com".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of Binance's `/api/v3/klines` response. The wire format is a fixed
|
||||
/// 12-element JSON array of mixed types; only the first seven fields carry the
|
||||
/// OHLCV candle, the rest are ignored. Deserializing positionally lets serde
|
||||
/// reject a malformed shape before we touch the numbers.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawRestKline(
|
||||
i64, // open time (ms)
|
||||
String, // open
|
||||
String, // high
|
||||
String, // low
|
||||
String, // close
|
||||
String, // volume
|
||||
serde::de::IgnoredAny, // close time
|
||||
serde::de::IgnoredAny, // quote asset volume
|
||||
serde::de::IgnoredAny, // number of trades
|
||||
serde::de::IgnoredAny, // taker buy base volume
|
||||
serde::de::IgnoredAny, // taker buy quote volume
|
||||
serde::de::IgnoredAny, // unused
|
||||
);
|
||||
|
||||
/// Parse one of the five OHLCV string fields into an `f64`, tagging the field
|
||||
/// name on failure so a bad payload is diagnosable.
|
||||
fn parse_field(raw: &str, field: &str) -> Result<f64> {
|
||||
raw.parse()
|
||||
.map_err(|_| Error::Malformed(format!("bad {field} '{raw}'")))
|
||||
}
|
||||
|
||||
impl RawRestKline {
|
||||
fn into_candle(self) -> Result<Candle> {
|
||||
let open = parse_field(&self.1, "open")?;
|
||||
let high = parse_field(&self.2, "high")?;
|
||||
let low = parse_field(&self.3, "low")?;
|
||||
let close = parse_field(&self.4, "close")?;
|
||||
let volume = parse_field(&self.5, "volume")?;
|
||||
Ok(Candle::new(open, high, low, close, volume, self.0)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch historical klines from Binance's production endpoint.
|
||||
///
|
||||
/// `symbol` is upper-cased to match Binance's convention; `limit` must be in
|
||||
/// `1..=1000`. `start_ms` / `end_ms` are optional inclusive Unix-millisecond
|
||||
/// bounds (`None` lets Binance pick the most recent `limit` candles). Returns
|
||||
/// the candles in ascending open-time order.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::Malformed`] for an out-of-range `limit` or an unparseable
|
||||
/// price field, [`Error::Http`] for a transport or non-2xx response,
|
||||
/// [`Error::Json`] for a malformed body, and [`Error::Core`] when a row's OHLC
|
||||
/// values violate candle invariants.
|
||||
pub fn fetch_klines(
|
||||
symbol: &str,
|
||||
interval: Interval,
|
||||
limit: u16,
|
||||
start_ms: Option<i64>,
|
||||
end_ms: Option<i64>,
|
||||
) -> Result<Vec<Candle>> {
|
||||
fetch_klines_with_config(
|
||||
symbol,
|
||||
interval,
|
||||
limit,
|
||||
start_ms,
|
||||
end_ms,
|
||||
&BinanceRestConfig::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Like [`fetch_klines`] but against a custom [`BinanceRestConfig`] (Testnet or
|
||||
/// a local mock server).
|
||||
///
|
||||
/// # Errors
|
||||
/// See [`fetch_klines`].
|
||||
pub fn fetch_klines_with_config(
|
||||
symbol: &str,
|
||||
interval: Interval,
|
||||
limit: u16,
|
||||
start_ms: Option<i64>,
|
||||
end_ms: Option<i64>,
|
||||
config: &BinanceRestConfig,
|
||||
) -> Result<Vec<Candle>> {
|
||||
if limit == 0 || limit > MAX_LIMIT {
|
||||
return Err(Error::Malformed(format!(
|
||||
"limit must be in 1..={MAX_LIMIT}, got {limit}"
|
||||
)));
|
||||
}
|
||||
let mut url = format!(
|
||||
"{}/api/v3/klines?symbol={}&interval={}&limit={}",
|
||||
config.base_url,
|
||||
symbol.to_uppercase(),
|
||||
interval.as_str(),
|
||||
limit
|
||||
);
|
||||
if let Some(start) = start_ms {
|
||||
let _ = write!(url, "&startTime={start}");
|
||||
}
|
||||
if let Some(end) = end_ms {
|
||||
let _ = write!(url, "&endTime={end}");
|
||||
}
|
||||
|
||||
// ureq 2.x does not auto-configure native-tls, so build an agent with an
|
||||
// explicit native-tls connector (verifies against the OS trust store; no
|
||||
// bundled CA roots). The connector is cheap and a one-shot fetch needs no
|
||||
// pooling, so we build it per call.
|
||||
let connector = native_tls::TlsConnector::new()
|
||||
.map_err(|e| Error::Malformed(format!("native-tls init failed: {e}")))?;
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.tls_connector(std::sync::Arc::new(connector))
|
||||
.build();
|
||||
let body = agent.get(&url).call().map_err(Box::new)?.into_string()?;
|
||||
let rows: Vec<RawRestKline> = serde_json::from_str(&body)?;
|
||||
rows.into_iter().map(RawRestKline::into_candle).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
/// A canonical single-row klines response (12-element array, OHLCV in the
|
||||
/// first seven slots) — mirrors Binance's documented wire format.
|
||||
fn sample_response() -> String {
|
||||
r#"[[1700000000000,"30000.0","30100.0","29950.0","30050.0","12.5",1700000059999,"375000.0",50,"6.25","187500.0","0"]]"#.to_string()
|
||||
}
|
||||
|
||||
/// Spawn a one-shot mock HTTP server: accept a single connection, read the
|
||||
/// request, and reply `200 OK` with `body`. Returns the base URL plus a
|
||||
/// [`JoinHandle`] the test joins so the handler thread always reaches its
|
||||
/// closing brace (every step `.unwrap()`s — a failure is a test bug).
|
||||
fn mock_http(status_line: &'static str, body: String) -> (String, JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = stream.read(&mut buf).unwrap();
|
||||
let response = format!(
|
||||
"{status_line}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(response.as_bytes()).unwrap();
|
||||
});
|
||||
(format!("http://{addr}"), handle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_parses_a_real_klines_response() {
|
||||
let (base, handle) = mock_http("HTTP/1.1 200 OK", sample_response());
|
||||
let candles = fetch_klines_with_config(
|
||||
"btcusdt",
|
||||
Interval::OneHour,
|
||||
1,
|
||||
Some(1_700_000_000_000),
|
||||
Some(1_700_000_059_999),
|
||||
&BinanceRestConfig { base_url: base },
|
||||
)
|
||||
.unwrap();
|
||||
handle.join().unwrap();
|
||||
assert_eq!(candles.len(), 1);
|
||||
assert_eq!(candles[0].open, 30_000.0);
|
||||
assert_eq!(candles[0].high, 30_100.0);
|
||||
assert_eq!(candles[0].low, 29_950.0);
|
||||
assert_eq!(candles[0].close, 30_050.0);
|
||||
assert_eq!(candles[0].volume, 12.5);
|
||||
assert_eq!(candles[0].timestamp, 1_700_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_handles_an_empty_result() {
|
||||
let (base, handle) = mock_http("HTTP/1.1 200 OK", "[]".to_string());
|
||||
let candles = fetch_klines_with_config(
|
||||
"BTCUSDT",
|
||||
Interval::OneMinute,
|
||||
10,
|
||||
None,
|
||||
None,
|
||||
&BinanceRestConfig { base_url: base },
|
||||
)
|
||||
.unwrap();
|
||||
handle.join().unwrap();
|
||||
assert!(candles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_rejects_a_zero_limit() {
|
||||
let err = fetch_klines("BTCUSDT", Interval::OneHour, 0, None, None).unwrap_err();
|
||||
assert!(matches!(err, Error::Malformed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_rejects_a_limit_above_the_cap() {
|
||||
let err =
|
||||
fetch_klines("BTCUSDT", Interval::OneHour, MAX_LIMIT + 1, None, None).unwrap_err();
|
||||
assert!(matches!(err, Error::Malformed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_surfaces_a_transport_error_for_an_unreachable_host() {
|
||||
// Port 1 is privileged and unbound — the connection is refused.
|
||||
let err = fetch_klines_with_config(
|
||||
"BTCUSDT",
|
||||
Interval::OneHour,
|
||||
1,
|
||||
None,
|
||||
None,
|
||||
&BinanceRestConfig {
|
||||
base_url: "http://127.0.0.1:1".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, Error::Http(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_surfaces_a_json_error_for_a_malformed_body() {
|
||||
let (base, handle) = mock_http("HTTP/1.1 200 OK", "not json".to_string());
|
||||
let err = fetch_klines_with_config(
|
||||
"BTCUSDT",
|
||||
Interval::OneHour,
|
||||
1,
|
||||
None,
|
||||
None,
|
||||
&BinanceRestConfig { base_url: base },
|
||||
)
|
||||
.unwrap_err();
|
||||
handle.join().unwrap();
|
||||
assert!(matches!(err, Error::Json(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_rejects_an_unparseable_price_field() {
|
||||
let body =
|
||||
r#"[[1700000000000,"not-a-number","0","0","0","0",0,"0",0,"0","0","0"]]"#.to_string();
|
||||
let (base, handle) = mock_http("HTTP/1.1 200 OK", body);
|
||||
let err = fetch_klines_with_config(
|
||||
"BTCUSDT",
|
||||
Interval::OneHour,
|
||||
1,
|
||||
None,
|
||||
None,
|
||||
&BinanceRestConfig { base_url: base },
|
||||
)
|
||||
.unwrap_err();
|
||||
handle.join().unwrap();
|
||||
assert!(matches!(err, Error::Malformed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_rejects_a_row_that_violates_candle_invariants() {
|
||||
// high (1) below low (100) — Candle::new rejects it.
|
||||
let body = r#"[[1700000000000,"50","1","100","50","1",0,"0",0,"0","0","0"]]"#.to_string();
|
||||
let (base, handle) = mock_http("HTTP/1.1 200 OK", body);
|
||||
let err = fetch_klines_with_config(
|
||||
"BTCUSDT",
|
||||
Interval::OneHour,
|
||||
1,
|
||||
None,
|
||||
None,
|
||||
&BinanceRestConfig { base_url: base },
|
||||
)
|
||||
.unwrap_err();
|
||||
handle.join().unwrap();
|
||||
assert!(matches!(err, Error::Core(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_config_default_targets_production() {
|
||||
assert_eq!(
|
||||
BinanceRestConfig::default().base_url,
|
||||
"https://api.binance.com"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user