feat(data): expose CandleReader (CSV) natively in all 10 languages (#311)
Add the data-layer CSV candle reader to every binding so loading OHLCV candles from a CSV no longer needs a per-language CSV/dataframe dependency. - C ABI: wickra_candle_reader_new(bytes, len) / _count / _read / _free over an opaque CandleReader handle (parse the whole buffer up front, then drain). - Native: Node/WASM CandleReader.read() -> Candle[], Python read() -> list[tuple]. - C-ABI languages: Go Read() []Candle, C# Candle[] Read(), Java Candle[] read(), R read() S3 generic (n x 6 matrix); C / C++ call the C ABI directly. - Cross-language golden testdata/golden/data_csv*.csv pins the parsed candles bit-for-bit across every binding. Verified locally across Rust (test+clippy+fmt), Node, WASM, Python, C#, Go, Java, R, and the C/C++ cmake parity suite.
This commit is contained in:
@@ -128,6 +128,8 @@ typedef struct CalmarRatio CalmarRatio;
|
||||
|
||||
typedef struct Camarilla Camarilla;
|
||||
|
||||
typedef struct CandleReader CandleReader;
|
||||
|
||||
typedef struct CandleVolume CandleVolume;
|
||||
|
||||
typedef struct Cci Cci;
|
||||
@@ -13736,6 +13738,16 @@ bool wickra_resampler_flush(struct Resampler *handle, struct WickraCandle *out);
|
||||
|
||||
void wickra_resampler_free(struct Resampler *handle);
|
||||
|
||||
struct CandleReader *wickra_candle_reader_new(const uint8_t *data, uintptr_t len);
|
||||
|
||||
uintptr_t wickra_candle_reader_count(const struct CandleReader *handle);
|
||||
|
||||
uintptr_t wickra_candle_reader_read(struct CandleReader *handle,
|
||||
struct WickraCandle *out,
|
||||
uintptr_t cap);
|
||||
|
||||
void wickra_candle_reader_free(struct CandleReader *handle);
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif // __cplusplus
|
||||
|
||||
@@ -68286,6 +68286,99 @@ pub unsafe extern "C" fn wickra_resampler_free(handle: *mut Resampler) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque CSV candle reader: parses an entire `timestamp,open,high,low,close,volume`
|
||||
/// CSV buffer up front and hands the candles out in drain order. Named
|
||||
/// `CandleReader` (the public C-ABI handle); the inner `wickra-data` reader is
|
||||
/// reached through its full path to avoid the name clash.
|
||||
#[derive(Debug)]
|
||||
pub struct CandleReader {
|
||||
candles: Vec<Candle>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
/// Parse an OHLCV CSV buffer (`len` bytes at `data`) into candles. The first line
|
||||
/// must be a header naming `timestamp,open,high,low,close,volume` (a leading UTF-8
|
||||
/// BOM and field whitespace are tolerated). Returns `NULL` on a `NULL` pointer or a
|
||||
/// malformed CSV (missing column, unparseable row, or an OHLC relation the core
|
||||
/// rejects). Read the candles with `wickra_candle_reader_read` and release with
|
||||
/// `wickra_candle_reader_free`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `data` must point to `len` readable bytes, or be `NULL`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wickra_candle_reader_new(
|
||||
data: *const u8,
|
||||
len: usize,
|
||||
) -> *mut CandleReader {
|
||||
if data.is_null() {
|
||||
return ptr::null_mut();
|
||||
}
|
||||
let bytes = slice::from_raw_parts(data, len);
|
||||
let Ok(mut reader) = wickra_data::csv::CandleReader::from_reader(bytes) else {
|
||||
return ptr::null_mut();
|
||||
};
|
||||
match reader.read_all() {
|
||||
Ok(candles) => Box::into_raw(Box::new(CandleReader { candles, pos: 0 })),
|
||||
Err(_) => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of candles not yet read from the reader. Returns `0` on a `NULL` handle.
|
||||
///
|
||||
/// # Safety
|
||||
/// `handle` must be valid (from `wickra_candle_reader_new`, not freed), or `NULL`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wickra_candle_reader_count(handle: *const CandleReader) -> usize {
|
||||
match handle.as_ref() {
|
||||
Some(reader) => reader.candles.len() - reader.pos,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy up to `cap` not-yet-read candles into `out`, advance past them, and return
|
||||
/// the number written. Returns `0` on a `NULL` handle / `out`. Call with `cap` equal
|
||||
/// to `wickra_candle_reader_count` to drain every candle in one call.
|
||||
///
|
||||
/// # Safety
|
||||
/// `handle` (from `wickra_candle_reader_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_candle_reader_read(
|
||||
handle: *mut CandleReader,
|
||||
out: *mut WickraCandle,
|
||||
cap: usize,
|
||||
) -> usize {
|
||||
let Some(reader) = handle.as_mut() else {
|
||||
return 0;
|
||||
};
|
||||
if out.is_null() {
|
||||
return 0;
|
||||
}
|
||||
let count = (reader.candles.len() - reader.pos).min(cap);
|
||||
let slots = slice::from_raw_parts_mut(out, count);
|
||||
for (slot, candle) in slots
|
||||
.iter_mut()
|
||||
.zip(&reader.candles[reader.pos..reader.pos + count])
|
||||
{
|
||||
*slot = candle_to_c(*candle);
|
||||
}
|
||||
reader.pos += count;
|
||||
count
|
||||
}
|
||||
|
||||
/// Destroy a candle reader created by `wickra_candle_reader_new`. No-op if `handle`
|
||||
/// is `NULL`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `handle` must have been returned by `wickra_candle_reader_new` and not previously
|
||||
/// freed, or `NULL`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn wickra_candle_reader_free(handle: *mut CandleReader) {
|
||||
if !handle.is_null() {
|
||||
drop(Box::from_raw(handle));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -59,6 +59,26 @@ public class DataLayerTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CandleReaderMatchesGolden()
|
||||
{
|
||||
var csv = File.ReadAllText(Path.Combine(GoldenDir(), "data_csv.csv"));
|
||||
using var reader = new CandleReader(csv);
|
||||
var candles = reader.Read();
|
||||
var want = Read("data_csv_candles");
|
||||
Assert.Equal(want.Length, candles.Length);
|
||||
for (var i = 0; i < candles.Length; i++)
|
||||
{
|
||||
var c = candles[i];
|
||||
var got = new[] { c.Open, c.High, c.Low, c.Close, c.Volume, (double)c.Timestamp };
|
||||
for (var j = 0; j < 6; j++)
|
||||
{
|
||||
var tol = 1e-9 * Math.Max(1, Math.Abs(want[i][j]));
|
||||
Assert.True(Math.Abs(got[j] - want[i][j]) <= tol, $"candle reader row {i} col {j}: {got[j]} vs {want[i][j]}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, "data_candles")]
|
||||
[InlineData(true, "data_candles_gap")]
|
||||
|
||||
@@ -4981,6 +4981,60 @@ public sealed class Camarilla : IDisposable
|
||||
public void Dispose() => _handle.Dispose();
|
||||
}
|
||||
|
||||
public sealed class CandleReader : IDisposable
|
||||
{
|
||||
private readonly WickraHandle _handle;
|
||||
|
||||
/// <summary>Parse a timestamp,open,high,low,close,volume CSV string (a
|
||||
/// leading UTF-8 BOM and field whitespace are tolerated).</summary>
|
||||
public CandleReader(string csv)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(csv);
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(csv);
|
||||
nint ptr;
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* data = bytes)
|
||||
{
|
||||
ptr = NativeMethods.wickra_candle_reader_new(data, (nuint)bytes.Length);
|
||||
}
|
||||
}
|
||||
if (ptr == nint.Zero)
|
||||
{
|
||||
throw new ArgumentException("invalid CandleReader CSV");
|
||||
}
|
||||
_handle = new WickraHandle(ptr, NativeMethods.wickra_candle_reader_free);
|
||||
}
|
||||
|
||||
/// <summary>Every candle parsed from the CSV, in file order.</summary>
|
||||
public Candle[] Read()
|
||||
{
|
||||
var count = (long)NativeMethods.wickra_candle_reader_count(_handle.DangerousGetHandle());
|
||||
GC.KeepAlive(_handle);
|
||||
if (count <= 0)
|
||||
{
|
||||
return Array.Empty<Candle>();
|
||||
}
|
||||
var buffer = new WickraCandle[count];
|
||||
unsafe
|
||||
{
|
||||
fixed (WickraCandle* ptr = buffer)
|
||||
{
|
||||
NativeMethods.wickra_candle_reader_read(_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 CandleVolume : IDisposable
|
||||
{
|
||||
private readonly WickraHandle _handle;
|
||||
|
||||
@@ -12430,6 +12430,18 @@ internal static partial class NativeMethods
|
||||
[LibraryImport(WickraNative.LibraryName)]
|
||||
internal static partial void wickra_resampler_free(nint handle);
|
||||
|
||||
[LibraryImport(WickraNative.LibraryName)]
|
||||
internal static unsafe partial nint wickra_candle_reader_new(byte* data, nuint len);
|
||||
|
||||
[LibraryImport(WickraNative.LibraryName)]
|
||||
internal static partial nuint wickra_candle_reader_count(nint handle);
|
||||
|
||||
[LibraryImport(WickraNative.LibraryName)]
|
||||
internal static unsafe partial nuint wickra_candle_reader_read(nint handle, WickraCandle* @out, nuint cap);
|
||||
|
||||
[LibraryImport(WickraNative.LibraryName)]
|
||||
internal static partial void wickra_candle_reader_free(nint handle);
|
||||
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
|
||||
@@ -2,6 +2,7 @@ package wickra
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
@@ -51,6 +52,35 @@ func TestResamplerGolden(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandleReaderGolden(t *testing.T) {
|
||||
csv, err := os.ReadFile("../../testdata/golden/data_csv.csv")
|
||||
if err != nil {
|
||||
t.Fatalf("read data_csv.csv: %v", err)
|
||||
}
|
||||
r, err := NewCandleReader(string(csv))
|
||||
if err != nil {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
candles := r.Read()
|
||||
got := make([][6]float64, len(candles))
|
||||
for i, c := range candles {
|
||||
got[i] = [6]float64{c.Open, c.High, c.Low, c.Close, c.Volume, float64(c.Timestamp)}
|
||||
}
|
||||
want := readGolden(t, "data_csv_candles")
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("candle reader: %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("candle reader 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 {
|
||||
|
||||
@@ -128,6 +128,8 @@ typedef struct CalmarRatio CalmarRatio;
|
||||
|
||||
typedef struct Camarilla Camarilla;
|
||||
|
||||
typedef struct CandleReader CandleReader;
|
||||
|
||||
typedef struct CandleVolume CandleVolume;
|
||||
|
||||
typedef struct Cci Cci;
|
||||
@@ -13736,6 +13738,16 @@ bool wickra_resampler_flush(struct Resampler *handle, struct WickraCandle *out);
|
||||
|
||||
void wickra_resampler_free(struct Resampler *handle);
|
||||
|
||||
struct CandleReader *wickra_candle_reader_new(const uint8_t *data, uintptr_t len);
|
||||
|
||||
uintptr_t wickra_candle_reader_count(const struct CandleReader *handle);
|
||||
|
||||
uintptr_t wickra_candle_reader_read(struct CandleReader *handle,
|
||||
struct WickraCandle *out,
|
||||
uintptr_t cap);
|
||||
|
||||
void wickra_candle_reader_free(struct CandleReader *handle);
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif // __cplusplus
|
||||
|
||||
@@ -5746,6 +5746,57 @@ func (ind *Camarilla) Close() {
|
||||
}
|
||||
}
|
||||
|
||||
// CandleReader parses an OHLCV CSV buffer into candles over the Wickra C ABI.
|
||||
type CandleReader struct {
|
||||
handle *C.struct_CandleReader
|
||||
}
|
||||
|
||||
// NewCandleReader parses a timestamp,open,high,low,close,volume CSV string (a
|
||||
// leading UTF-8 BOM and field whitespace are tolerated). It returns
|
||||
// ErrInvalidParams when the header or a row is malformed.
|
||||
func NewCandleReader(csv string) (*CandleReader, error) {
|
||||
data := []byte(csv)
|
||||
var ptr *C.struct_CandleReader
|
||||
if len(data) == 0 {
|
||||
ptr = C.wickra_candle_reader_new(nil, 0)
|
||||
} else {
|
||||
ptr = C.wickra_candle_reader_new((*C.uint8_t)(unsafe.Pointer(&data[0])), C.uintptr_t(len(data)))
|
||||
}
|
||||
if ptr == nil {
|
||||
return nil, ErrInvalidParams
|
||||
}
|
||||
obj := &CandleReader{handle: ptr}
|
||||
runtime.SetFinalizer(obj, (*CandleReader).Close)
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// Read returns every candle parsed from the CSV, in file order.
|
||||
func (ind *CandleReader) Read() []Candle {
|
||||
n := int(C.wickra_candle_reader_count(ind.handle))
|
||||
runtime.KeepAlive(ind)
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
buf := make([]C.struct_WickraCandle, n)
|
||||
C.wickra_candle_reader_read(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 *CandleReader) Close() {
|
||||
if ind.handle != nil {
|
||||
C.wickra_candle_reader_free(ind.handle)
|
||||
ind.handle = nil
|
||||
runtime.SetFinalizer(ind, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// CandleVolume wraps the CandleVolume indicator over the Wickra C ABI.
|
||||
type CandleVolume struct {
|
||||
handle *C.struct_CandleVolume
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// 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.*;
|
||||
|
||||
/** CSV candle reader over the Wickra C ABI. Not thread-safe; close when done. */
|
||||
public final class CandleReader implements AutoCloseable {
|
||||
private final MemorySegment handle;
|
||||
private final Cleaner.Cleanable cleanable;
|
||||
|
||||
/** Parse a timestamp,open,high,low,close,volume CSV string (a leading
|
||||
* UTF-8 BOM and field whitespace are tolerated). */
|
||||
public CandleReader(String csv) {
|
||||
if (csv == null) {
|
||||
throw new NullPointerException("csv");
|
||||
}
|
||||
byte[] bytes = csv.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
MemorySegment h;
|
||||
try (Arena a = Arena.ofConfined()) {
|
||||
MemorySegment data = a.allocate(Math.max(1L, bytes.length));
|
||||
MemorySegment.copy(bytes, 0, data, JAVA_BYTE, 0L, bytes.length);
|
||||
h = (MemorySegment) NativeMethods.WICKRA_CANDLE_READER_NEW.invokeExact(data, (long) bytes.length);
|
||||
} catch (Throwable t) {
|
||||
throw WickraNative.rethrow(t);
|
||||
}
|
||||
if (h.address() == 0L) {
|
||||
throw new IllegalArgumentException("invalid CandleReader CSV");
|
||||
}
|
||||
this.handle = h;
|
||||
this.cleanable = WickraNative.register(this, h, NativeMethods.WICKRA_CANDLE_READER_FREE);
|
||||
}
|
||||
|
||||
/** Every candle parsed from the CSV, in file order. */
|
||||
public Candle[] read() {
|
||||
try {
|
||||
long n = (long) NativeMethods.WICKRA_CANDLE_READER_COUNT.invokeExact(handle);
|
||||
if (n <= 0) {
|
||||
return new Candle[0];
|
||||
}
|
||||
try (Arena a = Arena.ofConfined()) {
|
||||
MemorySegment out = a.allocate(48L * n);
|
||||
long w = (long) NativeMethods.WICKRA_CANDLE_READER_READ.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();
|
||||
}
|
||||
}
|
||||
@@ -3955,6 +3955,10 @@ public final class NativeMethods {
|
||||
public static MethodHandle WICKRA_RESAMPLER_UPDATE;
|
||||
public static MethodHandle WICKRA_RESAMPLER_FLUSH;
|
||||
public static MethodHandle WICKRA_RESAMPLER_FREE;
|
||||
public static MethodHandle WICKRA_CANDLE_READER_NEW;
|
||||
public static MethodHandle WICKRA_CANDLE_READER_COUNT;
|
||||
public static MethodHandle WICKRA_CANDLE_READER_READ;
|
||||
public static MethodHandle WICKRA_CANDLE_READER_FREE;
|
||||
|
||||
static {
|
||||
init0();
|
||||
@@ -8031,6 +8035,10 @@ public final class NativeMethods {
|
||||
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));
|
||||
WICKRA_CANDLE_READER_NEW = h("wickra_candle_reader_new", FunctionDescriptor.of(ADDRESS, ADDRESS, JAVA_LONG));
|
||||
WICKRA_CANDLE_READER_COUNT = h("wickra_candle_reader_count", FunctionDescriptor.of(JAVA_LONG, ADDRESS));
|
||||
WICKRA_CANDLE_READER_READ = h("wickra_candle_reader_read", FunctionDescriptor.of(JAVA_LONG, ADDRESS, ADDRESS, JAVA_LONG));
|
||||
WICKRA_CANDLE_READER_FREE = h("wickra_candle_reader_free", FunctionDescriptor.ofVoid(ADDRESS));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -73,6 +73,25 @@ class DataLayerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void candleReaderMatchesGolden() throws IOException {
|
||||
String csv = Files.readString(goldenDir().resolve("data_csv.csv"));
|
||||
try (CandleReader reader = new CandleReader(csv)) {
|
||||
Candle[] candles = reader.read();
|
||||
double[][] want = read("data_csv_candles");
|
||||
assertEquals(want.length, candles.length, "candle reader count");
|
||||
for (int i = 0; i < candles.length; i++) {
|
||||
Candle k = candles[i];
|
||||
double[] got = {k.open(), k.high(), k.low(), k.close(), k.volume(), (double) k.timestamp()};
|
||||
for (int j = 0; j < 6; j++) {
|
||||
double w = want[i][j];
|
||||
assertTrue(Math.abs(got[j] - w) <= 1e-9 * Math.max(1, Math.abs(w)),
|
||||
"candle reader row " + i + " col " + j + ": " + got[j] + " vs " + w);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void tickAggregatorMatchesGolden() throws IOException {
|
||||
double[][] ticks = read("data_ticks");
|
||||
|
||||
@@ -27,10 +27,11 @@ 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']);
|
||||
// Data-layer types (tick aggregator, resampler, CSV candle reader) are not
|
||||
// `Indicator`s: they transform raw market data into candles and have their own
|
||||
// update/flush/read shape, so they are excluded from the streaming-indicator
|
||||
// completeness contract.
|
||||
const DATA_LAYER = new Set(['TickAggregator', 'Resampler', 'CandleReader']);
|
||||
|
||||
// An "indicator class" is an exported constructor whose prototype carries the
|
||||
// streaming `update` method. This excludes `version` (a plain function), the bar
|
||||
|
||||
@@ -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, Resampler } = require('..');
|
||||
const { TickAggregator, Resampler, CandleReader } = require('..');
|
||||
|
||||
const GOLDEN = path.resolve(__dirname, '..', '..', '..', 'testdata', 'golden');
|
||||
|
||||
@@ -52,6 +52,13 @@ test('tick aggregator gap-fill matches the golden candles', () => {
|
||||
assertCandles(run(true), readCsv('data_candles_gap'), 'gap');
|
||||
});
|
||||
|
||||
test('candle reader matches the golden candles', () => {
|
||||
const csv = fs.readFileSync(path.join(GOLDEN, 'data_csv.csv'), 'utf8');
|
||||
const reader = new CandleReader(csv);
|
||||
const got = reader.read().map((c) => [c.open, c.high, c.low, c.close, c.volume, c.timestamp]);
|
||||
assertCandles(got, readCsv('data_csv_candles'), 'candle-reader');
|
||||
});
|
||||
|
||||
const INPUT = readCsv('input'); // open,high,low,close,volume (timestamp = row index)
|
||||
|
||||
function runResample() {
|
||||
|
||||
Vendored
+11
@@ -5973,3 +5973,14 @@ export declare class Resampler {
|
||||
/** Emit the final, still-open candle (or `null` if none is pending). */
|
||||
flush(): CandleValue | null
|
||||
}
|
||||
export type CandleReaderNode = CandleReader
|
||||
/**
|
||||
* Parse OHLCV candles from a CSV string (header `timestamp,open,high,low,close,
|
||||
* volume`; a leading UTF-8 BOM is stripped).
|
||||
*/
|
||||
export declare class CandleReader {
|
||||
/** Parse the whole CSV up front; throws on a malformed header or row. */
|
||||
constructor(csv: string)
|
||||
/** Return every parsed candle as `{ open, high, low, close, volume, timestamp }`. */
|
||||
read(): Array<CandleValue>
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -22000,3 +22000,30 @@ impl ResamplerNode {
|
||||
.map(candle_to_value))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Data layer: CSV candle reader =====
|
||||
|
||||
/// Parse OHLCV candles from a CSV string (header `timestamp,open,high,low,close,
|
||||
/// volume`; a leading UTF-8 BOM is stripped).
|
||||
#[napi(js_name = "CandleReader")]
|
||||
pub struct CandleReaderNode {
|
||||
candles: Vec<wc::Candle>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl CandleReaderNode {
|
||||
/// Parse the whole CSV up front; throws on a malformed header or row.
|
||||
#[napi(constructor)]
|
||||
pub fn new(csv: String) -> napi::Result<Self> {
|
||||
let mut reader =
|
||||
wickra_data::csv::CandleReader::from_reader(csv.as_bytes()).map_err(map_data_err)?;
|
||||
let candles = reader.read_all().map_err(map_data_err)?;
|
||||
Ok(Self { candles })
|
||||
}
|
||||
|
||||
/// Return every parsed candle as `{ open, high, low, close, volume, timestamp }`.
|
||||
#[napi]
|
||||
pub fn read(&self) -> Vec<CandleValue> {
|
||||
self.candles.iter().map(|&c| candle_to_value(c)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +361,7 @@ from ._wickra import (
|
||||
# Data layer
|
||||
TickAggregator,
|
||||
Resampler,
|
||||
CandleReader,
|
||||
# Market Profile
|
||||
CompositeProfile,
|
||||
HighLowVolumeNodes,
|
||||
@@ -908,6 +909,7 @@ __all__ = [
|
||||
# Data layer
|
||||
"TickAggregator",
|
||||
"Resampler",
|
||||
"CandleReader",
|
||||
# Market Profile
|
||||
"CompositeProfile",
|
||||
"HighLowVolumeNodes",
|
||||
|
||||
@@ -28288,6 +28288,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
// Data layer.
|
||||
m.add_class::<PyTickAggregator>()?;
|
||||
m.add_class::<PyResampler>()?;
|
||||
m.add_class::<PyCandleReader>()?;
|
||||
// Candlestick patterns.
|
||||
m.add_class::<PyDoji>()?;
|
||||
m.add_class::<PyHammer>()?;
|
||||
@@ -28666,3 +28667,32 @@ impl PyResampler {
|
||||
.map(|c| (c.open, c.high, c.low, c.close, c.volume, c.timestamp)))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Data layer: CSV candle reader =====
|
||||
|
||||
/// Parse OHLCV candles from a CSV string (header `timestamp,open,high,low,close,
|
||||
/// volume`; a leading UTF-8 BOM is stripped).
|
||||
#[pyclass(name = "CandleReader", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyCandleReader {
|
||||
candles: Vec<wc::Candle>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyCandleReader {
|
||||
#[new]
|
||||
fn new(csv: &str) -> PyResult<Self> {
|
||||
let mut reader =
|
||||
wickra_data::csv::CandleReader::from_reader(csv.as_bytes()).map_err(map_data_err)?;
|
||||
let candles = reader.read_all().map_err(map_data_err)?;
|
||||
Ok(Self { candles })
|
||||
}
|
||||
|
||||
/// Return every parsed candle as `(open, high, low, close, volume, timestamp)`.
|
||||
fn read(&self) -> Vec<CandleTuple> {
|
||||
self.candles
|
||||
.iter()
|
||||
.map(|c| (c.open, c.high, c.low, c.close, c.volume, c.timestamp))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,18 @@ def test_tick_aggregator_matches_golden(gap_fill, fixture):
|
||||
assert abs(g[j] - w[j]) <= tol, f"row {i} col {j}: {g[j]} vs {w[j]}"
|
||||
|
||||
|
||||
def test_candle_reader_matches_golden():
|
||||
with open(os.path.join(GOLDEN, "data_csv.csv")) as f:
|
||||
text = f.read()
|
||||
got = ta.CandleReader(text).read()
|
||||
want = _read("data_csv_candles")
|
||||
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]}"
|
||||
|
||||
|
||||
INPUT = _read("input") # open,high,low,close,volume (timestamp = row index)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ S3method(flush,wickra_indicator)
|
||||
S3method(is_ready,wickra_indicator)
|
||||
S3method(name,wickra_indicator)
|
||||
S3method(push,wickra_indicator)
|
||||
S3method(read,wickra_indicator)
|
||||
S3method(reset,wickra_indicator)
|
||||
S3method(update,wickra_indicator)
|
||||
S3method(warmup_period,wickra_indicator)
|
||||
@@ -67,6 +68,7 @@ export(Butterfly)
|
||||
export(CalendarSpread)
|
||||
export(CalmarRatio)
|
||||
export(Camarilla)
|
||||
export(CandleReader)
|
||||
export(CandleVolume)
|
||||
export(Cci)
|
||||
export(CenterOfGravity)
|
||||
@@ -528,6 +530,7 @@ export(batch)
|
||||
export(is_ready)
|
||||
export(name)
|
||||
export(push)
|
||||
export(read)
|
||||
export(reset)
|
||||
export(warmup_period)
|
||||
importFrom(stats,update)
|
||||
|
||||
@@ -479,6 +479,14 @@ Camarilla <- function() {
|
||||
.wk_obj("camarilla", ptr, "Camarilla")
|
||||
}
|
||||
|
||||
#' CandleReader: parse OHLCV candles from a CSV string
|
||||
#' @keywords internal
|
||||
#' @export
|
||||
CandleReader <- function(csv) {
|
||||
ptr <- .Call("wk_candle_reader_new", csv, PACKAGE = "wickra")
|
||||
.wk_obj("candle_reader", ptr, "CandleReader")
|
||||
}
|
||||
|
||||
#' CandleVolume indicator
|
||||
#' @keywords internal
|
||||
#' @export
|
||||
|
||||
@@ -189,3 +189,26 @@ flush.wickra_indicator <- function(con) {
|
||||
out <- .Call(paste0("wk_", con$prefix, "_flush"), con$ptr, PACKAGE = "wickra")
|
||||
out
|
||||
}
|
||||
|
||||
#' Read every candle parsed by a CSV candle reader
|
||||
#'
|
||||
#' Returns all the candles a [CandleReader()] parsed from its CSV, as a numeric
|
||||
#' matrix with columns `open`, `high`, `low`, `close`, `volume`, `timestamp`.
|
||||
#'
|
||||
#' @param object A `wickra_indicator` created by [CandleReader()].
|
||||
#' @return A numeric matrix with six named columns (zero rows for an empty CSV).
|
||||
#' @examples
|
||||
#' r <- CandleReader("timestamp,open,high,low,close,volume\n0,100,101,99,100.5,10\n")
|
||||
#' read(r)
|
||||
#' @export
|
||||
read <- function(object) {
|
||||
UseMethod("read")
|
||||
}
|
||||
|
||||
#' @rdname read
|
||||
#' @export
|
||||
read.wickra_indicator <- function(object) {
|
||||
out <- .Call(paste0("wk_", object$prefix, "_read"), object$ptr, PACKAGE = "wickra")
|
||||
colnames(out) <- c("open", "high", "low", "close", "volume", "timestamp")
|
||||
out
|
||||
}
|
||||
|
||||
@@ -2603,6 +2603,40 @@ SEXP wk_camarilla_reset(SEXP e) {
|
||||
return R_NilValue;
|
||||
}
|
||||
|
||||
static void candle_reader_fin(SEXP e) {
|
||||
struct CandleReader *h = (struct CandleReader *)R_ExternalPtrAddr(e);
|
||||
if (h) wickra_candle_reader_free(h);
|
||||
R_ClearExternalPtr(e);
|
||||
}
|
||||
SEXP wk_candle_reader_new(SEXP csv) {
|
||||
SEXP s = STRING_ELT(csv, 0);
|
||||
const char *str = CHAR(s);
|
||||
struct CandleReader *h = wickra_candle_reader_new((const uint8_t *)str, (uintptr_t)LENGTH(s));
|
||||
if (!h) Rf_error("invalid CandleReader CSV");
|
||||
SEXP e = PROTECT(R_MakeExternalPtr(h, R_NilValue, R_NilValue));
|
||||
R_RegisterCFinalizerEx(e, candle_reader_fin, TRUE);
|
||||
UNPROTECT(1);
|
||||
return e;
|
||||
}
|
||||
SEXP wk_candle_reader_read(SEXP e) {
|
||||
struct CandleReader *h = (struct CandleReader *)R_ExternalPtrAddr(e);
|
||||
uintptr_t n = wickra_candle_reader_count(h);
|
||||
if (n == 0) return Rf_allocMatrix(REALSXP, 0, 6);
|
||||
struct WickraCandle *buf = (struct WickraCandle *)R_alloc(n, sizeof(struct WickraCandle));
|
||||
uintptr_t w = wickra_candle_reader_read(h, buf, n);
|
||||
SEXP r = PROTECT(Rf_allocMatrix(REALSXP, (int)w, 6));
|
||||
for (uintptr_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 candle_volume_fin(SEXP e) {
|
||||
struct CandleVolume *h = (struct CandleVolume *)R_ExternalPtrAddr(e);
|
||||
if (h) wickra_candle_volume_free(h);
|
||||
@@ -23021,6 +23055,8 @@ static const R_CallMethodDef CallEntries[] = {
|
||||
{"wk_camarilla_is_ready", (DL_FUNC)&wk_camarilla_is_ready, 1},
|
||||
{"wk_camarilla_name", (DL_FUNC)&wk_camarilla_name, 1},
|
||||
{"wk_camarilla_reset", (DL_FUNC)&wk_camarilla_reset, 1},
|
||||
{"wk_candle_reader_new", (DL_FUNC)&wk_candle_reader_new, 1},
|
||||
{"wk_candle_reader_read", (DL_FUNC)&wk_candle_reader_read, 1},
|
||||
{"wk_candle_volume_new", (DL_FUNC)&wk_candle_volume_new, 1},
|
||||
{"wk_candle_volume_update", (DL_FUNC)&wk_candle_volume_update, 7},
|
||||
{"wk_candle_volume_warmup_period", (DL_FUNC)&wk_candle_volume_warmup_period, 1},
|
||||
|
||||
@@ -51,6 +51,24 @@ test_that("tick aggregator matches the golden candles", {
|
||||
}
|
||||
})
|
||||
|
||||
test_that("candle reader 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]])))
|
||||
}
|
||||
|
||||
csv <- paste(readLines(file.path(gdir, "data_csv.csv")), collapse = "\n")
|
||||
reader <- CandleReader(csv)
|
||||
got <- unname(read(reader))
|
||||
want <- unname(read_mat("data_csv_candles"))
|
||||
expect_equal(nrow(got), nrow(want))
|
||||
expect_equal(got, want, tolerance = 1e-9)
|
||||
})
|
||||
|
||||
test_that("resampler matches the golden candles", {
|
||||
gdir <- find_data_golden_dir()
|
||||
skip_if(is.null(gdir), "golden fixtures not bundled with the package")
|
||||
|
||||
@@ -16101,3 +16101,34 @@ impl WasmResampler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Data layer: CSV candle reader =====
|
||||
|
||||
/// Parse OHLCV candles from a CSV string (header `timestamp,open,high,low,close,
|
||||
/// volume`; a leading UTF-8 BOM is stripped).
|
||||
#[wasm_bindgen(js_name = CandleReader)]
|
||||
pub struct WasmCandleReader {
|
||||
candles: Vec<wc::Candle>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = CandleReader)]
|
||||
impl WasmCandleReader {
|
||||
/// Parse the whole CSV up front; throws on a malformed header or row.
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(csv: &str) -> Result<WasmCandleReader, JsError> {
|
||||
let mut reader =
|
||||
wickra_data::csv::CandleReader::from_reader(csv.as_bytes()).map_err(map_data_err)?;
|
||||
let candles = reader.read_all().map_err(map_data_err)?;
|
||||
Ok(Self { candles })
|
||||
}
|
||||
|
||||
/// Return every parsed candle as a `{ open, high, low, close, volume,
|
||||
/// timestamp }` object.
|
||||
pub fn read(&self) -> Array {
|
||||
let arr = Array::new();
|
||||
for &c in &self.candles {
|
||||
arr.push(&candle_object(c));
|
||||
}
|
||||
arr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,13 @@ test('wasm tick aggregator gap-fill matches the golden candles', () => {
|
||||
assertCandles(run(true), readCsv('data_candles_gap'), 'gap');
|
||||
});
|
||||
|
||||
test('wasm candle reader matches the golden candles', () => {
|
||||
const csv = fs.readFileSync(path.join(GOLDEN, 'data_csv.csv'), 'utf8');
|
||||
const reader = new W.CandleReader(csv);
|
||||
const got = reader.read().map((c) => [c.open, c.high, c.low, c.close, c.volume, c.timestamp]);
|
||||
assertCandles(got, readCsv('data_csv_candles'), 'candle-reader');
|
||||
});
|
||||
|
||||
const INPUT = readCsv('input'); // open,high,low,close,volume (timestamp = row index)
|
||||
|
||||
function runResample() {
|
||||
|
||||
Reference in New Issue
Block a user