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:
kingchenc
2026-06-16 00:10:58 +02:00
committed by GitHub
parent cb6da4d737
commit d362ae26a3
31 changed files with 867 additions and 6 deletions
+12
View File
@@ -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
+93
View File
@@ -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)]
+30
View File
@@ -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 {
+12
View File
@@ -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
+51
View File
@@ -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");
+5 -4
View File
@@ -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 -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, 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() {
+11
View File
@@ -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
+27
View File
@@ -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",
+30
View File
@@ -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()
}
}
+12
View File
@@ -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)
+3
View File
@@ -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)
+8
View File
@@ -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
+23
View File
@@ -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
}
+36
View File
@@ -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")
+31
View File
@@ -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
}
}
+7
View File
@@ -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() {