feat(data-layer): TickAggregator (tick-to-candle) in all 10 languages (#309)

* feat(data-layer): TickAggregator in Node, WASM, Python + C ABI hub

First data-layer feature (F2): roll trade ticks up into fixed-timeframe OHLCV
candles, exposed natively and over the C ABI.

- wickra-data wired as a binding dependency (workspace dep; its wickra-core dep
  is default-features=false so it never forces rayon into the rayon-free WASM
  build — native bindings re-enable parallel through their own dependency).
- Node `TickAggregator(bucket, gapFill?)` -> `push(price, size, ts): Candle[]`;
  WASM the same (array of objects); Python `push(...) -> list[tuple]`.
- C ABI: `WickraCandle` struct + `wickra_tick_aggregator_new/push/free` (push
  writes candles into a caller buffer and returns the count), generated via the
  capi generator's new DATA_LAYER section; cbindgen now parses wickra-data so
  `TickAggregator` is a forward-declared opaque; header vendored to bindings/go.

Verified bit-identical across Node/WASM/Python/C/C++ (o=100 h=101 l=100 c=101
v=3 ts=0 for the shared 3-tick probe). WIP: Go/C#/Java/R generated bindings and
the cross-language golden are still pending.

* feat(data-layer): TickAggregator in Go, C#, Java, R (lossless push/drain)

Complete F2 across all 10 languages: the C-ABI tick aggregator now uses a
two-step push/drain so gap-fill candles are never lost, and the four generated
bindings expose it idiomatically.

- C ABI redesigned: opaque TickAggregator handle (inner aggregator + pending
  buffer); push consumes a tick and returns the closed-candle count, drain copies
  them into a count-sized caller buffer.
- Go: NewTickAggregator + Push(price,size,ts) []Candle; C#: TickAggregator +
  Candle[] Push(...); Java: TickAggregator + Candle[] push(...); R: TickAggregator
  constructor + push() S3 generic returning an (n x 6) numeric matrix.
- Candle output record generated per language from WickraCandle.

Verified bit-identical to the native bindings (o=100 h=101 l=100 c=101 v=3 ts=0)
in Go, C#, Java, and R at runtime; R passes R CMD check (pre-existing doc
warnings only). WIP: cross-language data-layer golden + CHANGELOG still pending.

* test(data-layer): cross-language golden for the tick aggregator + CHANGELOG

gen_golden emits a deterministic tick stream (testdata/golden/data_ticks.csv) and
the reference candle streams with and without gap filling (data_candles.csv,
data_candles_gap.csv). Every binding replays the shared ticks through its
TickAggregator and checks the candles bit-for-bit (fp tolerance) against the Rust
reference:

- Node / WASM / Python / Go / C# / Java / R: a dedicated parity test each.
- C / C++: data_layer_test.c (compiled as both, run as ctest).

The gap-fill fixture closes several candles from a single push, exercising the
lossless push/drain path. Records the feature under CHANGELOG [Unreleased].

* fix(examples): rename the CSV-loader candle to WickraBar

The example CSV helper (wickra_csv.h) defined its own struct WickraCandle, which
now collides with the public C ABI WickraCandle (the tick aggregator output) in
any example that includes both headers (backtest, multi_timeframe, the strategy
examples). The public type owns the name; rename the example loader's bar to
WickraBar. The generated golden_test.c is untouched (its only match was the
unrelated WickraCandleVolumeOutput).
This commit is contained in:
kingchenc
2026-06-15 21:24:33 +02:00
committed by GitHub
parent fd9f4c8bc6
commit 8a103ef920
48 changed files with 1368 additions and 29 deletions
@@ -0,0 +1,5 @@
// Generated from bindings/c/include/wickra.h. Do not edit by hand.
package org.wickra;
/** Output record produced by the matching indicator. */
public record Candle(double open, double high, double low, double close, double volume, double timestamp) {}
@@ -0,0 +1,62 @@
// 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.*;
/** Streaming TickAggregator indicator over the Wickra C ABI. Not thread-safe; close when done. */
public final class TickAggregator implements AutoCloseable {
private final MemorySegment handle;
private final Cleaner.Cleanable cleanable;
public TickAggregator(long bucket, boolean gapFill) {
MemorySegment h;
try {
h = (MemorySegment) NativeMethods.WICKRA_TICK_AGGREGATOR_NEW.invokeExact(bucket, (byte) (gapFill ? 1 : 0));
} catch (Throwable t) {
throw WickraNative.rethrow(t);
}
if (h.address() == 0L) {
throw new IllegalArgumentException("invalid TickAggregator parameters");
}
this.handle = h;
this.cleanable = WickraNative.register(this, h, NativeMethods.WICKRA_TICK_AGGREGATOR_FREE);
}
/** Feed one trade tick; returns the candles it closed (possibly empty). */
public Candle[] push(double price, double size, long timestamp) {
try {
long n = (long) NativeMethods.WICKRA_TICK_AGGREGATOR_PUSH.invokeExact(handle, price, size, timestamp);
if (n <= 0) {
return new Candle[0];
}
try (Arena a = Arena.ofConfined()) {
MemorySegment out = a.allocate(48L * n);
long w = (long) NativeMethods.WICKRA_TICK_AGGREGATOR_DRAIN.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();
}
}
@@ -3947,6 +3947,10 @@ public final class NativeMethods {
public static MethodHandle WICKRA_FOOTPRINT_NAME;
public static MethodHandle WICKRA_FOOTPRINT_RESET;
public static MethodHandle WICKRA_FOOTPRINT_FREE;
public static MethodHandle WICKRA_TICK_AGGREGATOR_NEW;
public static MethodHandle WICKRA_TICK_AGGREGATOR_PUSH;
public static MethodHandle WICKRA_TICK_AGGREGATOR_DRAIN;
public static MethodHandle WICKRA_TICK_AGGREGATOR_FREE;
static {
init0();
@@ -8015,6 +8019,10 @@ public final class NativeMethods {
WICKRA_FOOTPRINT_NAME = h("wickra_footprint_name", FunctionDescriptor.of(ADDRESS, ADDRESS));
WICKRA_FOOTPRINT_RESET = h("wickra_footprint_reset", FunctionDescriptor.ofVoid(ADDRESS));
WICKRA_FOOTPRINT_FREE = h("wickra_footprint_free", FunctionDescriptor.ofVoid(ADDRESS));
WICKRA_TICK_AGGREGATOR_NEW = h("wickra_tick_aggregator_new", FunctionDescriptor.of(ADDRESS, JAVA_LONG, JAVA_BYTE));
WICKRA_TICK_AGGREGATOR_PUSH = h("wickra_tick_aggregator_push", FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_DOUBLE, JAVA_DOUBLE, JAVA_LONG));
WICKRA_TICK_AGGREGATOR_DRAIN = h("wickra_tick_aggregator_drain", FunctionDescriptor.of(JAVA_LONG, ADDRESS, ADDRESS, JAVA_LONG));
WICKRA_TICK_AGGREGATOR_FREE = h("wickra_tick_aggregator_free", FunctionDescriptor.ofVoid(ADDRESS));
}
}
@@ -0,0 +1,76 @@
package org.wickra;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Cross-language data-layer parity: replay the shared golden tick stream through
* the TickAggregator and check the candles against the Rust reference, with and
* without gap filling.
*/
class DataLayerTest {
private static Path goldenDir() {
java.io.File d = new java.io.File("").getAbsoluteFile();
while (d != null) {
java.io.File g = new java.io.File(d, "testdata/golden");
if (g.isDirectory()) {
return g.toPath();
}
d = d.getParentFile();
}
throw new IllegalStateException("testdata/golden not found");
}
private static double[][] read(String name) throws IOException {
List<String> lines = Files.readAllLines(goldenDir().resolve(name + ".csv"));
List<double[]> rows = new ArrayList<>();
for (int i = 1; i < lines.size(); i++) {
if (lines.get(i).isEmpty()) {
continue;
}
String[] p = lines.get(i).split(",");
double[] r = new double[p.length];
for (int k = 0; k < p.length; k++) {
r[k] = Double.parseDouble(p[k]);
}
rows.add(r);
}
return rows.toArray(new double[0][]);
}
@Test
void tickAggregatorMatchesGolden() throws IOException {
double[][] ticks = read("data_ticks");
String[][] cases = {{"data_candles", "false"}, {"data_candles_gap", "true"}};
for (String[] c : cases) {
boolean gap = Boolean.parseBoolean(c[1]);
try (TickAggregator a = new TickAggregator(1000, gap)) {
List<double[]> got = new ArrayList<>();
for (double[] t : ticks) {
for (Candle k : a.push(t[0], t[1], (long) t[2])) {
got.add(new double[]{
k.open(), k.high(), k.low(), k.close(), k.volume(), (double) k.timestamp()
});
}
}
double[][] want = read(c[0]);
assertEquals(want.length, got.size(), c[0] + " candle count");
for (int i = 0; i < got.size(); i++) {
for (int j = 0; j < 6; j++) {
double w = want[i][j];
assertTrue(Math.abs(got.get(i)[j] - w) <= 1e-9 * Math.max(1, Math.abs(w)),
c[0] + " row " + i + " col " + j + ": " + got.get(i)[j] + " vs " + w);
}
}
}
}
}
}