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:
@@ -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
|
||||
|
||||
Generated
+4
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
Vendored
+27
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <math.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#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;
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -15,27 +15,27 @@
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
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);
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
Vendored
+22
@@ -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
|
||||
|
Vendored
+26
@@ -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
|
||||
|
Vendored
+61
@@ -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
|
||||
|
Reference in New Issue
Block a user