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
+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"
)
})