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:
kingchenc
2026-06-17 01:48:24 +02:00
committed by GitHub
parent b92ad32037
commit 2ae76bb90e
29 changed files with 863 additions and 209 deletions
+12
View File
@@ -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
View File
@@ -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"
+9
View File
@@ -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
+116
View File
@@ -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)]
+15
View File
@@ -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")
}
}
+9
View File
@@ -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
+29
View File
@@ -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"));
}
}
+16 -1
View File
@@ -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'));
});
+9
View File
@@ -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
+38
View File
@@ -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",
+44
View File
@@ -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",
+11
View File
@@ -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
+1
View File
@@ -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)
+21
View File
@@ -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
}
+26
View File
@@ -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}
};
+10
View File
@@ -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"
)
})
+13 -2
View File
@@ -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 }
+7
View File
@@ -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>`.
+3 -2
View File
@@ -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
+1
View File
@@ -2,3 +2,4 @@
//! lives behind the `live-binance` feature.
pub mod binance;
pub mod binance_rest;
+322
View File
@@ -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"
);
}
}