feat(data): expose CandleReader (CSV) natively in all 10 languages (#311)

Add the data-layer CSV candle reader to every binding so loading OHLCV
candles from a CSV no longer needs a per-language CSV/dataframe dependency.

- C ABI: wickra_candle_reader_new(bytes, len) / _count / _read / _free over
  an opaque CandleReader handle (parse the whole buffer up front, then drain).
- Native: Node/WASM CandleReader.read() -> Candle[], Python read() -> list[tuple].
- C-ABI languages: Go Read() []Candle, C# Candle[] Read(), Java Candle[] read(),
  R read() S3 generic (n x 6 matrix); C / C++ call the C ABI directly.
- Cross-language golden testdata/golden/data_csv*.csv pins the parsed candles
  bit-for-bit across every binding.

Verified locally across Rust (test+clippy+fmt), Node, WASM, Python, C#, Go,
Java, R, and the C/C++ cmake parity suite.
This commit is contained in:
kingchenc
2026-06-16 00:10:58 +02:00
committed by GitHub
parent cb6da4d737
commit d362ae26a3
31 changed files with 867 additions and 6 deletions
@@ -0,0 +1,69 @@
// 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 static java.lang.foreign.ValueLayout.*;
/** CSV candle reader over the Wickra C ABI. Not thread-safe; close when done. */
public final class CandleReader implements AutoCloseable {
private final MemorySegment handle;
private final Cleaner.Cleanable cleanable;
/** Parse a timestamp,open,high,low,close,volume CSV string (a leading
* UTF-8 BOM and field whitespace are tolerated). */
public CandleReader(String csv) {
if (csv == null) {
throw new NullPointerException("csv");
}
byte[] bytes = csv.getBytes(java.nio.charset.StandardCharsets.UTF_8);
MemorySegment h;
try (Arena a = Arena.ofConfined()) {
MemorySegment data = a.allocate(Math.max(1L, bytes.length));
MemorySegment.copy(bytes, 0, data, JAVA_BYTE, 0L, bytes.length);
h = (MemorySegment) NativeMethods.WICKRA_CANDLE_READER_NEW.invokeExact(data, (long) bytes.length);
} catch (Throwable t) {
throw WickraNative.rethrow(t);
}
if (h.address() == 0L) {
throw new IllegalArgumentException("invalid CandleReader CSV");
}
this.handle = h;
this.cleanable = WickraNative.register(this, h, NativeMethods.WICKRA_CANDLE_READER_FREE);
}
/** Every candle parsed from the CSV, in file order. */
public Candle[] read() {
try {
long n = (long) NativeMethods.WICKRA_CANDLE_READER_COUNT.invokeExact(handle);
if (n <= 0) {
return new Candle[0];
}
try (Arena a = Arena.ofConfined()) {
MemorySegment out = a.allocate(48L * n);
long w = (long) NativeMethods.WICKRA_CANDLE_READER_READ.invokeExact(handle, out, n);
Candle[] result = new Candle[(int) w];
for (int i = 0; i < w; i++) {
long b = (long) i * 48L;
result[i] = new Candle(
out.get(JAVA_DOUBLE, b + 0L),
out.get(JAVA_DOUBLE, b + 8L),
out.get(JAVA_DOUBLE, b + 16L),
out.get(JAVA_DOUBLE, b + 24L),
out.get(JAVA_DOUBLE, b + 32L),
(double) out.get(JAVA_LONG, b + 40L));
}
return result;
}
} catch (Throwable t) {
throw WickraNative.rethrow(t);
}
}
@Override public void close() {
cleanable.clean();
}
}
@@ -3955,6 +3955,10 @@ public final class NativeMethods {
public static MethodHandle WICKRA_RESAMPLER_UPDATE;
public static MethodHandle WICKRA_RESAMPLER_FLUSH;
public static MethodHandle WICKRA_RESAMPLER_FREE;
public static MethodHandle WICKRA_CANDLE_READER_NEW;
public static MethodHandle WICKRA_CANDLE_READER_COUNT;
public static MethodHandle WICKRA_CANDLE_READER_READ;
public static MethodHandle WICKRA_CANDLE_READER_FREE;
static {
init0();
@@ -8031,6 +8035,10 @@ public final class NativeMethods {
WICKRA_RESAMPLER_UPDATE = h("wickra_resampler_update", FunctionDescriptor.of(JAVA_BYTE, ADDRESS, JAVA_DOUBLE, JAVA_DOUBLE, JAVA_DOUBLE, JAVA_DOUBLE, JAVA_DOUBLE, JAVA_LONG, ADDRESS));
WICKRA_RESAMPLER_FLUSH = h("wickra_resampler_flush", FunctionDescriptor.of(JAVA_BYTE, ADDRESS, ADDRESS));
WICKRA_RESAMPLER_FREE = h("wickra_resampler_free", FunctionDescriptor.ofVoid(ADDRESS));
WICKRA_CANDLE_READER_NEW = h("wickra_candle_reader_new", FunctionDescriptor.of(ADDRESS, ADDRESS, JAVA_LONG));
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));
}
}
@@ -73,6 +73,25 @@ class DataLayerTest {
}
}
@Test
void candleReaderMatchesGolden() throws IOException {
String csv = Files.readString(goldenDir().resolve("data_csv.csv"));
try (CandleReader reader = new CandleReader(csv)) {
Candle[] candles = reader.read();
double[][] want = read("data_csv_candles");
assertEquals(want.length, candles.length, "candle reader count");
for (int i = 0; i < candles.length; i++) {
Candle k = candles[i];
double[] got = {k.open(), k.high(), k.low(), k.close(), k.volume(), (double) k.timestamp()};
for (int j = 0; j < 6; j++) {
double w = want[i][j];
assertTrue(Math.abs(got[j] - w) <= 1e-9 * Math.max(1, Math.abs(w)),
"candle reader row " + i + " col " + j + ": " + got[j] + " vs " + w);
}
}
}
}
@Test
void tickAggregatorMatchesGolden() throws IOException {
double[][] ticks = read("data_ticks");