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
+1
View File
@@ -57,3 +57,4 @@ parallel = ["wickra-core/parallel"]
# does not set it, which would leave rayon in the wasm build. wickra-c is
# `publish = false`, so no version pin is needed (and none to keep in sync).
wickra-core = { path = "../../crates/wickra-core", default-features = false }
wickra-data = { path = "../../crates/wickra-data" }
+1 -1
View File
@@ -17,4 +17,4 @@ documentation = false
# discovered and emitted as forward-declared opaque structs. Their fields are
# never exposed — only `T *` handles cross the boundary.
parse_deps = true
include = ["wickra-core"]
include = ["wickra-core", "wickra-data"]
+24
View File
@@ -876,6 +876,8 @@ typedef struct ThreeStarsInSouth ThreeStarsInSouth;
typedef struct Thrusting Thrusting;
typedef struct TickAggregator TickAggregator;
typedef struct TickBars TickBars;
typedef struct TickIndex TickIndex;
@@ -1675,6 +1677,15 @@ typedef struct WickraFootprintLevel {
double ask_vol;
} WickraFootprintLevel;
typedef struct WickraCandle {
double open;
double high;
double low;
double close;
double volume;
int64_t timestamp;
} WickraCandle;
#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
@@ -13695,6 +13706,19 @@ void wickra_footprint_reset(struct Footprint *handle);
void wickra_footprint_free(struct Footprint *handle);
struct TickAggregator *wickra_tick_aggregator_new(int64_t bucket, bool gap_fill);
intptr_t wickra_tick_aggregator_push(struct TickAggregator *handle,
double price,
double size,
int64_t timestamp);
intptr_t wickra_tick_aggregator_drain(struct TickAggregator *handle,
struct WickraCandle *out,
uintptr_t cap);
void wickra_tick_aggregator_free(struct TickAggregator *handle);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
+119 -3
View File
@@ -98,9 +98,9 @@ use wickra_core::{
TdDWave, TdDeMarker, TdDifferential, TdLines, TdMovingAverage, TdOpen, TdPressure,
TdPropulsion, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, TdTrap, Tema,
TermStructureBasis, ThreeDrives, ThreeInside, ThreeLineBreak, ThreeLineBreakBars,
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickBars,
TickIndex, Tii, TimeBasedStop, TimeOfDayReturnProfile, TowerTopBottom, TpoProfile, Trade,
TradeImbalance, TradeQuote, TradeSignAutocorrelation, TradeVolumeIndex, TrendLabel,
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, Tick,
TickBars, TickIndex, Tii, TimeBasedStop, TimeOfDayReturnProfile, TowerTopBottom, TpoProfile,
Trade, TradeImbalance, TradeQuote, TradeSignAutocorrelation, TradeVolumeIndex, TrendLabel,
TrendStrengthIndex, Trendflex, TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Tristar,
Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer,
TwiggsMoneyFlow, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver,
@@ -114,6 +114,8 @@ use wickra_core::{
T3,
};
use wickra_data::aggregator::{TickAggregator as DataTickAggregator, Timeframe};
// ===== Scalar indicators (f64 -> f64) =====
/// Create a `AdaptiveCycle` indicator.
@@ -68072,6 +68074,120 @@ pub unsafe extern "C" fn wickra_footprint_free(handle: *mut Footprint) {
}
}
// ===== Data layer (tick aggregation; caller buffer + count) =====
/// C-ABI view of an OHLCV candle (the tick aggregator's output).
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct WickraCandle {
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
pub timestamp: i64,
}
/// Opaque tick aggregator: the streaming aggregator plus the buffer of candles
/// the most recent `push` closed, awaiting `drain`. Named `TickAggregator` (the
/// public C-ABI handle); the inner `wickra-data` type is aliased to avoid the
/// name clash.
#[derive(Debug)]
pub struct TickAggregator {
inner: DataTickAggregator,
pending: Vec<Candle>,
}
/// Create a tick aggregator with the given bucket size (the same unit as the
/// tick timestamps). `gap_fill` emits a flat placeholder candle for every
/// skipped bucket. Returns `NULL` on a non-positive bucket; release with
/// `wickra_tick_aggregator_free`.
#[no_mangle]
pub extern "C" fn wickra_tick_aggregator_new(bucket: i64, gap_fill: bool) -> *mut TickAggregator {
match Timeframe::new(bucket) {
Ok(timeframe) => Box::into_raw(Box::new(TickAggregator {
inner: DataTickAggregator::new(timeframe).with_gap_fill(gap_fill),
pending: Vec::new(),
})),
Err(_) => ptr::null_mut(),
}
}
/// Push one trade tick. Buffers the candles it closed inside the handle and
/// returns the count (`0` if the open bar merely grew), or `-1` on a `NULL`
/// handle / invalid tick / malformed timestamp. Read the candles with
/// `wickra_tick_aggregator_drain`; the next `push` replaces the buffer.
///
/// # Safety
/// `handle` must be valid (from `wickra_tick_aggregator_new`, not freed), or `NULL`.
#[no_mangle]
pub unsafe extern "C" fn wickra_tick_aggregator_push(
handle: *mut TickAggregator,
price: f64,
size: f64,
timestamp: i64,
) -> isize {
let Some(aggregator) = handle.as_mut() else {
return -1;
};
let Ok(tick) = Tick::new(price, size, timestamp) else {
return -1;
};
let Ok(candles) = aggregator.inner.push(tick) else {
return -1;
};
aggregator.pending = candles;
isize::try_from(aggregator.pending.len()).unwrap_or(isize::MAX)
}
/// Copy up to `cap` buffered candles (from the last `push`) into `out`, remove
/// them from the buffer, and return the number written. Returns `0` on a `NULL`
/// handle / `out`. Call with `cap` equal to the last `push` return to drain the
/// whole batch losslessly.
///
/// # Safety
/// `handle` (from `wickra_tick_aggregator_new`, not freed) and `out` must be valid
/// or `NULL`; when non-`NULL`, `out` must cover `cap` `WickraCandle` elements.
#[no_mangle]
pub unsafe extern "C" fn wickra_tick_aggregator_drain(
handle: *mut TickAggregator,
out: *mut WickraCandle,
cap: usize,
) -> isize {
let Some(aggregator) = handle.as_mut() else {
return 0;
};
if out.is_null() {
return 0;
}
let count = aggregator.pending.len().min(cap);
let slots = slice::from_raw_parts_mut(out, count);
for (slot, candle) in slots.iter_mut().zip(aggregator.pending.drain(..count)) {
*slot = WickraCandle {
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
volume: candle.volume,
timestamp: candle.timestamp,
};
}
isize::try_from(count).unwrap_or(isize::MAX)
}
/// Destroy a tick aggregator created by `wickra_tick_aggregator_new`. No-op if
/// `handle` is `NULL`.
///
/// # Safety
/// `handle` must have been returned by `wickra_tick_aggregator_new` and not
/// previously freed, or `NULL`.
#[no_mangle]
pub unsafe extern "C" fn wickra_tick_aggregator_free(handle: *mut TickAggregator) {
if !handle.is_null() {
drop(Box::from_raw(handle));
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using Wickra;
using Xunit;
// 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.
public class DataLayerTests
{
private static string GoldenDir([System.Runtime.CompilerServices.CallerFilePath] string file = "") =>
Path.GetFullPath(Path.Combine(Path.GetDirectoryName(file)!, "..", "..", "..", "testdata", "golden"));
private static double[][] Read(string name)
{
var lines = File.ReadAllLines(Path.Combine(GoldenDir(), name + ".csv"));
var rows = new List<double[]>();
for (var i = 1; i < lines.Length; i++)
{
if (lines[i].Length == 0)
{
continue;
}
rows.Add(Array.ConvertAll(lines[i].Split(','), s => double.Parse(s, CultureInfo.InvariantCulture)));
}
return rows.ToArray();
}
[Theory]
[InlineData(false, "data_candles")]
[InlineData(true, "data_candles_gap")]
public void TickAggregatorMatchesGolden(bool gapFill, string fixture)
{
var ticks = Read("data_ticks");
using var agg = new TickAggregator(1000, gapFill);
var got = new List<double[]>();
foreach (var t in ticks)
{
foreach (var c in agg.Push(t[0], t[1], (long)t[2]))
{
got.Add(new[] { c.Open, c.High, c.Low, c.Close, c.Volume, (double)c.Timestamp });
}
}
var want = Read(fixture);
Assert.Equal(want.Length, got.Count);
for (var i = 0; i < got.Count; i++)
{
for (var j = 0; j < 6; j++)
{
var tol = 1e-9 * Math.Max(1, Math.Abs(want[i][j]));
Assert.True(
Math.Abs(got[i][j] - want[i][j]) <= tol,
$"{fixture} row {i} col {j}: {got[i][j]} vs {want[i][j]}");
}
}
}
}
@@ -16,6 +16,7 @@ public readonly record struct AutoFibOutput(double Level0, double Level236, doub
public readonly record struct BollingerOutput(double Upper, double Middle, double Lower, double Stddev);
public readonly record struct BomarBandsOutput(double Upper, double Middle, double Lower);
public readonly record struct CamarillaPivotsOutput(double Pp, double R1, double R2, double R3, double R4, double S1, double S2, double S3, double S4);
public readonly record struct Candle(double Open, double High, double Low, double Close, double Volume, double Timestamp);
public readonly record struct CandleVolumeOutput(double Body, double Width);
public readonly record struct CentralPivotRangeOutput(double Pivot, double Tc, double Bc);
public readonly record struct ChandeKrollStopOutput(double StopLong, double StopShort);
@@ -34704,6 +34705,51 @@ public sealed class Thrusting : IDisposable
public void Dispose() => _handle.Dispose();
}
public sealed class TickAggregator : IDisposable
{
private readonly WickraHandle _handle;
public TickAggregator(long bucket, bool gapFill)
{
var ptr = NativeMethods.wickra_tick_aggregator_new(bucket, gapFill);
if (ptr == nint.Zero)
{
throw new ArgumentException("invalid TickAggregator parameters");
}
_handle = new WickraHandle(ptr, NativeMethods.wickra_tick_aggregator_free);
}
/// <summary>Feed one trade tick; returns the candles it closed.</summary>
public Candle[] Push(double price, double size, long timestamp)
{
var count = (long)NativeMethods.wickra_tick_aggregator_push(_handle.DangerousGetHandle(), price, size, timestamp);
GC.KeepAlive(_handle);
if (count <= 0)
{
return Array.Empty<Candle>();
}
var buffer = new WickraCandle[count];
unsafe
{
fixed (WickraCandle* ptr = buffer)
{
NativeMethods.wickra_tick_aggregator_drain(_handle.DangerousGetHandle(), ptr, (nuint)count);
}
}
GC.KeepAlive(_handle);
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() => _handle.Dispose();
}
public sealed class TickBars : IDisposable
{
private readonly WickraHandle _handle;
@@ -12404,6 +12404,18 @@ internal static partial class NativeMethods
[LibraryImport(WickraNative.LibraryName)]
internal static partial void wickra_footprint_free(nint handle);
[LibraryImport(WickraNative.LibraryName)]
internal static partial nint wickra_tick_aggregator_new(long bucket, [MarshalAs(UnmanagedType.U1)] bool gapFill);
[LibraryImport(WickraNative.LibraryName)]
internal static partial nint wickra_tick_aggregator_push(nint handle, double price, double size, long timestamp);
[LibraryImport(WickraNative.LibraryName)]
internal static unsafe partial nint wickra_tick_aggregator_drain(nint handle, WickraCandle* @out, nuint cap);
[LibraryImport(WickraNative.LibraryName)]
internal static partial void wickra_tick_aggregator_free(nint handle);
}
[StructLayout(LayoutKind.Sequential)]
@@ -12503,6 +12515,17 @@ internal static partial class NativeMethods
public double s4;
}
[StructLayout(LayoutKind.Sequential)]
internal struct WickraCandle
{
public double open;
public double high;
public double low;
public double close;
public double volume;
public long timestamp;
}
[StructLayout(LayoutKind.Sequential)]
internal struct WickraCandleVolumeOutput
{
+58
View File
@@ -0,0 +1,58 @@
package wickra
import (
"math"
"strconv"
"testing"
)
// Cross-language data-layer parity: replay the shared golden tick stream through
// the TickAggregator and assert the candles match the Rust reference, with and
// without gap filling. Fixtures are generated by
// `cargo run -p wickra-examples --bin gen_golden`.
func dataParseF(t *testing.T, s string) float64 {
t.Helper()
v, err := strconv.ParseFloat(s, 64)
if err != nil {
t.Fatalf("parse %q: %v", s, err)
}
return v
}
func TestTickAggregatorGolden(t *testing.T) {
ticks := readGolden(t, "data_ticks")
cases := []struct {
gap bool
fixture string
}{
{false, "data_candles"},
{true, "data_candles_gap"},
}
for _, c := range cases {
agg, err := NewTickAggregator(1000, c.gap)
if err != nil {
t.Fatalf("new: %v", err)
}
var got [][6]float64
for _, r := range ticks {
price, size, ts := dataParseF(t, r[0]), dataParseF(t, r[1]), int64(dataParseF(t, r[2]))
for _, k := range agg.Push(price, size, ts) {
got = append(got, [6]float64{k.Open, k.High, k.Low, k.Close, k.Volume, float64(k.Timestamp)})
}
}
agg.Close()
want := readGolden(t, c.fixture)
if len(got) != len(want) {
t.Fatalf("%s: %d candles vs %d", c.fixture, len(got), len(want))
}
for i := range got {
for j := 0; j < 6; j++ {
w := dataParseF(t, want[i][j])
if math.Abs(got[i][j]-w) > 1e-9*math.Max(1, math.Abs(w)) {
t.Errorf("%s row %d col %d: %v vs %v", c.fixture, i, j, got[i][j], w)
}
}
}
}
}
+24
View File
@@ -876,6 +876,8 @@ typedef struct ThreeStarsInSouth ThreeStarsInSouth;
typedef struct Thrusting Thrusting;
typedef struct TickAggregator TickAggregator;
typedef struct TickBars TickBars;
typedef struct TickIndex TickIndex;
@@ -1675,6 +1677,15 @@ typedef struct WickraFootprintLevel {
double ask_vol;
} WickraFootprintLevel;
typedef struct WickraCandle {
double open;
double high;
double low;
double close;
double volume;
int64_t timestamp;
} WickraCandle;
#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
@@ -13695,6 +13706,19 @@ void wickra_footprint_reset(struct Footprint *handle);
void wickra_footprint_free(struct Footprint *handle);
struct TickAggregator *wickra_tick_aggregator_new(int64_t bucket, bool gap_fill);
intptr_t wickra_tick_aggregator_push(struct TickAggregator *handle,
double price,
double size,
int64_t timestamp);
intptr_t wickra_tick_aggregator_drain(struct TickAggregator *handle,
struct WickraCandle *out,
uintptr_t cap);
void wickra_tick_aggregator_free(struct TickAggregator *handle);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
+55
View File
@@ -102,6 +102,16 @@ type CamarillaPivotsOutput struct {
S4 float64
}
// Candle is the output of the Candle indicator.
type Candle struct {
Open float64
High float64
Low float64
Close float64
Volume float64
Timestamp int64
}
// CandleVolumeOutput is the output of the CandleVolume indicator.
type CandleVolumeOutput struct {
Body float64
@@ -36391,6 +36401,51 @@ func (ind *Thrusting) Close() {
}
}
// TickAggregator wraps the TickAggregator indicator over the Wickra C ABI.
type TickAggregator struct {
handle *C.struct_TickAggregator
}
// NewTickAggregator constructs a TickAggregator. It returns ErrInvalidParams when the
// native constructor rejects the arguments.
func NewTickAggregator(bucket int64, gapFill bool) (*TickAggregator, error) {
ptr := C.wickra_tick_aggregator_new(C.int64_t(bucket), C.bool(gapFill))
if ptr == nil {
return nil, ErrInvalidParams
}
obj := &TickAggregator{handle: ptr}
runtime.SetFinalizer(obj, (*TickAggregator).Close)
return obj, nil
}
// Push feeds one trade tick and returns the candles it closed (none while
// the open bar grows, one per closed bucket, plus gap-fill placeholders).
func (ind *TickAggregator) Push(price float64, size float64, timestamp int64) []Candle {
n := int(C.wickra_tick_aggregator_push(ind.handle, C.double(price), C.double(size), C.int64_t(timestamp)))
runtime.KeepAlive(ind)
if n <= 0 {
return nil
}
buf := make([]C.struct_WickraCandle, n)
C.wickra_tick_aggregator_drain(ind.handle, &buf[0], C.uintptr_t(n))
runtime.KeepAlive(ind)
out := make([]Candle, n)
for i := 0; i < n; i++ {
out[i] = Candle{float64(buf[i].open), float64(buf[i].high), float64(buf[i].low), float64(buf[i].close), float64(buf[i].volume), int64(buf[i].timestamp)}
}
return out
}
// Close frees the native handle. It is idempotent and safe to call
// alongside the finalizer.
func (ind *TickAggregator) Close() {
if ind.handle != nil {
C.wickra_tick_aggregator_free(ind.handle)
ind.handle = nil
runtime.SetFinalizer(ind, nil)
}
}
// TickBars wraps the TickBars indicator over the Wickra C ABI.
type TickBars struct {
handle *C.struct_TickBars
@@ -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);
}
}
}
}
}
}
+1
View File
@@ -25,6 +25,7 @@ workspace = true
[dependencies]
wickra-core = { workspace = true }
wickra-data = { workspace = true }
napi = { version = "2.16", features = ["napi8"] }
napi-derive = "2.16"
@@ -0,0 +1,53 @@
// Cross-language data-layer parity for the Node binding: replay the shared
// golden tick stream through the TickAggregator and check the candles bit-for-bit
// (within fp tolerance) against the Rust-generated fixtures, with and without
// gap filling. Fixtures are produced by `cargo run -p wickra-examples --bin
// gen_golden`.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { TickAggregator } = require('..');
const GOLDEN = path.resolve(__dirname, '..', '..', '..', 'testdata', 'golden');
function readCsv(name) {
const lines = fs.readFileSync(path.join(GOLDEN, `${name}.csv`), 'utf8').split(/\r?\n/);
lines.shift();
return lines.filter((l) => l.length > 0).map((l) => l.split(',').map(Number));
}
const TICKS = readCsv('data_ticks');
function run(gapFill) {
const agg = new TickAggregator(1000, gapFill);
const out = [];
for (const [price, size, ts] of TICKS) {
for (const c of agg.push(price, size, ts)) {
out.push([c.open, c.high, c.low, c.close, c.volume, c.timestamp]);
}
}
return out;
}
function assertCandles(got, want, label) {
assert.equal(got.length, want.length, `${label}: candle count ${got.length} vs ${want.length}`);
for (let i = 0; i < got.length; i++) {
for (let j = 0; j < 6; j++) {
const tol = 1e-9 * Math.max(1, Math.abs(want[i][j]));
assert.ok(
Math.abs(got[i][j] - want[i][j]) <= tol,
`${label} row ${i} col ${j}: ${got[i][j]} vs ${want[i][j]}`,
);
}
}
}
test('tick aggregator matches the golden candles', () => {
assertCandles(run(false), readCsv('data_candles'), 'no-gap');
});
test('tick aggregator gap-fill matches the golden candles', () => {
assertCandles(run(true), readCsv('data_candles_gap'), 'gap');
});
+27
View File
@@ -594,6 +594,15 @@ export interface VolumeWeightedMacdValue {
signal: number
histogram: number
}
/** One aggregated OHLCV candle emitted by [`TickAggregatorNode`]. */
export interface CandleValue {
open: number
high: number
low: number
close: number
volume: number
timestamp: number
}
export type SmaNode = SMA
export declare class SMA {
constructor(period: number)
@@ -5930,3 +5939,21 @@ export declare class VolumeWeightedMacd {
isReady(): boolean
warmupPeriod(): number
}
export type TickAggregatorNode = TickAggregator
/** Roll trade ticks up into fixed-timeframe OHLCV candles. */
export declare class TickAggregator {
/**
* Construct an aggregator with the given bucket size (in the same unit as
* the tick timestamps). Pass `gapFill = true` to emit a flat placeholder
* candle for every skipped bucket.
*/
constructor(bucket: number, gapFill?: boolean | undefined | null)
/**
* Push one trade tick; returns every candle that closed as a result (none
* while the open bar keeps growing, one on a bucket boundary, plus one flat
* candle per skipped bucket when gap filling is enabled).
*/
push(price: number, size: number, timestamp: number): Array<CandleValue>
/** Whether gap filling is enabled. */
fillsGaps(): boolean
}
File diff suppressed because one or more lines are too long
+72
View File
@@ -21865,3 +21865,75 @@ impl VolumeWeightedMacdNode {
self.inner.warmup_period() as u32
}
}
// ===== Data layer: tick-to-candle aggregation =====
/// Convert a `wickra-data` error into a JS error.
fn map_data_err(e: wickra_data::Error) -> NapiError {
NapiError::new(Status::InvalidArg, e.to_string())
}
/// One aggregated OHLCV candle emitted by [`TickAggregatorNode`].
#[napi(object)]
pub struct CandleValue {
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
pub timestamp: f64,
}
/// Roll trade ticks up into fixed-timeframe OHLCV candles.
#[napi(js_name = "TickAggregator")]
pub struct TickAggregatorNode {
inner: wickra_data::aggregator::TickAggregator,
}
#[napi]
impl TickAggregatorNode {
/// Construct an aggregator with the given bucket size (in the same unit as
/// the tick timestamps). Pass `gapFill = true` to emit a flat placeholder
/// candle for every skipped bucket.
#[napi(constructor)]
pub fn new(bucket: f64, gap_fill: Option<bool>) -> napi::Result<Self> {
let timeframe =
wickra_data::aggregator::Timeframe::new(bucket as i64).map_err(map_data_err)?;
let mut inner = wickra_data::aggregator::TickAggregator::new(timeframe);
if gap_fill.unwrap_or(false) {
inner = inner.with_gap_fill(true);
}
Ok(Self { inner })
}
/// Push one trade tick; returns every candle that closed as a result (none
/// while the open bar keeps growing, one on a bucket boundary, plus one flat
/// candle per skipped bucket when gap filling is enabled).
#[napi]
pub fn push(
&mut self,
price: f64,
size: f64,
timestamp: f64,
) -> napi::Result<Vec<CandleValue>> {
let tick = wc::Tick::new(price, size, timestamp as i64).map_err(map_err)?;
let candles = self.inner.push(tick).map_err(map_data_err)?;
Ok(candles
.into_iter()
.map(|c| CandleValue {
open: c.open,
high: c.high,
low: c.low,
close: c.close,
volume: c.volume,
timestamp: c.timestamp as f64,
})
.collect())
}
/// Whether gap filling is enabled.
#[napi(js_name = "fillsGaps")]
pub fn fills_gaps(&self) -> bool {
self.inner.fills_gaps()
}
}
+1
View File
@@ -22,5 +22,6 @@ workspace = true
[dependencies]
wickra-core = { workspace = true }
wickra-data = { workspace = true }
pyo3 = { workspace = true }
numpy = { workspace = true }
@@ -358,6 +358,8 @@ from ._wickra import (
ThreeLineBreak,
Equivolume,
CandleVolume,
# Data layer
TickAggregator,
# Market Profile
CompositeProfile,
HighLowVolumeNodes,
@@ -902,6 +904,8 @@ __all__ = [
"ThreeLineBreak",
"Equivolume",
"CandleVolume",
# Data layer
"TickAggregator",
# Market Profile
"CompositeProfile",
"HighLowVolumeNodes",
+59
View File
@@ -28285,6 +28285,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyProfileShape>()?;
m.add_class::<PyHighLowVolumeNodes>()?;
m.add_class::<PyCompositeProfile>()?;
// Data layer.
m.add_class::<PyTickAggregator>()?;
// Candlestick patterns.
m.add_class::<PyDoji>()?;
m.add_class::<PyHammer>()?;
@@ -28557,3 +28559,60 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyM2Measure>()?;
Ok(())
}
// ===== Data layer: tick-to-candle aggregation =====
/// One aggregated candle as `(open, high, low, close, volume, timestamp)`.
type CandleTuple = (f64, f64, f64, f64, f64, i64);
/// Convert a `wickra-data` error into a Python `ValueError`.
fn map_data_err(e: wickra_data::Error) -> PyErr {
PyValueError::new_err(e.to_string())
}
/// Roll trade ticks up into fixed-timeframe OHLCV candles.
#[pyclass(
name = "TickAggregator",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyTickAggregator {
inner: wickra_data::aggregator::TickAggregator,
}
#[pymethods]
impl PyTickAggregator {
#[new]
#[pyo3(signature = (bucket, gap_fill = false))]
fn new(bucket: i64, gap_fill: bool) -> PyResult<Self> {
let timeframe = wickra_data::aggregator::Timeframe::new(bucket).map_err(map_data_err)?;
let mut inner = wickra_data::aggregator::TickAggregator::new(timeframe);
if gap_fill {
inner = inner.with_gap_fill(true);
}
Ok(Self { inner })
}
/// Push one trade tick; returns the candles closed as a result, each a
/// `(open, high, low, close, volume, timestamp)` tuple.
fn push(&mut self, price: f64, size: f64, timestamp: i64) -> PyResult<Vec<CandleTuple>> {
let tick = wc::Tick::new(price, size, timestamp).map_err(map_err)?;
Ok(self
.inner
.push(tick)
.map_err(map_data_err)?
.into_iter()
.map(|c| (c.open, c.high, c.low, c.close, c.volume, c.timestamp))
.collect())
}
#[getter]
fn fills_gaps(&self) -> bool {
self.inner.fills_gaps()
}
fn __repr__(&self) -> String {
format!("TickAggregator(fills_gaps={})", self.inner.fills_gaps())
}
}
+44
View File
@@ -0,0 +1,44 @@
"""Cross-language data-layer parity for the Python binding: replay the shared
golden tick stream through the TickAggregator and check the candles against the
Rust-generated fixtures, with and without gap filling. Fixtures are produced by
``cargo run -p wickra-examples --bin gen_golden``.
"""
import csv
import os
import pytest
import wickra as ta
HERE = os.path.dirname(__file__)
GOLDEN = os.path.normpath(os.path.join(HERE, "..", "..", "..", "testdata", "golden"))
def _read(name):
with open(os.path.join(GOLDEN, name + ".csv"), newline="") as f:
rows = list(csv.reader(f))
return [[float(x) for x in r] for r in rows[1:] if r]
TICKS = _read("data_ticks")
def _run(gap_fill):
agg = ta.TickAggregator(1000, gap_fill=gap_fill)
out = []
for price, size, ts in TICKS:
out.extend(agg.push(price, size, int(ts)))
return out
@pytest.mark.parametrize(
"gap_fill,fixture",
[(False, "data_candles"), (True, "data_candles_gap")],
)
def test_tick_aggregator_matches_golden(gap_fill, fixture):
got = _run(gap_fill)
want = _read(fixture)
assert len(got) == len(want)
for i, (g, w) in enumerate(zip(got, want)):
for j in range(6):
tol = 1e-9 * max(1.0, abs(w[j]))
assert abs(g[j] - w[j]) <= tol, f"row {i} col {j}: {g[j]} vs {w[j]}"
+3
View File
@@ -3,6 +3,7 @@
S3method(batch,wickra_indicator)
S3method(is_ready,wickra_indicator)
S3method(name,wickra_indicator)
S3method(push,wickra_indicator)
S3method(reset,wickra_indicator)
S3method(update,wickra_indicator)
S3method(warmup_period,wickra_indicator)
@@ -439,6 +440,7 @@ export(ThreeOutside)
export(ThreeSoldiersOrCrows)
export(ThreeStarsInSouth)
export(Thrusting)
export(TickAggregator)
export(TickBars)
export(TickIndex)
export(Tii)
@@ -523,6 +525,7 @@ export(Zlema)
export(batch)
export(is_ready)
export(name)
export(push)
export(reset)
export(warmup_period)
importFrom(stats,update)
+8
View File
@@ -3471,6 +3471,14 @@ Thrusting <- function() {
.wk_obj("thrusting", ptr, "Thrusting")
}
#' TickAggregator indicator
#' @keywords internal
#' @export
TickAggregator <- function(bucket, gap_fill) {
ptr <- .Call("wk_tick_aggregator_new", bucket, gap_fill, PACKAGE = "wickra")
.wk_obj("tick_aggregator", ptr, "TickAggregator")
}
#' TickBars indicator
#' @keywords internal
#' @export
+32
View File
@@ -138,3 +138,35 @@ name <- function(object) {
name.wickra_indicator <- function(object) {
.Call(paste0("wk_", object$prefix, "_name"), object$ptr, PACKAGE = "wickra")
}
#' Push a trade tick into a tick aggregator
#'
#' Feeds one trade tick to a [TickAggregator()] and returns the candles it
#' closed as a numeric matrix with columns `open`, `high`, `low`, `close`,
#' `volume`, `timestamp` (zero rows while the open bar merely grows).
#'
#' @param object A `wickra_indicator` created by [TickAggregator()].
#' @param price Trade price.
#' @param size Trade size (volume).
#' @param timestamp Trade timestamp, in the same unit as the aggregator bucket.
#' @return A numeric matrix with six named columns (possibly zero rows).
#' @examples
#' agg <- TickAggregator(1000)
#' push(agg, 100, 1, 0)
#' push(agg, 102, 1, 1000) # closes the first bucket
#' @export
push <- function(object, price, size, timestamp) {
UseMethod("push")
}
#' @rdname push
#' @export
push.wickra_indicator <- function(object, price, size, timestamp) {
out <- .Call(
paste0("wk_", object$prefix, "_push"),
object$ptr, price, size, timestamp,
PACKAGE = "wickra"
)
colnames(out) <- c("open", "high", "low", "close", "volume", "timestamp")
out
}
+34
View File
@@ -18965,6 +18965,38 @@ SEXP wk_thrusting_reset(SEXP e) {
return R_NilValue;
}
static void tick_aggregator_fin(SEXP e) {
struct TickAggregator *h = (struct TickAggregator *)R_ExternalPtrAddr(e);
if (h) wickra_tick_aggregator_free(h);
R_ClearExternalPtr(e);
}
SEXP wk_tick_aggregator_new(SEXP a0, SEXP a1) {
struct TickAggregator *h = wickra_tick_aggregator_new((int64_t)Rf_asReal(a0), (bool)(Rf_asLogical(a1) == TRUE));
if (!h) Rf_error("invalid TickAggregator parameters");
SEXP e = PROTECT(R_MakeExternalPtr(h, R_NilValue, R_NilValue));
R_RegisterCFinalizerEx(e, tick_aggregator_fin, TRUE);
UNPROTECT(1);
return e;
}
SEXP wk_tick_aggregator_push(SEXP e, SEXP price, SEXP size, SEXP ts) {
struct TickAggregator *h = (struct TickAggregator *)R_ExternalPtrAddr(e);
intptr_t n = wickra_tick_aggregator_push(h, Rf_asReal(price), Rf_asReal(size), (int64_t)Rf_asReal(ts));
if (n <= 0) return Rf_allocMatrix(REALSXP, 0, 6);
struct WickraCandle *buf = (struct WickraCandle *)R_alloc(n, sizeof(struct WickraCandle));
intptr_t w = wickra_tick_aggregator_drain(h, buf, (uintptr_t)n);
SEXP r = PROTECT(Rf_allocMatrix(REALSXP, (int)w, 6));
for (intptr_t i = 0; i < w; i++) {
REAL(r)[i + w * 0] = buf[i].open;
REAL(r)[i + w * 1] = buf[i].high;
REAL(r)[i + w * 2] = buf[i].low;
REAL(r)[i + w * 3] = buf[i].close;
REAL(r)[i + w * 4] = buf[i].volume;
REAL(r)[i + w * 5] = (double)buf[i].timestamp;
}
UNPROTECT(1);
return r;
}
static void tick_bars_fin(SEXP e) {
struct TickBars *h = (struct TickBars *)R_ExternalPtrAddr(e);
if (h) wickra_tick_bars_free(h);
@@ -25418,6 +25450,8 @@ static const R_CallMethodDef CallEntries[] = {
{"wk_thrusting_is_ready", (DL_FUNC)&wk_thrusting_is_ready, 1},
{"wk_thrusting_name", (DL_FUNC)&wk_thrusting_name, 1},
{"wk_thrusting_reset", (DL_FUNC)&wk_thrusting_reset, 1},
{"wk_tick_aggregator_new", (DL_FUNC)&wk_tick_aggregator_new, 2},
{"wk_tick_aggregator_push", (DL_FUNC)&wk_tick_aggregator_push, 4},
{"wk_tick_bars_new", (DL_FUNC)&wk_tick_bars_new, 1},
{"wk_tick_bars_update", (DL_FUNC)&wk_tick_bars_update, 7},
{"wk_tick_bars_name", (DL_FUNC)&wk_tick_bars_name, 1},
@@ -0,0 +1,52 @@
# 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. Fixtures are generated by
# `cargo run -p wickra-examples --bin gen_golden`.
find_data_golden_dir <- function() {
d <- normalizePath(".", mustWork = FALSE)
repeat {
g <- file.path(d, "testdata", "golden")
if (dir.exists(g)) {
return(g)
}
parent <- dirname(d)
if (identical(parent, d)) {
return(NULL)
}
d <- parent
}
}
test_that("tick aggregator matches the golden candles", {
gdir <- find_data_golden_dir()
skip_if(is.null(gdir), "golden fixtures not bundled with the package")
read_mat <- function(name) {
lines <- readLines(file.path(gdir, paste0(name, ".csv")))[-1]
lines <- lines[nzchar(lines)]
if (length(lines) == 0) {
return(matrix(numeric(0), 0, 6))
}
do.call(rbind, lapply(lines, function(l) as.numeric(strsplit(l, ",")[[1]])))
}
ticks <- read_mat("data_ticks")
specs <- list(
list(gap = FALSE, fixture = "data_candles"),
list(gap = TRUE, fixture = "data_candles_gap")
)
for (spec in specs) {
agg <- TickAggregator(1000, spec$gap)
got <- matrix(numeric(0), 0, 6)
for (i in seq_len(nrow(ticks))) {
out <- push(agg, ticks[i, 1], ticks[i, 2], ticks[i, 3])
if (nrow(out) > 0) {
got <- rbind(got, unname(out))
}
}
want <- unname(read_mat(spec$fixture))
expect_equal(nrow(got), nrow(want), info = spec$fixture)
expect_equal(got, want, tolerance = 1e-9, info = spec$fixture)
}
})
+1
View File
@@ -24,6 +24,7 @@ workspace = true
# depend on the local path directly with default features disabled. This also
# strips the parallel batch helpers we don't ship to JavaScript.
wickra-core = { path = "../../crates/wickra-core", default-features = false }
wickra-data = { path = "../../crates/wickra-data" }
wasm-bindgen = "0.2"
js-sys = "0.3"
serde = { version = "1", features = ["derive"] }
+54
View File
@@ -15989,3 +15989,57 @@ impl Default for WasmIntradayIntensity {
Self::new()
}
}
// ===== Data layer: tick-to-candle aggregation =====
/// Convert a `wickra-data` error into a JS error.
fn map_data_err(e: wickra_data::Error) -> JsError {
JsError::new(&e.to_string())
}
/// Roll trade ticks up into fixed-timeframe OHLCV candles.
#[wasm_bindgen(js_name = TickAggregator)]
pub struct WasmTickAggregator {
inner: wickra_data::aggregator::TickAggregator,
}
#[wasm_bindgen(js_class = TickAggregator)]
impl WasmTickAggregator {
/// Construct an aggregator with the given bucket size (same unit as the tick
/// timestamps). Pass `gapFill = true` to emit a flat placeholder candle for
/// every skipped bucket.
#[wasm_bindgen(constructor)]
pub fn new(bucket: f64, gap_fill: Option<bool>) -> Result<WasmTickAggregator, JsError> {
let timeframe =
wickra_data::aggregator::Timeframe::new(bucket as i64).map_err(map_data_err)?;
let mut inner = wickra_data::aggregator::TickAggregator::new(timeframe);
if gap_fill.unwrap_or(false) {
inner = inner.with_gap_fill(true);
}
Ok(Self { inner })
}
/// Push one trade tick; returns an array of `{ open, high, low, close,
/// volume, timestamp }` candles closed as a result.
pub fn push(&mut self, price: f64, size: f64, timestamp: f64) -> Result<Array, JsError> {
let tick = wc::Tick::new(price, size, timestamp as i64).map_err(map_err)?;
let arr = Array::new();
for c in self.inner.push(tick).map_err(map_data_err)? {
let obj = Object::new();
Reflect::set(&obj, &"open".into(), &c.open.into()).ok();
Reflect::set(&obj, &"high".into(), &c.high.into()).ok();
Reflect::set(&obj, &"low".into(), &c.low.into()).ok();
Reflect::set(&obj, &"close".into(), &c.close.into()).ok();
Reflect::set(&obj, &"volume".into(), &c.volume.into()).ok();
Reflect::set(&obj, &"timestamp".into(), &(c.timestamp as f64).into()).ok();
arr.push(&obj);
}
Ok(arr)
}
/// Whether gap filling is enabled.
#[wasm_bindgen(js_name = fillsGaps)]
pub fn fills_gaps(&self) -> bool {
self.inner.fills_gaps()
}
}
+51
View File
@@ -0,0 +1,51 @@
// Cross-language data-layer parity for the WASM binding: replay the shared
// golden tick stream through TickAggregator and check the candles against the
// Rust-generated fixtures, with and without gap filling.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const W = require('../pkg/wickra_wasm.js');
const GOLDEN = path.resolve(__dirname, '..', '..', '..', 'testdata', 'golden');
function readCsv(name) {
const lines = fs.readFileSync(path.join(GOLDEN, `${name}.csv`), 'utf8').split(/\r?\n/);
lines.shift();
return lines.filter((l) => l.length > 0).map((l) => l.split(',').map(Number));
}
const TICKS = readCsv('data_ticks');
function run(gapFill) {
const agg = new W.TickAggregator(1000, gapFill);
const out = [];
for (const [price, size, ts] of TICKS) {
for (const c of agg.push(price, size, ts)) {
out.push([c.open, c.high, c.low, c.close, c.volume, c.timestamp]);
}
}
return out;
}
function assertCandles(got, want, label) {
assert.equal(got.length, want.length, `${label}: candle count ${got.length} vs ${want.length}`);
for (let i = 0; i < got.length; i++) {
for (let j = 0; j < 6; j++) {
const tol = 1e-9 * Math.max(1, Math.abs(want[i][j]));
assert.ok(
Math.abs(got[i][j] - want[i][j]) <= tol,
`${label} row ${i} col ${j}: ${got[i][j]} vs ${want[i][j]}`,
);
}
}
}
test('wasm tick aggregator matches the golden candles', () => {
assertCandles(run(false), readCsv('data_candles'), 'no-gap');
});
test('wasm tick aggregator gap-fill matches the golden candles', () => {
assertCandles(run(true), readCsv('data_candles_gap'), 'gap');
});