feat(data-layer): Resampler (candle resampling) in all 10 languages (#310)

* feat(data-layer): Resampler (candle resampling) in all 10 languages

Second data-layer feature (F3): resample candles into a higher timeframe.

- Native (Node.js/WASM): new Resampler(timeframe) -> update(o,h,l,c,v,ts):
  Candle|null + flush(): Candle|null. Python the same -> tuple|None.
- C ABI: wickra_resampler_new/update/flush/free (update has the multi-output
  shape so the generators auto-emit it; flush is bespoke). Go Update -> (Candle,
  bool) + Flush; C# Candle? Update/Flush; Java Candle update/flush; R update()
  generic + a flush() S3 method (extends base::flush); C/C++ direct.
- Cross-language golden (testdata/golden/data_resampled.csv): the shared input
  candles resampled into 5-unit buckets, the final partial bucket via flush,
  pinned bit-for-bit across every binding.

Verified locally in all 10 (3 candles for the 5-unit smoke; 16 for the golden).
The WickraCandle output record is shared with the tick aggregator (deduped).

* test(node): exclude data-layer types from the indicator completeness contract

The Resampler exposes update(), so the completeness test flagged it as an
indicator and required batch/reset/isReady/warmupPeriod, which a data-layer type
does not have. Exclude TickAggregator and Resampler like the bar builders.
This commit is contained in:
kingchenc
2026-06-15 22:36:16 +02:00
committed by GitHub
parent 8a103ef920
commit cb6da4d737
30 changed files with 901 additions and 4 deletions
+8
View File
@@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Candle resampling in all 10 languages (data layer).** The `Resampler`
aggregates candles into a higher timeframe (e.g. 1m → 5m): `update(open, high,
low, close, volume, timestamp)` returns the completed higher-timeframe candle on
a bucket boundary (else `null`/`None`/`NA`), and `flush()` emits the final,
still-open candle. Exposed natively (Node.js / WASM / Python) and over the C ABI
(Go / C# / Java return `(Candle, bool)` / `Candle?` / `Candle`; R via `update()`
and a `flush()` S3 method; C / C++ directly). A cross-language golden
(`testdata/golden/data_resampled.csv`) pins the resampled stream identically.
- **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,
+17
View File
@@ -678,6 +678,8 @@ typedef struct RenkoBars RenkoBars;
typedef struct RenkoTrailingStop RenkoTrailingStop;
typedef struct Resampler Resampler;
typedef struct RickshawMan RickshawMan;
typedef struct RisingThreeMethods RisingThreeMethods;
@@ -13719,6 +13721,21 @@ intptr_t wickra_tick_aggregator_drain(struct TickAggregator *handle,
void wickra_tick_aggregator_free(struct TickAggregator *handle);
struct Resampler *wickra_resampler_new(int64_t timeframe);
bool wickra_resampler_update(struct Resampler *handle,
double open,
double high,
double low,
double close,
double volume,
int64_t timestamp,
struct WickraCandle *out);
bool wickra_resampler_flush(struct Resampler *handle, struct WickraCandle *out);
void wickra_resampler_free(struct Resampler *handle);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
+98
View File
@@ -116,6 +116,8 @@ use wickra_core::{
use wickra_data::aggregator::{TickAggregator as DataTickAggregator, Timeframe};
use wickra_data::resample::Resampler;
// ===== Scalar indicators (f64 -> f64) =====
/// Create a `AdaptiveCycle` indicator.
@@ -68188,6 +68190,102 @@ pub unsafe extern "C" fn wickra_tick_aggregator_free(handle: *mut TickAggregator
}
}
/// Convert a core candle into the C-ABI view.
fn candle_to_c(candle: Candle) -> WickraCandle {
WickraCandle {
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
volume: candle.volume,
timestamp: candle.timestamp,
}
}
/// Create a resampler aggregating input candles into `timeframe`-sized candles
/// (the same unit as the candle timestamps). Returns `NULL` on a non-positive
/// timeframe; release with `wickra_resampler_free`.
#[no_mangle]
pub extern "C" fn wickra_resampler_new(timeframe: i64) -> *mut Resampler {
match Timeframe::new(timeframe) {
Ok(tf) => Box::into_raw(Box::new(Resampler::new(tf))),
Err(_) => ptr::null_mut(),
}
}
/// Push one candle. On `true` the completed higher-timeframe candle is written to
/// `*out`; `false` means none closed yet (still aggregating), a `NULL` handle /
/// `out`, or an invalid input candle.
///
/// # Safety
/// `handle` (from `wickra_resampler_new`, not freed) and `out` must be valid or `NULL`.
#[no_mangle]
pub unsafe extern "C" fn wickra_resampler_update(
handle: *mut Resampler,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
out: *mut WickraCandle,
) -> bool {
let Some(resampler) = handle.as_mut() else {
return false;
};
if out.is_null() {
return false;
}
let Ok(candle) = Candle::new(open, high, low, close, volume, timestamp) else {
return false;
};
match resampler.push(candle) {
Ok(Some(emitted)) => {
*out = candle_to_c(emitted);
true
}
_ => false,
}
}
/// Flush the final, still-open candle. On `true` it is written to `*out`; `false`
/// means nothing was pending or `handle` / `out` is `NULL`.
///
/// # Safety
/// `handle` (from `wickra_resampler_new`, not freed) and `out` must be valid or `NULL`.
#[no_mangle]
pub unsafe extern "C" fn wickra_resampler_flush(
handle: *mut Resampler,
out: *mut WickraCandle,
) -> bool {
let Some(resampler) = handle.as_mut() else {
return false;
};
if out.is_null() {
return false;
}
match resampler.flush() {
Ok(Some(emitted)) => {
*out = candle_to_c(emitted);
true
}
_ => false,
}
}
/// Destroy a resampler created by `wickra_resampler_new`. No-op if `handle` is
/// `NULL`.
///
/// # Safety
/// `handle` must have been returned by `wickra_resampler_new` and not previously
/// freed, or `NULL`.
#[no_mangle]
pub unsafe extern "C" fn wickra_resampler_free(handle: *mut Resampler) {
if !handle.is_null() {
drop(Box::from_raw(handle));
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -28,6 +28,37 @@ public class DataLayerTests
return rows.ToArray();
}
[Fact]
public void ResamplerMatchesGolden()
{
var input = Read("input"); // open,high,low,close,volume (timestamp = row index)
using var r = new Resampler(5);
var got = new List<double[]>();
for (var i = 0; i < input.Length; i++)
{
var c = r.Update(input[i][0], input[i][1], input[i][2], input[i][3], input[i][4], i);
if (c.HasValue)
{
got.Add(new[] { c.Value.Open, c.Value.High, c.Value.Low, c.Value.Close, c.Value.Volume, (double)c.Value.Timestamp });
}
}
var f = r.Flush();
if (f.HasValue)
{
got.Add(new[] { f.Value.Open, f.Value.High, f.Value.Low, f.Value.Close, f.Value.Volume, (double)f.Value.Timestamp });
}
var want = Read("data_resampled");
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, $"resample row {i} col {j}: {got[i][j]} vs {want[i][j]}");
}
}
}
[Theory]
[InlineData(false, "data_candles")]
[InlineData(true, "data_candles_gap")]
@@ -26580,6 +26580,50 @@ public sealed class RenkoTrailingStop : IDisposable
public void Dispose() => _handle.Dispose();
}
public sealed class Resampler : IDisposable
{
private readonly WickraHandle _handle;
public Resampler(long timeframe)
{
var ptr = NativeMethods.wickra_resampler_new(timeframe);
if (ptr == nint.Zero)
{
throw new ArgumentException("invalid Resampler parameters");
}
_handle = new WickraHandle(ptr, NativeMethods.wickra_resampler_free);
}
public Candle? Update(double open, double high, double low, double close, double volume, long timestamp)
{
WickraCandle native;
bool ok;
unsafe
{
ok = NativeMethods.wickra_resampler_update(_handle.DangerousGetHandle(), open, high, low, close, volume, timestamp, &native);
}
GC.KeepAlive(_handle);
return ok ? new Candle(native.open, native.high, native.low, native.close, native.volume, native.timestamp) : null;
}
/// <summary>Emit the final, still-open candle (null if none is pending).</summary>
public Candle? Flush()
{
WickraCandle native;
bool ok;
unsafe
{
ok = NativeMethods.wickra_resampler_flush(_handle.DangerousGetHandle(), &native);
}
GC.KeepAlive(_handle);
return ok ? new Candle(native.open, native.high, native.low, native.close, native.volume, native.timestamp) : null;
}
public void Dispose() => _handle.Dispose();
}
public sealed class RickshawMan : IDisposable
{
private readonly WickraHandle _handle;
@@ -12416,6 +12416,20 @@ internal static partial class NativeMethods
[LibraryImport(WickraNative.LibraryName)]
internal static partial void wickra_tick_aggregator_free(nint handle);
[LibraryImport(WickraNative.LibraryName)]
internal static partial nint wickra_resampler_new(long timeframe);
[LibraryImport(WickraNative.LibraryName)]
[return: MarshalAs(UnmanagedType.U1)]
internal static unsafe partial bool wickra_resampler_update(nint handle, double open, double high, double low, double close, double volume, long timestamp, WickraCandle* @out);
[LibraryImport(WickraNative.LibraryName)]
[return: MarshalAs(UnmanagedType.U1)]
internal static unsafe partial bool wickra_resampler_flush(nint handle, WickraCandle* @out);
[LibraryImport(WickraNative.LibraryName)]
internal static partial void wickra_resampler_free(nint handle);
}
[StructLayout(LayoutKind.Sequential)]
+31
View File
@@ -20,6 +20,37 @@ func dataParseF(t *testing.T, s string) float64 {
return v
}
func TestResamplerGolden(t *testing.T) {
input := readGolden(t, "input") // open,high,low,close,volume (timestamp = row index)
r, err := NewResampler(5)
if err != nil {
t.Fatalf("new: %v", err)
}
var got [][6]float64
for i, row := range input {
o, h, l, c, v := dataParseF(t, row[0]), dataParseF(t, row[1]), dataParseF(t, row[2]), dataParseF(t, row[3]), dataParseF(t, row[4])
if k, ok := r.Update(o, h, l, c, v, int64(i)); ok {
got = append(got, [6]float64{k.Open, k.High, k.Low, k.Close, k.Volume, float64(k.Timestamp)})
}
}
if k, ok := r.Flush(); ok {
got = append(got, [6]float64{k.Open, k.High, k.Low, k.Close, k.Volume, float64(k.Timestamp)})
}
r.Close()
want := readGolden(t, "data_resampled")
if len(got) != len(want) {
t.Fatalf("resample: %d candles vs %d", 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("resample row %d col %d: %v vs %v", i, j, got[i][j], w)
}
}
}
}
func TestTickAggregatorGolden(t *testing.T) {
ticks := readGolden(t, "data_ticks")
cases := []struct {
+17
View File
@@ -678,6 +678,8 @@ typedef struct RenkoBars RenkoBars;
typedef struct RenkoTrailingStop RenkoTrailingStop;
typedef struct Resampler Resampler;
typedef struct RickshawMan RickshawMan;
typedef struct RisingThreeMethods RisingThreeMethods;
@@ -13719,6 +13721,21 @@ intptr_t wickra_tick_aggregator_drain(struct TickAggregator *handle,
void wickra_tick_aggregator_free(struct TickAggregator *handle);
struct Resampler *wickra_resampler_new(int64_t timeframe);
bool wickra_resampler_update(struct Resampler *handle,
double open,
double high,
double low,
double close,
double volume,
int64_t timestamp,
struct WickraCandle *out);
bool wickra_resampler_flush(struct Resampler *handle, struct WickraCandle *out);
void wickra_resampler_free(struct Resampler *handle);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
+50
View File
@@ -28057,6 +28057,56 @@ func (ind *RenkoTrailingStop) Close() {
}
}
// Resampler wraps the Resampler indicator over the Wickra C ABI.
type Resampler struct {
handle *C.struct_Resampler
}
// NewResampler constructs a Resampler. It returns ErrInvalidParams when the
// native constructor rejects the arguments.
func NewResampler(timeframe int64) (*Resampler, error) {
ptr := C.wickra_resampler_new(C.int64_t(timeframe))
if ptr == nil {
return nil, ErrInvalidParams
}
obj := &Resampler{handle: ptr}
runtime.SetFinalizer(obj, (*Resampler).Close)
return obj, nil
}
// Update feeds one observation. The bool reports whether a value is
// available yet (false during warmup).
func (ind *Resampler) Update(open float64, high float64, low float64, close float64, volume float64, timestamp int64) (Candle, bool) {
var out C.struct_WickraCandle
ok := bool(C.wickra_resampler_update(ind.handle, C.double(open), C.double(high), C.double(low), C.double(close), C.double(volume), C.int64_t(timestamp), &out))
runtime.KeepAlive(ind)
if !ok {
return Candle{}, false
}
return Candle{float64(out.open), float64(out.high), float64(out.low), float64(out.close), float64(out.volume), int64(out.timestamp)}, true
}
// Flush emits the final, still-open candle (ok is false if none is pending).
func (ind *Resampler) Flush() (Candle, bool) {
var out C.struct_WickraCandle
ok := bool(C.wickra_resampler_flush(ind.handle, &out))
runtime.KeepAlive(ind)
if !ok {
return Candle{}, false
}
return Candle{float64(out.open), float64(out.high), float64(out.low), float64(out.close), float64(out.volume), int64(out.timestamp)}, true
}
// Close frees the native handle. It is idempotent and safe to call
// alongside the finalizer.
func (ind *Resampler) Close() {
if ind.handle != nil {
C.wickra_resampler_free(ind.handle)
ind.handle = nil
runtime.SetFinalizer(ind, nil)
}
}
// RickshawMan wraps the RickshawMan indicator over the Wickra C ABI.
type RickshawMan struct {
handle *C.struct_RickshawMan
@@ -0,0 +1,73 @@
// 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 Resampler indicator over the Wickra C ABI. Not thread-safe; close when done. */
public final class Resampler implements AutoCloseable {
private final MemorySegment handle;
private final Cleaner.Cleanable cleanable;
public Resampler(long timeframe) {
MemorySegment h;
try {
h = (MemorySegment) NativeMethods.WICKRA_RESAMPLER_NEW.invokeExact(timeframe);
} catch (Throwable t) {
throw WickraNative.rethrow(t);
}
if (h.address() == 0L) {
throw new IllegalArgumentException("invalid Resampler parameters");
}
this.handle = h;
this.cleanable = WickraNative.register(this, h, NativeMethods.WICKRA_RESAMPLER_FREE);
}
/** Push one observation; returns the result, or null during warmup. */
public Candle update(double open, double high, double low, double close, double volume, long timestamp) {
try (Arena a = Arena.ofConfined()) {
MemorySegment out = a.allocate(48L);
byte ok = (byte) NativeMethods.WICKRA_RESAMPLER_UPDATE.invokeExact(handle, open, high, low, close, volume, timestamp, out);
if (ok == 0) {
return null;
}
return new Candle(
out.get(JAVA_DOUBLE, 0L),
out.get(JAVA_DOUBLE, 8L),
out.get(JAVA_DOUBLE, 16L),
out.get(JAVA_DOUBLE, 24L),
out.get(JAVA_DOUBLE, 32L),
(double) out.get(JAVA_LONG, 40L));
} catch (Throwable t) {
throw WickraNative.rethrow(t);
}
}
/** Emit the final, still-open candle (null if none is pending). */
public Candle flush() {
try (Arena a = Arena.ofConfined()) {
MemorySegment out = a.allocate(48L);
byte ok = (byte) NativeMethods.WICKRA_RESAMPLER_FLUSH.invokeExact(handle, out);
if (ok == 0) {
return null;
}
return new Candle(
out.get(JAVA_DOUBLE, 0L),
out.get(JAVA_DOUBLE, 8L),
out.get(JAVA_DOUBLE, 16L),
out.get(JAVA_DOUBLE, 24L),
out.get(JAVA_DOUBLE, 32L),
(double) out.get(JAVA_LONG, 40L));
} catch (Throwable t) {
throw WickraNative.rethrow(t);
}
}
@Override public void close() {
cleanable.clean();
}
}
@@ -3951,6 +3951,10 @@ public final class NativeMethods {
public static MethodHandle WICKRA_TICK_AGGREGATOR_PUSH;
public static MethodHandle WICKRA_TICK_AGGREGATOR_DRAIN;
public static MethodHandle WICKRA_TICK_AGGREGATOR_FREE;
public static MethodHandle WICKRA_RESAMPLER_NEW;
public static MethodHandle WICKRA_RESAMPLER_UPDATE;
public static MethodHandle WICKRA_RESAMPLER_FLUSH;
public static MethodHandle WICKRA_RESAMPLER_FREE;
static {
init0();
@@ -8023,6 +8027,10 @@ public final class NativeMethods {
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));
WICKRA_RESAMPLER_NEW = h("wickra_resampler_new", FunctionDescriptor.of(ADDRESS, JAVA_LONG));
WICKRA_RESAMPLER_UPDATE = h("wickra_resampler_update", FunctionDescriptor.of(JAVA_BYTE, ADDRESS, JAVA_DOUBLE, JAVA_DOUBLE, JAVA_DOUBLE, JAVA_DOUBLE, JAVA_DOUBLE, JAVA_LONG, ADDRESS));
WICKRA_RESAMPLER_FLUSH = h("wickra_resampler_flush", FunctionDescriptor.of(JAVA_BYTE, ADDRESS, ADDRESS));
WICKRA_RESAMPLER_FREE = h("wickra_resampler_free", FunctionDescriptor.ofVoid(ADDRESS));
}
}
@@ -46,6 +46,33 @@ class DataLayerTest {
return rows.toArray(new double[0][]);
}
@Test
void resamplerMatchesGolden() throws IOException {
double[][] input = read("input"); // open,high,low,close,volume (timestamp = row index)
try (Resampler r = new Resampler(5)) {
List<double[]> got = new ArrayList<>();
for (int i = 0; i < input.length; i++) {
Candle c = r.update(input[i][0], input[i][1], input[i][2], input[i][3], input[i][4], i);
if (c != null) {
got.add(new double[]{c.open(), c.high(), c.low(), c.close(), c.volume(), (double) c.timestamp()});
}
}
Candle f = r.flush();
if (f != null) {
got.add(new double[]{f.open(), f.high(), f.low(), f.close(), f.volume(), (double) f.timestamp()});
}
double[][] want = read("data_resampled");
assertEquals(want.length, got.size(), "resample 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)),
"resample row " + i + " col " + j + ": " + got.get(i)[j] + " vs " + w);
}
}
}
}
@Test
void tickAggregatorMatchesGolden() throws IOException {
double[][] ticks = read("data_ticks");
+8 -2
View File
@@ -27,9 +27,14 @@ const BAR_BUILDERS = new Set([
'ThreeLineBreakBars',
]);
// Data-layer types (tick aggregator, resampler) are not `Indicator`s: they
// transform raw market data into candles and have their own update/flush shape,
// so they are excluded from the streaming-indicator completeness contract.
const DATA_LAYER = new Set(['TickAggregator', 'Resampler']);
// An "indicator class" is an exported constructor whose prototype carries the
// streaming `update` method. This excludes `version` (a plain function), the bar
// builders, and any non-indicator export.
// builders, the data-layer types, and any non-indicator export.
function indicatorClasses() {
return Object.keys(wickra).filter((name) => {
const value = wickra[name];
@@ -37,7 +42,8 @@ function indicatorClasses() {
typeof value === 'function' &&
value.prototype &&
typeof value.prototype.update === 'function' &&
!BAR_BUILDERS.has(name)
!BAR_BUILDERS.has(name) &&
!DATA_LAYER.has(name)
);
});
}
+23 -1
View File
@@ -8,7 +8,7 @@ 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 { TickAggregator, Resampler } = require('..');
const GOLDEN = path.resolve(__dirname, '..', '..', '..', 'testdata', 'golden');
@@ -51,3 +51,25 @@ test('tick aggregator matches the golden candles', () => {
test('tick aggregator gap-fill matches the golden candles', () => {
assertCandles(run(true), readCsv('data_candles_gap'), 'gap');
});
const INPUT = readCsv('input'); // open,high,low,close,volume (timestamp = row index)
function runResample() {
const r = new Resampler(5);
const out = [];
INPUT.forEach(([o, h, l, c, v], i) => {
const candle = r.update(o, h, l, c, v, i);
if (candle) {
out.push([candle.open, candle.high, candle.low, candle.close, candle.volume, candle.timestamp]);
}
});
const f = r.flush();
if (f) {
out.push([f.open, f.high, f.low, f.close, f.volume, f.timestamp]);
}
return out;
}
test('resampler matches the golden candles', () => {
assertCandles(runResample(), readCsv('data_resampled'), 'resample');
});
+16
View File
@@ -5957,3 +5957,19 @@ export declare class TickAggregator {
/** Whether gap filling is enabled. */
fillsGaps(): boolean
}
export type ResamplerNode = Resampler
/** Resample candles into a higher timeframe (e.g. 1m -> 5m). */
export declare class Resampler {
/**
* Construct a resampler that aggregates inputs into `timeframe`-sized
* candles (same unit as the candle timestamps).
*/
constructor(timeframe: number)
/**
* Push one candle; returns the completed higher-timeframe candle when a
* bucket boundary is crossed, otherwise `null`.
*/
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): CandleValue | null
/** Emit the final, still-open candle (or `null` if none is pending). */
flush(): CandleValue | null
}
File diff suppressed because one or more lines are too long
+63
View File
@@ -21937,3 +21937,66 @@ impl TickAggregatorNode {
self.inner.fills_gaps()
}
}
// ===== Data layer: resampling (candle -> higher-timeframe candle) =====
fn candle_to_value(c: wc::Candle) -> CandleValue {
CandleValue {
open: c.open,
high: c.high,
low: c.low,
close: c.close,
volume: c.volume,
timestamp: c.timestamp as f64,
}
}
/// Resample candles into a higher timeframe (e.g. 1m -> 5m).
#[napi(js_name = "Resampler")]
pub struct ResamplerNode {
inner: wickra_data::resample::Resampler,
}
#[napi]
impl ResamplerNode {
/// Construct a resampler that aggregates inputs into `timeframe`-sized
/// candles (same unit as the candle timestamps).
#[napi(constructor)]
pub fn new(timeframe: f64) -> napi::Result<Self> {
let tf = wickra_data::aggregator::Timeframe::new(timeframe as i64).map_err(map_data_err)?;
Ok(Self {
inner: wickra_data::resample::Resampler::new(tf),
})
}
/// Push one candle; returns the completed higher-timeframe candle when a
/// bucket boundary is crossed, otherwise `null`.
#[napi]
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: f64,
) -> napi::Result<Option<CandleValue>> {
let candle =
wc::Candle::new(open, high, low, close, volume, timestamp as i64).map_err(map_err)?;
Ok(self
.inner
.push(candle)
.map_err(map_data_err)?
.map(candle_to_value))
}
/// Emit the final, still-open candle (or `null` if none is pending).
#[napi]
pub fn flush(&mut self) -> napi::Result<Option<CandleValue>> {
Ok(self
.inner
.flush()
.map_err(map_data_err)?
.map(candle_to_value))
}
}
@@ -360,6 +360,7 @@ from ._wickra import (
CandleVolume,
# Data layer
TickAggregator,
Resampler,
# Market Profile
CompositeProfile,
HighLowVolumeNodes,
@@ -906,6 +907,7 @@ __all__ = [
"CandleVolume",
# Data layer
"TickAggregator",
"Resampler",
# Market Profile
"CompositeProfile",
"HighLowVolumeNodes",
+50
View File
@@ -28287,6 +28287,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyCompositeProfile>()?;
// Data layer.
m.add_class::<PyTickAggregator>()?;
m.add_class::<PyResampler>()?;
// Candlestick patterns.
m.add_class::<PyDoji>()?;
m.add_class::<PyHammer>()?;
@@ -28616,3 +28617,52 @@ impl PyTickAggregator {
format!("TickAggregator(fills_gaps={})", self.inner.fills_gaps())
}
}
// ===== Data layer: resampling (candle -> higher-timeframe candle) =====
/// Resample candles into a higher timeframe (e.g. 1m -> 5m).
#[pyclass(name = "Resampler", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyResampler {
inner: wickra_data::resample::Resampler,
}
#[pymethods]
impl PyResampler {
#[new]
fn new(timeframe: i64) -> PyResult<Self> {
let tf = wickra_data::aggregator::Timeframe::new(timeframe).map_err(map_data_err)?;
Ok(Self {
inner: wickra_data::resample::Resampler::new(tf),
})
}
/// Push one candle; returns the completed higher-timeframe candle as
/// `(open, high, low, close, volume, timestamp)` on a bucket boundary, else
/// `None`.
fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> PyResult<Option<CandleTuple>> {
let candle = wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?;
Ok(self
.inner
.push(candle)
.map_err(map_data_err)?
.map(|c| (c.open, c.high, c.low, c.close, c.volume, c.timestamp)))
}
/// Emit the final, still-open candle (or `None` if none is pending).
fn flush(&mut self) -> PyResult<Option<CandleTuple>> {
Ok(self
.inner
.flush()
.map_err(map_data_err)?
.map(|c| (c.open, c.high, c.low, c.close, c.volume, c.timestamp)))
}
}
+21
View File
@@ -42,3 +42,24 @@ def test_tick_aggregator_matches_golden(gap_fill, fixture):
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]}"
INPUT = _read("input") # open,high,low,close,volume (timestamp = row index)
def test_resampler_matches_golden():
r = ta.Resampler(5)
got = []
for i, (o, h, l, c, v) in enumerate(INPUT):
candle = r.update(o, h, l, c, v, i)
if candle is not None:
got.append(candle)
f = r.flush()
if f is not None:
got.append(f)
want = _read("data_resampled")
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]}"
+2
View File
@@ -1,6 +1,7 @@
# Generated by roxygen2: do not edit by hand
S3method(batch,wickra_indicator)
S3method(flush,wickra_indicator)
S3method(is_ready,wickra_indicator)
S3method(name,wickra_indicator)
S3method(push,wickra_indicator)
@@ -341,6 +342,7 @@ export(RegimeLabel)
export(RelativeStrengthAB)
export(RenkoBars)
export(RenkoTrailingStop)
export(Resampler)
export(RickshawMan)
export(RisingThreeMethods)
export(Rmi)
+8
View File
@@ -2679,6 +2679,14 @@ RenkoTrailingStop <- function(block_size) {
.wk_obj("renko_trailing_stop", ptr, "RenkoTrailingStop")
}
#' Resampler indicator
#' @keywords internal
#' @export
Resampler <- function(timeframe) {
ptr <- .Call("wk_resampler_new", timeframe, PACKAGE = "wickra")
.wk_obj("resampler", ptr, "Resampler")
}
#' RickshawMan indicator
#' @keywords internal
#' @export
+19
View File
@@ -170,3 +170,22 @@ push.wickra_indicator <- function(object, price, size, timestamp) {
colnames(out) <- c("open", "high", "low", "close", "volume", "timestamp")
out
}
#' Flush a resampler's final candle
#'
#' Emit the final, still-open candle a [Resampler()] is aggregating (the partial
#' higher-timeframe bar that no later input has closed yet). Extends the base
#' generic [base::flush()].
#'
#' @param con A `wickra_indicator` created by [Resampler()].
#' @return A named numeric vector (`open`, `high`, `low`, `close`, `volume`,
#' `timestamp`), or `NULL` if nothing is pending.
#' @examples
#' r <- Resampler(5)
#' for (t in 0:6) update(r, 100 + t, 101 + t, 99 + t, 100 + t, 10, t)
#' flush(r)
#' @exportS3Method base::flush
flush.wickra_indicator <- function(con) {
out <- .Call(paste0("wk_", con$prefix, "_flush"), con$ptr, PACKAGE = "wickra")
out
}
+61
View File
@@ -14606,6 +14606,64 @@ SEXP wk_renko_trailing_stop_reset(SEXP e) {
return R_NilValue;
}
static void resampler_fin(SEXP e) {
struct Resampler *h = (struct Resampler *)R_ExternalPtrAddr(e);
if (h) wickra_resampler_free(h);
R_ClearExternalPtr(e);
}
SEXP wk_resampler_new(SEXP a0) {
struct Resampler *h = wickra_resampler_new((int64_t)Rf_asReal(a0));
if (!h) Rf_error("invalid Resampler parameters");
SEXP e = PROTECT(R_MakeExternalPtr(h, R_NilValue, R_NilValue));
R_RegisterCFinalizerEx(e, resampler_fin, TRUE);
UNPROTECT(1);
return e;
}
SEXP wk_resampler_update(SEXP e, SEXP a0, SEXP a1, SEXP a2, SEXP a3, SEXP a4, SEXP a5) {
struct Resampler *h = (struct Resampler *)R_ExternalPtrAddr(e);
struct WickraCandle out;
int ok = wickra_resampler_update(h, Rf_asReal(a0), Rf_asReal(a1), Rf_asReal(a2), Rf_asReal(a3), Rf_asReal(a4), (int64_t)Rf_asReal(a5), &out);
SEXP r = PROTECT(Rf_allocVector(REALSXP, 6));
REAL(r)[0] = ok ? (double)out.open : NA_REAL;
REAL(r)[1] = ok ? (double)out.high : NA_REAL;
REAL(r)[2] = ok ? (double)out.low : NA_REAL;
REAL(r)[3] = ok ? (double)out.close : NA_REAL;
REAL(r)[4] = ok ? (double)out.volume : NA_REAL;
REAL(r)[5] = ok ? (double)out.timestamp : NA_REAL;
SEXP nm = PROTECT(Rf_allocVector(STRSXP, 6));
SET_STRING_ELT(nm, 0, Rf_mkChar("open"));
SET_STRING_ELT(nm, 1, Rf_mkChar("high"));
SET_STRING_ELT(nm, 2, Rf_mkChar("low"));
SET_STRING_ELT(nm, 3, Rf_mkChar("close"));
SET_STRING_ELT(nm, 4, Rf_mkChar("volume"));
SET_STRING_ELT(nm, 5, Rf_mkChar("timestamp"));
Rf_setAttrib(r, R_NamesSymbol, nm);
UNPROTECT(2);
return r;
}
SEXP wk_resampler_flush(SEXP e) {
struct Resampler *h = (struct Resampler *)R_ExternalPtrAddr(e);
struct WickraCandle out;
if (!wickra_resampler_flush(h, &out)) return R_NilValue;
SEXP r = PROTECT(Rf_allocVector(REALSXP, 6));
REAL(r)[0] = out.open;
REAL(r)[1] = out.high;
REAL(r)[2] = out.low;
REAL(r)[3] = out.close;
REAL(r)[4] = out.volume;
REAL(r)[5] = (double)out.timestamp;
SEXP nm = PROTECT(Rf_allocVector(STRSXP, 6));
SET_STRING_ELT(nm, 0, Rf_mkChar("open"));
SET_STRING_ELT(nm, 1, Rf_mkChar("high"));
SET_STRING_ELT(nm, 2, Rf_mkChar("low"));
SET_STRING_ELT(nm, 3, Rf_mkChar("close"));
SET_STRING_ELT(nm, 4, Rf_mkChar("volume"));
SET_STRING_ELT(nm, 5, Rf_mkChar("timestamp"));
Rf_setAttrib(r, R_NamesSymbol, nm);
UNPROTECT(2);
return r;
}
static void rickshaw_man_fin(SEXP e) {
struct RickshawMan *h = (struct RickshawMan *)R_ExternalPtrAddr(e);
if (h) wickra_rickshaw_man_free(h);
@@ -24781,6 +24839,9 @@ static const R_CallMethodDef CallEntries[] = {
{"wk_renko_trailing_stop_is_ready", (DL_FUNC)&wk_renko_trailing_stop_is_ready, 1},
{"wk_renko_trailing_stop_name", (DL_FUNC)&wk_renko_trailing_stop_name, 1},
{"wk_renko_trailing_stop_reset", (DL_FUNC)&wk_renko_trailing_stop_reset, 1},
{"wk_resampler_new", (DL_FUNC)&wk_resampler_new, 1},
{"wk_resampler_update", (DL_FUNC)&wk_resampler_update, 7},
{"wk_resampler_flush", (DL_FUNC)&wk_resampler_flush, 1},
{"wk_rickshaw_man_new", (DL_FUNC)&wk_rickshaw_man_new, 0},
{"wk_rickshaw_man_update", (DL_FUNC)&wk_rickshaw_man_update, 7},
{"wk_rickshaw_man_batch", (DL_FUNC)&wk_rickshaw_man_batch, 7},
@@ -50,3 +50,31 @@ test_that("tick aggregator matches the golden candles", {
expect_equal(got, want, tolerance = 1e-9, info = spec$fixture)
}
})
test_that("resampler 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)]
do.call(rbind, lapply(lines, function(l) as.numeric(strsplit(l, ",")[[1]])))
}
input <- read_mat("input") # open,high,low,close,volume (timestamp = row index)
r <- Resampler(5)
got <- matrix(numeric(0), 0, 6)
for (i in seq_len(nrow(input))) {
c <- update(r, input[i, 1], input[i, 2], input[i, 3], input[i, 4], input[i, 5], i - 1)
if (!is.na(c[1])) {
got <- rbind(got, unname(c))
}
}
f <- flush(r)
if (!is.null(f)) {
got <- rbind(got, unname(f))
}
want <- unname(read_mat("data_resampled"))
expect_equal(nrow(got), nrow(want))
expect_equal(got, want, tolerance = 1e-9)
})
+58
View File
@@ -16043,3 +16043,61 @@ impl WasmTickAggregator {
self.inner.fills_gaps()
}
}
// ===== Data layer: resampling (candle -> higher-timeframe candle) =====
fn candle_object(c: wc::Candle) -> Object {
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();
obj
}
/// Resample candles into a higher timeframe (e.g. 1m -> 5m).
#[wasm_bindgen(js_name = Resampler)]
pub struct WasmResampler {
inner: wickra_data::resample::Resampler,
}
#[wasm_bindgen(js_class = Resampler)]
impl WasmResampler {
/// Construct a resampler aggregating inputs into `timeframe`-sized candles.
#[wasm_bindgen(constructor)]
pub fn new(timeframe: f64) -> Result<WasmResampler, JsError> {
let tf = wickra_data::aggregator::Timeframe::new(timeframe as i64).map_err(map_data_err)?;
Ok(Self {
inner: wickra_data::resample::Resampler::new(tf),
})
}
/// Push one candle; returns a `{ open, high, low, close, volume, timestamp }`
/// object on a bucket boundary, otherwise `null`.
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: f64,
) -> Result<JsValue, JsError> {
let candle =
wc::Candle::new(open, high, low, close, volume, timestamp as i64).map_err(map_err)?;
match self.inner.push(candle).map_err(map_data_err)? {
Some(c) => Ok(candle_object(c).into()),
None => Ok(JsValue::NULL),
}
}
/// Emit the final, still-open candle (or `null` if none is pending).
pub fn flush(&mut self) -> Result<JsValue, JsError> {
match self.inner.flush().map_err(map_data_err)? {
Some(c) => Ok(candle_object(c).into()),
None => Ok(JsValue::NULL),
}
}
}
+22
View File
@@ -49,3 +49,25 @@ test('wasm tick aggregator matches the golden candles', () => {
test('wasm tick aggregator gap-fill matches the golden candles', () => {
assertCandles(run(true), readCsv('data_candles_gap'), 'gap');
});
const INPUT = readCsv('input'); // open,high,low,close,volume (timestamp = row index)
function runResample() {
const r = new W.Resampler(5);
const out = [];
INPUT.forEach(([o, h, l, c, v], i) => {
const candle = r.update(o, h, l, c, v, i);
if (candle) {
out.push([candle.open, candle.high, candle.low, candle.close, candle.volume, candle.timestamp]);
}
});
const f = r.flush();
if (f) {
out.push([f.open, f.high, f.low, f.close, f.volume, f.timestamp]);
}
return out;
}
test('wasm resampler matches the golden candles', () => {
assertCandles(runResample(), readCsv('data_resampled'), 'resample');
});
+54
View File
@@ -94,12 +94,66 @@ static int check(const char *fixture, bool gap, const double *ticks, int nt) {
return fails;
}
static int check_resample(void) {
double input[MAXROWS * 5];
int ni = read_csv("input", input, 5);
double want[MAXROWS * 6];
int nw = read_csv("data_resampled", want, 6);
struct Resampler *r = wickra_resampler_new(5);
if (!r) {
printf("FAIL resample: new returned NULL\n");
return 1;
}
double got[MAXROWS * 6];
int ng = 0;
struct WickraCandle out;
for (int i = 0; i < ni; i++) {
if (wickra_resampler_update(r, input[i * 5 + 0], input[i * 5 + 1], input[i * 5 + 2],
input[i * 5 + 3], input[i * 5 + 4], (int64_t)i, &out)) {
got[ng * 6 + 0] = out.open;
got[ng * 6 + 1] = out.high;
got[ng * 6 + 2] = out.low;
got[ng * 6 + 3] = out.close;
got[ng * 6 + 4] = out.volume;
got[ng * 6 + 5] = (double)out.timestamp;
ng++;
}
}
if (wickra_resampler_flush(r, &out)) {
got[ng * 6 + 0] = out.open;
got[ng * 6 + 1] = out.high;
got[ng * 6 + 2] = out.low;
got[ng * 6 + 3] = out.close;
got[ng * 6 + 4] = out.volume;
got[ng * 6 + 5] = (double)out.timestamp;
ng++;
}
wickra_resampler_free(r);
if (ng != nw) {
printf("FAIL resample: %d candles vs %d\n", 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 resample row %d col %d: %g vs %g\n", 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);
fails += check_resample();
if (fails == 0) {
printf("C/C++ data layer: OK (%d ticks)\n", nt);
}
+29
View File
@@ -20,6 +20,7 @@ use wickra::{
Indicator, IntradayIntensity, MacdIndicator, Rsi, Sma, Tick,
};
use wickra_data::aggregator::{TickAggregator, Timeframe};
use wickra_data::resample::Resampler;
const N: usize = 80;
@@ -185,9 +186,37 @@ fn main() {
emit_profiles(dir, &candles);
emit_bars(dir, &candles);
emit_data_layer(dir);
emit_resampler(dir, &candles);
println!("golden fixtures written to {}", dir.display());
}
/// Data layer: the resampler. Resamples the shared input candles (timestamp =
/// row index) into 5-unit buckets; the final partial bucket comes out of flush.
fn emit_resampler(dir: &Path, candles: &[Candle]) {
let mut resampler = Resampler::new(Timeframe::new(5).unwrap());
let mut rows = Vec::new();
for &candle in candles {
if let Some(out) = resampler.push(candle).expect("valid resample push") {
rows.push(format!(
"{},{},{},{},{},{}",
out.open, out.high, out.low, out.close, out.volume, out.timestamp
));
}
}
if let Some(out) = resampler.flush().expect("valid resample flush") {
rows.push(format!(
"{},{},{},{},{},{}",
out.open, out.high, out.low, out.close, out.volume, out.timestamp
));
}
write_csv(
dir,
"data_resampled",
"open,high,low,close,volume,timestamp",
&rows,
);
}
/// 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
+17
View File
@@ -0,0 +1,17 @@
open,high,low,close,volume,timestamp
99,112.90403177129735,98,111.32039085967226,5187.008623739826,0
113.27609348158748,115.1642566915892,107.76539185209613,108.7737988023383,5473.238103468329,5
106.12753789513545,107.73969337995807,96.34710045689485,98.28424227586412,5324.374653317379,10
97.378063505514,106.32510161131832,95.93821562552817,103.99314457402363,5150.941358775185,15
108.04491654708718,121.38046215528227,105.7105413401633,119.93667863849153,5456.9835954080345,20
120.88220148856881,124.2878498649492,119.39438848583472,121.12969230082183,5371.101223522536,25
119.8808727652764,121.29920058454442,108.51535390119264,110.00125312406458,5128.47088205189,30
108.48360243707184,113.29382811110736,106.26786689008574,111.88016416080967,5431.582557578566,35
114.22618875818226,130.20955349568973,113.09073586402833,127.92073514707224,5410.400200215731,40
131.41114890560974,135.065460892756,129.49585703767033,132.95746831142935,5120.058973527027,45
130.5116755897077,132.71696973631924,121.2254215875191,122.27578013601534,5397.543391924787,50
121.09703354508844,122.45602084147426,117.25536324735009,120.37417550208815,5441.485014111252,55
122.33587608239566,136.79793765689536,120.87761530843784,135.43314928819893,5132.5539248008245,60
138.08766685694894,146.57143212166807,136.58837142074304,144.11152724502116,5355.547392578692,65
144.27024859045207,145.7471249168318,132.66155074344638,134.9266357939324,5463.733502447633,70
131.6480122345848,134.02210748888697,127.39947545719488,129.59514479102842,5163.946538828373,75
1 open high low close volume timestamp
2 99 112.90403177129735 98 111.32039085967226 5187.008623739826 0
3 113.27609348158748 115.1642566915892 107.76539185209613 108.7737988023383 5473.238103468329 5
4 106.12753789513545 107.73969337995807 96.34710045689485 98.28424227586412 5324.374653317379 10
5 97.378063505514 106.32510161131832 95.93821562552817 103.99314457402363 5150.941358775185 15
6 108.04491654708718 121.38046215528227 105.7105413401633 119.93667863849153 5456.9835954080345 20
7 120.88220148856881 124.2878498649492 119.39438848583472 121.12969230082183 5371.101223522536 25
8 119.8808727652764 121.29920058454442 108.51535390119264 110.00125312406458 5128.47088205189 30
9 108.48360243707184 113.29382811110736 106.26786689008574 111.88016416080967 5431.582557578566 35
10 114.22618875818226 130.20955349568973 113.09073586402833 127.92073514707224 5410.400200215731 40
11 131.41114890560974 135.065460892756 129.49585703767033 132.95746831142935 5120.058973527027 45
12 130.5116755897077 132.71696973631924 121.2254215875191 122.27578013601534 5397.543391924787 50
13 121.09703354508844 122.45602084147426 117.25536324735009 120.37417550208815 5441.485014111252 55
14 122.33587608239566 136.79793765689536 120.87761530843784 135.43314928819893 5132.5539248008245 60
15 138.08766685694894 146.57143212166807 136.58837142074304 144.11152724502116 5355.547392578692 65
16 144.27024859045207 145.7471249168318 132.66155074344638 134.9266357939324 5463.733502447633 70
17 131.6480122345848 134.02210748888697 127.39947545719488 129.59514479102842 5163.946538828373 75