feat(data): native Binance REST kline fetcher in 9 languages (#315)

Adds `BinanceRest::fetch_klines` to `wickra-data`: a blocking historical kline
downloader (`GET /api/v3/klines`) and the historical counterpart to the F4 live
`BinanceFeed`. It is the last native data-layer primitive needed to drop
third-party HTTP/JSON download helpers (`jackson`, `jsonlite`, `urllib`, …) from
the examples.

## What

- **Core** (`wickra-data`): `fetch_klines(symbol, interval, limit, start?, end?)`
  built on `ureq` with native-tls — sharing the exact same TLS backend
  (native-tls 0.2 / SChannel) as the existing tokio-tungstenite live feed, so the
  two pull one TLS stack, not two. Parses Binance's 12-element array rows via the
  existing serde infrastructure into validated `Candle`s. Blocking by design (a
  one-shot request needs no async runtime; the FFI boundary is synchronous
  anyway). Nine mock-HTTP-server tests cover parse / empty / limit / transport /
  JSON / invariant-violation paths.
- **C ABI**: `wickra_binance_fetch_klines(...)` (blocking drain into a caller
  buffer, `-1` on error) + regenerated cbindgen header and its vendored Go copy.
- **Bindings**: native Node `fetchBinanceKlines` / Python `fetch_binance_klines`;
  generated Go `FetchBinanceKlines` / C# `BinanceFeed.FetchKlines` / Java
  `BinanceFeed.fetchKlines` / R `fetch_binance_klines`. C / C++ call the C ABI
  directly. **WASM is excluded** (browsers use the host `fetch`).

The four C-ABI bindings are regenerated from the ScriptHelpers generators (not
hand-edited); the regen diff is exactly the new wrapper in each.

## Verification

All ten toolchains green locally: Rust (`cargo test`/`clippy`/`fmt`), Node, Python,
Go, C#, Java, R (`R CMD INSTALL` + smoke), WASM (`cargo check`, confirmed `ureq`
is not pulled). Each binding has an error-path smoke test; the parse/HTTP success
path is covered by the Rust mock-server tests.

No release in this PR — ships with the data-layer + numpy bundle later.
This commit is contained in:
kingchenc
2026-06-17 01:48:24 +02:00
committed by GitHub
parent b92ad32037
commit 2ae76bb90e
29 changed files with 863 additions and 209 deletions
@@ -27,4 +27,27 @@ public class BinanceFeedTests
Assert.Throws<ArgumentException>(() =>
new BinanceFeed("BTCUSDT", BinanceInterval.OneMinute, "ws://127.0.0.1:1"));
}
// The REST fetcher's parse/HTTP success path is covered by the Rust
// mock-HTTP-server tests; here we only assert the binding's error paths.
[Fact]
public void FetchKlinesRejectsUnknownInterval()
{
Assert.Throws<ArgumentException>(() =>
BinanceFeed.FetchKlines("BTCUSDT", (BinanceInterval)99, 1));
}
[Fact]
public void FetchKlinesRejectsZeroLimit()
{
Assert.Throws<ArgumentException>(() =>
BinanceFeed.FetchKlines("BTCUSDT", BinanceInterval.OneHour, 0));
}
[Fact]
public void FetchKlinesSurfacesUnreachableEndpoint()
{
Assert.Throws<ArgumentException>(() =>
BinanceFeed.FetchKlines("BTCUSDT", BinanceInterval.OneHour, 1, baseUrl: "http://127.0.0.1:1"));
}
}
@@ -41589,6 +41589,43 @@ public sealed class BinanceFeed : IDisposable
return new KlineEvent(symbol, ev.open, ev.high, ev.low, ev.close, ev.volume, ev.open_time, ev.is_closed != 0);
}
/// <summary>Fetch historical klines from Binance's REST endpoint. <paramref name="symbol"/>
/// is the trading pair (case-insensitive), <paramref name="limit"/> the number of
/// candles (1..=1000). <paramref name="startMs"/>/<paramref name="endMs"/> are inclusive
/// Unix-millisecond bounds (negative = unset); <paramref name="baseUrl"/> overrides the
/// host (null = production). Blocks until the response arrives.</summary>
public static Candle[] FetchKlines(string symbol, BinanceInterval interval, uint limit, long startMs = -1, long endMs = -1, string? baseUrl = null)
{
ArgumentNullException.ThrowIfNull(symbol);
if (limit == 0)
{
throw new ArgumentException("limit must be in 1..=1000");
}
var symBytes = System.Text.Encoding.UTF8.GetBytes(symbol + '\0');
var urlBytes = baseUrl is null ? null : System.Text.Encoding.UTF8.GetBytes(baseUrl + '\0');
var buffer = new WickraCandle[limit];
long count;
unsafe
{
fixed (byte* sp = symBytes)
fixed (byte* up = urlBytes)
fixed (WickraCandle* ptr = buffer)
{
count = (long)NativeMethods.wickra_binance_fetch_klines(sp, (byte)interval, limit, startMs, endMs, up, ptr, (nuint)limit);
}
}
if (count < 0)
{
throw new ArgumentException("invalid FetchKlines parameters or transport error");
}
var result = new Candle[count];
for (var i = 0; i < count; i++)
{
result[i] = new Candle(buffer[i].open, buffer[i].high, buffer[i].low, buffer[i].close, buffer[i].volume, buffer[i].timestamp);
}
return result;
}
public void Dispose()
{
unsafe
@@ -12454,6 +12454,9 @@ internal static partial class NativeMethods
[LibraryImport(WickraNative.LibraryName)]
internal static partial void wickra_binance_free(nint handle);
[LibraryImport(WickraNative.LibraryName)]
internal static unsafe partial nint wickra_binance_fetch_klines(byte* symbol, byte interval, uint limit, long startMs, long endMs, byte* baseUrl, WickraCandle* @out, nuint cap);
}
[StructLayout(LayoutKind.Sequential)]