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:
@@ -13773,6 +13773,15 @@ void wickra_binance_close(struct BinanceStream *handle);
|
||||
|
||||
void wickra_binance_free(struct BinanceStream *handle);
|
||||
|
||||
intptr_t wickra_binance_fetch_klines(const char *symbol,
|
||||
uint8_t interval,
|
||||
uint32_t limit,
|
||||
int64_t start_ms,
|
||||
int64_t end_ms,
|
||||
const char *base_url,
|
||||
struct WickraCandle *out,
|
||||
uintptr_t cap);
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif // __cplusplus
|
||||
|
||||
@@ -68572,6 +68572,71 @@ pub unsafe extern "C" fn wickra_binance_free(handle: *mut BinanceStream) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch historical klines from Binance's REST endpoint into `out` (up to `cap`
|
||||
/// candles). `symbol` is the trading pair (case-insensitive, e.g. `"BTCUSDT"`),
|
||||
/// `interval` the code `0..=15` (the `Interval` declaration order), and `limit`
|
||||
/// the number of candles to request (`1..=1000`). `start_ms`/`end_ms` are
|
||||
/// inclusive Unix-millisecond bounds; pass a negative value for "unset".
|
||||
/// `base_url` overrides the host (`NULL` = production `https://api.binance.com`;
|
||||
/// pass an `http://…` URL to target a test server). This blocks until the HTTP
|
||||
/// response arrives. Returns the number of candles written (`<= cap`), or `-1`
|
||||
/// on a null/invalid symbol, an unknown interval, an out-of-range limit, a
|
||||
/// transport/JSON error, or a `NULL` `out`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `symbol` must be a valid NUL-terminated UTF-8 C string; `base_url` must be
|
||||
/// `NULL` or a valid NUL-terminated UTF-8 C string; when non-`NULL`, `out` must
|
||||
/// cover `cap` `WickraCandle` elements.
|
||||
#[cfg(feature = "live-binance")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wickra_binance_fetch_klines(
|
||||
symbol: *const c_char,
|
||||
interval: u8,
|
||||
limit: u32,
|
||||
start_ms: i64,
|
||||
end_ms: i64,
|
||||
base_url: *const c_char,
|
||||
out: *mut WickraCandle,
|
||||
cap: usize,
|
||||
) -> isize {
|
||||
if symbol.is_null() || out.is_null() {
|
||||
return -1;
|
||||
}
|
||||
let Ok(symbol_str) = core::ffi::CStr::from_ptr(symbol).to_str() else {
|
||||
return -1;
|
||||
};
|
||||
let Some(interval) = binance_interval(interval) else {
|
||||
return -1;
|
||||
};
|
||||
let Ok(limit) = u16::try_from(limit) else {
|
||||
return -1;
|
||||
};
|
||||
let start = (start_ms >= 0).then_some(start_ms);
|
||||
let end = (end_ms >= 0).then_some(end_ms);
|
||||
let fetched = if base_url.is_null() {
|
||||
wickra_data::live::binance_rest::fetch_klines(symbol_str, interval, limit, start, end)
|
||||
} else {
|
||||
let Ok(url) = core::ffi::CStr::from_ptr(base_url).to_str() else {
|
||||
return -1;
|
||||
};
|
||||
let config = wickra_data::live::binance_rest::BinanceRestConfig {
|
||||
base_url: url.to_owned(),
|
||||
};
|
||||
wickra_data::live::binance_rest::fetch_klines_with_config(
|
||||
symbol_str, interval, limit, start, end, &config,
|
||||
)
|
||||
};
|
||||
let Ok(candles) = fetched else {
|
||||
return -1;
|
||||
};
|
||||
let count = candles.len().min(cap);
|
||||
let slots = slice::from_raw_parts_mut(out, count);
|
||||
for (slot, candle) in slots.iter_mut().zip(&candles[..count]) {
|
||||
*slot = candle_to_c(*candle);
|
||||
}
|
||||
isize::try_from(count).unwrap_or(isize::MAX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -68621,4 +68686,55 @@ mod tests {
|
||||
wickra_sma_free(ptr::null_mut());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-binance")]
|
||||
#[test]
|
||||
fn fetch_klines_rejects_invalid_arguments() {
|
||||
// Each of these short-circuits to -1 before any network access, so the
|
||||
// test is deterministic and offline.
|
||||
unsafe {
|
||||
let symbol = c"BTCUSDT".as_ptr();
|
||||
let mut out = [WickraCandle {
|
||||
open: 0.0,
|
||||
high: 0.0,
|
||||
low: 0.0,
|
||||
close: 0.0,
|
||||
volume: 0.0,
|
||||
timestamp: 0,
|
||||
}; 4];
|
||||
// Null symbol.
|
||||
assert_eq!(
|
||||
wickra_binance_fetch_klines(
|
||||
ptr::null(),
|
||||
6,
|
||||
1,
|
||||
-1,
|
||||
-1,
|
||||
ptr::null(),
|
||||
out.as_mut_ptr(),
|
||||
4
|
||||
),
|
||||
-1
|
||||
);
|
||||
// Null output buffer.
|
||||
assert_eq!(
|
||||
wickra_binance_fetch_klines(symbol, 6, 1, -1, -1, ptr::null(), ptr::null_mut(), 4),
|
||||
-1
|
||||
);
|
||||
// Unknown interval code.
|
||||
assert_eq!(
|
||||
wickra_binance_fetch_klines(
|
||||
symbol,
|
||||
99,
|
||||
1,
|
||||
-1,
|
||||
-1,
|
||||
ptr::null(),
|
||||
out.as_mut_ptr(),
|
||||
4
|
||||
),
|
||||
-1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,4 +27,27 @@ public class BinanceFeedTests
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new BinanceFeed("BTCUSDT", BinanceInterval.OneMinute, "ws://127.0.0.1:1"));
|
||||
}
|
||||
|
||||
// The REST fetcher's parse/HTTP success path is covered by the Rust
|
||||
// mock-HTTP-server tests; here we only assert the binding's error paths.
|
||||
[Fact]
|
||||
public void FetchKlinesRejectsUnknownInterval()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
BinanceFeed.FetchKlines("BTCUSDT", (BinanceInterval)99, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FetchKlinesRejectsZeroLimit()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
BinanceFeed.FetchKlines("BTCUSDT", BinanceInterval.OneHour, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FetchKlinesSurfacesUnreachableEndpoint()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
BinanceFeed.FetchKlines("BTCUSDT", BinanceInterval.OneHour, 1, baseUrl: "http://127.0.0.1:1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41589,6 +41589,43 @@ public sealed class BinanceFeed : IDisposable
|
||||
return new KlineEvent(symbol, ev.open, ev.high, ev.low, ev.close, ev.volume, ev.open_time, ev.is_closed != 0);
|
||||
}
|
||||
|
||||
/// <summary>Fetch historical klines from Binance's REST endpoint. <paramref name="symbol"/>
|
||||
/// is the trading pair (case-insensitive), <paramref name="limit"/> the number of
|
||||
/// candles (1..=1000). <paramref name="startMs"/>/<paramref name="endMs"/> are inclusive
|
||||
/// Unix-millisecond bounds (negative = unset); <paramref name="baseUrl"/> overrides the
|
||||
/// host (null = production). Blocks until the response arrives.</summary>
|
||||
public static Candle[] FetchKlines(string symbol, BinanceInterval interval, uint limit, long startMs = -1, long endMs = -1, string? baseUrl = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbol);
|
||||
if (limit == 0)
|
||||
{
|
||||
throw new ArgumentException("limit must be in 1..=1000");
|
||||
}
|
||||
var symBytes = System.Text.Encoding.UTF8.GetBytes(symbol + '\0');
|
||||
var urlBytes = baseUrl is null ? null : System.Text.Encoding.UTF8.GetBytes(baseUrl + '\0');
|
||||
var buffer = new WickraCandle[limit];
|
||||
long count;
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* sp = symBytes)
|
||||
fixed (byte* up = urlBytes)
|
||||
fixed (WickraCandle* ptr = buffer)
|
||||
{
|
||||
count = (long)NativeMethods.wickra_binance_fetch_klines(sp, (byte)interval, limit, startMs, endMs, up, ptr, (nuint)limit);
|
||||
}
|
||||
}
|
||||
if (count < 0)
|
||||
{
|
||||
throw new ArgumentException("invalid FetchKlines parameters or transport error");
|
||||
}
|
||||
var result = new Candle[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
result[i] = new Candle(buffer[i].open, buffer[i].high, buffer[i].low, buffer[i].close, buffer[i].volume, buffer[i].timestamp);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
unsafe
|
||||
|
||||
@@ -12454,6 +12454,9 @@ internal static partial class NativeMethods
|
||||
[LibraryImport(WickraNative.LibraryName)]
|
||||
internal static partial void wickra_binance_free(nint handle);
|
||||
|
||||
[LibraryImport(WickraNative.LibraryName)]
|
||||
internal static unsafe partial nint wickra_binance_fetch_klines(byte* symbol, byte interval, uint limit, long startMs, long endMs, byte* baseUrl, WickraCandle* @out, nuint cap);
|
||||
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
|
||||
@@ -18,3 +18,18 @@ func TestBinanceFeedRejectsBadParams(t *testing.T) {
|
||||
t.Fatal("expected an error connecting to an unreachable endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
// The REST fetcher's parse/HTTP success path is covered by the Rust
|
||||
// mock-HTTP-server tests in wickra-data; here we only assert the binding's
|
||||
// error paths, which need no reachable network.
|
||||
func TestFetchBinanceKlinesRejectsBadParams(t *testing.T) {
|
||||
if _, err := FetchBinanceKlines("BTCUSDT", BinanceInterval(99), 1, -1, -1, ""); err == nil {
|
||||
t.Fatal("expected an error for an unknown interval code")
|
||||
}
|
||||
if _, err := FetchBinanceKlines("BTCUSDT", OneHour, 0, -1, -1, ""); err == nil {
|
||||
t.Fatal("expected an error for a zero limit")
|
||||
}
|
||||
if _, err := FetchBinanceKlines("BTCUSDT", OneHour, 1, -1, -1, "http://127.0.0.1:1"); err == nil {
|
||||
t.Fatal("expected an error connecting to an unreachable endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13773,6 +13773,15 @@ void wickra_binance_close(struct BinanceStream *handle);
|
||||
|
||||
void wickra_binance_free(struct BinanceStream *handle);
|
||||
|
||||
intptr_t wickra_binance_fetch_klines(const char *symbol,
|
||||
uint8_t interval,
|
||||
uint32_t limit,
|
||||
int64_t start_ms,
|
||||
int64_t end_ms,
|
||||
const char *base_url,
|
||||
struct WickraCandle *out,
|
||||
uintptr_t cap);
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif // __cplusplus
|
||||
|
||||
@@ -43431,3 +43431,32 @@ func (f *BinanceFeed) Close() {
|
||||
runtime.SetFinalizer(f, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// FetchBinanceKlines fetches historical klines from Binance's REST endpoint.
|
||||
// symbol is the trading pair (case-insensitive), interval the kline interval,
|
||||
// and limit the number of candles to request (1..=1000). startMs/endMs are
|
||||
// inclusive Unix-millisecond bounds (negative = unset); baseURL overrides the
|
||||
// host ("" = production https://api.binance.com). It blocks until the response
|
||||
// arrives and returns ErrInvalidParams on a bad argument or transport error.
|
||||
func FetchBinanceKlines(symbol string, interval BinanceInterval, limit uint32, startMs, endMs int64, baseURL string) ([]Candle, error) {
|
||||
if limit == 0 {
|
||||
return nil, ErrInvalidParams
|
||||
}
|
||||
csym := C.CString(symbol)
|
||||
defer C.free(unsafe.Pointer(csym))
|
||||
var curl *C.char
|
||||
if baseURL != "" {
|
||||
curl = C.CString(baseURL)
|
||||
defer C.free(unsafe.Pointer(curl))
|
||||
}
|
||||
buf := make([]C.struct_WickraCandle, limit)
|
||||
n := int(C.wickra_binance_fetch_klines(csym, C.uint8_t(interval), C.uint32_t(limit), C.int64_t(startMs), C.int64_t(endMs), curl, &buf[0], C.uintptr_t(limit)))
|
||||
if n < 0 {
|
||||
return nil, ErrInvalidParams
|
||||
}
|
||||
out := make([]Candle, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = Candle{float64(buf[i].open), float64(buf[i].high), float64(buf[i].low), float64(buf[i].close), float64(buf[i].volume), int64(buf[i].timestamp)}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -81,6 +81,51 @@ public final class BinanceFeed implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch historical klines from Binance's REST endpoint. symbol is the
|
||||
* trading pair (case-insensitive), limit the number of candles (1..=1000).
|
||||
* startMs/endMs are inclusive Unix-millisecond bounds (negative = unset);
|
||||
* baseUrl overrides the host (null = production). Blocks until done. */
|
||||
public static Candle[] fetchKlines(String symbol, BinanceInterval interval, int limit,
|
||||
long startMs, long endMs, String baseUrl) {
|
||||
if (symbol == null) {
|
||||
throw new NullPointerException("symbol");
|
||||
}
|
||||
if (limit <= 0) {
|
||||
throw new IllegalArgumentException("limit must be in 1..=1000");
|
||||
}
|
||||
try (Arena a = Arena.ofConfined()) {
|
||||
byte[] sb = symbol.getBytes(StandardCharsets.UTF_8);
|
||||
MemorySegment sym = a.allocate(sb.length + 1L);
|
||||
MemorySegment.copy(sb, 0, sym, JAVA_BYTE, 0L, sb.length);
|
||||
MemorySegment url = MemorySegment.NULL;
|
||||
if (baseUrl != null) {
|
||||
byte[] ub = baseUrl.getBytes(StandardCharsets.UTF_8);
|
||||
url = a.allocate(ub.length + 1L);
|
||||
MemorySegment.copy(ub, 0, url, JAVA_BYTE, 0L, ub.length);
|
||||
}
|
||||
MemorySegment out = a.allocate(48L * limit);
|
||||
long n = (long) NativeMethods.WICKRA_BINANCE_FETCH_KLINES.invokeExact(
|
||||
sym, (byte) interval.ordinal(), limit, startMs, endMs, url, out, (long) limit);
|
||||
if (n < 0) {
|
||||
throw new IllegalArgumentException("invalid fetchKlines parameters or transport error");
|
||||
}
|
||||
Candle[] result = new Candle[(int) n];
|
||||
for (int i = 0; i < n; i++) {
|
||||
long b = (long) i * 48L;
|
||||
result[i] = new Candle(
|
||||
out.get(JAVA_DOUBLE, b + 0L),
|
||||
out.get(JAVA_DOUBLE, b + 8L),
|
||||
out.get(JAVA_DOUBLE, b + 16L),
|
||||
out.get(JAVA_DOUBLE, b + 24L),
|
||||
out.get(JAVA_DOUBLE, b + 32L),
|
||||
(double) out.get(JAVA_LONG, b + 40L));
|
||||
}
|
||||
return result;
|
||||
} catch (Throwable t) {
|
||||
throw WickraNative.rethrow(t);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
try {
|
||||
NativeMethods.WICKRA_BINANCE_CLOSE.invokeExact(handle);
|
||||
|
||||
@@ -3963,6 +3963,7 @@ public final class NativeMethods {
|
||||
public static MethodHandle WICKRA_BINANCE_NEXT;
|
||||
public static MethodHandle WICKRA_BINANCE_CLOSE;
|
||||
public static MethodHandle WICKRA_BINANCE_FREE;
|
||||
public static MethodHandle WICKRA_BINANCE_FETCH_KLINES;
|
||||
|
||||
static {
|
||||
init0();
|
||||
@@ -8047,6 +8048,7 @@ public final class NativeMethods {
|
||||
WICKRA_BINANCE_NEXT = h("wickra_binance_next", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG));
|
||||
WICKRA_BINANCE_CLOSE = h("wickra_binance_close", FunctionDescriptor.ofVoid(ADDRESS));
|
||||
WICKRA_BINANCE_FREE = h("wickra_binance_free", FunctionDescriptor.ofVoid(ADDRESS));
|
||||
WICKRA_BINANCE_FETCH_KLINES = h("wickra_binance_fetch_klines", FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_BYTE, JAVA_INT, JAVA_LONG, JAVA_LONG, ADDRESS, ADDRESS, JAVA_LONG));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -21,4 +21,18 @@ class BinanceFeedTest {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new BinanceFeed("BTCUSDT", BinanceInterval.ONE_MINUTE, "ws://127.0.0.1:1"));
|
||||
}
|
||||
|
||||
// The REST fetcher's parse/HTTP success path is covered by the Rust
|
||||
// mock-HTTP-server tests; here we only assert the binding's error paths.
|
||||
@Test
|
||||
void fetchKlinesRejectsZeroLimit() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> BinanceFeed.fetchKlines("BTCUSDT", BinanceInterval.ONE_HOUR, 0, -1L, -1L, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchKlinesSurfacesUnreachableEndpoint() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> BinanceFeed.fetchKlines("BTCUSDT", BinanceInterval.ONE_HOUR, 1, -1L, -1L, "http://127.0.0.1:1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { BinanceFeed } = require('..');
|
||||
const { BinanceFeed, fetchBinanceKlines } = require('..');
|
||||
|
||||
test('binance feed rejects an unknown interval', () => {
|
||||
assert.throws(() => new BinanceFeed('BTCUSDT', 99));
|
||||
@@ -17,3 +17,18 @@ test('binance feed rejects an empty symbol list', () => {
|
||||
test('binance feed rejects an unreachable endpoint', () => {
|
||||
assert.throws(() => new BinanceFeed('BTCUSDT', 1, 'ws://127.0.0.1:1'));
|
||||
});
|
||||
|
||||
// The REST fetcher's parse/HTTP success path is covered deterministically by the
|
||||
// Rust mock-HTTP-server tests in wickra-data; here we only assert the binding's
|
||||
// error paths, which short-circuit before (or fail fast at) the network.
|
||||
test('fetchBinanceKlines rejects an unknown interval', () => {
|
||||
assert.throws(() => fetchBinanceKlines('BTCUSDT', 99, 1));
|
||||
});
|
||||
|
||||
test('fetchBinanceKlines rejects an out-of-range limit', () => {
|
||||
assert.throws(() => fetchBinanceKlines('BTCUSDT', 6, 0));
|
||||
});
|
||||
|
||||
test('fetchBinanceKlines surfaces an unreachable endpoint', () => {
|
||||
assert.throws(() => fetchBinanceKlines('BTCUSDT', 6, 1, null, null, 'http://127.0.0.1:1'));
|
||||
});
|
||||
|
||||
Vendored
+9
@@ -614,6 +614,15 @@ export interface KlineEvent {
|
||||
openTime: number
|
||||
isClosed: boolean
|
||||
}
|
||||
/**
|
||||
* Fetch historical klines from Binance's REST endpoint. `symbol` is the trading
|
||||
* pair (case-insensitive, e.g. `"BTCUSDT"`), `interval` the code `0..=15` (the
|
||||
* `Interval` declaration order), and `limit` the number of candles to request
|
||||
* (`1..=1000`). `startMs`/`endMs` are optional inclusive Unix-millisecond
|
||||
* bounds; `baseUrl` overrides the host (omit for production). This is a blocking
|
||||
* call — run it on a Worker thread to keep the event loop responsive.
|
||||
*/
|
||||
export declare function fetchBinanceKlines(symbol: string, interval: number, limit: number, startMs?: number | undefined | null, endMs?: number | undefined | null, baseUrl?: string | undefined | null): Array<CandleValue>
|
||||
export type SmaNode = SMA
|
||||
export declare class SMA {
|
||||
constructor(period: number)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -22016,6 +22016,44 @@ impl BinanceFeedNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch historical klines from Binance's REST endpoint. `symbol` is the trading
|
||||
/// pair (case-insensitive, e.g. `"BTCUSDT"`), `interval` the code `0..=15` (the
|
||||
/// `Interval` declaration order), and `limit` the number of candles to request
|
||||
/// (`1..=1000`). `startMs`/`endMs` are optional inclusive Unix-millisecond
|
||||
/// bounds; `baseUrl` overrides the host (omit for production). This is a blocking
|
||||
/// call — run it on a Worker thread to keep the event loop responsive.
|
||||
#[napi(js_name = "fetchBinanceKlines")]
|
||||
pub fn fetch_binance_klines(
|
||||
symbol: String,
|
||||
interval: u8,
|
||||
limit: u32,
|
||||
start_ms: Option<f64>,
|
||||
end_ms: Option<f64>,
|
||||
base_url: Option<String>,
|
||||
) -> napi::Result<Vec<CandleValue>> {
|
||||
let iv = binance_interval(interval).ok_or_else(|| {
|
||||
NapiError::new(
|
||||
Status::InvalidArg,
|
||||
"unknown interval code (expected 0..=15)",
|
||||
)
|
||||
})?;
|
||||
let limit = u16::try_from(limit)
|
||||
.map_err(|_| NapiError::new(Status::InvalidArg, "limit must be in 1..=1000"))?;
|
||||
let start = start_ms.map(|v| v as i64);
|
||||
let end = end_ms.map(|v| v as i64);
|
||||
let candles = match base_url {
|
||||
Some(url) => {
|
||||
let config = wickra_data::live::binance_rest::BinanceRestConfig { base_url: url };
|
||||
wickra_data::live::binance_rest::fetch_klines_with_config(
|
||||
&symbol, iv, limit, start, end, &config,
|
||||
)
|
||||
}
|
||||
None => wickra_data::live::binance_rest::fetch_klines(&symbol, iv, limit, start, end),
|
||||
}
|
||||
.map_err(map_data_err)?;
|
||||
Ok(candles.into_iter().map(candle_to_value).collect())
|
||||
}
|
||||
|
||||
/// Roll trade ticks up into fixed-timeframe OHLCV candles.
|
||||
#[napi(js_name = "TickAggregator")]
|
||||
pub struct TickAggregatorNode {
|
||||
|
||||
@@ -363,6 +363,7 @@ from ._wickra import (
|
||||
Resampler,
|
||||
CandleReader,
|
||||
BinanceFeed,
|
||||
fetch_binance_klines,
|
||||
# Market Profile
|
||||
CompositeProfile,
|
||||
HighLowVolumeNodes,
|
||||
@@ -912,6 +913,7 @@ __all__ = [
|
||||
"Resampler",
|
||||
"CandleReader",
|
||||
"BinanceFeed",
|
||||
"fetch_binance_klines",
|
||||
# Market Profile
|
||||
"CompositeProfile",
|
||||
"HighLowVolumeNodes",
|
||||
|
||||
@@ -28290,6 +28290,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyResampler>()?;
|
||||
m.add_class::<PyCandleReader>()?;
|
||||
m.add_class::<PyBinanceFeed>()?;
|
||||
m.add_function(wrap_pyfunction!(fetch_binance_klines, m)?)?;
|
||||
// Candlestick patterns.
|
||||
m.add_class::<PyDoji>()?;
|
||||
m.add_class::<PyHammer>()?;
|
||||
@@ -28679,6 +28680,49 @@ impl PyBinanceFeed {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch historical klines from Binance's REST endpoint. `symbol` is the trading
|
||||
/// pair (case-insensitive, e.g. `"BTCUSDT"`), `interval` the code `0..=15` (the
|
||||
/// `Interval` declaration order), and `limit` the number of candles to request
|
||||
/// (`1..=1000`). `start_ms`/`end_ms` are optional inclusive Unix-millisecond
|
||||
/// bounds; `base_url` overrides the host (omit for production). Returns a list of
|
||||
/// `(open, high, low, close, volume, timestamp)` tuples. This blocks until the
|
||||
/// HTTP response arrives, releasing the GIL while it waits.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (symbol, interval, limit, start_ms = None, end_ms = None, base_url = None))]
|
||||
fn fetch_binance_klines(
|
||||
py: Python<'_>,
|
||||
symbol: &str,
|
||||
interval: u8,
|
||||
limit: u32,
|
||||
start_ms: Option<i64>,
|
||||
end_ms: Option<i64>,
|
||||
base_url: Option<&str>,
|
||||
) -> PyResult<Vec<CandleTuple>> {
|
||||
let iv = binance_interval(interval)
|
||||
.ok_or_else(|| PyValueError::new_err("unknown interval code (expected 0..=15)"))?;
|
||||
let limit =
|
||||
u16::try_from(limit).map_err(|_| PyValueError::new_err("limit must be in 1..=1000"))?;
|
||||
let symbol = symbol.to_owned();
|
||||
let base_url = base_url.map(str::to_owned);
|
||||
let candles = py
|
||||
.detach(move || match base_url {
|
||||
Some(url) => {
|
||||
let config = wickra_data::live::binance_rest::BinanceRestConfig { base_url: url };
|
||||
wickra_data::live::binance_rest::fetch_klines_with_config(
|
||||
&symbol, iv, limit, start_ms, end_ms, &config,
|
||||
)
|
||||
}
|
||||
None => {
|
||||
wickra_data::live::binance_rest::fetch_klines(&symbol, iv, limit, start_ms, end_ms)
|
||||
}
|
||||
})
|
||||
.map_err(map_data_err)?;
|
||||
Ok(candles
|
||||
.into_iter()
|
||||
.map(|c| (c.open, c.high, c.low, c.close, c.volume, c.timestamp))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Roll trade ticks up into fixed-timeframe OHLCV candles.
|
||||
#[pyclass(
|
||||
name = "TickAggregator",
|
||||
|
||||
@@ -13,3 +13,14 @@ def test_binance_feed_rejects_bad_params():
|
||||
ta.BinanceFeed("", 1) # empty symbol list
|
||||
with pytest.raises(ValueError):
|
||||
ta.BinanceFeed("BTCUSDT", 1, "ws://127.0.0.1:1") # unreachable endpoint
|
||||
|
||||
|
||||
def test_fetch_binance_klines_rejects_bad_params():
|
||||
# The parse/HTTP success path is covered by the Rust mock-HTTP-server tests;
|
||||
# here we only assert the binding's error paths.
|
||||
with pytest.raises(ValueError):
|
||||
ta.fetch_binance_klines("BTCUSDT", 99, 1) # unknown interval code
|
||||
with pytest.raises(ValueError):
|
||||
ta.fetch_binance_klines("BTCUSDT", 6, 0) # out-of-range limit
|
||||
with pytest.raises(ValueError):
|
||||
ta.fetch_binance_klines("BTCUSDT", 6, 1, base_url="http://127.0.0.1:1") # unreachable
|
||||
|
||||
@@ -530,6 +530,7 @@ export(Zlema)
|
||||
export(batch)
|
||||
export(binance_close)
|
||||
export(binance_next)
|
||||
export(fetch_binance_klines)
|
||||
export(is_ready)
|
||||
export(name)
|
||||
export(push)
|
||||
|
||||
@@ -4180,3 +4180,24 @@ binance_close <- function(feed) {
|
||||
invisible(NULL)
|
||||
}
|
||||
|
||||
#' Fetch historical Binance klines over REST
|
||||
#'
|
||||
#' Downloads up to `limit` (`1:1000`) historical klines for `symbol` at the given
|
||||
#' `interval` code (an integer `0:15`, the same order as the other bindings).
|
||||
#' `start_ms`/`end_ms` are optional inclusive Unix-millisecond bounds (a negative
|
||||
#' value means unset); `base_url` overrides the host (`NULL` = production). Returns
|
||||
#' an `n x 6` numeric matrix with columns `open`, `high`, `low`, `close`,
|
||||
#' `volume`, `timestamp`. Blocks until the response arrives. Not available in the
|
||||
#' wasm (r-universe/webR) build, which has no raw sockets.
|
||||
#'
|
||||
#' @keywords internal
|
||||
#' @export
|
||||
fetch_binance_klines <- function(symbol, interval, limit, start_ms = -1,
|
||||
end_ms = -1, base_url = NULL) {
|
||||
m <- .Call("wk_binance_fetch_klines", symbol, as.integer(interval),
|
||||
as.integer(limit), as.numeric(start_ms), as.numeric(end_ms),
|
||||
base_url, PACKAGE = "wickra")
|
||||
colnames(m) <- c("open", "high", "low", "close", "volume", "timestamp")
|
||||
m
|
||||
}
|
||||
|
||||
|
||||
@@ -22707,6 +22707,31 @@ SEXP wk_binance_close(SEXP e) {
|
||||
wickra_binance_close(h);
|
||||
return R_NilValue;
|
||||
}
|
||||
SEXP wk_binance_fetch_klines(SEXP symbol, SEXP interval, SEXP limit,
|
||||
SEXP start_ms, SEXP end_ms, SEXP base_url) {
|
||||
const char *url = (base_url == R_NilValue || Rf_xlength(base_url) == 0)
|
||||
? NULL : CHAR(STRING_ELT(base_url, 0));
|
||||
uint32_t lim = (uint32_t)Rf_asInteger(limit);
|
||||
if (lim == 0) Rf_error("limit must be in 1..=1000");
|
||||
struct WickraCandle *buf =
|
||||
(struct WickraCandle *)R_alloc(lim, sizeof(struct WickraCandle));
|
||||
intptr_t n = wickra_binance_fetch_klines(
|
||||
CHAR(STRING_ELT(symbol, 0)), (uint8_t)Rf_asInteger(interval), lim,
|
||||
(int64_t)Rf_asReal(start_ms), (int64_t)Rf_asReal(end_ms), url, buf,
|
||||
(uintptr_t)lim);
|
||||
if (n < 0) Rf_error("invalid fetch_binance_klines parameters or transport error");
|
||||
SEXP r = PROTECT(Rf_allocMatrix(REALSXP, (int)n, 6));
|
||||
for (intptr_t i = 0; i < n; i++) {
|
||||
REAL(r)[i + n * 0] = buf[i].open;
|
||||
REAL(r)[i + n * 1] = buf[i].high;
|
||||
REAL(r)[i + n * 2] = buf[i].low;
|
||||
REAL(r)[i + n * 3] = buf[i].close;
|
||||
REAL(r)[i + n * 4] = buf[i].volume;
|
||||
REAL(r)[i + n * 5] = (double)buf[i].timestamp;
|
||||
}
|
||||
UNPROTECT(1);
|
||||
return r;
|
||||
}
|
||||
#endif
|
||||
|
||||
static const R_CallMethodDef CallEntries[] = {
|
||||
@@ -26141,6 +26166,7 @@ static const R_CallMethodDef CallEntries[] = {
|
||||
{"wk_binance_connect", (DL_FUNC)&wk_binance_connect, 3},
|
||||
{"wk_binance_next", (DL_FUNC)&wk_binance_next, 2},
|
||||
{"wk_binance_close", (DL_FUNC)&wk_binance_close, 1},
|
||||
{"wk_binance_fetch_klines", (DL_FUNC)&wk_binance_fetch_klines, 6},
|
||||
#endif
|
||||
{NULL, NULL, 0}
|
||||
};
|
||||
|
||||
@@ -6,3 +6,13 @@ test_that("binance feed rejects bad parameters", {
|
||||
expect_error(BinanceFeed("", 1L), "BinanceFeed")
|
||||
expect_error(BinanceFeed("BTCUSDT", 1L, "ws://127.0.0.1:1"), "BinanceFeed")
|
||||
})
|
||||
|
||||
# The REST fetcher's parse/HTTP success path is covered by the Rust
|
||||
# mock-HTTP-server tests; here we only assert the binding's error paths.
|
||||
test_that("fetch_binance_klines rejects bad parameters", {
|
||||
expect_error(fetch_binance_klines("BTCUSDT", 6L, 0L), "limit")
|
||||
expect_error(
|
||||
fetch_binance_klines("BTCUSDT", 6L, 1L, base_url = "http://127.0.0.1:1"),
|
||||
"fetch_binance_klines"
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user