diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a2b2b7..60c09601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and map to the correct wire-format strings. ### Added +- **Native 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 diff --git a/Cargo.lock b/Cargo.lock index 12979cbf..3281ee74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/bindings/c/Cargo.toml b/bindings/c/Cargo.toml index 5ea6f6d9..ab383a1a 100644 --- a/bindings/c/Cargo.toml +++ b/bindings/c/Cargo.toml @@ -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 } diff --git a/bindings/c/include/wickra.h b/bindings/c/include/wickra.h index aa43950c..a8a20edf 100644 --- a/bindings/c/include/wickra.h +++ b/bindings/c/include/wickra.h @@ -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 diff --git a/bindings/c/src/lib.rs b/bindings/c/src/lib.rs index fd3beafa..949a34e9 100644 --- a/bindings/c/src/lib.rs +++ b/bindings/c/src/lib.rs @@ -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 { + 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 = 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::*; diff --git a/bindings/csharp/Wickra.Tests/BinanceFeedTests.cs b/bindings/csharp/Wickra.Tests/BinanceFeedTests.cs new file mode 100644 index 00000000..4b73b456 --- /dev/null +++ b/bindings/csharp/Wickra.Tests/BinanceFeedTests.cs @@ -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(() => + new BinanceFeed("BTCUSDT", (BinanceInterval)99)); + } + + [Fact] + public void RejectsEmptySymbols() + { + Assert.Throws(() => + new BinanceFeed("", BinanceInterval.OneMinute)); + } + + [Fact] + public void RejectsUnreachableEndpoint() + { + Assert.Throws(() => + new BinanceFeed("BTCUSDT", BinanceInterval.OneMinute, "ws://127.0.0.1:1")); + } +} diff --git a/bindings/csharp/Wickra/Generated/Indicators.g.cs b/bindings/csharp/Wickra/Generated/Indicators.g.cs index b836d873..6d5a4934 100644 --- a/bindings/csharp/Wickra/Generated/Indicators.g.cs +++ b/bindings/csharp/Wickra/Generated/Indicators.g.cs @@ -41508,3 +41508,95 @@ public sealed class Zlema : IDisposable public void Dispose() => _handle.Dispose(); } +/// Kline interval for the live Binance feed. +public enum BinanceInterval : byte +{ + OneSecond, + OneMinute, + ThreeMinutes, + FiveMinutes, + FifteenMinutes, + ThirtyMinutes, + OneHour, + TwoHours, + FourHours, + SixHours, + EightHours, + TwelveHours, + OneDay, + ThreeDays, + OneWeek, + OneMonth, +} + +/// One event from the live Binance feed. +public readonly record struct KlineEvent(string Symbol, double Open, double High, double Low, double Close, double Volume, long OpenTime, bool IsClosed); + +/// A live Binance kline stream over the Wickra C ABI. +public sealed class BinanceFeed : IDisposable +{ + private readonly WickraHandle _handle; + + /// Connect to Binance's live kline stream for the given comma-separated symbols + /// (case-insensitive) at . overrides the + /// endpoint (null = production; pass a ws:// URL to target a test server). + 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); + } + + /// Poll for the next kline event, waiting up to . + /// Returns the event, or null on timeout (call again). Throws once the stream is + /// closed or has errored out. + 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(); + } +} + diff --git a/bindings/csharp/Wickra/Generated/NativeMethods.g.cs b/bindings/csharp/Wickra/Generated/NativeMethods.g.cs index c5419bad..0e0c39a0 100644 --- a/bindings/csharp/Wickra/Generated/NativeMethods.g.cs +++ b/bindings/csharp/Wickra/Generated/NativeMethods.g.cs @@ -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; + } + diff --git a/bindings/go/binance_test.go b/bindings/go/binance_test.go new file mode 100644 index 00000000..471f78b3 --- /dev/null +++ b/bindings/go/binance_test.go @@ -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") + } +} diff --git a/bindings/go/include/wickra.h b/bindings/go/include/wickra.h index aa43950c..a8a20edf 100644 --- a/bindings/go/include/wickra.h +++ b/bindings/go/include/wickra.h @@ -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 diff --git a/bindings/go/indicators_gen.go b/bindings/go/indicators_gen.go index 4a624250..bfe002a0 100644 --- a/bindings/go/indicators_gen.go +++ b/bindings/go/indicators_gen.go @@ -3,12 +3,14 @@ package wickra /* +#include #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) + } +} diff --git a/bindings/go/wickra.go b/bindings/go/wickra.go index 02e3ecd2..b6daff02 100644 --- a/bindings/go/wickra.go +++ b/bindings/go/wickra.go @@ -28,3 +28,7 @@ import "errors" // ErrInvalidParams is returned by a New 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") diff --git a/bindings/java/src/main/java/org/wickra/BinanceFeed.java b/bindings/java/src/main/java/org/wickra/BinanceFeed.java new file mode 100644 index 00000000..d64d6d88 --- /dev/null +++ b/bindings/java/src/main/java/org/wickra/BinanceFeed.java @@ -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(); + } +} diff --git a/bindings/java/src/main/java/org/wickra/BinanceInterval.java b/bindings/java/src/main/java/org/wickra/BinanceInterval.java new file mode 100644 index 00000000..970c08ad --- /dev/null +++ b/bindings/java/src/main/java/org/wickra/BinanceInterval.java @@ -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 +} diff --git a/bindings/java/src/main/java/org/wickra/KlineEvent.java b/bindings/java/src/main/java/org/wickra/KlineEvent.java new file mode 100644 index 00000000..38e1cb5a --- /dev/null +++ b/bindings/java/src/main/java/org/wickra/KlineEvent.java @@ -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) {} diff --git a/bindings/java/src/main/java/org/wickra/internal/NativeMethods.java b/bindings/java/src/main/java/org/wickra/internal/NativeMethods.java index d7596c1d..1c883cb1 100644 --- a/bindings/java/src/main/java/org/wickra/internal/NativeMethods.java +++ b/bindings/java/src/main/java/org/wickra/internal/NativeMethods.java @@ -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)); } } diff --git a/bindings/java/src/test/java/org/wickra/BinanceFeedTest.java b/bindings/java/src/test/java/org/wickra/BinanceFeedTest.java new file mode 100644 index 00000000..ac073931 --- /dev/null +++ b/bindings/java/src/test/java/org/wickra/BinanceFeedTest.java @@ -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")); + } +} diff --git a/bindings/node/Cargo.toml b/bindings/node/Cargo.toml index c1de419a..320cda0a 100644 --- a/bindings/node/Cargo.toml +++ b/bindings/node/Cargo.toml @@ -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" diff --git a/bindings/node/__tests__/binance.test.js b/bindings/node/__tests__/binance.test.js new file mode 100644 index 00000000..9a45fb5d --- /dev/null +++ b/bindings/node/__tests__/binance.test.js @@ -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')); +}); diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 5c3310a5..945fe925 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -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 { diff --git a/bindings/node/index.js b/bindings/node/index.js index 4101f540..5ef38172 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, LogReturn, RealizedVolatility, RollingIqr, RollingPercentileRank, TrendLabel, WinRate, Expectancy, SWMA, GMA, EHMA, MedianMA, AdaptiveLaguerre, DisparityIndex, FisherRSI, RSX, DynamicMomentumIndex, TREND_STRENGTH_INDEX, TsfOscillator, BipowerVariation, JARQUEBERA, ROLLINGMINMAX, HIGHPASS, REFLEX, TRENDFLEX, CTI, ADAPTIVERSI, UNIVERSALOSC, SterlingRatio, BurkeRatio, MartinRatio, TailRatio, KRatio, CommonSenseRatio, GainToPainRatio, UpsidePotentialRatio, M2Measure, BANDPASS, EVENBETTERSINE, AUTOCORRPGRAM, SHANNONENT, SAMPLEENT, EwmaVolatility, Garch11, VolatilityOfVolatility, VolatilityCone, JumpIndicator, RegimeLabel, RollingQuantile, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpreadAr1Coefficient, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, KendallTau, BetaNeutralSpread, HasbrouckInformationShare, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, CloseVsOpen, BodySizePct, WickRatio, HighLowRange, StochasticCCI, IMI, QQE, ElderRay, TTM_TREND, Qstick, POLARIZED_FRACTAL_EFFICIENCY, WAVE_PM, GatorOscillator, KasePermissionStochastic, VolatilityRatio, ProjectionOscillator, TimeBasedStop, ADAPTIVECCI, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, GD, HoltWinters, RMI, DerivativeOscillator, MacdHistogram, PpoHistogram, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, ADOSC, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, KaseDevStop, ElderSafeZone, AtrRatchet, Nrtr, ModifiedMaStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, QuartileBands, BomarBands, MedianChannel, ProjectionBands, CentralPivotRange, MurreyMathLines, AndrewsPitchfork, VolumeWeightedSr, PivotReversal, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDDWave, TDMovingAverage, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, HeikinAshiOscillator, ThreeLineBreak, SmoothedHeikinAshi, Equivolume, CandleVolume, FryPanBottom, DumplingTop, NewPriceLines, ValueArea, NakedPoc, SinglePrints, ProfileShape, HighLowVolumeNodes, CompositeProfile, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, TDCamouflage, TDClop, TDClopwin, TDPropulsion, TDTrap, Tristar, HaramiCross, TowerTopBottom, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, TradeSignAutocorrelation, Pin, OrderFlowImbalance, Vpin, AmihudIlliquidity, RollMeasure, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, EstimatedLeverageRatio, OiToVolumeRatio, PerpetualPremiumIndex, FundingImpliedApr, OpenInterestMomentum, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, RangeBars, TickBars, VolumeBars, DollarBars, ImbalanceBars, RunBars, ThreeLineBreakBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones, VolumeRsi, Wad, TwiggsMoneyFlow, TradeVolumeIndex, IntradayIntensity, BetterVolume, VolumeWeightedMacd, TickAggregator, Resampler, CandleReader } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, LogReturn, RealizedVolatility, RollingIqr, RollingPercentileRank, TrendLabel, WinRate, Expectancy, SWMA, GMA, EHMA, MedianMA, AdaptiveLaguerre, DisparityIndex, FisherRSI, RSX, DynamicMomentumIndex, TREND_STRENGTH_INDEX, TsfOscillator, BipowerVariation, JARQUEBERA, ROLLINGMINMAX, HIGHPASS, REFLEX, TRENDFLEX, CTI, ADAPTIVERSI, UNIVERSALOSC, SterlingRatio, BurkeRatio, MartinRatio, TailRatio, KRatio, CommonSenseRatio, GainToPainRatio, UpsidePotentialRatio, M2Measure, BANDPASS, EVENBETTERSINE, AUTOCORRPGRAM, SHANNONENT, SAMPLEENT, EwmaVolatility, Garch11, VolatilityOfVolatility, VolatilityCone, JumpIndicator, RegimeLabel, RollingQuantile, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpreadAr1Coefficient, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, KendallTau, BetaNeutralSpread, HasbrouckInformationShare, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, CloseVsOpen, BodySizePct, WickRatio, HighLowRange, StochasticCCI, IMI, QQE, ElderRay, TTM_TREND, Qstick, POLARIZED_FRACTAL_EFFICIENCY, WAVE_PM, GatorOscillator, KasePermissionStochastic, VolatilityRatio, ProjectionOscillator, TimeBasedStop, ADAPTIVECCI, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, GD, HoltWinters, RMI, DerivativeOscillator, MacdHistogram, PpoHistogram, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, ADOSC, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, KaseDevStop, ElderSafeZone, AtrRatchet, Nrtr, ModifiedMaStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, QuartileBands, BomarBands, MedianChannel, ProjectionBands, CentralPivotRange, MurreyMathLines, AndrewsPitchfork, VolumeWeightedSr, PivotReversal, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDDWave, TDMovingAverage, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, HeikinAshiOscillator, ThreeLineBreak, SmoothedHeikinAshi, Equivolume, CandleVolume, FryPanBottom, DumplingTop, NewPriceLines, ValueArea, NakedPoc, SinglePrints, ProfileShape, HighLowVolumeNodes, CompositeProfile, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, TDCamouflage, TDClop, TDClopwin, TDPropulsion, TDTrap, Tristar, HaramiCross, TowerTopBottom, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, TradeSignAutocorrelation, Pin, OrderFlowImbalance, Vpin, AmihudIlliquidity, RollMeasure, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, EstimatedLeverageRatio, OiToVolumeRatio, PerpetualPremiumIndex, FundingImpliedApr, OpenInterestMomentum, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, RangeBars, TickBars, VolumeBars, DollarBars, ImbalanceBars, RunBars, ThreeLineBreakBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones, VolumeRsi, Wad, TwiggsMoneyFlow, TradeVolumeIndex, IntradayIntensity, BetterVolume, VolumeWeightedMacd, BinanceFeed, TickAggregator, Resampler, CandleReader } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -827,6 +827,7 @@ module.exports.TradeVolumeIndex = TradeVolumeIndex module.exports.IntradayIntensity = IntradayIntensity module.exports.BetterVolume = BetterVolume module.exports.VolumeWeightedMacd = VolumeWeightedMacd +module.exports.BinanceFeed = BinanceFeed module.exports.TickAggregator = TickAggregator module.exports.Resampler = Resampler module.exports.CandleReader = CandleReader diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index a57677bd..e8fa4ca0 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -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 { + 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) -> napi::Result { + let iv = binance_interval(interval).ok_or_else(|| { + NapiError::new( + Status::InvalidArg, + "unknown interval code (expected 0..=15)", + ) + })?; + let symbol_list: Vec = 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> { + 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 { diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml index fb445027..f6121395 100644 --- a/bindings/python/Cargo.toml +++ b/bindings/python/Cargo.toml @@ -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"] } diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index fc4cf8a5..11de1ac4 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -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", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index bf447b2a..7c0583bd 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -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::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; // Candlestick patterns. m.add_class::()?; m.add_class::()?; @@ -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 { + 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 { + let iv = binance_interval(interval) + .ok_or_else(|| PyValueError::new_err("unknown interval code (expected 0..=15)"))?; + let symbol_list: Vec = 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> { + 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", diff --git a/bindings/python/tests/test_binance.py b/bindings/python/tests/test_binance.py new file mode 100644 index 00000000..cc474e73 --- /dev/null +++ b/bindings/python/tests/test_binance.py @@ -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 diff --git a/bindings/r/NAMESPACE b/bindings/r/NAMESPACE index 294cbc26..8e055d64 100644 --- a/bindings/r/NAMESPACE +++ b/bindings/r/NAMESPACE @@ -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) diff --git a/bindings/r/R/indicators.R b/bindings/r/R/indicators.R index d47ecfc7..1e3db5d1 100644 --- a/bindings/r/R/indicators.R +++ b/bindings/r/R/indicators.R @@ -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) +} + diff --git a/bindings/r/src/wickra.c b/bindings/r/src/wickra.c index daebb82d..ee4e0974 100644 --- a/bindings/r/src/wickra.c +++ b/bindings/r/src/wickra.c @@ -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} }; diff --git a/bindings/r/tests/testthat/test-binance.R b/bindings/r/tests/testthat/test-binance.R new file mode 100644 index 00000000..3fbc4e77 --- /dev/null +++ b/bindings/r/tests/testthat/test-binance.R @@ -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") +})