diff --git a/CHANGELOG.md b/CHANGELOG.md index 60c09601..f2f9b794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 3281ee74..3ee2b581 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/bindings/c/include/wickra.h b/bindings/c/include/wickra.h index a8a20edf..abf674b4 100644 --- a/bindings/c/include/wickra.h +++ b/bindings/c/include/wickra.h @@ -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 diff --git a/bindings/c/src/lib.rs b/bindings/c/src/lib.rs index 949a34e9..b5102e52 100644 --- a/bindings/c/src/lib.rs +++ b/bindings/c/src/lib.rs @@ -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 + ); + } + } } diff --git a/bindings/csharp/Wickra.Tests/BinanceFeedTests.cs b/bindings/csharp/Wickra.Tests/BinanceFeedTests.cs index 4b73b456..856e089b 100644 --- a/bindings/csharp/Wickra.Tests/BinanceFeedTests.cs +++ b/bindings/csharp/Wickra.Tests/BinanceFeedTests.cs @@ -27,4 +27,27 @@ public class BinanceFeedTests Assert.Throws(() => 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(() => + BinanceFeed.FetchKlines("BTCUSDT", (BinanceInterval)99, 1)); + } + + [Fact] + public void FetchKlinesRejectsZeroLimit() + { + Assert.Throws(() => + BinanceFeed.FetchKlines("BTCUSDT", BinanceInterval.OneHour, 0)); + } + + [Fact] + public void FetchKlinesSurfacesUnreachableEndpoint() + { + Assert.Throws(() => + BinanceFeed.FetchKlines("BTCUSDT", BinanceInterval.OneHour, 1, baseUrl: "http://127.0.0.1:1")); + } } diff --git a/bindings/csharp/Wickra/Generated/Indicators.g.cs b/bindings/csharp/Wickra/Generated/Indicators.g.cs index 6d5a4934..98e33d32 100644 --- a/bindings/csharp/Wickra/Generated/Indicators.g.cs +++ b/bindings/csharp/Wickra/Generated/Indicators.g.cs @@ -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); } + /// Fetch historical klines from Binance's REST endpoint. + /// is the trading pair (case-insensitive), the number of + /// candles (1..=1000). / are inclusive + /// Unix-millisecond bounds (negative = unset); overrides the + /// host (null = production). Blocks until the response arrives. + 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 diff --git a/bindings/csharp/Wickra/Generated/NativeMethods.g.cs b/bindings/csharp/Wickra/Generated/NativeMethods.g.cs index 0e0c39a0..f060f027 100644 --- a/bindings/csharp/Wickra/Generated/NativeMethods.g.cs +++ b/bindings/csharp/Wickra/Generated/NativeMethods.g.cs @@ -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)] diff --git a/bindings/go/binance_test.go b/bindings/go/binance_test.go index 471f78b3..af0c7af2 100644 --- a/bindings/go/binance_test.go +++ b/bindings/go/binance_test.go @@ -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") + } +} diff --git a/bindings/go/include/wickra.h b/bindings/go/include/wickra.h index a8a20edf..abf674b4 100644 --- a/bindings/go/include/wickra.h +++ b/bindings/go/include/wickra.h @@ -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 diff --git a/bindings/go/indicators_gen.go b/bindings/go/indicators_gen.go index bfe002a0..efd0ae4d 100644 --- a/bindings/go/indicators_gen.go +++ b/bindings/go/indicators_gen.go @@ -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 +} diff --git a/bindings/java/src/main/java/org/wickra/BinanceFeed.java b/bindings/java/src/main/java/org/wickra/BinanceFeed.java index d64d6d88..08f02884 100644 --- a/bindings/java/src/main/java/org/wickra/BinanceFeed.java +++ b/bindings/java/src/main/java/org/wickra/BinanceFeed.java @@ -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); diff --git a/bindings/java/src/main/java/org/wickra/internal/NativeMethods.java b/bindings/java/src/main/java/org/wickra/internal/NativeMethods.java index 1c883cb1..1490a059 100644 --- a/bindings/java/src/main/java/org/wickra/internal/NativeMethods.java +++ b/bindings/java/src/main/java/org/wickra/internal/NativeMethods.java @@ -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)); } } diff --git a/bindings/java/src/test/java/org/wickra/BinanceFeedTest.java b/bindings/java/src/test/java/org/wickra/BinanceFeedTest.java index ac073931..6e4b93f5 100644 --- a/bindings/java/src/test/java/org/wickra/BinanceFeedTest.java +++ b/bindings/java/src/test/java/org/wickra/BinanceFeedTest.java @@ -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")); + } } diff --git a/bindings/node/__tests__/binance.test.js b/bindings/node/__tests__/binance.test.js index 9a45fb5d..fbf0b78a 100644 --- a/bindings/node/__tests__/binance.test.js +++ b/bindings/node/__tests__/binance.test.js @@ -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')); +}); diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 945fe925..b49b82cc 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -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 export type SmaNode = SMA export declare class SMA { constructor(period: number) diff --git a/bindings/node/index.js b/bindings/node/index.js index 5ef38172..532b98ee 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, LogReturn, RealizedVolatility, RollingIqr, RollingPercentileRank, TrendLabel, WinRate, Expectancy, SWMA, GMA, EHMA, MedianMA, AdaptiveLaguerre, DisparityIndex, FisherRSI, RSX, DynamicMomentumIndex, TREND_STRENGTH_INDEX, TsfOscillator, BipowerVariation, JARQUEBERA, ROLLINGMINMAX, HIGHPASS, REFLEX, TRENDFLEX, CTI, ADAPTIVERSI, UNIVERSALOSC, SterlingRatio, BurkeRatio, MartinRatio, TailRatio, KRatio, CommonSenseRatio, GainToPainRatio, UpsidePotentialRatio, M2Measure, BANDPASS, EVENBETTERSINE, AUTOCORRPGRAM, SHANNONENT, SAMPLEENT, EwmaVolatility, Garch11, VolatilityOfVolatility, VolatilityCone, JumpIndicator, RegimeLabel, RollingQuantile, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpreadAr1Coefficient, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, KendallTau, BetaNeutralSpread, HasbrouckInformationShare, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, CloseVsOpen, BodySizePct, WickRatio, HighLowRange, StochasticCCI, IMI, QQE, ElderRay, TTM_TREND, Qstick, POLARIZED_FRACTAL_EFFICIENCY, WAVE_PM, GatorOscillator, KasePermissionStochastic, VolatilityRatio, ProjectionOscillator, TimeBasedStop, ADAPTIVECCI, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, GD, HoltWinters, RMI, DerivativeOscillator, MacdHistogram, PpoHistogram, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, ADOSC, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, KaseDevStop, ElderSafeZone, AtrRatchet, Nrtr, ModifiedMaStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, QuartileBands, BomarBands, MedianChannel, ProjectionBands, CentralPivotRange, MurreyMathLines, AndrewsPitchfork, VolumeWeightedSr, PivotReversal, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDDWave, TDMovingAverage, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, HeikinAshiOscillator, ThreeLineBreak, SmoothedHeikinAshi, Equivolume, CandleVolume, FryPanBottom, DumplingTop, NewPriceLines, ValueArea, NakedPoc, SinglePrints, ProfileShape, HighLowVolumeNodes, CompositeProfile, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, TDCamouflage, TDClop, TDClopwin, TDPropulsion, TDTrap, Tristar, HaramiCross, TowerTopBottom, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, TradeSignAutocorrelation, Pin, OrderFlowImbalance, Vpin, AmihudIlliquidity, RollMeasure, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, EstimatedLeverageRatio, OiToVolumeRatio, PerpetualPremiumIndex, FundingImpliedApr, OpenInterestMomentum, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, RangeBars, TickBars, VolumeBars, DollarBars, ImbalanceBars, RunBars, ThreeLineBreakBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones, VolumeRsi, Wad, TwiggsMoneyFlow, TradeVolumeIndex, IntradayIntensity, BetterVolume, VolumeWeightedMacd, BinanceFeed, TickAggregator, Resampler, CandleReader } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, LogReturn, RealizedVolatility, RollingIqr, RollingPercentileRank, TrendLabel, WinRate, Expectancy, SWMA, GMA, EHMA, MedianMA, AdaptiveLaguerre, DisparityIndex, FisherRSI, RSX, DynamicMomentumIndex, TREND_STRENGTH_INDEX, TsfOscillator, BipowerVariation, JARQUEBERA, ROLLINGMINMAX, HIGHPASS, REFLEX, TRENDFLEX, CTI, ADAPTIVERSI, UNIVERSALOSC, SterlingRatio, BurkeRatio, MartinRatio, TailRatio, KRatio, CommonSenseRatio, GainToPainRatio, UpsidePotentialRatio, M2Measure, BANDPASS, EVENBETTERSINE, AUTOCORRPGRAM, SHANNONENT, SAMPLEENT, EwmaVolatility, Garch11, VolatilityOfVolatility, VolatilityCone, JumpIndicator, RegimeLabel, RollingQuantile, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpreadAr1Coefficient, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, KendallTau, BetaNeutralSpread, HasbrouckInformationShare, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, CloseVsOpen, BodySizePct, WickRatio, HighLowRange, StochasticCCI, IMI, QQE, ElderRay, TTM_TREND, Qstick, POLARIZED_FRACTAL_EFFICIENCY, WAVE_PM, GatorOscillator, KasePermissionStochastic, VolatilityRatio, ProjectionOscillator, TimeBasedStop, ADAPTIVECCI, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, GD, HoltWinters, RMI, DerivativeOscillator, MacdHistogram, PpoHistogram, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, ADOSC, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, KaseDevStop, ElderSafeZone, AtrRatchet, Nrtr, ModifiedMaStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, QuartileBands, BomarBands, MedianChannel, ProjectionBands, CentralPivotRange, MurreyMathLines, AndrewsPitchfork, VolumeWeightedSr, PivotReversal, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDDWave, TDMovingAverage, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, HeikinAshiOscillator, ThreeLineBreak, SmoothedHeikinAshi, Equivolume, CandleVolume, FryPanBottom, DumplingTop, NewPriceLines, ValueArea, NakedPoc, SinglePrints, ProfileShape, HighLowVolumeNodes, CompositeProfile, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, TDCamouflage, TDClop, TDClopwin, TDPropulsion, TDTrap, Tristar, HaramiCross, TowerTopBottom, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, TradeSignAutocorrelation, Pin, OrderFlowImbalance, Vpin, AmihudIlliquidity, RollMeasure, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, EstimatedLeverageRatio, OiToVolumeRatio, PerpetualPremiumIndex, FundingImpliedApr, OpenInterestMomentum, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, RangeBars, TickBars, VolumeBars, DollarBars, ImbalanceBars, RunBars, ThreeLineBreakBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones, VolumeRsi, Wad, TwiggsMoneyFlow, TradeVolumeIndex, IntradayIntensity, BetterVolume, VolumeWeightedMacd, BinanceFeed, fetchBinanceKlines, TickAggregator, Resampler, CandleReader } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -828,6 +828,7 @@ module.exports.IntradayIntensity = IntradayIntensity module.exports.BetterVolume = BetterVolume module.exports.VolumeWeightedMacd = VolumeWeightedMacd module.exports.BinanceFeed = BinanceFeed +module.exports.fetchBinanceKlines = fetchBinanceKlines module.exports.TickAggregator = TickAggregator module.exports.Resampler = Resampler module.exports.CandleReader = CandleReader diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index e8fa4ca0..29ceff91 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -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, + end_ms: Option, + base_url: Option, +) -> napi::Result> { + 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 { diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 11de1ac4..8e0e1aed 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -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", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 7c0583bd..234e303b 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -28290,6 +28290,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_function(wrap_pyfunction!(fetch_binance_klines, m)?)?; // Candlestick patterns. m.add_class::()?; m.add_class::()?; @@ -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, + end_ms: Option, + base_url: Option<&str>, +) -> PyResult> { + 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", diff --git a/bindings/python/tests/test_binance.py b/bindings/python/tests/test_binance.py index cc474e73..d098dc15 100644 --- a/bindings/python/tests/test_binance.py +++ b/bindings/python/tests/test_binance.py @@ -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 diff --git a/bindings/r/NAMESPACE b/bindings/r/NAMESPACE index 8e055d64..426f9672 100644 --- a/bindings/r/NAMESPACE +++ b/bindings/r/NAMESPACE @@ -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) diff --git a/bindings/r/R/indicators.R b/bindings/r/R/indicators.R index 1e3db5d1..956dffa0 100644 --- a/bindings/r/R/indicators.R +++ b/bindings/r/R/indicators.R @@ -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 +} + diff --git a/bindings/r/src/wickra.c b/bindings/r/src/wickra.c index ee4e0974..9267af4d 100644 --- a/bindings/r/src/wickra.c +++ b/bindings/r/src/wickra.c @@ -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} }; diff --git a/bindings/r/tests/testthat/test-binance.R b/bindings/r/tests/testthat/test-binance.R index 3fbc4e77..4c52b586 100644 --- a/bindings/r/tests/testthat/test-binance.R +++ b/bindings/r/tests/testthat/test-binance.R @@ -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" + ) +}) diff --git a/crates/wickra-data/Cargo.toml b/crates/wickra-data/Cargo.toml index 1b9cef58..bea33f7a 100644 --- a/crates/wickra-data/Cargo.toml +++ b/crates/wickra-data/Cargo.toml @@ -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 } diff --git a/crates/wickra-data/src/error.rs b/crates/wickra-data/src/error.rs index d977ef06..941f77d7 100644 --- a/crates/wickra-data/src/error.rs +++ b/crates/wickra-data/src/error.rs @@ -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), } /// Convenience alias for `Result`. diff --git a/crates/wickra-data/src/lib.rs b/crates/wickra-data/src/lib.rs index 37b89360..99e784b1 100644 --- a/crates/wickra-data/src/lib.rs +++ b/crates/wickra-data/src/lib.rs @@ -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 diff --git a/crates/wickra-data/src/live.rs b/crates/wickra-data/src/live.rs index b70ac1a4..766409b2 100644 --- a/crates/wickra-data/src/live.rs +++ b/crates/wickra-data/src/live.rs @@ -2,3 +2,4 @@ //! lives behind the `live-binance` feature. pub mod binance; +pub mod binance_rest; diff --git a/crates/wickra-data/src/live/binance_rest.rs b/crates/wickra-data/src/live/binance_rest.rs new file mode 100644 index 00000000..d6256d11 --- /dev/null +++ b/crates/wickra-data/src/live/binance_rest.rs @@ -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 { + raw.parse() + .map_err(|_| Error::Malformed(format!("bad {field} '{raw}'"))) +} + +impl RawRestKline { + fn into_candle(self) -> Result { + 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, + end_ms: Option, +) -> Result> { + 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, + end_ms: Option, + config: &BinanceRestConfig, +) -> Result> { + 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 = 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" + ); + } +}