From 8a103ef92006e0fbdcb8fc2d2a6e4c74f26ebb08 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Mon, 15 Jun 2026 21:24:33 +0200 Subject: [PATCH] feat(data-layer): TickAggregator (tick-to-candle) in all 10 languages (#309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). --- CHANGELOG.md | 11 ++ Cargo.lock | 4 + Cargo.toml | 1 + bindings/c/Cargo.toml | 1 + bindings/c/cbindgen.toml | 2 +- bindings/c/include/wickra.h | 24 ++++ bindings/c/src/lib.rs | 122 +++++++++++++++++- .../csharp/Wickra.Tests/DataLayerTests.cs | 59 +++++++++ .../csharp/Wickra/Generated/Indicators.g.cs | 46 +++++++ .../Wickra/Generated/NativeMethods.g.cs | 23 ++++ bindings/go/data_layer_test.go | 58 +++++++++ bindings/go/include/wickra.h | 24 ++++ bindings/go/indicators_gen.go | 55 ++++++++ .../java/src/main/java/org/wickra/Candle.java | 5 + .../main/java/org/wickra/TickAggregator.java | 62 +++++++++ .../org/wickra/internal/NativeMethods.java | 8 ++ .../test/java/org/wickra/DataLayerTest.java | 76 +++++++++++ bindings/node/Cargo.toml | 1 + bindings/node/__tests__/data_layer.test.js | 53 ++++++++ bindings/node/index.d.ts | 27 ++++ bindings/node/index.js | 3 +- bindings/node/src/lib.rs | 72 +++++++++++ bindings/python/Cargo.toml | 1 + bindings/python/python/wickra/__init__.py | 4 + bindings/python/src/lib.rs | 59 +++++++++ bindings/python/tests/test_data_layer.py | 44 +++++++ bindings/r/NAMESPACE | 3 + bindings/r/R/indicators.R | 8 ++ bindings/r/R/methods.R | 32 +++++ bindings/r/src/wickra.c | 34 +++++ bindings/r/tests/testthat/test-data-layer.R | 52 ++++++++ bindings/wasm/Cargo.toml | 1 + bindings/wasm/src/lib.rs | 54 ++++++++ bindings/wasm/tests/data_layer.test.js | 51 ++++++++ crates/wickra-data/Cargo.toml | 7 +- examples/c/CMakeLists.txt | 3 +- examples/c/backtest.c | 4 +- examples/c/data_layer_test.c | 107 +++++++++++++++ examples/c/data_layer_test.cpp | 3 + examples/c/multi_timeframe.c | 14 +- examples/c/strategy_bollinger_squeeze.c | 4 +- examples/c/strategy_macd_adx.c | 4 +- examples/c/strategy_rsi_mean_reversion.c | 2 +- examples/c/wickra_csv.h | 14 +- examples/rust/src/bin/gen_golden.rs | 46 ++++++- testdata/golden/data_candles.csv | 22 ++++ testdata/golden/data_candles_gap.csv | 26 ++++ testdata/golden/data_ticks.csv | 61 +++++++++ 48 files changed, 1368 insertions(+), 29 deletions(-) create mode 100644 bindings/csharp/Wickra.Tests/DataLayerTests.cs create mode 100644 bindings/go/data_layer_test.go create mode 100644 bindings/java/src/main/java/org/wickra/Candle.java create mode 100644 bindings/java/src/main/java/org/wickra/TickAggregator.java create mode 100644 bindings/java/src/test/java/org/wickra/DataLayerTest.java create mode 100644 bindings/node/__tests__/data_layer.test.js create mode 100644 bindings/python/tests/test_data_layer.py create mode 100644 bindings/r/tests/testthat/test-data-layer.R create mode 100644 bindings/wasm/tests/data_layer.test.js create mode 100644 examples/c/data_layer_test.c create mode 100644 examples/c/data_layer_test.cpp create mode 100644 testdata/golden/data_candles.csv create mode 100644 testdata/golden/data_candles_gap.csv create mode 100644 testdata/golden/data_ticks.csv diff --git a/CHANGELOG.md b/CHANGELOG.md index c0cdc7bb..7427276a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Tick-to-candle aggregation in all 10 languages (data layer).** The + `TickAggregator` — roll trade ticks up into fixed-timeframe OHLCV candles, with + optional gap filling — is now exposed natively (Node.js / WASM `push(price, + size, ts): Candle[]`, Python `push(...) -> list[tuple]`) and over the C ABI as + Go `Push() []Candle`, C# `Candle[] Push()`, Java `Candle[] push()`, and the R + `push()` generic (an `n×6` matrix); C / C++ call the C ABI directly. The C ABI + uses a lossless two-step `wickra_tick_aggregator_push` / `_drain` so a single + push that gap-fills across many empty buckets never overflows a fixed buffer. A + new cross-language golden (`testdata/golden/data_*.csv`) pins the candle stream + identically across every binding. This is the first feature of a data layer that + makes the non-Rust bindings dependency-free for tick aggregation. - **`name()` on every indicator in all 10 languages.** The canonical `Indicator::name()` / `BarBuilder::name()` accessor is now exposed through every binding — Node.js `name()`, WASM `name()`, Python `name()`, and the C ABI diff --git a/Cargo.lock b/Cargo.lock index a1f02174..12979cbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1969,6 +1969,7 @@ name = "wickra-c" version = "0.9.2" dependencies = [ "wickra-core", + "wickra-data", ] [[package]] @@ -2016,6 +2017,7 @@ dependencies = [ "napi-build", "napi-derive", "wickra-core", + "wickra-data", ] [[package]] @@ -2025,6 +2027,7 @@ dependencies = [ "numpy", "pyo3", "wickra-core", + "wickra-data", ] [[package]] @@ -2038,6 +2041,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-test", "wickra-core", + "wickra-data", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5d1c1037..e76352da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ categories = ["finance", "mathematics", "science"] [workspace.dependencies] wickra-core = { path = "crates/wickra-core", version = "0.9.2" } +wickra-data = { path = "crates/wickra-data", version = "0.9.2" } thiserror = "2" rayon = "1.10" diff --git a/bindings/c/Cargo.toml b/bindings/c/Cargo.toml index 43b508f1..5ea6f6d9 100644 --- a/bindings/c/Cargo.toml +++ b/bindings/c/Cargo.toml @@ -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" } diff --git a/bindings/c/cbindgen.toml b/bindings/c/cbindgen.toml index 20ea02d8..cfb1df53 100644 --- a/bindings/c/cbindgen.toml +++ b/bindings/c/cbindgen.toml @@ -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"] diff --git a/bindings/c/include/wickra.h b/bindings/c/include/wickra.h index 4b057fed..9a660819 100644 --- a/bindings/c/include/wickra.h +++ b/bindings/c/include/wickra.h @@ -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 diff --git a/bindings/c/src/lib.rs b/bindings/c/src/lib.rs index e6d729f3..f3ada17c 100644 --- a/bindings/c/src/lib.rs +++ b/bindings/c/src/lib.rs @@ -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, +} + +/// 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::*; diff --git a/bindings/csharp/Wickra.Tests/DataLayerTests.cs b/bindings/csharp/Wickra.Tests/DataLayerTests.cs new file mode 100644 index 00000000..c06f3224 --- /dev/null +++ b/bindings/csharp/Wickra.Tests/DataLayerTests.cs @@ -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(); + 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(); + 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]}"); + } + } + } +} diff --git a/bindings/csharp/Wickra/Generated/Indicators.g.cs b/bindings/csharp/Wickra/Generated/Indicators.g.cs index b8ead9ff..fdcba531 100644 --- a/bindings/csharp/Wickra/Generated/Indicators.g.cs +++ b/bindings/csharp/Wickra/Generated/Indicators.g.cs @@ -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); + } + + + /// Feed one trade tick; returns the candles it closed. + 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(); + } + 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; diff --git a/bindings/csharp/Wickra/Generated/NativeMethods.g.cs b/bindings/csharp/Wickra/Generated/NativeMethods.g.cs index 6503494e..b5f745c1 100644 --- a/bindings/csharp/Wickra/Generated/NativeMethods.g.cs +++ b/bindings/csharp/Wickra/Generated/NativeMethods.g.cs @@ -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 { diff --git a/bindings/go/data_layer_test.go b/bindings/go/data_layer_test.go new file mode 100644 index 00000000..e07b7ad8 --- /dev/null +++ b/bindings/go/data_layer_test.go @@ -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) + } + } + } + } +} diff --git a/bindings/go/include/wickra.h b/bindings/go/include/wickra.h index 4b057fed..9a660819 100644 --- a/bindings/go/include/wickra.h +++ b/bindings/go/include/wickra.h @@ -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 diff --git a/bindings/go/indicators_gen.go b/bindings/go/indicators_gen.go index ba37f544..9e5cc4a8 100644 --- a/bindings/go/indicators_gen.go +++ b/bindings/go/indicators_gen.go @@ -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 diff --git a/bindings/java/src/main/java/org/wickra/Candle.java b/bindings/java/src/main/java/org/wickra/Candle.java new file mode 100644 index 00000000..439501cb --- /dev/null +++ b/bindings/java/src/main/java/org/wickra/Candle.java @@ -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) {} diff --git a/bindings/java/src/main/java/org/wickra/TickAggregator.java b/bindings/java/src/main/java/org/wickra/TickAggregator.java new file mode 100644 index 00000000..96ddae65 --- /dev/null +++ b/bindings/java/src/main/java/org/wickra/TickAggregator.java @@ -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(); + } +} diff --git a/bindings/java/src/main/java/org/wickra/internal/NativeMethods.java b/bindings/java/src/main/java/org/wickra/internal/NativeMethods.java index 0dfec1f5..22988ad1 100644 --- a/bindings/java/src/main/java/org/wickra/internal/NativeMethods.java +++ b/bindings/java/src/main/java/org/wickra/internal/NativeMethods.java @@ -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)); } } diff --git a/bindings/java/src/test/java/org/wickra/DataLayerTest.java b/bindings/java/src/test/java/org/wickra/DataLayerTest.java new file mode 100644 index 00000000..36b5f0c9 --- /dev/null +++ b/bindings/java/src/test/java/org/wickra/DataLayerTest.java @@ -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 lines = Files.readAllLines(goldenDir().resolve(name + ".csv")); + List 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 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); + } + } + } + } + } +} diff --git a/bindings/node/Cargo.toml b/bindings/node/Cargo.toml index 85db5c29..c1de419a 100644 --- a/bindings/node/Cargo.toml +++ b/bindings/node/Cargo.toml @@ -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" diff --git a/bindings/node/__tests__/data_layer.test.js b/bindings/node/__tests__/data_layer.test.js new file mode 100644 index 00000000..0b5e8a52 --- /dev/null +++ b/bindings/node/__tests__/data_layer.test.js @@ -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'); +}); diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 7cdd5675..bf1350d5 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -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 + /** Whether gap filling is enabled. */ + fillsGaps(): boolean +} diff --git a/bindings/node/index.js b/bindings/node/index.js index 45ac57b1..f14c8e03 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, LogReturn, RealizedVolatility, RollingIqr, RollingPercentileRank, TrendLabel, WinRate, Expectancy, SWMA, GMA, EHMA, MedianMA, AdaptiveLaguerre, DisparityIndex, FisherRSI, RSX, DynamicMomentumIndex, TREND_STRENGTH_INDEX, TsfOscillator, BipowerVariation, JARQUEBERA, ROLLINGMINMAX, HIGHPASS, REFLEX, TRENDFLEX, CTI, ADAPTIVERSI, UNIVERSALOSC, SterlingRatio, BurkeRatio, MartinRatio, TailRatio, KRatio, CommonSenseRatio, GainToPainRatio, UpsidePotentialRatio, M2Measure, BANDPASS, EVENBETTERSINE, AUTOCORRPGRAM, SHANNONENT, SAMPLEENT, EwmaVolatility, Garch11, VolatilityOfVolatility, VolatilityCone, JumpIndicator, RegimeLabel, RollingQuantile, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpreadAr1Coefficient, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, KendallTau, BetaNeutralSpread, HasbrouckInformationShare, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, CloseVsOpen, BodySizePct, WickRatio, HighLowRange, StochasticCCI, IMI, QQE, ElderRay, TTM_TREND, Qstick, POLARIZED_FRACTAL_EFFICIENCY, WAVE_PM, GatorOscillator, KasePermissionStochastic, VolatilityRatio, ProjectionOscillator, TimeBasedStop, ADAPTIVECCI, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, GD, HoltWinters, RMI, DerivativeOscillator, MacdHistogram, PpoHistogram, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, ADOSC, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, KaseDevStop, ElderSafeZone, AtrRatchet, Nrtr, ModifiedMaStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, QuartileBands, BomarBands, MedianChannel, ProjectionBands, CentralPivotRange, MurreyMathLines, AndrewsPitchfork, VolumeWeightedSr, PivotReversal, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDDWave, TDMovingAverage, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, HeikinAshiOscillator, ThreeLineBreak, SmoothedHeikinAshi, Equivolume, CandleVolume, FryPanBottom, DumplingTop, NewPriceLines, ValueArea, NakedPoc, SinglePrints, ProfileShape, HighLowVolumeNodes, CompositeProfile, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, TDCamouflage, TDClop, TDClopwin, TDPropulsion, TDTrap, Tristar, HaramiCross, TowerTopBottom, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, TradeSignAutocorrelation, Pin, OrderFlowImbalance, Vpin, AmihudIlliquidity, RollMeasure, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, EstimatedLeverageRatio, OiToVolumeRatio, PerpetualPremiumIndex, FundingImpliedApr, OpenInterestMomentum, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, RangeBars, TickBars, VolumeBars, DollarBars, ImbalanceBars, RunBars, ThreeLineBreakBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones, VolumeRsi, Wad, TwiggsMoneyFlow, TradeVolumeIndex, IntradayIntensity, BetterVolume, VolumeWeightedMacd } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, LogReturn, RealizedVolatility, RollingIqr, RollingPercentileRank, TrendLabel, WinRate, Expectancy, SWMA, GMA, EHMA, MedianMA, AdaptiveLaguerre, DisparityIndex, FisherRSI, RSX, DynamicMomentumIndex, TREND_STRENGTH_INDEX, TsfOscillator, BipowerVariation, JARQUEBERA, ROLLINGMINMAX, HIGHPASS, REFLEX, TRENDFLEX, CTI, ADAPTIVERSI, UNIVERSALOSC, SterlingRatio, BurkeRatio, MartinRatio, TailRatio, KRatio, CommonSenseRatio, GainToPainRatio, UpsidePotentialRatio, M2Measure, BANDPASS, EVENBETTERSINE, AUTOCORRPGRAM, SHANNONENT, SAMPLEENT, EwmaVolatility, Garch11, VolatilityOfVolatility, VolatilityCone, JumpIndicator, RegimeLabel, RollingQuantile, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpreadAr1Coefficient, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, KendallTau, BetaNeutralSpread, HasbrouckInformationShare, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, CloseVsOpen, BodySizePct, WickRatio, HighLowRange, StochasticCCI, IMI, QQE, ElderRay, TTM_TREND, Qstick, POLARIZED_FRACTAL_EFFICIENCY, WAVE_PM, GatorOscillator, KasePermissionStochastic, VolatilityRatio, ProjectionOscillator, TimeBasedStop, ADAPTIVECCI, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, GD, HoltWinters, RMI, DerivativeOscillator, MacdHistogram, PpoHistogram, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, ADOSC, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, KaseDevStop, ElderSafeZone, AtrRatchet, Nrtr, ModifiedMaStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, QuartileBands, BomarBands, MedianChannel, ProjectionBands, CentralPivotRange, MurreyMathLines, AndrewsPitchfork, VolumeWeightedSr, PivotReversal, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDDWave, TDMovingAverage, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, HeikinAshiOscillator, ThreeLineBreak, SmoothedHeikinAshi, Equivolume, CandleVolume, FryPanBottom, DumplingTop, NewPriceLines, ValueArea, NakedPoc, SinglePrints, ProfileShape, HighLowVolumeNodes, CompositeProfile, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, TDCamouflage, TDClop, TDClopwin, TDPropulsion, TDTrap, Tristar, HaramiCross, TowerTopBottom, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, TradeSignAutocorrelation, Pin, OrderFlowImbalance, Vpin, AmihudIlliquidity, RollMeasure, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, EstimatedLeverageRatio, OiToVolumeRatio, PerpetualPremiumIndex, FundingImpliedApr, OpenInterestMomentum, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, RangeBars, TickBars, VolumeBars, DollarBars, ImbalanceBars, RunBars, ThreeLineBreakBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones, VolumeRsi, Wad, TwiggsMoneyFlow, TradeVolumeIndex, IntradayIntensity, BetterVolume, VolumeWeightedMacd, TickAggregator } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -827,3 +827,4 @@ module.exports.TradeVolumeIndex = TradeVolumeIndex module.exports.IntradayIntensity = IntradayIntensity module.exports.BetterVolume = BetterVolume module.exports.VolumeWeightedMacd = VolumeWeightedMacd +module.exports.TickAggregator = TickAggregator diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 69ae29cd..4c12399e 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -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) -> napi::Result { + 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> { + 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() + } +} diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml index 6ed98f2d..fb445027 100644 --- a/bindings/python/Cargo.toml +++ b/bindings/python/Cargo.toml @@ -22,5 +22,6 @@ workspace = true [dependencies] wickra-core = { workspace = true } +wickra-data = { workspace = true } pyo3 = { workspace = true } numpy = { workspace = true } diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index e00c9ada..5b4dccb0 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -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", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 41c1a551..705492ab 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -28285,6 +28285,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + // Data layer. + m.add_class::()?; // Candlestick patterns. m.add_class::()?; m.add_class::()?; @@ -28557,3 +28559,60 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; 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 { + 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> { + 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()) + } +} diff --git a/bindings/python/tests/test_data_layer.py b/bindings/python/tests/test_data_layer.py new file mode 100644 index 00000000..a959a833 --- /dev/null +++ b/bindings/python/tests/test_data_layer.py @@ -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]}" diff --git a/bindings/r/NAMESPACE b/bindings/r/NAMESPACE index 6be8f6ad..54a98938 100644 --- a/bindings/r/NAMESPACE +++ b/bindings/r/NAMESPACE @@ -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) diff --git a/bindings/r/R/indicators.R b/bindings/r/R/indicators.R index a4f28785..91646b6c 100644 --- a/bindings/r/R/indicators.R +++ b/bindings/r/R/indicators.R @@ -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 diff --git a/bindings/r/R/methods.R b/bindings/r/R/methods.R index 6bf5cb34..37363c12 100644 --- a/bindings/r/R/methods.R +++ b/bindings/r/R/methods.R @@ -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 +} diff --git a/bindings/r/src/wickra.c b/bindings/r/src/wickra.c index 0c4a6f80..9a177f5a 100644 --- a/bindings/r/src/wickra.c +++ b/bindings/r/src/wickra.c @@ -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}, diff --git a/bindings/r/tests/testthat/test-data-layer.R b/bindings/r/tests/testthat/test-data-layer.R new file mode 100644 index 00000000..81f6de22 --- /dev/null +++ b/bindings/r/tests/testthat/test-data-layer.R @@ -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) + } +}) diff --git a/bindings/wasm/Cargo.toml b/bindings/wasm/Cargo.toml index 121c299d..6529ae1e 100644 --- a/bindings/wasm/Cargo.toml +++ b/bindings/wasm/Cargo.toml @@ -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"] } diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 868d2b78..340823fc 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -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) -> Result { + 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 { + 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() + } +} diff --git a/bindings/wasm/tests/data_layer.test.js b/bindings/wasm/tests/data_layer.test.js new file mode 100644 index 00000000..d1a6a99c --- /dev/null +++ b/bindings/wasm/tests/data_layer.test.js @@ -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'); +}); diff --git a/crates/wickra-data/Cargo.toml b/crates/wickra-data/Cargo.toml index b7ef7125..1b9cef58 100644 --- a/crates/wickra-data/Cargo.toml +++ b/crates/wickra-data/Cargo.toml @@ -22,7 +22,12 @@ all-features = true workspace = true [dependencies] -wickra-core = { workspace = true } +# Direct path+version (not the workspace inheritance) so `default-features = false` +# is honoured: depending on wickra-data must NOT force wickra-core's `parallel` +# (rayon) feature on — the WASM binding needs a rayon-free build and the data +# layer never uses the parallel batch path. Native bindings re-enable `parallel` +# through their own wickra-core dependency (cargo unifies the features). +wickra-core = { path = "../wickra-core", version = "0.9.2", default-features = false } thiserror = { workspace = true } csv = "1.3" serde = { version = "1", features = ["derive"] } diff --git a/examples/c/CMakeLists.txt b/examples/c/CMakeLists.txt index 1ddfbe59..b2de102a 100644 --- a/examples/c/CMakeLists.txt +++ b/examples/c/CMakeLists.txt @@ -83,7 +83,8 @@ endif() # prove the C ABI header is consumable from each language. The fixtures live at # the repo root; pass the absolute path as the test argument. get_filename_component(WICKRA_GOLDEN_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../testdata/golden" ABSOLUTE) -foreach(gt_pair "golden_test:golden_test.c" "golden_test_cpp:golden_test.cpp") +foreach(gt_pair "golden_test:golden_test.c" "golden_test_cpp:golden_test.cpp" + "data_layer_test:data_layer_test.c" "data_layer_test_cpp:data_layer_test.cpp") string(REPLACE ":" ";" gt_list "${gt_pair}") list(GET gt_list 0 gt_name) list(GET gt_list 1 gt_src) diff --git a/examples/c/backtest.c b/examples/c/backtest.c index 975084fb..a65d940a 100644 --- a/examples/c/backtest.c +++ b/examples/c/backtest.c @@ -34,7 +34,7 @@ static double last_finite(const double *v, size_t n) { int main(int argc, char **argv) { const char *path = (argc > 1) ? argv[1] : WICKRA_DATA_DIR "/btcusdt-1d.csv"; - WickraCandle *candles = NULL; + WickraBar *candles = NULL; size_t n = wickra_load_csv(path, &candles); if (n == 0) { fprintf(stderr, "backtest: no candles read from %s\n", path); @@ -77,7 +77,7 @@ int main(int argc, char **argv) { double last_atr = NAN; double last_obv = NAN; for (size_t i = 0; i < n; ++i) { - const WickraCandle *c = &candles[i]; + const WickraBar *c = &candles[i]; WickraBollingerOutput bo; if (wickra_bollinger_bands_update(bb, c->close, &bo)) { last_bb = bo; diff --git a/examples/c/data_layer_test.c b/examples/c/data_layer_test.c new file mode 100644 index 00000000..7db34d3a --- /dev/null +++ b/examples/c/data_layer_test.c @@ -0,0 +1,107 @@ +/* Cross-language data-layer parity for the C / C++ binding: replay the shared + * golden tick stream through the TickAggregator (push/drain) and check the + * candles against the Rust reference, with and without gap filling. The same + * source compiles under both a C and a C++ compiler. Fixtures are generated by + * `cargo run -p wickra-examples --bin gen_golden`; the golden dir is argv[1]. + */ +#include +#include +#include +#include +#include +#include +#include "wickra.h" + +#define MAXROWS 256 + +static const char *GDIR; + +static int read_csv(const char *name, double *rows, int ncol) { + char path[1024]; + snprintf(path, sizeof(path), "%s/%s.csv", GDIR, name); + FILE *f = fopen(path, "r"); + if (!f) { + fprintf(stderr, "cannot open %s\n", path); + exit(2); + } + char line[4096]; + int n = 0, first = 1; + while (fgets(line, sizeof(line), f)) { + line[strcspn(line, "\r\n")] = '\0'; + if (first) { + first = 0; + continue; + } + if (line[0] == '\0') { + continue; + } + char *p = line; + for (int j = 0; j < ncol; j++) { + rows[n * ncol + j] = strtod(p, &p); + if (*p == ',') { + p++; + } + } + n++; + } + fclose(f); + return n; +} + +static int check(const char *fixture, bool gap, const double *ticks, int nt) { + struct TickAggregator *a = wickra_tick_aggregator_new(1000, gap); + if (!a) { + printf("FAIL %s: new returned NULL\n", fixture); + return 1; + } + double want[MAXROWS * 6]; + int nw = read_csv(fixture, want, 6); + double got[MAXROWS * 6]; + int ng = 0; + for (int i = 0; i < nt; i++) { + intptr_t n = wickra_tick_aggregator_push(a, ticks[i * 3 + 0], ticks[i * 3 + 1], + (int64_t)ticks[i * 3 + 2]); + if (n > 0) { + struct WickraCandle buf[MAXROWS]; + intptr_t w = wickra_tick_aggregator_drain(a, buf, (uintptr_t)n); + for (intptr_t j = 0; j < w; j++) { + got[ng * 6 + 0] = buf[j].open; + got[ng * 6 + 1] = buf[j].high; + got[ng * 6 + 2] = buf[j].low; + got[ng * 6 + 3] = buf[j].close; + got[ng * 6 + 4] = buf[j].volume; + got[ng * 6 + 5] = (double)buf[j].timestamp; + ng++; + } + } + } + wickra_tick_aggregator_free(a); + if (ng != nw) { + printf("FAIL %s: %d candles vs %d\n", fixture, ng, nw); + return 1; + } + int fails = 0; + for (int i = 0; i < ng; i++) { + for (int j = 0; j < 6; j++) { + double w = want[i * 6 + j]; + double tol = 1e-9 * fmax(1.0, fabs(w)); + if (fabs(got[i * 6 + j] - w) > tol) { + printf("FAIL %s row %d col %d: %g vs %g\n", fixture, i, j, got[i * 6 + j], w); + fails++; + } + } + } + return fails; +} + +int main(int argc, char **argv) { + GDIR = (argc > 1) ? argv[1] : "testdata/golden"; + double ticks[MAXROWS * 3]; + int nt = read_csv("data_ticks", ticks, 3); + int fails = check("data_candles", false, ticks, nt); + fails += check("data_candles_gap", true, ticks, nt); + if (fails == 0) { + printf("C/C++ data layer: OK (%d ticks)\n", nt); + } + return fails ? 1 : 0; +} diff --git a/examples/c/data_layer_test.cpp b/examples/c/data_layer_test.cpp new file mode 100644 index 00000000..03ed2445 --- /dev/null +++ b/examples/c/data_layer_test.cpp @@ -0,0 +1,3 @@ +/* C++ build of the data-layer parity test: the C ABI header is `extern "C"`, so + * the exact same source compiles under a C++ compiler too. */ +#include "data_layer_test.c" diff --git a/examples/c/multi_timeframe.c b/examples/c/multi_timeframe.c index f61ebe4f..4dc788a6 100644 --- a/examples/c/multi_timeframe.c +++ b/examples/c/multi_timeframe.c @@ -30,12 +30,12 @@ /* Aggregate `in` into fixed-width time buckets of `tf_ms`. Writes the bucketed * candles into the caller-owned `out` (capacity >= count) and returns how many * buckets were produced. */ -static size_t resample(const WickraCandle *in, size_t count, int64_t tf_ms, - WickraCandle *out) { +static size_t resample(const WickraBar *in, size_t count, int64_t tf_ms, + WickraBar *out) { size_t produced = 0; int open_bucket = 0; int64_t bucket_start = 0; - WickraCandle cur = {0}; + WickraBar cur = {0}; for (size_t i = 0; i < count; ++i) { int64_t b = in[i].timestamp - (in[i].timestamp % tf_ms); if (!open_bucket || b != bucket_start) { @@ -63,7 +63,7 @@ static size_t resample(const WickraCandle *in, size_t count, int64_t tf_ms, return produced; } -static void summarize(const char *label, const WickraCandle *candles, size_t n) { +static void summarize(const char *label, const WickraBar *candles, size_t n) { if (n == 0) { printf(" %-5s (empty)\n", label); return; @@ -76,7 +76,7 @@ static void summarize(const char *label, const WickraCandle *candles, size_t n) double last_hist = NAN; double last_adx = NAN; for (size_t i = 0; i < n; ++i) { - const WickraCandle *c = &candles[i]; + const WickraBar *c = &candles[i]; double r = wickra_rsi_update(rsi, c->close); if (isfinite(r)) { last_rsi = r; @@ -119,14 +119,14 @@ static void summarize(const char *label, const WickraCandle *candles, size_t n) int main(int argc, char **argv) { const char *path = (argc > 1) ? argv[1] : WICKRA_DATA_DIR "/btcusdt-1m.csv"; - WickraCandle *ones = NULL; + WickraBar *ones = NULL; size_t n = wickra_load_csv(path, &ones); if (n == 0) { fprintf(stderr, "multi_timeframe: no candles read from %s\n", path); return 1; } /* Resampling only ever produces fewer candles than the 1m source. */ - WickraCandle *buf = (WickraCandle *)malloc(n * sizeof(*buf)); + WickraBar *buf = (WickraBar *)malloc(n * sizeof(*buf)); if (buf == NULL) { fprintf(stderr, "multi_timeframe: allocation failed\n"); free(ones); diff --git a/examples/c/strategy_bollinger_squeeze.c b/examples/c/strategy_bollinger_squeeze.c index 5e0b0f78..2d5f3f64 100644 --- a/examples/c/strategy_bollinger_squeeze.c +++ b/examples/c/strategy_bollinger_squeeze.c @@ -37,7 +37,7 @@ int main(int argc, char **argv) { const char *path = (argc > 1) ? argv[1] : WICKRA_DATA_DIR "/btcusdt-1d.csv"; - WickraCandle *candles = NULL; + WickraBar *candles = NULL; size_t n = wickra_load_csv(path, &candles); if (n < SQUEEZE_LOOKBACK + BB_PERIOD) { fprintf(stderr, "dataset has only %llu bars; need at least %d\n", @@ -64,7 +64,7 @@ int main(int argc, char **argv) { double equity = 1.0; for (size_t i = 0; i < n; ++i) { - const WickraCandle *c = &candles[i]; + const WickraBar *c = &candles[i]; double price = c->close; WickraBollingerOutput b; int bb_ready = wickra_bollinger_bands_update(bb, price, &b); diff --git a/examples/c/strategy_macd_adx.c b/examples/c/strategy_macd_adx.c index 4ab80d6a..e62b5dc9 100644 --- a/examples/c/strategy_macd_adx.c +++ b/examples/c/strategy_macd_adx.c @@ -34,7 +34,7 @@ int main(int argc, char **argv) { const char *path = (argc > 1) ? argv[1] : WICKRA_DATA_DIR "/btcusdt-1h.csv"; - WickraCandle *candles = NULL; + WickraBar *candles = NULL; size_t n = wickra_load_csv(path, &candles); if (n == 0) { fprintf(stderr, "CSV is empty: %s\n", path); @@ -58,7 +58,7 @@ int main(int argc, char **argv) { int prev_hist_sign = -1; for (size_t i = 0; i < n; ++i) { - const WickraCandle *c = &candles[i]; + const WickraBar *c = &candles[i]; double price = c->close; WickraMacdOutput m; int macd_ready = wickra_macd_indicator_update(macd, price, &m); diff --git a/examples/c/strategy_rsi_mean_reversion.c b/examples/c/strategy_rsi_mean_reversion.c index 0a10babe..7bd339f0 100644 --- a/examples/c/strategy_rsi_mean_reversion.c +++ b/examples/c/strategy_rsi_mean_reversion.c @@ -34,7 +34,7 @@ int main(int argc, char **argv) { const char *path = (argc > 1) ? argv[1] : WICKRA_DATA_DIR "/btcusdt-1h.csv"; - WickraCandle *candles = NULL; + WickraBar *candles = NULL; size_t n = wickra_load_csv(path, &candles); if (n < RSI_PERIOD * 4) { fprintf(stderr, "dataset too small: %llu\n", (unsigned long long)n); diff --git a/examples/c/wickra_csv.h b/examples/c/wickra_csv.h index 893b700a..5e8b5b7c 100644 --- a/examples/c/wickra_csv.h +++ b/examples/c/wickra_csv.h @@ -15,27 +15,27 @@ #include #include -typedef struct WickraCandle { +typedef struct WickraBar { int64_t timestamp; double open; double high; double low; double close; double volume; -} WickraCandle; +} WickraBar; /* Load an OHLCV CSV into a malloc'd array. Returns the candle count and stores * the array in *out (caller frees with free()). Returns 0 and leaves *out NULL * on any error (missing file, no parseable rows). A leading header line whose * first field is non-numeric is skipped. */ -size_t wickra_load_csv(const char *path, WickraCandle **out); +size_t wickra_load_csv(const char *path, WickraBar **out); #ifdef WICKRA_CSV_IMPL #include #include -size_t wickra_load_csv(const char *path, WickraCandle **out) { +size_t wickra_load_csv(const char *path, WickraBar **out) { *out = NULL; FILE *f = fopen(path, "r"); if (f == NULL) { @@ -45,7 +45,7 @@ size_t wickra_load_csv(const char *path, WickraCandle **out) { size_t cap = 1024; size_t n = 0; - WickraCandle *rows = (WickraCandle *)malloc(cap * sizeof(*rows)); + WickraBar *rows = (WickraBar *)malloc(cap * sizeof(*rows)); if (rows == NULL) { fclose(f); return 0; @@ -53,7 +53,7 @@ size_t wickra_load_csv(const char *path, WickraCandle **out) { char line[512]; while (fgets(line, (int)sizeof(line), f) != NULL) { - WickraCandle c; + WickraBar c; long long ts = 0; /* sscanf returns the number of fields successfully matched. A header * row ("timestamp,...") matches 0 and is skipped. */ @@ -66,7 +66,7 @@ size_t wickra_load_csv(const char *path, WickraCandle **out) { if (n == cap) { cap *= 2; - WickraCandle *grown = (WickraCandle *)realloc(rows, cap * sizeof(*rows)); + WickraBar *grown = (WickraBar *)realloc(rows, cap * sizeof(*rows)); if (grown == NULL) { free(rows); fclose(f); diff --git a/examples/rust/src/bin/gen_golden.rs b/examples/rust/src/bin/gen_golden.rs index 1265e2c9..310ead39 100644 --- a/examples/rust/src/bin/gen_golden.rs +++ b/examples/rust/src/bin/gen_golden.rs @@ -17,8 +17,9 @@ use std::path::Path; use wickra::{ AdOscillator, Adx, Atr, AverageDrawdown, AwesomeOscillatorHistogram, Beta, Candle, Ema, - Indicator, IntradayIntensity, MacdIndicator, Rsi, Sma, + Indicator, IntradayIntensity, MacdIndicator, Rsi, Sma, Tick, }; +use wickra_data::aggregator::{TickAggregator, Timeframe}; const N: usize = 80; @@ -183,9 +184,52 @@ fn main() { emit_special(dir, &candles); emit_profiles(dir, &candles); emit_bars(dir, &candles); + emit_data_layer(dir); println!("golden fixtures written to {}", dir.display()); } +/// Deterministic trade tick `i`: price on the shared varied path, a small +/// repeating size, and a timestamp that places roughly three ticks per +/// 1000-unit bucket. A deliberate jump at `i == 36` opens a multi-bucket gap so +/// the gap-fill fixture exercises several flat candles emitted from one push. +fn tick(i: usize) -> (f64, f64, i64) { + let t = i as f64; + let price = 100.0 + 10.0 * (t * 0.3).sin() + 0.5 * t; + let size = 1.0 + (i % 5) as f64; + let base = i64::try_from(i).expect("tick index fits i64") * 350; + let ts = if i >= 36 { base + 5000 } else { base }; + (price, size, ts) +} + +/// Data layer: the tick-to-candle aggregator. Writes the shared tick input plus +/// the reference candle streams with and without gap filling. +fn emit_data_layer(dir: &Path) { + const N_TICKS: usize = 60; + let ticks: Vec<(f64, f64, i64)> = (0..N_TICKS).map(tick).collect(); + + let mut tin = Vec::with_capacity(N_TICKS); + for &(price, size, ts) in &ticks { + tin.push(format!("{price},{size},{ts}")); + } + write_csv(dir, "data_ticks", "price,size,timestamp", &tin); + + let header = "open,high,low,close,volume,timestamp"; + for (name, gap_fill) in [("data_candles", false), ("data_candles_gap", true)] { + let mut agg = TickAggregator::new(Timeframe::new(1000).unwrap()).with_gap_fill(gap_fill); + let mut rows = Vec::new(); + for &(price, size, ts) in &ticks { + let tick = Tick::new(price, size, ts).expect("valid tick"); + for c in agg.push(tick).expect("valid push") { + rows.push(format!( + "{},{},{},{},{},{}", + c.open, c.high, c.low, c.close, c.volume, c.timestamp + )); + } + } + write_csv(dir, name, header, &rows); + } +} + // AUTO-GENERATED scalar-output golden tranche (single f64 output). #[allow(clippy::too_many_lines)] fn emit_scalar(dir: &Path, candles: &[Candle], closes: &[f64]) { diff --git a/testdata/golden/data_candles.csv b/testdata/golden/data_candles.csv new file mode 100644 index 00000000..bed29f87 --- /dev/null +++ b/testdata/golden/data_candles.csv @@ -0,0 +1,22 @@ +open,high,low,close,volume,timestamp +100,106.64642473395035,100,106.64642473395035,6,0 +109.33326909627483,112.47494986604055,109.33326909627483,112.47494986604055,10,1000 +112.73847630878196,112.73847630878196,110.75463180551151,110.75463180551151,9,2000 +108.7737988023383,108.7737988023383,103.92254305856751,103.92254305856751,8,3000 +101.57479556705148,101.57479556705148,98.28424227586412,98.28424227586412,12,4000 +97.72469882334903,99.24185317672267,97.72469882334903,99.24185317672267,6,5000 +101.27235512444012,103.99314457402363,101.27235512444012,103.99314457402363,9,6000 +107.20584501801073,114.11541363513378,107.20584501801073,114.11541363513378,6,7000 +117.28439764388199,121.87999976774739,117.28439764388199,121.87999976774739,10,8000 +122.98543345374605,123.19889810845086,122.5459890808828,122.5459890808828,9,9000 +121.12969230082183,121.12969230082183,116.74454423507063,116.74454423507063,8,10000 +114.2567321877702,114.2567321877702,110.00125312406458,110.00125312406458,12,11000 +108.7030424002833,108.7030424002833,108.7030424002833,108.7030424002833,1,12000 +108.19063769933508,108.55447411796011,108.19063769933508,108.55447411796011,5,17000 +109.80671474335324,111.88016416080967,109.80671474335324,111.88016416080967,9,18000 +114.63427081999565,121.33623047221137,114.63427081999565,121.33623047221137,6,19000 +124.77474439137693,130.5378442655162,124.77474439137693,130.5378442655162,10,20000 +132.43695669444105,133.65657776549278,132.43695669444105,133.65657776549278,9,21000 +132.95746831142935,132.95746831142935,129.46740573130614,129.46740573130614,8,22000 +127.07753652299444,127.07753652299444,122.27578013601534,122.27578013601534,12,23000 +120.38214657630877,120.38214657630877,118.65934994918358,118.65934994918358,6,24000 diff --git a/testdata/golden/data_candles_gap.csv b/testdata/golden/data_candles_gap.csv new file mode 100644 index 00000000..2fffcddb --- /dev/null +++ b/testdata/golden/data_candles_gap.csv @@ -0,0 +1,26 @@ +open,high,low,close,volume,timestamp +100,106.64642473395035,100,106.64642473395035,6,0 +109.33326909627483,112.47494986604055,109.33326909627483,112.47494986604055,10,1000 +112.73847630878196,112.73847630878196,110.75463180551151,110.75463180551151,9,2000 +108.7737988023383,108.7737988023383,103.92254305856751,103.92254305856751,8,3000 +101.57479556705148,101.57479556705148,98.28424227586412,98.28424227586412,12,4000 +97.72469882334903,99.24185317672267,97.72469882334903,99.24185317672267,6,5000 +101.27235512444012,103.99314457402363,101.27235512444012,103.99314457402363,9,6000 +107.20584501801073,114.11541363513378,107.20584501801073,114.11541363513378,6,7000 +117.28439764388199,121.87999976774739,117.28439764388199,121.87999976774739,10,8000 +122.98543345374605,123.19889810845086,122.5459890808828,122.5459890808828,9,9000 +121.12969230082183,121.12969230082183,116.74454423507063,116.74454423507063,8,10000 +114.2567321877702,114.2567321877702,110.00125312406458,110.00125312406458,12,11000 +108.7030424002833,108.7030424002833,108.7030424002833,108.7030424002833,1,12000 +108.7030424002833,108.7030424002833,108.7030424002833,108.7030424002833,0,13000 +108.7030424002833,108.7030424002833,108.7030424002833,108.7030424002833,0,14000 +108.7030424002833,108.7030424002833,108.7030424002833,108.7030424002833,0,15000 +108.7030424002833,108.7030424002833,108.7030424002833,108.7030424002833,0,16000 +108.19063769933508,108.55447411796011,108.19063769933508,108.55447411796011,5,17000 +109.80671474335324,111.88016416080967,109.80671474335324,111.88016416080967,9,18000 +114.63427081999565,121.33623047221137,114.63427081999565,121.33623047221137,6,19000 +124.77474439137693,130.5378442655162,124.77474439137693,130.5378442655162,10,20000 +132.43695669444105,133.65657776549278,132.43695669444105,133.65657776549278,9,21000 +132.95746831142935,132.95746831142935,129.46740573130614,129.46740573130614,8,22000 +127.07753652299444,127.07753652299444,122.27578013601534,122.27578013601534,12,23000 +120.38214657630877,120.38214657630877,118.65934994918358,118.65934994918358,6,24000 diff --git a/testdata/golden/data_ticks.csv b/testdata/golden/data_ticks.csv new file mode 100644 index 00000000..70cb3dff --- /dev/null +++ b/testdata/golden/data_ticks.csv @@ -0,0 +1,61 @@ +price,size,timestamp +100,1,0 +103.4552020666134,2,350 +106.64642473395035,3,700 +109.33326909627483,4,1050 +111.32039085967226,5,1400 +112.47494986604055,1,1750 +112.73847630878196,2,2100 +112.13209366648874,3,2450 +110.75463180551151,4,2800 +108.7737988023383,5,3150 +106.41120008059868,1,3500 +103.92254305856751,2,3850 +101.57479556705148,3,4200 +99.62233840816026,4,4550 +98.28424227586412,5,4900 +97.72469882334903,1,5250 +98.0383539116416,2,5600 +99.24185317672267,3,5950 +101.27235512444012,4,6300 +103.99314457402363,5,6650 +107.20584501801073,1,7000 +110.6681390048435,2,7350 +114.11541363513378,3,7700 +117.28439764388199,4,8050 +119.93667863849153,5,8400 +121.87999976774739,1,8750 +122.98543345374605,2,9100 +123.19889810845086,3,9450 +122.5459890808828,4,9800 +121.12969230082183,5,10150 +119.12118485241757,1,10500 +116.74454423507063,2,10850 +114.2567321877702,3,11200 +111.92464106224679,4,11550 +110.00125312406458,5,11900 +108.7030424002833,1,12250 +108.19063769933508,2,17600 +108.55447411796011,3,17950 +109.80671474335324,4,18300 +111.88016416080967,5,18650 +114.63427081999565,1,19000 +117.86768208634197,2,19350 +121.33623047221137,3,19700 +124.77474439137693,4,20050 +127.92073514707224,5,20400 +130.5378442655162,1,20750 +132.43695669444105,2,21100 +133.49309388747918,3,21450 +133.65657776549278,4,21800 +132.95746831142935,5,22150 +131.50287840157117,1,22500 +129.46740573130614,2,22850 +127.07753652299444,3,23200 +124.59141418625812,4,23550 +122.27578013601534,5,23900 +120.38214657630877,1,24250 +119.12432966418496,2,24600 +118.65934994918358,3,24950 +119.07340619529367,4,25300 +120.37417550208815,5,25650