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
@@ -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"));
}
}