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:
@@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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
|
- **Tick-to-candle aggregation in all 10 languages (data layer).** The
|
||||||
`TickAggregator` — roll trade ticks up into fixed-timeframe OHLCV candles, with
|
`TickAggregator` — roll trade ticks up into fixed-timeframe OHLCV candles, with
|
||||||
optional gap filling — is now exposed natively (Node.js / WASM `push(price,
|
optional gap filling — is now exposed natively (Node.js / WASM `push(price,
|
||||||
|
|||||||
@@ -678,6 +678,8 @@ typedef struct RenkoBars RenkoBars;
|
|||||||
|
|
||||||
typedef struct RenkoTrailingStop RenkoTrailingStop;
|
typedef struct RenkoTrailingStop RenkoTrailingStop;
|
||||||
|
|
||||||
|
typedef struct Resampler Resampler;
|
||||||
|
|
||||||
typedef struct RickshawMan RickshawMan;
|
typedef struct RickshawMan RickshawMan;
|
||||||
|
|
||||||
typedef struct RisingThreeMethods RisingThreeMethods;
|
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);
|
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
|
#ifdef __cplusplus
|
||||||
} // extern "C"
|
} // extern "C"
|
||||||
#endif // __cplusplus
|
#endif // __cplusplus
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ use wickra_core::{
|
|||||||
|
|
||||||
use wickra_data::aggregator::{TickAggregator as DataTickAggregator, Timeframe};
|
use wickra_data::aggregator::{TickAggregator as DataTickAggregator, Timeframe};
|
||||||
|
|
||||||
|
use wickra_data::resample::Resampler;
|
||||||
|
|
||||||
// ===== Scalar indicators (f64 -> f64) =====
|
// ===== Scalar indicators (f64 -> f64) =====
|
||||||
|
|
||||||
/// Create a `AdaptiveCycle` indicator.
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -28,6 +28,37 @@ public class DataLayerTests
|
|||||||
return rows.ToArray();
|
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]
|
[Theory]
|
||||||
[InlineData(false, "data_candles")]
|
[InlineData(false, "data_candles")]
|
||||||
[InlineData(true, "data_candles_gap")]
|
[InlineData(true, "data_candles_gap")]
|
||||||
|
|||||||
@@ -26580,6 +26580,50 @@ public sealed class RenkoTrailingStop : IDisposable
|
|||||||
public void Dispose() => _handle.Dispose();
|
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
|
public sealed class RickshawMan : IDisposable
|
||||||
{
|
{
|
||||||
private readonly WickraHandle _handle;
|
private readonly WickraHandle _handle;
|
||||||
|
|||||||
@@ -12416,6 +12416,20 @@ internal static partial class NativeMethods
|
|||||||
[LibraryImport(WickraNative.LibraryName)]
|
[LibraryImport(WickraNative.LibraryName)]
|
||||||
internal static partial void wickra_tick_aggregator_free(nint handle);
|
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)]
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
|||||||
@@ -20,6 +20,37 @@ func dataParseF(t *testing.T, s string) float64 {
|
|||||||
return v
|
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) {
|
func TestTickAggregatorGolden(t *testing.T) {
|
||||||
ticks := readGolden(t, "data_ticks")
|
ticks := readGolden(t, "data_ticks")
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|||||||
@@ -678,6 +678,8 @@ typedef struct RenkoBars RenkoBars;
|
|||||||
|
|
||||||
typedef struct RenkoTrailingStop RenkoTrailingStop;
|
typedef struct RenkoTrailingStop RenkoTrailingStop;
|
||||||
|
|
||||||
|
typedef struct Resampler Resampler;
|
||||||
|
|
||||||
typedef struct RickshawMan RickshawMan;
|
typedef struct RickshawMan RickshawMan;
|
||||||
|
|
||||||
typedef struct RisingThreeMethods RisingThreeMethods;
|
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);
|
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
|
#ifdef __cplusplus
|
||||||
} // extern "C"
|
} // extern "C"
|
||||||
#endif // __cplusplus
|
#endif // __cplusplus
|
||||||
|
|||||||
@@ -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.
|
// RickshawMan wraps the RickshawMan indicator over the Wickra C ABI.
|
||||||
type RickshawMan struct {
|
type RickshawMan struct {
|
||||||
handle *C.struct_RickshawMan
|
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_PUSH;
|
||||||
public static MethodHandle WICKRA_TICK_AGGREGATOR_DRAIN;
|
public static MethodHandle WICKRA_TICK_AGGREGATOR_DRAIN;
|
||||||
public static MethodHandle WICKRA_TICK_AGGREGATOR_FREE;
|
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 {
|
static {
|
||||||
init0();
|
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_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_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_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][]);
|
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
|
@Test
|
||||||
void tickAggregatorMatchesGolden() throws IOException {
|
void tickAggregatorMatchesGolden() throws IOException {
|
||||||
double[][] ticks = read("data_ticks");
|
double[][] ticks = read("data_ticks");
|
||||||
|
|||||||
@@ -27,9 +27,14 @@ const BAR_BUILDERS = new Set([
|
|||||||
'ThreeLineBreakBars',
|
'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
|
// An "indicator class" is an exported constructor whose prototype carries the
|
||||||
// streaming `update` method. This excludes `version` (a plain function), the bar
|
// 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() {
|
function indicatorClasses() {
|
||||||
return Object.keys(wickra).filter((name) => {
|
return Object.keys(wickra).filter((name) => {
|
||||||
const value = wickra[name];
|
const value = wickra[name];
|
||||||
@@ -37,7 +42,8 @@ function indicatorClasses() {
|
|||||||
typeof value === 'function' &&
|
typeof value === 'function' &&
|
||||||
value.prototype &&
|
value.prototype &&
|
||||||
typeof value.prototype.update === 'function' &&
|
typeof value.prototype.update === 'function' &&
|
||||||
!BAR_BUILDERS.has(name)
|
!BAR_BUILDERS.has(name) &&
|
||||||
|
!DATA_LAYER.has(name)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const test = require('node:test');
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { TickAggregator } = require('..');
|
const { TickAggregator, Resampler } = require('..');
|
||||||
|
|
||||||
const GOLDEN = path.resolve(__dirname, '..', '..', '..', 'testdata', 'golden');
|
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', () => {
|
test('tick aggregator gap-fill matches the golden candles', () => {
|
||||||
assertCandles(run(true), readCsv('data_candles_gap'), 'gap');
|
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');
|
||||||
|
});
|
||||||
|
|||||||
Vendored
+16
@@ -5957,3 +5957,19 @@ export declare class TickAggregator {
|
|||||||
/** Whether gap filling is enabled. */
|
/** Whether gap filling is enabled. */
|
||||||
fillsGaps(): boolean
|
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
@@ -21937,3 +21937,66 @@ impl TickAggregatorNode {
|
|||||||
self.inner.fills_gaps()
|
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,
|
CandleVolume,
|
||||||
# Data layer
|
# Data layer
|
||||||
TickAggregator,
|
TickAggregator,
|
||||||
|
Resampler,
|
||||||
# Market Profile
|
# Market Profile
|
||||||
CompositeProfile,
|
CompositeProfile,
|
||||||
HighLowVolumeNodes,
|
HighLowVolumeNodes,
|
||||||
@@ -906,6 +907,7 @@ __all__ = [
|
|||||||
"CandleVolume",
|
"CandleVolume",
|
||||||
# Data layer
|
# Data layer
|
||||||
"TickAggregator",
|
"TickAggregator",
|
||||||
|
"Resampler",
|
||||||
# Market Profile
|
# Market Profile
|
||||||
"CompositeProfile",
|
"CompositeProfile",
|
||||||
"HighLowVolumeNodes",
|
"HighLowVolumeNodes",
|
||||||
|
|||||||
@@ -28287,6 +28287,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|||||||
m.add_class::<PyCompositeProfile>()?;
|
m.add_class::<PyCompositeProfile>()?;
|
||||||
// Data layer.
|
// Data layer.
|
||||||
m.add_class::<PyTickAggregator>()?;
|
m.add_class::<PyTickAggregator>()?;
|
||||||
|
m.add_class::<PyResampler>()?;
|
||||||
// Candlestick patterns.
|
// Candlestick patterns.
|
||||||
m.add_class::<PyDoji>()?;
|
m.add_class::<PyDoji>()?;
|
||||||
m.add_class::<PyHammer>()?;
|
m.add_class::<PyHammer>()?;
|
||||||
@@ -28616,3 +28617,52 @@ impl PyTickAggregator {
|
|||||||
format!("TickAggregator(fills_gaps={})", self.inner.fills_gaps())
|
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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,3 +42,24 @@ def test_tick_aggregator_matches_golden(gap_fill, fixture):
|
|||||||
for j in range(6):
|
for j in range(6):
|
||||||
tol = 1e-9 * max(1.0, abs(w[j]))
|
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]}"
|
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]}"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Generated by roxygen2: do not edit by hand
|
# Generated by roxygen2: do not edit by hand
|
||||||
|
|
||||||
S3method(batch,wickra_indicator)
|
S3method(batch,wickra_indicator)
|
||||||
|
S3method(flush,wickra_indicator)
|
||||||
S3method(is_ready,wickra_indicator)
|
S3method(is_ready,wickra_indicator)
|
||||||
S3method(name,wickra_indicator)
|
S3method(name,wickra_indicator)
|
||||||
S3method(push,wickra_indicator)
|
S3method(push,wickra_indicator)
|
||||||
@@ -341,6 +342,7 @@ export(RegimeLabel)
|
|||||||
export(RelativeStrengthAB)
|
export(RelativeStrengthAB)
|
||||||
export(RenkoBars)
|
export(RenkoBars)
|
||||||
export(RenkoTrailingStop)
|
export(RenkoTrailingStop)
|
||||||
|
export(Resampler)
|
||||||
export(RickshawMan)
|
export(RickshawMan)
|
||||||
export(RisingThreeMethods)
|
export(RisingThreeMethods)
|
||||||
export(Rmi)
|
export(Rmi)
|
||||||
|
|||||||
@@ -2679,6 +2679,14 @@ RenkoTrailingStop <- function(block_size) {
|
|||||||
.wk_obj("renko_trailing_stop", ptr, "RenkoTrailingStop")
|
.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
|
#' RickshawMan indicator
|
||||||
#' @keywords internal
|
#' @keywords internal
|
||||||
#' @export
|
#' @export
|
||||||
|
|||||||
@@ -170,3 +170,22 @@ push.wickra_indicator <- function(object, price, size, timestamp) {
|
|||||||
colnames(out) <- c("open", "high", "low", "close", "volume", "timestamp")
|
colnames(out) <- c("open", "high", "low", "close", "volume", "timestamp")
|
||||||
out
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -14606,6 +14606,64 @@ SEXP wk_renko_trailing_stop_reset(SEXP e) {
|
|||||||
return R_NilValue;
|
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) {
|
static void rickshaw_man_fin(SEXP e) {
|
||||||
struct RickshawMan *h = (struct RickshawMan *)R_ExternalPtrAddr(e);
|
struct RickshawMan *h = (struct RickshawMan *)R_ExternalPtrAddr(e);
|
||||||
if (h) wickra_rickshaw_man_free(h);
|
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_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_name", (DL_FUNC)&wk_renko_trailing_stop_name, 1},
|
||||||
{"wk_renko_trailing_stop_reset", (DL_FUNC)&wk_renko_trailing_stop_reset, 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_new", (DL_FUNC)&wk_rickshaw_man_new, 0},
|
||||||
{"wk_rickshaw_man_update", (DL_FUNC)&wk_rickshaw_man_update, 7},
|
{"wk_rickshaw_man_update", (DL_FUNC)&wk_rickshaw_man_update, 7},
|
||||||
{"wk_rickshaw_man_batch", (DL_FUNC)&wk_rickshaw_man_batch, 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)
|
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)
|
||||||
|
})
|
||||||
|
|||||||
@@ -16043,3 +16043,61 @@ impl WasmTickAggregator {
|
|||||||
self.inner.fills_gaps()
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,3 +49,25 @@ test('wasm tick aggregator matches the golden candles', () => {
|
|||||||
test('wasm tick aggregator gap-fill matches the golden candles', () => {
|
test('wasm tick aggregator gap-fill matches the golden candles', () => {
|
||||||
assertCandles(run(true), readCsv('data_candles_gap'), 'gap');
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -94,12 +94,66 @@ static int check(const char *fixture, bool gap, const double *ticks, int nt) {
|
|||||||
return fails;
|
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) {
|
int main(int argc, char **argv) {
|
||||||
GDIR = (argc > 1) ? argv[1] : "testdata/golden";
|
GDIR = (argc > 1) ? argv[1] : "testdata/golden";
|
||||||
double ticks[MAXROWS * 3];
|
double ticks[MAXROWS * 3];
|
||||||
int nt = read_csv("data_ticks", ticks, 3);
|
int nt = read_csv("data_ticks", ticks, 3);
|
||||||
int fails = check("data_candles", false, ticks, nt);
|
int fails = check("data_candles", false, ticks, nt);
|
||||||
fails += check("data_candles_gap", true, ticks, nt);
|
fails += check("data_candles_gap", true, ticks, nt);
|
||||||
|
fails += check_resample();
|
||||||
if (fails == 0) {
|
if (fails == 0) {
|
||||||
printf("C/C++ data layer: OK (%d ticks)\n", nt);
|
printf("C/C++ data layer: OK (%d ticks)\n", nt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use wickra::{
|
|||||||
Indicator, IntradayIntensity, MacdIndicator, Rsi, Sma, Tick,
|
Indicator, IntradayIntensity, MacdIndicator, Rsi, Sma, Tick,
|
||||||
};
|
};
|
||||||
use wickra_data::aggregator::{TickAggregator, Timeframe};
|
use wickra_data::aggregator::{TickAggregator, Timeframe};
|
||||||
|
use wickra_data::resample::Resampler;
|
||||||
|
|
||||||
const N: usize = 80;
|
const N: usize = 80;
|
||||||
|
|
||||||
@@ -185,9 +186,37 @@ fn main() {
|
|||||||
emit_profiles(dir, &candles);
|
emit_profiles(dir, &candles);
|
||||||
emit_bars(dir, &candles);
|
emit_bars(dir, &candles);
|
||||||
emit_data_layer(dir);
|
emit_data_layer(dir);
|
||||||
|
emit_resampler(dir, &candles);
|
||||||
println!("golden fixtures written to {}", dir.display());
|
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
|
/// Deterministic trade tick `i`: price on the shared varied path, a small
|
||||||
/// repeating size, and a timestamp that places roughly three ticks per
|
/// 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
|
/// 1000-unit bucket. A deliberate jump at `i == 36` opens a multi-bucket gap so
|
||||||
|
|||||||
Vendored
+17
@@ -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
|
||||||
|
Reference in New Issue
Block a user