feat(data): native live Binance kline feed in 9 languages (+ 3d/1M intervals) (#313)

* feat(data): C-ABI Binance feed + Go binding (F4 wip)

C ABI exposes the existing async BinanceKlineStream (tokio + TLS, auto-reconnect,
mock-server-tested in wickra-data) through a blocking poll: wickra_binance_connect
/ _next(out, timeout_ms) -> {1 event, 0 timeout, -1 closed} / _close / _free over
an opaque BinanceStream that owns a current-thread runtime. WickraKlineEvent
carries OHLCV + open_time + is_closed + a 16-byte symbol buffer. `live-binance`
is now a default feature of wickra-c (the published DLL ships the feed; the wasm
build drops it via --no-default-features).

Go: NewBinanceFeed(symbols, interval, baseURL) + Next(timeout) + Close, with a
deterministic error-path smoke (the connect->event pipeline is covered by the
Rust mock-WS-server tests).

* feat(data): Binance feed C# + Java bindings (F4 wip)

C#: BinanceFeed(symbols, interval, baseUrl?) + Next(timeout) -> KlineEvent? +
Dispose; bespoke WickraKlineEvent native struct (fixed symbol buffer + byte
is_closed) since the scalar struct parser can't model it. `char` maps to `byte`
for the const char* params.

Java: BinanceFeed + KlineEvent record + BinanceInterval enum over Panama FFM;
the event is read at hand-computed offsets (symbol@0, doubles@16..48,
open_time@56, is_closed@64; 72-byte struct). Both with deterministic error-path
smokes (pipeline covered by the Rust mock-WS-server tests).

* feat(data): Binance feed R binding (F4 wip)

R: BinanceFeed(symbols, interval, base_url) + binance_next(feed, timeout_ms) ->
named list | NULL + binance_close, via bespoke .Call glue (wk_binance_*). The
glue + its registration entries are gated out of the Emscripten/wasm build
(#ifndef __EMSCRIPTEN__) since r-universe/webR has no raw sockets. NAMESPACE
exports added by hand (roxygen2 not installed locally). Deterministic error-path
smoke; pipeline covered by the Rust mock-WS-server tests.

* feat(data): native Binance feed for Node + Python; CHANGELOG (F4 complete)

Node (napi) BinanceFeed: new(symbols, interval, baseUrl?) + next(timeoutMs) ->
KlineEvent | null + close. Python (pyo3) BinanceFeed: same, with next releasing
the GIL (py.detach) while it waits. Both drive the mock-server-tested async
BinanceKlineStream on a single-thread tokio runtime (blocking poll); wickra-data
gains the live-binance feature + tokio in each binding.

Completes F4: the live Binance kline feed is now native in all 9 languages
(WASM excluded), with no third-party WebSocket client in any of them.
This commit is contained in:
kingchenc
2026-06-16 02:01:37 +02:00
committed by GitHub
parent baf4d0ff47
commit 3a709d9a66
30 changed files with 1127 additions and 5 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 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
binding. Construct it with comma-separated symbols + an interval, then poll
`next(timeout)` for the next event (or `null`/`None` on timeout); the connection
reconnects transparently. Exposed natively (Node.js / Python — a blocking poll
that drives the tested async stream on a tokio runtime) and over the C ABI as Go
`Next()`, C# `Next()`, Java `next()`, and the R `binance_next()`; C / C++ call
`wickra_binance_connect` / `_next` / `_close` / `_free` directly. The connect →
read → reconnect pipeline is covered by the existing mock-WS-server tests. WASM
is excluded (a browser has no raw sockets; use the host `WebSocket`). The C ABI
ships the feed by default (`live-binance` feature); the wasm build drops it.
- **CSV candle reading in all 10 languages (data layer).** The `CandleReader`
parses a `timestamp,open,high,low,close,volume` CSV buffer (a leading UTF-8 BOM
and field whitespace are tolerated) into candles: construct it from a CSV string
Generated
+3
View File
@@ -1968,6 +1968,7 @@ dependencies = [
name = "wickra-c"
version = "0.9.2"
dependencies = [
"tokio",
"wickra-core",
"wickra-data",
]
@@ -2016,6 +2017,7 @@ dependencies = [
"napi",
"napi-build",
"napi-derive",
"tokio",
"wickra-core",
"wickra-data",
]
@@ -2026,6 +2028,7 @@ version = "0.9.2"
dependencies = [
"numpy",
"pyo3",
"tokio",
"wickra-core",
"wickra-data",
]
+13 -1
View File
@@ -48,8 +48,16 @@ float_cmp = "allow"
# package's wasm build (r-universe / webR) compiles this crate with
# --no-default-features to drop rayon; wickra-core falls back to its serial
# batch path, which is cfg-gated behind the same feature.
default = ["parallel"]
#
# `live-binance` is on by default too so the published C ABI ships the native
# Binance kline feed (tokio + TLS WebSocket) — the six C-ABI languages with no
# native WebSocket (C, C++, Go, R, plus Node/Python via their own bindings) get
# it without a third-party client. The wasm build drops it via
# --no-default-features (a browser has no raw TCP/TLS sockets; WASM users use the
# host `WebSocket`), exactly like rayon.
default = ["parallel", "live-binance"]
parallel = ["wickra-core/parallel"]
live-binance = ["wickra-data/live-binance", "dep:tokio"]
[dependencies]
# Direct path dep rather than `workspace = true`: a member-level
@@ -58,3 +66,7 @@ parallel = ["wickra-core/parallel"]
# `publish = false`, so no version pin is needed (and none to keep in sync).
wickra-core = { path = "../../crates/wickra-core", default-features = false }
wickra-data = { path = "../../crates/wickra-data" }
# Only pulled in by `live-binance`: a single-thread runtime drives the async
# Binance feed behind the blocking C-ABI poll. The feed's own I/O features come
# transitively from wickra-data; this just needs the runtime + timeout.
tokio = { version = "1", features = ["rt", "net", "time"], optional = true }
+25
View File
@@ -102,6 +102,8 @@ typedef struct BetaNeutralSpread BetaNeutralSpread;
typedef struct BetterVolume BetterVolume;
typedef struct BinanceStream BinanceStream;
typedef struct BipowerVariation BipowerVariation;
typedef struct BodySizePct BodySizePct;
@@ -1690,6 +1692,17 @@ typedef struct WickraCandle {
int64_t timestamp;
} WickraCandle;
typedef struct WickraKlineEvent {
uint8_t symbol[16];
double open;
double high;
double low;
double close;
double volume;
int64_t open_time;
bool is_closed;
} WickraKlineEvent;
#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
@@ -13748,6 +13761,18 @@ uintptr_t wickra_candle_reader_read(struct CandleReader *handle,
void wickra_candle_reader_free(struct CandleReader *handle);
struct BinanceStream *wickra_binance_connect(const char *symbols,
uint8_t interval,
const char *base_url);
int32_t wickra_binance_next(struct BinanceStream *handle,
struct WickraKlineEvent *out,
int64_t timeout_ms);
void wickra_binance_close(struct BinanceStream *handle);
void wickra_binance_free(struct BinanceStream *handle);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
+193
View File
@@ -68379,6 +68379,199 @@ pub unsafe extern "C" fn wickra_candle_reader_free(handle: *mut CandleReader) {
}
}
// ===== Live Binance kline feed (feature `live-binance`; blocking poll) =====
/// C-ABI view of one kline event from the live Binance feed.
#[cfg(feature = "live-binance")]
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct WickraKlineEvent {
/// NUL-terminated lowercase symbol (e.g. `b"btcusdt\0"`), truncated to 15 bytes.
pub symbol: [u8; 16],
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
/// Candle open time as Binance sends it (milliseconds since the Unix epoch).
pub open_time: i64,
/// Whether the candle is closed; only closed candles are final.
pub is_closed: bool,
}
/// Opaque live Binance kline stream: the async stream plus a single-thread tokio
/// runtime that drives it. Each poll runs one async step to completion.
#[cfg(feature = "live-binance")]
#[derive(Debug)]
pub struct BinanceStream {
runtime: tokio::runtime::Runtime,
inner: wickra_data::live::binance::BinanceKlineStream,
}
/// Map a `u8` interval code (the `Interval` declaration order, `0..=15`) to the
/// feed's interval. Returns `None` for an unknown code.
#[cfg(feature = "live-binance")]
fn binance_interval(code: u8) -> Option<wickra_data::live::binance::Interval> {
use wickra_data::live::binance::Interval;
Some(match code {
0 => Interval::OneSecond,
1 => Interval::OneMinute,
2 => Interval::ThreeMinutes,
3 => Interval::FiveMinutes,
4 => Interval::FifteenMinutes,
5 => Interval::ThirtyMinutes,
6 => Interval::OneHour,
7 => Interval::TwoHours,
8 => Interval::FourHours,
9 => Interval::SixHours,
10 => Interval::EightHours,
11 => Interval::TwelveHours,
12 => Interval::OneDay,
13 => Interval::ThreeDays,
14 => Interval::OneWeek,
15 => Interval::OneMonth,
_ => return None,
})
}
/// Connect to Binance's live kline stream for one or more comma-separated
/// `symbols` (case-insensitive, e.g. `"BTCUSDT,ETHUSDT"`) at the given `interval`
/// code (`0..=15`, the `Interval` declaration order). `base_url` overrides the
/// endpoint (`NULL` = production `wss://stream.binance.com:9443`); pass a `ws://…`
/// URL to target a test server. Returns `NULL` on a null/empty/invalid symbol
/// list, an unknown interval, a bad URL, or a failed initial connect. Release
/// with `wickra_binance_free`.
///
/// # Safety
/// `symbols` must be a valid NUL-terminated UTF-8 C string; `base_url` must be
/// `NULL` or a valid NUL-terminated UTF-8 C string.
#[cfg(feature = "live-binance")]
#[no_mangle]
pub unsafe extern "C" fn wickra_binance_connect(
symbols: *const c_char,
interval: u8,
base_url: *const c_char,
) -> *mut BinanceStream {
if symbols.is_null() {
return ptr::null_mut();
}
let Ok(symbols_str) = core::ffi::CStr::from_ptr(symbols).to_str() else {
return ptr::null_mut();
};
let symbol_list: Vec<String> = symbols_str
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
if symbol_list.is_empty() {
return ptr::null_mut();
}
let Some(interval) = binance_interval(interval) else {
return ptr::null_mut();
};
let mut config = wickra_data::live::binance::BinanceConfig::default();
if !base_url.is_null() {
let Ok(url) = core::ffi::CStr::from_ptr(base_url).to_str() else {
return ptr::null_mut();
};
url.clone_into(&mut config.base_url);
}
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
return ptr::null_mut();
};
match runtime.block_on(
wickra_data::live::binance::BinanceKlineStream::connect_with_config(
&symbol_list,
interval,
config,
),
) {
Ok(inner) => Box::into_raw(Box::new(BinanceStream { runtime, inner })),
Err(_) => ptr::null_mut(),
}
}
/// Poll for the next kline event, waiting up to `timeout_ms` milliseconds.
/// Returns `1` and writes the event to `*out` when one arrives, `0` on timeout
/// (no event yet — call again), or `-1` if the stream is closed/errored or
/// `handle`/`out` is `NULL`. A dropped connection is reconnected transparently
/// within a single call (which may consume part of the timeout).
///
/// # Safety
/// `handle` (from `wickra_binance_connect`, not freed) and `out` must be valid or `NULL`.
#[cfg(feature = "live-binance")]
#[no_mangle]
pub unsafe extern "C" fn wickra_binance_next(
handle: *mut BinanceStream,
out: *mut WickraKlineEvent,
timeout_ms: i64,
) -> i32 {
let Some(stream) = handle.as_mut() else {
return -1;
};
if out.is_null() {
return -1;
}
let timeout = std::time::Duration::from_millis(timeout_ms.max(0) as u64);
let runtime = &stream.runtime;
let inner = &mut stream.inner;
let result =
runtime.block_on(async { tokio::time::timeout(timeout, inner.next_event()).await });
match result {
Ok(Ok(Some(event))) => {
let mut symbol = [0_u8; 16];
for (slot, byte) in symbol.iter_mut().zip(event.symbol.bytes()).take(15) {
*slot = byte;
}
*out = WickraKlineEvent {
symbol,
open: event.candle.open,
high: event.candle.high,
low: event.candle.low,
close: event.candle.close,
volume: event.candle.volume,
open_time: event.candle.timestamp,
is_closed: event.is_closed,
};
1
}
// Closed stream or any transport/parse error: both terminal for the poll.
Ok(Ok(None) | Err(_)) => -1,
Err(_) => 0,
}
}
/// Close the stream's connection; afterwards every `wickra_binance_next` returns
/// `-1`. No-op on a `NULL` handle.
///
/// # Safety
/// `handle` must be valid (from `wickra_binance_connect`, not freed), or `NULL`.
#[cfg(feature = "live-binance")]
#[no_mangle]
pub unsafe extern "C" fn wickra_binance_close(handle: *mut BinanceStream) {
if let Some(stream) = handle.as_mut() {
let runtime = &stream.runtime;
let inner = &mut stream.inner;
let _ = runtime.block_on(inner.close());
}
}
/// Destroy a stream created by `wickra_binance_connect`. No-op if `handle` is `NULL`.
///
/// # Safety
/// `handle` must have been returned by `wickra_binance_connect` and not previously freed, or `NULL`.
#[cfg(feature = "live-binance")]
#[no_mangle]
pub unsafe extern "C" fn wickra_binance_free(handle: *mut BinanceStream) {
if !handle.is_null() {
drop(Box::from_raw(handle));
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,30 @@
using System;
using Wickra;
using Xunit;
// The live Binance feed's connect → read → reconnect pipeline is covered
// deterministically by the Rust mock-WS-server tests in wickra-data. Here we
// only assert the binding's error paths, which need no network.
public class BinanceFeedTests
{
[Fact]
public void RejectsUnknownInterval()
{
Assert.Throws<ArgumentException>(() =>
new BinanceFeed("BTCUSDT", (BinanceInterval)99));
}
[Fact]
public void RejectsEmptySymbols()
{
Assert.Throws<ArgumentException>(() =>
new BinanceFeed("", BinanceInterval.OneMinute));
}
[Fact]
public void RejectsUnreachableEndpoint()
{
Assert.Throws<ArgumentException>(() =>
new BinanceFeed("BTCUSDT", BinanceInterval.OneMinute, "ws://127.0.0.1:1"));
}
}
@@ -41508,3 +41508,95 @@ public sealed class Zlema : IDisposable
public void Dispose() => _handle.Dispose();
}
/// <summary>Kline interval for the live Binance feed.</summary>
public enum BinanceInterval : byte
{
OneSecond,
OneMinute,
ThreeMinutes,
FiveMinutes,
FifteenMinutes,
ThirtyMinutes,
OneHour,
TwoHours,
FourHours,
SixHours,
EightHours,
TwelveHours,
OneDay,
ThreeDays,
OneWeek,
OneMonth,
}
/// <summary>One event from the live Binance feed.</summary>
public readonly record struct KlineEvent(string Symbol, double Open, double High, double Low, double Close, double Volume, long OpenTime, bool IsClosed);
/// <summary>A live Binance kline stream over the Wickra C ABI.</summary>
public sealed class BinanceFeed : IDisposable
{
private readonly WickraHandle _handle;
/// <summary>Connect to Binance's live kline stream for the given comma-separated symbols
/// (case-insensitive) at <paramref name="interval"/>. <paramref name="baseUrl"/> overrides the
/// endpoint (null = production; pass a ws:// URL to target a test server).</summary>
public BinanceFeed(string symbols, BinanceInterval interval, string? baseUrl = null)
{
ArgumentNullException.ThrowIfNull(symbols);
var symBytes = System.Text.Encoding.UTF8.GetBytes(symbols + '\0');
var urlBytes = baseUrl is null ? null : System.Text.Encoding.UTF8.GetBytes(baseUrl + '\0');
nint ptr;
unsafe
{
fixed (byte* sp = symBytes)
fixed (byte* up = urlBytes)
{
ptr = NativeMethods.wickra_binance_connect(sp, (byte)interval, up);
}
}
if (ptr == nint.Zero)
{
throw new ArgumentException("invalid BinanceFeed parameters");
}
_handle = new WickraHandle(ptr, NativeMethods.wickra_binance_free);
}
/// <summary>Poll for the next kline event, waiting up to <paramref name="timeout"/>.
/// Returns the event, or null on timeout (call again). Throws once the stream is
/// closed or has errored out.</summary>
public KlineEvent? Next(TimeSpan timeout)
{
WickraKlineEvent ev;
int code;
unsafe
{
code = NativeMethods.wickra_binance_next(_handle.DangerousGetHandle(), &ev, (long)timeout.TotalMilliseconds);
}
GC.KeepAlive(_handle);
if (code == 0)
{
return null;
}
if (code != 1)
{
throw new InvalidOperationException("binance feed closed");
}
string symbol;
unsafe
{
symbol = Marshal.PtrToStringUTF8((nint)ev.symbol) ?? string.Empty;
}
return new KlineEvent(symbol, ev.open, ev.high, ev.low, ev.close, ev.volume, ev.open_time, ev.is_closed != 0);
}
public void Dispose()
{
unsafe
{
NativeMethods.wickra_binance_close(_handle.DangerousGetHandle());
}
GC.KeepAlive(_handle);
_handle.Dispose();
}
}
@@ -12442,6 +12442,18 @@ internal static partial class NativeMethods
[LibraryImport(WickraNative.LibraryName)]
internal static partial void wickra_candle_reader_free(nint handle);
[LibraryImport(WickraNative.LibraryName)]
internal static unsafe partial nint wickra_binance_connect(byte* symbols, byte interval, byte* baseUrl);
[LibraryImport(WickraNative.LibraryName)]
internal static unsafe partial int wickra_binance_next(nint handle, WickraKlineEvent* @out, long timeoutMs);
[LibraryImport(WickraNative.LibraryName)]
internal static partial void wickra_binance_close(nint handle);
[LibraryImport(WickraNative.LibraryName)]
internal static partial void wickra_binance_free(nint handle);
}
[StructLayout(LayoutKind.Sequential)]
@@ -13298,3 +13310,16 @@ internal static partial class NativeMethods
public double direction;
}
[StructLayout(LayoutKind.Sequential)]
internal unsafe struct WickraKlineEvent
{
public fixed byte symbol[16];
public double open;
public double high;
public double low;
public double close;
public double volume;
public long open_time;
public byte is_closed;
}
+20
View File
@@ -0,0 +1,20 @@
package wickra
import "testing"
// The live Binance feed's connect → read → reconnect pipeline is covered
// deterministically by the Rust mock-WS-server tests in wickra-data. Here we
// only assert the binding's error paths, which need no network: a bad interval,
// an empty symbol list, and an unreachable endpoint must all surface as an
// error rather than a usable handle.
func TestBinanceFeedRejectsBadParams(t *testing.T) {
if _, err := NewBinanceFeed("BTCUSDT", BinanceInterval(99), ""); err == nil {
t.Fatal("expected an error for an unknown interval code")
}
if _, err := NewBinanceFeed("", OneMinute, ""); err == nil {
t.Fatal("expected an error for an empty symbol list")
}
if _, err := NewBinanceFeed("BTCUSDT", OneMinute, "ws://127.0.0.1:1"); err == nil {
t.Fatal("expected an error connecting to an unreachable endpoint")
}
}
+25
View File
@@ -102,6 +102,8 @@ typedef struct BetaNeutralSpread BetaNeutralSpread;
typedef struct BetterVolume BetterVolume;
typedef struct BinanceStream BinanceStream;
typedef struct BipowerVariation BipowerVariation;
typedef struct BodySizePct BodySizePct;
@@ -1690,6 +1692,17 @@ typedef struct WickraCandle {
int64_t timestamp;
} WickraCandle;
typedef struct WickraKlineEvent {
uint8_t symbol[16];
double open;
double high;
double low;
double close;
double volume;
int64_t open_time;
bool is_closed;
} WickraKlineEvent;
#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
@@ -13748,6 +13761,18 @@ uintptr_t wickra_candle_reader_read(struct CandleReader *handle,
void wickra_candle_reader_free(struct CandleReader *handle);
struct BinanceStream *wickra_binance_connect(const char *symbols,
uint8_t interval,
const char *base_url);
int32_t wickra_binance_next(struct BinanceStream *handle,
struct WickraKlineEvent *out,
int64_t timeout_ms);
void wickra_binance_close(struct BinanceStream *handle);
void wickra_binance_free(struct BinanceStream *handle);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
+112
View File
@@ -3,12 +3,14 @@
package wickra
/*
#include <stdlib.h>
#include "wickra.h"
*/
import "C"
import (
"runtime"
"time"
"unsafe"
)
@@ -43319,3 +43321,113 @@ func (ind *Zlema) Close() {
runtime.SetFinalizer(ind, nil)
}
}
// ===== Live Binance kline feed (feature `live-binance`) =====
// BinanceInterval selects a kline interval (the Interval declaration order).
type BinanceInterval uint8
// Kline intervals supported by the live Binance feed.
const (
OneSecond BinanceInterval = iota
OneMinute
ThreeMinutes
FiveMinutes
FifteenMinutes
ThirtyMinutes
OneHour
TwoHours
FourHours
SixHours
EightHours
TwelveHours
OneDay
ThreeDays
OneWeek
OneMonth
)
// KlineEvent is one event from the live Binance feed.
type KlineEvent struct {
Symbol string
Open float64
High float64
Low float64
Close float64
Volume float64
OpenTime int64
IsClosed bool
}
// BinanceFeed is a live Binance kline stream over the Wickra C ABI.
type BinanceFeed struct {
handle *C.struct_BinanceStream
}
// NewBinanceFeed connects to Binance's live kline stream for the given
// comma-separated symbols (case-insensitive) at interval. baseURL overrides
// the endpoint ("" = production wss://stream.binance.com:9443; pass a ws://
// URL to target a test server). It returns ErrInvalidParams on a bad symbol
// list, an unknown interval, a bad URL, or a failed initial connect.
func NewBinanceFeed(symbols string, interval BinanceInterval, baseURL string) (*BinanceFeed, error) {
csym := C.CString(symbols)
defer C.free(unsafe.Pointer(csym))
var curl *C.char
if baseURL != "" {
curl = C.CString(baseURL)
defer C.free(unsafe.Pointer(curl))
}
ptr := C.wickra_binance_connect(csym, C.uint8_t(interval), curl)
if ptr == nil {
return nil, ErrInvalidParams
}
obj := &BinanceFeed{handle: ptr}
runtime.SetFinalizer(obj, (*BinanceFeed).Close)
return obj, nil
}
// Next polls for the next kline event, waiting up to timeout. It returns the
// event and true when one arrives, the zero value and false on timeout, or
// ErrFeedClosed once the stream is closed or has errored out.
func (f *BinanceFeed) Next(timeout time.Duration) (KlineEvent, bool, error) {
var ev C.struct_WickraKlineEvent
code := int(C.wickra_binance_next(f.handle, &ev, C.int64_t(timeout.Milliseconds())))
runtime.KeepAlive(f)
switch code {
case 1:
return klineFromC(&ev), true, nil
case 0:
return KlineEvent{}, false, nil
default:
return KlineEvent{}, false, ErrFeedClosed
}
}
func klineFromC(ev *C.struct_WickraKlineEvent) KlineEvent {
n := 0
for n < len(ev.symbol) && ev.symbol[n] != 0 {
n++
}
sym := C.GoBytes(unsafe.Pointer(&ev.symbol[0]), C.int(n))
return KlineEvent{
Symbol: string(sym),
Open: float64(ev.open),
High: float64(ev.high),
Low: float64(ev.low),
Close: float64(ev.close),
Volume: float64(ev.volume),
OpenTime: int64(ev.open_time),
IsClosed: bool(ev.is_closed),
}
}
// Close ends the stream and frees the native handle. Idempotent and safe to
// call alongside the finalizer.
func (f *BinanceFeed) Close() {
if f.handle != nil {
C.wickra_binance_close(f.handle)
C.wickra_binance_free(f.handle)
f.handle = nil
runtime.SetFinalizer(f, nil)
}
}
+4
View File
@@ -28,3 +28,7 @@ import "errors"
// ErrInvalidParams is returned by a New<Indicator> constructor when the native
// constructor rejects the supplied parameters (for example a zero period).
var ErrInvalidParams = errors.New("wickra: invalid indicator parameters")
// ErrFeedClosed is returned by BinanceFeed.Next once the stream has been closed
// or has errored out and exhausted its reconnect attempts.
var ErrFeedClosed = errors.New("wickra: binance feed closed")
@@ -0,0 +1,92 @@
// Generated from bindings/c/include/wickra.h. Do not edit by hand.
package org.wickra;
import org.wickra.internal.NativeMethods;
import org.wickra.internal.WickraNative;
import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment;
import java.lang.ref.Cleaner;
import java.nio.charset.StandardCharsets;
import static java.lang.foreign.ValueLayout.*;
/** A live Binance kline stream over the Wickra C ABI. Not thread-safe; close when done. */
public final class BinanceFeed implements AutoCloseable {
private final MemorySegment handle;
private final Cleaner.Cleanable cleanable;
/** Connect to Binance's live kline stream for the given comma-separated
* symbols (case-insensitive) at interval. baseUrl overrides the endpoint
* (null = production; pass a ws:// URL to target a test server). */
public BinanceFeed(String symbols, BinanceInterval interval, String baseUrl) {
if (symbols == null) {
throw new NullPointerException("symbols");
}
MemorySegment h;
try (Arena a = Arena.ofConfined()) {
byte[] sb = symbols.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);
}
h = (MemorySegment) NativeMethods.WICKRA_BINANCE_CONNECT.invokeExact(
sym, (byte) interval.ordinal(), url);
} catch (Throwable t) {
throw WickraNative.rethrow(t);
}
if (h.address() == 0L) {
throw new IllegalArgumentException("invalid BinanceFeed parameters");
}
this.handle = h;
this.cleanable = WickraNative.register(this, h, NativeMethods.WICKRA_BINANCE_FREE);
}
/** Connect with the production endpoint. */
public BinanceFeed(String symbols, BinanceInterval interval) {
this(symbols, interval, null);
}
/** Poll for the next kline event, waiting up to timeoutMillis. Returns the
* event, or null on timeout (call again). Throws once the stream is closed. */
public KlineEvent next(long timeoutMillis) {
try (Arena a = Arena.ofConfined()) {
MemorySegment out = a.allocate(72L);
int code = (int) NativeMethods.WICKRA_BINANCE_NEXT.invokeExact(handle, out, timeoutMillis);
if (code == 0) {
return null;
}
if (code != 1) {
throw new IllegalStateException("binance feed closed");
}
byte[] symBytes = new byte[16];
MemorySegment.copy(out, JAVA_BYTE, 0L, symBytes, 0, 16);
int n = 0;
while (n < 16 && symBytes[n] != 0) {
n++;
}
return new KlineEvent(
new String(symBytes, 0, n, StandardCharsets.UTF_8),
out.get(JAVA_DOUBLE, 16L),
out.get(JAVA_DOUBLE, 24L),
out.get(JAVA_DOUBLE, 32L),
out.get(JAVA_DOUBLE, 40L),
out.get(JAVA_DOUBLE, 48L),
out.get(JAVA_LONG, 56L),
out.get(JAVA_BYTE, 64L) != 0);
} catch (Throwable t) {
throw WickraNative.rethrow(t);
}
}
@Override public void close() {
try {
NativeMethods.WICKRA_BINANCE_CLOSE.invokeExact(handle);
} catch (Throwable t) {
throw WickraNative.rethrow(t);
}
cleanable.clean();
}
}
@@ -0,0 +1,22 @@
// Generated from bindings/c/include/wickra.h. Do not edit by hand.
package org.wickra;
/** Kline interval for the live Binance feed (ordinal = the C ABI code). */
public enum BinanceInterval {
ONE_SECOND,
ONE_MINUTE,
THREE_MINUTES,
FIVE_MINUTES,
FIFTEEN_MINUTES,
THIRTY_MINUTES,
ONE_HOUR,
TWO_HOURS,
FOUR_HOURS,
SIX_HOURS,
EIGHT_HOURS,
TWELVE_HOURS,
ONE_DAY,
THREE_DAYS,
ONE_WEEK,
ONE_MONTH
}
@@ -0,0 +1,6 @@
// Generated from bindings/c/include/wickra.h. Do not edit by hand.
package org.wickra;
/** One event from the live Binance feed. */
public record KlineEvent(String symbol, double open, double high, double low,
double close, double volume, long openTime, boolean isClosed) {}
@@ -3959,6 +3959,10 @@ public final class NativeMethods {
public static MethodHandle WICKRA_CANDLE_READER_COUNT;
public static MethodHandle WICKRA_CANDLE_READER_READ;
public static MethodHandle WICKRA_CANDLE_READER_FREE;
public static MethodHandle WICKRA_BINANCE_CONNECT;
public static MethodHandle WICKRA_BINANCE_NEXT;
public static MethodHandle WICKRA_BINANCE_CLOSE;
public static MethodHandle WICKRA_BINANCE_FREE;
static {
init0();
@@ -8039,6 +8043,10 @@ public final class NativeMethods {
WICKRA_CANDLE_READER_COUNT = h("wickra_candle_reader_count", FunctionDescriptor.of(JAVA_LONG, ADDRESS));
WICKRA_CANDLE_READER_READ = h("wickra_candle_reader_read", FunctionDescriptor.of(JAVA_LONG, ADDRESS, ADDRESS, JAVA_LONG));
WICKRA_CANDLE_READER_FREE = h("wickra_candle_reader_free", FunctionDescriptor.ofVoid(ADDRESS));
WICKRA_BINANCE_CONNECT = h("wickra_binance_connect", FunctionDescriptor.of(ADDRESS, ADDRESS, JAVA_BYTE, ADDRESS));
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));
}
}
@@ -0,0 +1,24 @@
package org.wickra;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* The live Binance feed's connect read reconnect pipeline is covered
* deterministically by the Rust mock-WS-server tests in wickra-data. Here we
* only assert the binding's error paths, which need no network.
*/
class BinanceFeedTest {
@Test
void rejectsEmptySymbols() {
assertThrows(IllegalArgumentException.class,
() -> new BinanceFeed("", BinanceInterval.ONE_MINUTE));
}
@Test
void rejectsUnreachableEndpoint() {
assertThrows(IllegalArgumentException.class,
() -> new BinanceFeed("BTCUSDT", BinanceInterval.ONE_MINUTE, "ws://127.0.0.1:1"));
}
}
+3 -1
View File
@@ -25,9 +25,11 @@ workspace = true
[dependencies]
wickra-core = { workspace = true }
wickra-data = { workspace = true }
wickra-data = { workspace = true, features = ["live-binance"] }
napi = { version = "2.16", features = ["napi8"] }
napi-derive = "2.16"
# Drives the async Binance feed behind the blocking poll.
tokio = { version = "1", features = ["rt", "net", "time"] }
[build-dependencies]
napi-build = "2"
+19
View File
@@ -0,0 +1,19 @@
// The live Binance feed's connect → read → reconnect pipeline is covered
// deterministically by the Rust mock-WS-server tests in wickra-data. Here we only
// assert the binding's error paths, which need no network.
const test = require('node:test');
const assert = require('node:assert/strict');
const { BinanceFeed } = require('..');
test('binance feed rejects an unknown interval', () => {
assert.throws(() => new BinanceFeed('BTCUSDT', 99));
});
test('binance feed rejects an empty symbol list', () => {
assert.throws(() => new BinanceFeed('', 1));
});
test('binance feed rejects an unreachable endpoint', () => {
assert.throws(() => new BinanceFeed('BTCUSDT', 1, 'ws://127.0.0.1:1'));
});
+35
View File
@@ -603,6 +603,17 @@ export interface CandleValue {
volume: number
timestamp: number
}
/** One event from the live Binance feed. */
export interface KlineEvent {
symbol: string
open: number
high: number
low: number
close: number
volume: number
openTime: number
isClosed: boolean
}
export type SmaNode = SMA
export declare class SMA {
constructor(period: number)
@@ -5939,6 +5950,30 @@ export declare class VolumeWeightedMacd {
isReady(): boolean
warmupPeriod(): number
}
export type BinanceFeedNode = BinanceFeed
/**
* A live Binance kline feed. `next` is a blocking poll (it waits up to the
* timeout on the calling thread) driving the mock-server-tested wickra-data
* `BinanceKlineStream` on a single-thread tokio runtime use a short timeout in
* a loop, or run it on a Worker thread, to keep the event loop responsive.
*/
export declare class BinanceFeed {
/**
* Connect to Binance's live kline stream for the given comma-separated
* `symbols` (case-insensitive) at the given `interval` code (`0..=15`).
* `baseUrl` overrides the endpoint (omit for production; pass a `ws://` URL
* to target a test server).
*/
constructor(symbols: string, interval: number, baseUrl?: string | undefined | null)
/**
* Poll for the next kline event, waiting up to `timeoutMs` milliseconds.
* Returns the event, or `null` on timeout (call again). Throws once the
* stream is closed.
*/
next(timeoutMs: number): KlineEvent | null
/** Close the stream; subsequent `next` calls throw. */
close(): void
}
export type TickAggregatorNode = TickAggregator
/** Roll trade ticks up into fixed-timeframe OHLCV candles. */
export declare class TickAggregator {
File diff suppressed because one or more lines are too long
+132
View File
@@ -21884,6 +21884,138 @@ pub struct CandleValue {
pub timestamp: f64,
}
/// One event from the live Binance feed.
#[napi(object)]
pub struct KlineEvent {
pub symbol: String,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
pub open_time: f64,
pub is_closed: bool,
}
/// Map a `u8` interval code (`0..=15`, the `Interval` declaration order) to the
/// feed interval.
fn binance_interval(code: u8) -> Option<wickra_data::live::binance::Interval> {
use wickra_data::live::binance::Interval;
Some(match code {
0 => Interval::OneSecond,
1 => Interval::OneMinute,
2 => Interval::ThreeMinutes,
3 => Interval::FiveMinutes,
4 => Interval::FifteenMinutes,
5 => Interval::ThirtyMinutes,
6 => Interval::OneHour,
7 => Interval::TwoHours,
8 => Interval::FourHours,
9 => Interval::SixHours,
10 => Interval::EightHours,
11 => Interval::TwelveHours,
12 => Interval::OneDay,
13 => Interval::ThreeDays,
14 => Interval::OneWeek,
15 => Interval::OneMonth,
_ => return None,
})
}
/// A live Binance kline feed. `next` is a blocking poll (it waits up to the
/// timeout on the calling thread) driving the mock-server-tested wickra-data
/// `BinanceKlineStream` on a single-thread tokio runtime — use a short timeout in
/// a loop, or run it on a Worker thread, to keep the event loop responsive.
#[napi(js_name = "BinanceFeed")]
pub struct BinanceFeedNode {
runtime: tokio::runtime::Runtime,
inner: wickra_data::live::binance::BinanceKlineStream,
}
#[napi]
impl BinanceFeedNode {
/// Connect to Binance's live kline stream for the given comma-separated
/// `symbols` (case-insensitive) at the given `interval` code (`0..=15`).
/// `baseUrl` overrides the endpoint (omit for production; pass a `ws://` URL
/// to target a test server).
#[napi(constructor)]
pub fn new(symbols: String, interval: u8, base_url: Option<String>) -> napi::Result<Self> {
let iv = binance_interval(interval).ok_or_else(|| {
NapiError::new(
Status::InvalidArg,
"unknown interval code (expected 0..=15)",
)
})?;
let symbol_list: Vec<String> = symbols
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
if symbol_list.is_empty() {
return Err(NapiError::new(
Status::InvalidArg,
"at least one symbol is required",
));
}
let mut config = wickra_data::live::binance::BinanceConfig::default();
if let Some(url) = base_url {
config.base_url = url;
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| NapiError::new(Status::GenericFailure, e.to_string()))?;
let inner = runtime
.block_on(
wickra_data::live::binance::BinanceKlineStream::connect_with_config(
&symbol_list,
iv,
config,
),
)
.map_err(map_data_err)?;
Ok(Self { runtime, inner })
}
/// Poll for the next kline event, waiting up to `timeoutMs` milliseconds.
/// Returns the event, or `null` on timeout (call again). Throws once the
/// stream is closed.
#[napi]
pub fn next(&mut self, timeout_ms: f64) -> napi::Result<Option<KlineEvent>> {
let dur = std::time::Duration::from_millis(timeout_ms.max(0.0) as u64);
let runtime = &self.runtime;
let inner = &mut self.inner;
let result = runtime.block_on(tokio::time::timeout(dur, inner.next_event()));
match result {
Ok(Ok(Some(ev))) => Ok(Some(KlineEvent {
symbol: ev.symbol,
open: ev.candle.open,
high: ev.candle.high,
low: ev.candle.low,
close: ev.candle.close,
volume: ev.candle.volume,
#[allow(clippy::cast_precision_loss)]
open_time: ev.candle.timestamp as f64,
is_closed: ev.is_closed,
})),
Ok(Ok(None) | Err(_)) => Err(NapiError::new(
Status::GenericFailure,
"binance feed closed",
)),
Err(_) => Ok(None),
}
}
/// Close the stream; subsequent `next` calls throw.
#[napi]
pub fn close(&mut self) {
let runtime = &self.runtime;
let inner = &mut self.inner;
let _ = runtime.block_on(inner.close());
}
}
/// Roll trade ticks up into fixed-timeframe OHLCV candles.
#[napi(js_name = "TickAggregator")]
pub struct TickAggregatorNode {
+3 -1
View File
@@ -22,6 +22,8 @@ workspace = true
[dependencies]
wickra-core = { workspace = true }
wickra-data = { workspace = true }
wickra-data = { workspace = true, features = ["live-binance"] }
pyo3 = { workspace = true }
numpy = { workspace = true }
# Drives the async Binance feed behind the blocking, GIL-releasing poll.
tokio = { version = "1", features = ["rt", "net", "time"] }
@@ -362,6 +362,7 @@ from ._wickra import (
TickAggregator,
Resampler,
CandleReader,
BinanceFeed,
# Market Profile
CompositeProfile,
HighLowVolumeNodes,
@@ -910,6 +911,7 @@ __all__ = [
"TickAggregator",
"Resampler",
"CandleReader",
"BinanceFeed",
# Market Profile
"CompositeProfile",
"HighLowVolumeNodes",
+108 -1
View File
@@ -13,7 +13,7 @@
#![allow(clippy::many_single_char_names)]
use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1};
use pyo3::exceptions::{PyTypeError, PyValueError};
use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::PyDict;
use wickra_core as wc;
@@ -28289,6 +28289,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyTickAggregator>()?;
m.add_class::<PyResampler>()?;
m.add_class::<PyCandleReader>()?;
m.add_class::<PyBinanceFeed>()?;
// Candlestick patterns.
m.add_class::<PyDoji>()?;
m.add_class::<PyHammer>()?;
@@ -28572,6 +28573,112 @@ fn map_data_err(e: wickra_data::Error) -> PyErr {
PyValueError::new_err(e.to_string())
}
/// Map a `u8` interval code (`0..=15`, the `Interval` declaration order) to the
/// feed interval.
fn binance_interval(code: u8) -> Option<wickra_data::live::binance::Interval> {
use wickra_data::live::binance::Interval;
Some(match code {
0 => Interval::OneSecond,
1 => Interval::OneMinute,
2 => Interval::ThreeMinutes,
3 => Interval::FiveMinutes,
4 => Interval::FifteenMinutes,
5 => Interval::ThirtyMinutes,
6 => Interval::OneHour,
7 => Interval::TwoHours,
8 => Interval::FourHours,
9 => Interval::SixHours,
10 => Interval::EightHours,
11 => Interval::TwelveHours,
12 => Interval::OneDay,
13 => Interval::ThreeDays,
14 => Interval::OneWeek,
15 => Interval::OneMonth,
_ => return None,
})
}
/// `(symbol, open, high, low, close, volume, open_time, is_closed)`.
type KlineTuple = (String, f64, f64, f64, f64, f64, i64, bool);
/// A live Binance kline feed (blocking poll). The connect / read / reconnect
/// pipeline is the mock-server-tested wickra-data `BinanceKlineStream`, driven on
/// a single-thread tokio runtime; `next` releases the GIL while it waits.
#[pyclass(name = "BinanceFeed", module = "wickra._wickra", skip_from_py_object)]
struct PyBinanceFeed {
runtime: tokio::runtime::Runtime,
inner: wickra_data::live::binance::BinanceKlineStream,
}
#[pymethods]
impl PyBinanceFeed {
#[new]
#[pyo3(signature = (symbols, interval, base_url = None))]
fn new(symbols: &str, interval: u8, base_url: Option<&str>) -> PyResult<Self> {
let iv = binance_interval(interval)
.ok_or_else(|| PyValueError::new_err("unknown interval code (expected 0..=15)"))?;
let symbol_list: Vec<String> = symbols
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
if symbol_list.is_empty() {
return Err(PyValueError::new_err("at least one symbol is required"));
}
let mut config = wickra_data::live::binance::BinanceConfig::default();
if let Some(url) = base_url {
url.clone_into(&mut config.base_url);
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
let inner = runtime
.block_on(
wickra_data::live::binance::BinanceKlineStream::connect_with_config(
&symbol_list,
iv,
config,
),
)
.map_err(map_data_err)?;
Ok(Self { runtime, inner })
}
/// Poll for the next kline event, waiting up to `timeout_ms`. Returns a
/// `(symbol, open, high, low, close, volume, open_time, is_closed)` tuple, or
/// `None` on timeout (call again). Raises once the stream is closed.
#[pyo3(signature = (timeout_ms = 1000.0))]
fn next(&mut self, py: Python<'_>, timeout_ms: f64) -> PyResult<Option<KlineTuple>> {
let dur = std::time::Duration::from_millis(timeout_ms.max(0.0) as u64);
let runtime = &self.runtime;
let inner = &mut self.inner;
let result = py.detach(|| runtime.block_on(tokio::time::timeout(dur, inner.next_event())));
match result {
Ok(Ok(Some(ev))) => Ok(Some((
ev.symbol,
ev.candle.open,
ev.candle.high,
ev.candle.low,
ev.candle.close,
ev.candle.volume,
ev.candle.timestamp,
ev.is_closed,
))),
Ok(Ok(None) | Err(_)) => Err(PyRuntimeError::new_err("binance feed closed")),
Err(_) => Ok(None),
}
}
/// Close the stream; subsequent `next` calls raise.
fn close(&mut self) {
let runtime = &self.runtime;
let inner = &mut self.inner;
let _ = runtime.block_on(inner.close());
}
}
/// Roll trade ticks up into fixed-timeframe OHLCV candles.
#[pyclass(
name = "TickAggregator",
+15
View File
@@ -0,0 +1,15 @@
"""The live Binance feed's connect -> read -> reconnect pipeline is covered
deterministically by the Rust mock-WS-server tests in wickra-data. Here we only
assert the binding's error paths, which need no network.
"""
import pytest
import wickra as ta
def test_binance_feed_rejects_bad_params():
with pytest.raises(ValueError):
ta.BinanceFeed("BTCUSDT", 99) # unknown interval code
with pytest.raises(ValueError):
ta.BinanceFeed("", 1) # empty symbol list
with pytest.raises(ValueError):
ta.BinanceFeed("BTCUSDT", 1, "ws://127.0.0.1:1") # unreachable endpoint
+3
View File
@@ -55,6 +55,7 @@ export(BeltHold)
export(Beta)
export(BetaNeutralSpread)
export(BetterVolume)
export(BinanceFeed)
export(BipowerVariation)
export(BodySizePct)
export(BollingerBands)
@@ -527,6 +528,8 @@ export(ZeroLagMacd)
export(ZigZag)
export(Zlema)
export(batch)
export(binance_close)
export(binance_next)
export(is_ready)
export(name)
export(push)
+37
View File
@@ -4143,3 +4143,40 @@ Zlema <- function(period) {
.wk_obj("zlema", ptr, "Zlema")
}
#' Connect to a live Binance kline feed
#'
#' Opens a live Binance kline stream for one or more comma-separated `symbols`
#' (case-insensitive) at the given `interval` code (an integer `0:15`, the same
#' order as the other bindings: 0 = 1s, 1 = 1m, ... 12 = 1d, 13 = 3d, 14 = 1w,
#' 15 = 1M). `base_url` overrides the endpoint (`NULL` = production). Not
#' available in the wasm (r-universe/webR) build, which has no raw sockets.
#'
#' @keywords internal
#' @export
BinanceFeed <- function(symbols, interval, base_url = NULL) {
ptr <- .Call("wk_binance_connect", symbols, as.integer(interval), base_url,
PACKAGE = "wickra")
structure(list(ptr = ptr), class = "wickra_binance_feed")
}
#' Poll a Binance feed for the next kline event
#'
#' Waits up to `timeout_ms` milliseconds. Returns a named list (`symbol`, `open`,
#' `high`, `low`, `close`, `volume`, `open_time`, `is_closed`) when an event
#' arrives, or `NULL` on timeout. Errors once the stream is closed.
#'
#' @keywords internal
#' @export
binance_next <- function(feed, timeout_ms = 1000) {
.Call("wk_binance_next", feed$ptr, as.numeric(timeout_ms), PACKAGE = "wickra")
}
#' Close a Binance feed
#'
#' @keywords internal
#' @export
binance_close <- function(feed) {
.Call("wk_binance_close", feed$ptr, PACKAGE = "wickra")
invisible(NULL)
}
+54
View File
@@ -22660,6 +22660,55 @@ SEXP wk_zlema_reset(SEXP e) {
return R_NilValue;
}
/* Live Binance kline feed (feature `live-binance`). Gated out of the
* Emscripten/wasm build (r-universe/webR), which has no raw TCP/TLS sockets. */
#ifndef __EMSCRIPTEN__
static void binance_fin(SEXP e) {
struct BinanceStream *h = (struct BinanceStream *)R_ExternalPtrAddr(e);
if (h) wickra_binance_free(h);
R_ClearExternalPtr(e);
}
SEXP wk_binance_connect(SEXP symbols, SEXP interval, SEXP base_url) {
const char *url = (base_url == R_NilValue || Rf_xlength(base_url) == 0)
? NULL : CHAR(STRING_ELT(base_url, 0));
struct BinanceStream *h = wickra_binance_connect(
CHAR(STRING_ELT(symbols, 0)), (uint8_t)Rf_asInteger(interval), url);
if (!h) Rf_error("invalid BinanceFeed parameters");
SEXP e = PROTECT(R_MakeExternalPtr(h, R_NilValue, R_NilValue));
R_RegisterCFinalizerEx(e, binance_fin, TRUE);
UNPROTECT(1);
return e;
}
SEXP wk_binance_next(SEXP e, SEXP timeout_ms) {
struct BinanceStream *h = (struct BinanceStream *)R_ExternalPtrAddr(e);
struct WickraKlineEvent ev;
int code = wickra_binance_next(h, &ev, (int64_t)Rf_asReal(timeout_ms));
if (code == 0) return R_NilValue;
if (code != 1) Rf_error("binance feed closed");
const char *names[] = {"symbol", "open", "high", "low", "close", "volume",
"open_time", "is_closed"};
SEXP out = PROTECT(Rf_allocVector(VECSXP, 8));
SET_VECTOR_ELT(out, 0, Rf_mkString((const char *)ev.symbol));
SET_VECTOR_ELT(out, 1, Rf_ScalarReal(ev.open));
SET_VECTOR_ELT(out, 2, Rf_ScalarReal(ev.high));
SET_VECTOR_ELT(out, 3, Rf_ScalarReal(ev.low));
SET_VECTOR_ELT(out, 4, Rf_ScalarReal(ev.close));
SET_VECTOR_ELT(out, 5, Rf_ScalarReal(ev.volume));
SET_VECTOR_ELT(out, 6, Rf_ScalarReal((double)ev.open_time));
SET_VECTOR_ELT(out, 7, Rf_ScalarLogical(ev.is_closed));
SEXP nm = PROTECT(Rf_allocVector(STRSXP, 8));
for (int i = 0; i < 8; i++) SET_STRING_ELT(nm, i, Rf_mkChar(names[i]));
Rf_setAttrib(out, R_NamesSymbol, nm);
UNPROTECT(2);
return out;
}
SEXP wk_binance_close(SEXP e) {
struct BinanceStream *h = (struct BinanceStream *)R_ExternalPtrAddr(e);
wickra_binance_close(h);
return R_NilValue;
}
#endif
static const R_CallMethodDef CallEntries[] = {
{"wk_abandoned_baby_new", (DL_FUNC)&wk_abandoned_baby_new, 0},
{"wk_abandoned_baby_update", (DL_FUNC)&wk_abandoned_baby_update, 7},
@@ -26088,6 +26137,11 @@ static const R_CallMethodDef CallEntries[] = {
{"wk_zlema_is_ready", (DL_FUNC)&wk_zlema_is_ready, 1},
{"wk_zlema_name", (DL_FUNC)&wk_zlema_name, 1},
{"wk_zlema_reset", (DL_FUNC)&wk_zlema_reset, 1},
#ifndef __EMSCRIPTEN__
{"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},
#endif
{NULL, NULL, 0}
};
+8
View File
@@ -0,0 +1,8 @@
# The live Binance feed's connect -> read -> reconnect pipeline is covered
# deterministically by the Rust mock-WS-server tests in wickra-data. Here we only
# assert the binding's error paths, which need no network.
test_that("binance feed rejects bad parameters", {
expect_error(BinanceFeed("", 1L), "BinanceFeed")
expect_error(BinanceFeed("BTCUSDT", 1L, "ws://127.0.0.1:1"), "BinanceFeed")
})