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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user