From 677ea374024b487e6af8ebeda9e42854559c2ee3 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Wed, 17 Jun 2026 01:49:11 +0200 Subject: [PATCH] examples: migrate to the native data layer (drop ws/coder-websocket/jackson/jsonlite) (#316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #315 (the native Binance REST fetcher). Retarget to `main` once #315 merges. Migrates the runnable examples off third-party data-I/O packages onto Wickra's native data layer (`CandleReader`, `Resampler`, `BinanceFeed`, `fetch_*klines`). ## Third-party packages removed (the zero-dep selling point) - **Node**: `ws` (live feed → BinanceFeed) — dropped from package.json + lockfile - **Go**: `github.com/coder/websocket` — dropped from go.mod / go.sum (`go mod tidy`) - **Java**: `jackson-databind` (live feed + REST fetch) — dropped from pom.xml - **R**: `jsonlite` + `websocket` + `later` — dropped from the README notes Each language's CSV loading now goes through `CandleReader`, manual resampling through `Resampler`, the live feed through `BinanceFeed`, and (Java/R) the REST download through the native fetcher. ## Verification Ran the offline examples per language against the bundled data — backtest and multi_timeframe produce identical output across Python / Node / Go / Java / R (e.g. ATR(14) last 345.1010; 1h→5m resamples to 240 bars, →15m to 80 bars). C# / C / WASM (stdlib-only, no third-party deps to remove) follow in this branch. Note: the streaming `strategy_*` examples have pre-existing candle-indicator runtime bugs (CI only syntax-smokes them); the CSV migration preserves their shape and leaves those bugs for a separate fix. --- examples/c/multi_timeframe.c | 49 +++---- examples/c/wickra_csv.h | 99 ++++++++------ examples/csharp/_common/MarketData.cs | 35 ++--- examples/csharp/fetch_btcusdt/Program.cs | 28 ++-- examples/csharp/live_binance/Program.cs | 44 ++---- examples/csharp/multi_timeframe/Program.cs | 22 +-- examples/go/go.mod | 2 - examples/go/go.sum | 2 - examples/go/internal/market/market.go | 47 +++---- examples/go/live_binance/main.go | 44 +++--- examples/go/multi_timeframe/main.go | 32 ++--- examples/java/pom.xml | 6 - .../org/wickra/examples/FetchBtcusdt.java | 32 ++--- .../java/org/wickra/examples/LiveBinance.java | 77 +++-------- .../java/org/wickra/examples/MarketData.java | 48 ++----- .../org/wickra/examples/MultiTimeframe.java | 28 ++-- examples/node/backtest.js | 47 ++----- examples/node/live_binance.js | 102 ++++++-------- examples/node/multi_timeframe.js | 90 ++++-------- examples/node/package-lock.json | 27 +--- examples/node/package.json | 3 - examples/node/strategy_bollinger_squeeze.js | 42 +----- examples/node/strategy_macd_adx.js | 42 +----- examples/node/strategy_rsi_mean_reversion.js | 42 +----- examples/python/backtest.py | 65 +++------ examples/python/live_binance.py | 124 ++++++----------- examples/python/multi_timeframe.py | 128 +++++------------- examples/python/strategy_bollinger_squeeze.py | 21 ++- examples/python/strategy_macd_adx.py | 21 ++- .../python/strategy_rsi_mean_reversion.py | 21 ++- examples/r/README.md | 12 +- examples/r/_common.R | 13 +- examples/r/fetch_btcusdt.R | 18 ++- examples/r/live_binance.R | 31 +++-- examples/r/multi_timeframe.R | 22 ++- examples/wasm/backtest.html | 36 ++--- examples/wasm/multi_timeframe.html | 71 ++++------ examples/wasm/strategy_bollinger_squeeze.html | 35 ++--- examples/wasm/strategy_macd_adx.html | 35 ++--- .../wasm/strategy_rsi_mean_reversion.html | 35 ++--- 40 files changed, 576 insertions(+), 1102 deletions(-) diff --git a/examples/c/multi_timeframe.c b/examples/c/multi_timeframe.c index 4dc788a6..f1daf8c5 100644 --- a/examples/c/multi_timeframe.c +++ b/examples/c/multi_timeframe.c @@ -32,34 +32,37 @@ * buckets were produced. */ static size_t resample(const WickraBar *in, size_t count, int64_t tf_ms, WickraBar *out) { + /* Native Resampler — no hand-written bucketing. update() emits a closed + * candle on a bucket boundary; flush() yields the final partial bucket. */ + struct Resampler *r = wickra_resampler_new(tf_ms); + if (r == NULL) { + return 0; + } size_t produced = 0; - int open_bucket = 0; - int64_t bucket_start = 0; - WickraBar cur = {0}; + struct WickraCandle c; for (size_t i = 0; i < count; ++i) { - int64_t b = in[i].timestamp - (in[i].timestamp % tf_ms); - if (!open_bucket || b != bucket_start) { - if (open_bucket) { - out[produced++] = cur; - } - bucket_start = b; - cur = in[i]; - cur.timestamp = b; - open_bucket = 1; - } else { - if (in[i].high > cur.high) { - cur.high = in[i].high; - } - if (in[i].low < cur.low) { - cur.low = in[i].low; - } - cur.close = in[i].close; - cur.volume += in[i].volume; + if (wickra_resampler_update(r, in[i].open, in[i].high, in[i].low, + in[i].close, in[i].volume, in[i].timestamp, + &c)) { + out[produced].timestamp = c.timestamp; + out[produced].open = c.open; + out[produced].high = c.high; + out[produced].low = c.low; + out[produced].close = c.close; + out[produced].volume = c.volume; + produced++; } } - if (open_bucket) { - out[produced++] = cur; + if (wickra_resampler_flush(r, &c)) { + out[produced].timestamp = c.timestamp; + out[produced].open = c.open; + out[produced].high = c.high; + out[produced].low = c.low; + out[produced].close = c.close; + out[produced].volume = c.volume; + produced++; } + wickra_resampler_free(r); return produced; } diff --git a/examples/c/wickra_csv.h b/examples/c/wickra_csv.h index 5e8b5b7c..51c5c3c2 100644 --- a/examples/c/wickra_csv.h +++ b/examples/c/wickra_csv.h @@ -1,13 +1,14 @@ /* Shared OHLCV CSV loader for the Wickra C examples. * - * The C ABI exposes only the indicators, not the wickra-data IO layer, so the - * examples read CSV themselves. This header-only helper is the C counterpart of - * `wickra_data::csv::CandleReader` used by the Rust examples: it parses the - * standard `timestamp,open,high,low,close,volume` files shipped under - * `examples/data/`. + * Thin wrapper over the native C-ABI `wickra_candle_reader_*` (the same + * `wickra-data::csv::CandleReader` the Rust examples use): it reads the whole + * file into memory and parses the standard `timestamp,open,high,low,close,volume` + * files shipped under `examples/data/` through the library — no hand-written CSV + * parsing. * * Header-only: define WICKRA_CSV_IMPL in exactly one translation unit (each * example is a single .c file, so it just defines it before including this). + * `wickra.h` must be included before this header. */ #ifndef WICKRA_CSV_H #define WICKRA_CSV_H @@ -26,8 +27,7 @@ typedef struct WickraBar { /* Load an OHLCV CSV into a malloc'd array. Returns the candle count and stores * the array in *out (caller frees with free()). Returns 0 and leaves *out NULL - * on any error (missing file, no parseable rows). A leading header line whose - * first field is non-numeric is skipped. */ + * on any error (missing file, malformed header or row). */ size_t wickra_load_csv(const char *path, WickraBar **out); #ifdef WICKRA_CSV_IMPL @@ -35,55 +35,66 @@ size_t wickra_load_csv(const char *path, WickraBar **out); #include #include +#include "wickra.h" + size_t wickra_load_csv(const char *path, WickraBar **out) { *out = NULL; - FILE *f = fopen(path, "r"); + FILE *f = fopen(path, "rb"); if (f == NULL) { fprintf(stderr, "wickra_load_csv: cannot open %s\n", path); return 0; } - - size_t cap = 1024; - size_t n = 0; - WickraBar *rows = (WickraBar *)malloc(cap * sizeof(*rows)); - if (rows == NULL) { + fseek(f, 0, SEEK_END); + long size = ftell(f); + fseek(f, 0, SEEK_SET); + if (size <= 0) { fclose(f); return 0; } - - char line[512]; - while (fgets(line, (int)sizeof(line), f) != NULL) { - WickraBar c; - long long ts = 0; - /* sscanf returns the number of fields successfully matched. A header - * row ("timestamp,...") matches 0 and is skipped. */ - int matched = sscanf(line, "%lld,%lf,%lf,%lf,%lf,%lf", &ts, &c.open, - &c.high, &c.low, &c.close, &c.volume); - if (matched != 6) { - continue; - } - c.timestamp = (int64_t)ts; - - if (n == cap) { - cap *= 2; - WickraBar *grown = (WickraBar *)realloc(rows, cap * sizeof(*rows)); - if (grown == NULL) { - free(rows); - fclose(f); - return 0; - } - rows = grown; - } - rows[n++] = c; - } - fclose(f); - - if (n == 0) { - free(rows); + char *buf = (char *)malloc((size_t)size); + if (buf == NULL) { + fclose(f); return 0; } + size_t read_bytes = fread(buf, 1, (size_t)size, f); + fclose(f); + + /* Parse the whole buffer with the native reader. */ + struct CandleReader *reader = + wickra_candle_reader_new((const uint8_t *)buf, (uintptr_t)read_bytes); + free(buf); + if (reader == NULL) { + return 0; + } + size_t n = (size_t)wickra_candle_reader_count(reader); + if (n == 0) { + wickra_candle_reader_free(reader); + return 0; + } + struct WickraCandle *candles = (struct WickraCandle *)malloc(n * sizeof(*candles)); + if (candles == NULL) { + wickra_candle_reader_free(reader); + return 0; + } + size_t got = (size_t)wickra_candle_reader_read(reader, candles, (uintptr_t)n); + wickra_candle_reader_free(reader); + + WickraBar *rows = (WickraBar *)malloc(got * sizeof(*rows)); + if (rows == NULL) { + free(candles); + return 0; + } + for (size_t i = 0; i < got; ++i) { + rows[i].timestamp = candles[i].timestamp; + rows[i].open = candles[i].open; + rows[i].high = candles[i].high; + rows[i].low = candles[i].low; + rows[i].close = candles[i].close; + rows[i].volume = candles[i].volume; + } + free(candles); *out = rows; - return n; + return got; } #endif /* WICKRA_CSV_IMPL */ diff --git a/examples/csharp/_common/MarketData.cs b/examples/csharp/_common/MarketData.cs index c3170c69..25517442 100644 --- a/examples/csharp/_common/MarketData.cs +++ b/examples/csharp/_common/MarketData.cs @@ -45,34 +45,17 @@ public static class MarketData /// public static Bar[] LoadOhlcvCsv(string path) { - var bars = new List(); - foreach (var rawLine in File.ReadLines(path)) + // Native CandleReader: header validation, BOM and field-whitespace tolerance. + // No manual CSV parsing. + using var reader = new Wickra.CandleReader(File.ReadAllText(path)); + var candles = reader.Read(); + var bars = new Bar[candles.Length]; + for (var i = 0; i < candles.Length; i++) { - var line = rawLine.Trim(); - if (line.Length == 0) - { - continue; - } - - var cols = line.Split(','); - if (!double.TryParse(cols[0], System.Globalization.CultureInfo.InvariantCulture, out _) && - !long.TryParse(cols[0], out _)) - { - continue; // header row - } - - double F(int i) => double.Parse(cols[i], System.Globalization.CultureInfo.InvariantCulture); - - if (cols.Length >= 6) - { - bars.Add(new Bar(F(1), F(2), F(3), F(4), F(5), long.Parse(cols[0]))); - } - else - { - bars.Add(new Bar(F(0), F(1), F(2), F(3), F(4), bars.Count)); - } + var c = candles[i]; + bars[i] = new Bar(c.Open, c.High, c.Low, c.Close, c.Volume, (long)c.Timestamp); } - return bars.ToArray(); + return bars; } } diff --git a/examples/csharp/fetch_btcusdt/Program.cs b/examples/csharp/fetch_btcusdt/Program.cs index 00696c24..08af728c 100644 --- a/examples/csharp/fetch_btcusdt/Program.cs +++ b/examples/csharp/fetch_btcusdt/Program.cs @@ -1,33 +1,23 @@ using System.Globalization; -using System.Text.Json; +using Wickra; // Download real BTCUSDT hourly klines from the Binance REST API into a CSV that the -// other examples can consume. Requires network access (build-only in CI). -const string url = "https://api.binance.com/api/v3/klines?symbol=BTCUSDT&interval=1h&limit=500"; +// other examples can consume, using Wickra's native fetcher — no third-party HTTP or +// JSON library. Requires network access (build-only in CI). +Console.WriteLine("Fetching 500 BTCUSDT 1h klines from Binance..."); -using var http = new HttpClient(); -Console.WriteLine($"Fetching {url}"); -var json = await http.GetStringAsync(url); +var klines = BinanceFeed.FetchKlines("BTCUSDT", BinanceInterval.OneHour, 500); -using var doc = JsonDocument.Parse(json); var dir = Path.Combine(AppContext.BaseDirectory, "data"); Directory.CreateDirectory(dir); var path = Path.Combine(dir, "btcusdt_1h.csv"); using var writer = new StreamWriter(path); writer.WriteLine("timestamp,open,high,low,close,volume"); -var count = 0; -foreach (var kline in doc.RootElement.EnumerateArray()) +foreach (var k in klines) { - // Binance kline array: [openTime, open, high, low, close, volume, ...] - var ts = kline[0].GetInt64(); - var o = kline[1].GetString(); - var h = kline[2].GetString(); - var l = kline[3].GetString(); - var c = kline[4].GetString(); - var v = kline[5].GetString(); - writer.WriteLine(string.Create(CultureInfo.InvariantCulture, $"{ts},{o},{h},{l},{c},{v}")); - count++; + writer.WriteLine(string.Create(CultureInfo.InvariantCulture, + $"{(long)k.Timestamp},{k.Open},{k.High},{k.Low},{k.Close},{k.Volume}")); } -Console.WriteLine($"Wrote {count} klines to {path}"); +Console.WriteLine($"Wrote {klines.Length} klines to {path}"); diff --git a/examples/csharp/live_binance/Program.cs b/examples/csharp/live_binance/Program.cs index 2fafb272..822b3db3 100644 --- a/examples/csharp/live_binance/Program.cs +++ b/examples/csharp/live_binance/Program.cs @@ -1,40 +1,24 @@ -using System.Globalization; -using System.Net.WebSockets; -using System.Text; -using System.Text.Json; using Wickra; -// Stream live BTCUSDT 1-minute klines from Binance and feed each close through EMA(20). +// Stream live BTCUSDT 1-minute klines from Binance and feed each close through EMA(20), +// using Wickra's native BinanceFeed — no third-party WebSocket or JSON library. // Requires network access (build-only in CI). Runs for up to 60 seconds. -var uri = new Uri("wss://stream.binance.com:9443/ws/btcusdt@kline_1m"); -Console.WriteLine($"Connecting to {uri} (up to 60s)..."); +Console.WriteLine("Streaming live BTCUSDT 1-minute klines from Binance (up to 60s)..."); -using var ws = new ClientWebSocket(); -using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); +// Native feed: a blocking poll over the same tested stream as the Rust core. +// Next(timeout) returns the event, or null on timeout (poll again). +using var feed = new BinanceFeed("BTCUSDT", BinanceInterval.OneMinute); using var ema = new Ema(20); -var buffer = new byte[8192]; -try +var deadline = DateTime.UtcNow.AddSeconds(60); +while (DateTime.UtcNow < deadline) { - await ws.ConnectAsync(uri, cts.Token); - while (ws.State == WebSocketState.Open && !cts.IsCancellationRequested) + var ev = feed.Next(TimeSpan.FromSeconds(1)); + if (ev is null) { - var result = await ws.ReceiveAsync(buffer, cts.Token); - if (result.MessageType == WebSocketMessageType.Close) - { - break; - } - - using var doc = JsonDocument.Parse(Encoding.UTF8.GetString(buffer, 0, result.Count)); - if (doc.RootElement.TryGetProperty("k", out var k)) - { - var close = double.Parse(k.GetProperty("c").GetString()!, CultureInfo.InvariantCulture); - var value = ema.Update(close); - Console.WriteLine($"close={close:F2} EMA(20)={value:F2}"); - } + continue; } + Console.WriteLine($"close={ev.Value.Close:F2} EMA(20)={ema.Update(ev.Value.Close):F2}"); } -catch (OperationCanceledException) -{ - Console.WriteLine("Done (time limit reached)."); -} + +Console.WriteLine("Done (time limit reached)."); diff --git a/examples/csharp/multi_timeframe/Program.cs b/examples/csharp/multi_timeframe/Program.cs index e92f6d37..c1ce4fe2 100644 --- a/examples/csharp/multi_timeframe/Program.cs +++ b/examples/csharp/multi_timeframe/Program.cs @@ -25,20 +25,26 @@ static Bar[] Resample(Bar[] source, int factor) return source; } + // Native Resampler: bucket by an absolute timeframe (the synthetic bars step + // 60_000 ms, so factor minutes == factor*60_000 ms). No hand-written bucketing. + using var r = new Resampler((long)factor * 60_000); var output = new List(); - for (var i = 0; i < source.Length; i += factor) + foreach (var b in source) { - var end = Math.Min(i + factor, source.Length); - double high = double.MinValue, low = double.MaxValue, volume = 0; - for (var j = i; j < end; j++) + var c = r.Update(b.Open, b.High, b.Low, b.Close, b.Volume, b.Timestamp); + if (c is not null) { - high = Math.Max(high, source[j].High); - low = Math.Min(low, source[j].Low); - volume += source[j].Volume; + output.Add(ToBar(c.Value)); } + } - output.Add(new Bar(source[i].Open, high, low, source[end - 1].Close, volume, source[i].Timestamp)); + var last = r.Flush(); + if (last is not null) + { + output.Add(ToBar(last.Value)); } return output.ToArray(); } + +static Bar ToBar(Candle c) => new(c.Open, c.High, c.Low, c.Close, c.Volume, (long)c.Timestamp); diff --git a/examples/go/go.mod b/examples/go/go.mod index 85d41ca1..0e4d5dc9 100644 --- a/examples/go/go.mod +++ b/examples/go/go.mod @@ -4,6 +4,4 @@ go 1.23 require github.com/wickra-lib/wickra/bindings/go v0.0.0 -require github.com/coder/websocket v1.8.14 - replace github.com/wickra-lib/wickra/bindings/go => ../../bindings/go diff --git a/examples/go/go.sum b/examples/go/go.sum index c80e2f07..e69de29b 100644 --- a/examples/go/go.sum +++ b/examples/go/go.sum @@ -1,2 +0,0 @@ -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= diff --git a/examples/go/internal/market/market.go b/examples/go/internal/market/market.go index 3bb66a5a..7f39c225 100644 --- a/examples/go/internal/market/market.go +++ b/examples/go/internal/market/market.go @@ -5,12 +5,11 @@ package market import ( - "bufio" "fmt" "math" "os" - "strconv" - "strings" + + wickra "github.com/wickra-lib/wickra/bindings/go" ) // Bar is one OHLCV bar with a millisecond timestamp. @@ -62,39 +61,25 @@ func SyntheticCandlesStep(count int, startTimestamp, stepMs int64) []Bar { return bars } -// LoadOhlcvCsv loads an OHLCV CSV. It accepts rows of -// timestamp,open,high,low,close,volume or open,high,low,close,volume; a -// non-numeric first row is treated as a header and skipped. +// LoadOhlcvCsv loads a timestamp,open,high,low,close,volume OHLCV CSV with +// Wickra's native CandleReader (header validation, BOM and field-whitespace +// tolerance) — no manual CSV parsing. func LoadOhlcvCsv(path string) ([]Bar, error) { - file, err := os.Open(path) + data, err := os.ReadFile(path) if err != nil { return nil, err } - defer file.Close() - - var bars []Bar - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - cols := strings.Split(line, ",") - if _, err := strconv.ParseFloat(cols[0], 64); err != nil { - continue // header row - } - f := func(i int) float64 { - v, _ := strconv.ParseFloat(strings.TrimSpace(cols[i]), 64) - return v - } - if len(cols) >= 6 { - ts, _ := strconv.ParseInt(strings.TrimSpace(cols[0]), 10, 64) - bars = append(bars, Bar{f(1), f(2), f(3), f(4), f(5), ts}) - } else { - bars = append(bars, Bar{f(0), f(1), f(2), f(3), f(4), int64(len(bars))}) - } + reader, err := wickra.NewCandleReader(string(data)) + if err != nil { + return nil, err } - return bars, scanner.Err() + defer reader.Close() + candles := reader.Read() + bars := make([]Bar, len(candles)) + for i, c := range candles { + bars[i] = Bar{c.Open, c.High, c.Low, c.Close, c.Volume, c.Timestamp} + } + return bars, nil } // EquityResult holds summary statistics for a long-only equity curve. diff --git a/examples/go/live_binance/main.go b/examples/go/live_binance/main.go index 030cd589..75b54828 100644 --- a/examples/go/live_binance/main.go +++ b/examples/go/live_binance/main.go @@ -1,54 +1,42 @@ // Stream live BTCUSDT 1-minute klines from Binance and feed each close through EMA(20). -// Requires network access (build-only in CI). Runs for up to 60 seconds. +// Uses Wickra's native BinanceFeed — no third-party WebSocket client. Requires +// network access (build-only in CI). Runs for up to 60 seconds. package main import ( - "context" - "encoding/json" "fmt" "log" - "strconv" "time" - "github.com/coder/websocket" wickra "github.com/wickra-lib/wickra/bindings/go" ) func main() { - const url = "wss://stream.binance.com:9443/ws/btcusdt@kline_1m" - fmt.Printf("Connecting to %s (up to 60s)...\n", url) + fmt.Println("Streaming live BTCUSDT 1-minute klines from Binance (up to 60s)...") - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - conn, _, err := websocket.Dial(ctx, url, nil) + // Native feed: a blocking poll over the same tested stream as the Rust core. + feed, err := wickra.NewBinanceFeed("BTCUSDT", wickra.OneMinute, "") if err != nil { - log.Fatalf("dial: %v", err) + log.Fatalf("connect: %v", err) } - defer conn.CloseNow() + defer feed.Close() ema, _ := wickra.NewEma(20) defer ema.Close() - for { - _, data, err := conn.Read(ctx) + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + // next() returns the event and ok=true, ok=false on timeout (poll again), + // or an error once the stream is closed. + event, ok, err := feed.Next(time.Second) if err != nil { - fmt.Println("Done (time limit reached).") + fmt.Println("Done (feed closed).") return } - - var msg struct { - K struct { - Close string `json:"c"` - } `json:"k"` - } - if err := json.Unmarshal(data, &msg); err != nil || msg.K.Close == "" { + if !ok { continue } - closePx, err := strconv.ParseFloat(msg.K.Close, 64) - if err != nil { - continue - } - fmt.Printf("close=%.2f EMA(20)=%.2f\n", closePx, ema.Update(closePx)) + fmt.Printf("close=%.2f EMA(20)=%.2f\n", event.Close, ema.Update(event.Close)) } + fmt.Println("Done (time limit reached).") } diff --git a/examples/go/multi_timeframe/main.go b/examples/go/multi_timeframe/main.go index e2413075..22d05e06 100644 --- a/examples/go/multi_timeframe/main.go +++ b/examples/go/multi_timeframe/main.go @@ -3,7 +3,6 @@ package main import ( "fmt" - "math" wickra "github.com/wickra-lib/wickra/bindings/go" "github.com/wickra-lib/wickra/examples/go/internal/market" @@ -29,26 +28,21 @@ func resample(source []market.Bar, factor int) []market.Bar { if factor <= 1 { return source } + // Native Resampler: bucket by an absolute timeframe (the synthetic bars step + // 60_000 ms, so factor minutes == factor*60_000 ms). No hand-written bucketing. + r, _ := wickra.NewResampler(int64(factor) * 60_000) + defer r.Close() var out []market.Bar - for i := 0; i < len(source); i += factor { - end := i + factor - if end > len(source) { - end = len(source) + emit := func(c wickra.Candle) { + out = append(out, market.Bar{Open: c.Open, High: c.High, Low: c.Low, Close: c.Close, Volume: c.Volume, Timestamp: c.Timestamp}) + } + for _, b := range source { + if c, ok := r.Update(b.Open, b.High, b.Low, b.Close, b.Volume, b.Timestamp); ok { + emit(c) } - high, low, volume := math.Inf(-1), math.Inf(1), 0.0 - for j := i; j < end; j++ { - high = math.Max(high, source[j].High) - low = math.Min(low, source[j].Low) - volume += source[j].Volume - } - out = append(out, market.Bar{ - Open: source[i].Open, - High: high, - Low: low, - Close: source[end-1].Close, - Volume: volume, - Timestamp: source[i].Timestamp, - }) + } + if c, ok := r.Flush(); ok { + emit(c) } return out } diff --git a/examples/java/pom.xml b/examples/java/pom.xml index 8da5f780..7a1b9d6f 100644 --- a/examples/java/pom.xml +++ b/examples/java/pom.xml @@ -23,12 +23,6 @@ wickra 0.9.2 - - - com.fasterxml.jackson.core - jackson-databind - 2.22.0 - diff --git a/examples/java/src/main/java/org/wickra/examples/FetchBtcusdt.java b/examples/java/src/main/java/org/wickra/examples/FetchBtcusdt.java index e8c65af6..dbbd9c72 100644 --- a/examples/java/src/main/java/org/wickra/examples/FetchBtcusdt.java +++ b/examples/java/src/main/java/org/wickra/examples/FetchBtcusdt.java @@ -1,48 +1,38 @@ package org.wickra.examples; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import org.wickra.BinanceFeed; +import org.wickra.BinanceInterval; +import org.wickra.Candle; import java.io.BufferedWriter; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; /** * Download real BTCUSDT hourly klines from the Binance REST API into a CSV that the - * other examples can consume. Requires network access (build-only in CI). + * other examples can consume, using Wickra's native fetcher — no third-party HTTP or + * JSON library. Requires network access (build-only in CI). */ public final class FetchBtcusdt { public static void main(String[] args) throws Exception { - String url = "https://api.binance.com/api/v3/klines?symbol=BTCUSDT&interval=1h&limit=500"; - System.out.println("Fetching " + url); + System.out.println("Fetching 500 BTCUSDT 1h klines from Binance..."); - HttpClient http = HttpClient.newHttpClient(); - HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build(); - HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + Candle[] klines = BinanceFeed.fetchKlines("BTCUSDT", BinanceInterval.ONE_HOUR, 500, -1L, -1L, null); - JsonNode klines = new ObjectMapper().readTree(response.body()); Path dir = Path.of("data"); Files.createDirectories(dir); Path path = dir.resolve("btcusdt_1h.csv"); - int count = 0; try (BufferedWriter writer = Files.newBufferedWriter(path)) { writer.write("timestamp,open,high,low,close,volume"); writer.newLine(); - for (JsonNode kline : klines) { - // Binance kline array: [openTime, open, high, low, close, volume, ...] - writer.write(kline.get(0).asLong() + "," + kline.get(1).asText() + "," - + kline.get(2).asText() + "," + kline.get(3).asText() + "," - + kline.get(4).asText() + "," + kline.get(5).asText()); + for (Candle k : klines) { + writer.write((long) k.timestamp() + "," + k.open() + "," + k.high() + "," + + k.low() + "," + k.close() + "," + k.volume()); writer.newLine(); - count++; } } - System.out.printf("Wrote %d klines to %s%n", count, path.toAbsolutePath()); + System.out.printf("Wrote %d klines to %s%n", klines.length, path.toAbsolutePath()); } } diff --git a/examples/java/src/main/java/org/wickra/examples/LiveBinance.java b/examples/java/src/main/java/org/wickra/examples/LiveBinance.java index 0df46a79..3d3a9a6f 100644 --- a/examples/java/src/main/java/org/wickra/examples/LiveBinance.java +++ b/examples/java/src/main/java/org/wickra/examples/LiveBinance.java @@ -1,73 +1,32 @@ package org.wickra.examples; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import org.wickra.BinanceFeed; +import org.wickra.BinanceInterval; import org.wickra.Ema; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.WebSocket; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; +import org.wickra.KlineEvent; /** - * Stream live BTCUSDT 1-minute klines from Binance and feed each close through EMA(20). + * Stream live BTCUSDT 1-minute klines from Binance and feed each close through EMA(20), + * using Wickra's native BinanceFeed — no third-party WebSocket or JSON library. * Requires network access (build-only in CI). Runs for up to 60 seconds. */ public final class LiveBinance { - public static void main(String[] args) throws Exception { - URI uri = URI.create("wss://stream.binance.com:9443/ws/btcusdt@kline_1m"); - System.out.println("Connecting to " + uri + " (up to 60s)..."); + public static void main(String[] args) { + System.out.println("Streaming live BTCUSDT 1-minute klines from Binance (up to 60s)..."); - ObjectMapper mapper = new ObjectMapper(); - CountDownLatch done = new CountDownLatch(1); - - try (Ema ema = new Ema(20)) { - WebSocket.Listener listener = new WebSocket.Listener() { - private final StringBuilder buffer = new StringBuilder(); - - @Override - public void onOpen(WebSocket webSocket) { - webSocket.request(1); + // Native feed: a blocking poll over the same tested stream as the Rust core. + // next(timeoutMillis) returns the event, or null on timeout (poll again). + try (BinanceFeed feed = new BinanceFeed("BTCUSDT", BinanceInterval.ONE_MINUTE); + Ema ema = new Ema(20)) { + long deadline = System.currentTimeMillis() + 60_000L; + while (System.currentTimeMillis() < deadline) { + KlineEvent event = feed.next(1000); + if (event == null) { + continue; } - - @Override - public java.util.concurrent.CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { - buffer.append(data); - if (last) { - try { - JsonNode root = mapper.readTree(buffer.toString()); - JsonNode k = root.get("k"); - if (k != null) { - double close = Double.parseDouble(k.get("c").asText()); - double value = ema.update(close); - System.out.printf("close=%.2f EMA(20)=%.2f%n", close, value); - } - } catch (Exception e) { - System.err.println("parse error: " + e.getMessage()); - } - buffer.setLength(0); - } - webSocket.request(1); - return null; - } - - @Override - public void onError(WebSocket webSocket, Throwable error) { - System.err.println("websocket error: " + error.getMessage()); - done.countDown(); - } - }; - - WebSocket ws = HttpClient.newHttpClient() - .newWebSocketBuilder() - .buildAsync(uri, listener) - .join(); - - if (!done.await(60, TimeUnit.SECONDS)) { - System.out.println("Done (time limit reached)."); + System.out.printf("close=%.2f EMA(20)=%.2f%n", event.close(), ema.update(event.close())); } - ws.sendClose(WebSocket.NORMAL_CLOSURE, "done"); } + System.out.println("Done (time limit reached)."); } } diff --git a/examples/java/src/main/java/org/wickra/examples/MarketData.java b/examples/java/src/main/java/org/wickra/examples/MarketData.java index 5a5d514d..1b84e8bf 100644 --- a/examples/java/src/main/java/org/wickra/examples/MarketData.java +++ b/examples/java/src/main/java/org/wickra/examples/MarketData.java @@ -3,8 +3,9 @@ package org.wickra.examples; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; + +import org.wickra.Candle; +import org.wickra.CandleReader; /** * Deterministic synthetic market data plus a small OHLCV CSV loader, shared by @@ -51,41 +52,20 @@ public final class MarketData { } /** - * Loads an OHLCV CSV. Accepts rows of {@code timestamp,open,high,low,close,volume} - * or {@code open,high,low,close,volume}; a non-numeric first row is treated as a header. + * Loads a {@code timestamp,open,high,low,close,volume} OHLCV CSV with Wickra's + * native CandleReader (header validation, BOM and field-whitespace tolerance) — + * no manual CSV parsing. */ public static Bar[] loadOhlcvCsv(String path) throws IOException { - List bars = new ArrayList<>(); - for (String rawLine : Files.readAllLines(Path.of(path))) { - String line = rawLine.trim(); - if (line.isEmpty()) { - continue; + String csv = Files.readString(Path.of(path)); + try (CandleReader reader = new CandleReader(csv)) { + Candle[] candles = reader.read(); + Bar[] bars = new Bar[candles.length]; + for (int i = 0; i < candles.length; i++) { + Candle c = candles[i]; + bars[i] = new Bar(c.open(), c.high(), c.low(), c.close(), c.volume(), (long) c.timestamp()); } - String[] cols = line.split(","); - if (!isNumeric(cols[0])) { - continue; // header row - } - if (cols.length >= 6) { - bars.add(new Bar( - Double.parseDouble(cols[1]), Double.parseDouble(cols[2]), - Double.parseDouble(cols[3]), Double.parseDouble(cols[4]), - Double.parseDouble(cols[5]), Long.parseLong(cols[0]))); - } else { - bars.add(new Bar( - Double.parseDouble(cols[0]), Double.parseDouble(cols[1]), - Double.parseDouble(cols[2]), Double.parseDouble(cols[3]), - Double.parseDouble(cols[4]), bars.size())); - } - } - return bars.toArray(new Bar[0]); - } - - private static boolean isNumeric(String s) { - try { - Double.parseDouble(s); - return true; - } catch (NumberFormatException e) { - return false; + return bars; } } } diff --git a/examples/java/src/main/java/org/wickra/examples/MultiTimeframe.java b/examples/java/src/main/java/org/wickra/examples/MultiTimeframe.java index 03d5fc1a..fa67067d 100644 --- a/examples/java/src/main/java/org/wickra/examples/MultiTimeframe.java +++ b/examples/java/src/main/java/org/wickra/examples/MultiTimeframe.java @@ -1,6 +1,8 @@ package org.wickra.examples; +import org.wickra.Candle; import org.wickra.Ema; +import org.wickra.Resampler; import org.wickra.examples.MarketData.Bar; import java.util.ArrayList; @@ -28,19 +30,25 @@ public final class MultiTimeframe { if (factor <= 1) { return source; } + // Native Resampler: bucket by an absolute timeframe (the synthetic bars step + // 60_000 ms, so factor minutes == factor*60_000 ms). No hand-written bucketing. List output = new ArrayList<>(); - for (int i = 0; i < source.length; i += factor) { - int end = Math.min(i + factor, source.length); - double high = Double.MIN_VALUE; - double low = Double.MAX_VALUE; - double volume = 0; - for (int j = i; j < end; j++) { - high = Math.max(high, source[j].high()); - low = Math.min(low, source[j].low()); - volume += source[j].volume(); + try (Resampler r = new Resampler((long) factor * 60_000L)) { + for (Bar b : source) { + Candle c = r.update(b.open(), b.high(), b.low(), b.close(), b.volume(), b.timestamp()); + if (c != null) { + output.add(toBar(c)); + } + } + Candle last = r.flush(); + if (last != null) { + output.add(toBar(last)); } - output.add(new Bar(source[i].open(), high, low, source[end - 1].close(), volume, source[i].timestamp())); } return output.toArray(new Bar[0]); } + + private static Bar toBar(Candle c) { + return new Bar(c.open(), c.high(), c.low(), c.close(), c.volume(), (long) c.timestamp()); + } } diff --git a/examples/node/backtest.js b/examples/node/backtest.js index 103c373f..ec20e924 100644 --- a/examples/node/backtest.js +++ b/examples/node/backtest.js @@ -18,9 +18,6 @@ const path = require('node:path'); const wickra = require('wickra'); -// The OHLCV columns the default layout requires; the CSV header must name -// every one of them (any extra columns are ignored). -const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume']; // Default dataset: the checked-in BTCUSDT daily candles under the workspace // `examples/data/` directory, resolved relative to this file. @@ -31,42 +28,22 @@ const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1d.csv'); // The Wickra CSV layout is plain numeric — no quoted fields, no embedded // commas — so splitting on `,` is a complete and correct parse for it. function readHistory(csvPath) { + // Native CandleReader: validates the header (timestamp,open,high,low,close, + // volume), tolerates a UTF-8 BOM and field whitespace, and throws on a + // malformed row. No manual CSV parsing. const text = fs.readFileSync(csvPath, 'utf8'); - const lines = text.split(/\r?\n/).filter((line) => line.length > 0); - if (lines.length === 0) { - throw new Error(`${csvPath}: file is empty`); - } - - const header = lines[0].split(',').map((cell) => cell.trim()); - const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col)); - if (missing.length > 0) { - throw new Error( - `${csvPath}: CSV header is missing required column(s): ${missing.join(', ')}; ` + - `found: ${header.join(', ')}`, - ); - } - if (lines.length === 1) { + const candles = new wickra.CandleReader(text).read(); + if (candles.length === 0) { throw new Error(`${csvPath}: CSV has a header but no data rows`); } - - const columnIndex = {}; - for (const col of REQUIRED_COLUMNS) { - columnIndex[col] = header.indexOf(col); - } - const cols = { timestamp: [], open: [], high: [], low: [], close: [], volume: [] }; - for (let row = 1; row < lines.length; row++) { - const cells = lines[row].split(','); - for (const col of REQUIRED_COLUMNS) { - const raw = cells[columnIndex[col]]; - const value = raw === undefined ? NaN : Number(raw.trim()); - if (raw === undefined || raw.trim() === '' || !Number.isFinite(value)) { - throw new Error( - `${csvPath}: row ${row + 1} column '${col}' is not numeric: ${JSON.stringify(raw)}`, - ); - } - cols[col].push(value); - } + for (const k of candles) { + cols.timestamp.push(k.timestamp); + cols.open.push(k.open); + cols.high.push(k.high); + cols.low.push(k.low); + cols.close.push(k.close); + cols.volume.push(k.volume); } return cols; } diff --git a/examples/node/live_binance.js b/examples/node/live_binance.js index 5b0c8806..814cb1d5 100644 --- a/examples/node/live_binance.js +++ b/examples/node/live_binance.js @@ -1,37 +1,28 @@ // Live Binance feed example for the Wickra Node binding. // -// Connects to Binance's public WebSocket feed (no API key needed) and runs -// RSI / MACD / Bollinger Bands on the incoming close prices. When RSI crosses -// the common overbought / oversold thresholds *and* the MACD histogram -// confirms the direction *and* price pierces the matching Bollinger band, a -// signal line is printed. No orders are placed. It is the Node counterpart of +// Connects to Binance's public kline feed (no API key needed) through Wickra's +// native `BinanceFeed` — no third-party WebSocket client — and runs RSI / MACD / +// Bollinger Bands on the incoming close prices. When RSI crosses the common +// overbought / oversold thresholds *and* the MACD histogram confirms the +// direction *and* price pierces the matching Bollinger band, a signal line is +// printed. No orders are placed. It is the Node counterpart of // examples/python/live_binance.py. // // Run it from the repository after building the native binding: // // cd bindings/node && npm install && npx napi build --platform --release -// cd ../../examples/node && npm install # pulls `ws` for this example -// node live_binance.js --symbol BTCUSDT --interval 1m +// cd ../../examples/node && node live_binance.js --symbol BTCUSDT --interval 1m // // Stop it with Ctrl+C. const wickra = require('wickra'); -let WebSocket; -try { - WebSocket = require('ws'); -} catch (err) { - console.error('This example needs the `ws` package — run `npm install` in examples/node.'); - process.exit(1); -} - -const BINANCE_WS = 'wss://stream.binance.com:9443/stream'; - -// Kline intervals the public Binance WebSocket API recognises. -const VALID_INTERVALS = new Set([ - '1s', '1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '6h', '8h', '12h', - '1d', '3d', '1w', '1M', -]); +// Kline interval string -> BinanceFeed interval code (the Interval declaration +// order shared by every binding). +const INTERVAL_CODES = { + '1s': 0, '1m': 1, '3m': 2, '5m': 3, '15m': 4, '30m': 5, '1h': 6, '2h': 7, + '4h': 8, '6h': 9, '8h': 10, '12h': 11, '1d': 12, '3d': 13, '1w': 14, '1M': 15, +}; // A Binance symbol is strictly alphanumeric (e.g. BTCUSDT). const SYMBOL_RE = /^[A-Za-z0-9]+$/; @@ -53,19 +44,17 @@ function parseArgs(argv) { return args; } -// Reject a symbol or interval that is not safe to splice into the WS URL. -// Both are interpolated straight into the stream name, so validating up front -// keeps the URL well-formed without needing to escape it. +// Reject a symbol or interval the feed cannot use. function validateArgs(symbol, interval) { if (typeof symbol !== 'string' || !SYMBOL_RE.test(symbol)) { throw new Error( `invalid --symbol ${JSON.stringify(symbol)}: expected only letters and digits, e.g. BTCUSDT`, ); } - if (!VALID_INTERVALS.has(interval)) { + if (!(interval in INTERVAL_CODES)) { throw new Error( `invalid --interval ${JSON.stringify(interval)}: expected one of ` + - [...VALID_INTERVALS].join(', '), + Object.keys(INTERVAL_CODES).join(', '), ); } } @@ -88,31 +77,35 @@ function main() { const macd = new wickra.MACD(12, 26, 9); const bb = new wickra.BollingerBands(20, 2.0); - const stream = `${args.symbol.toLowerCase()}@kline_${args.interval}`; - const url = `${BINANCE_WS}?streams=${stream}`; - console.log(`Connecting to ${url}`); + // Native feed: a blocking poll driving the same tested WebSocket stream as the + // Rust core. `next(timeoutMs)` returns the next event or null on timeout, so a + // short timeout in a loop keeps Ctrl+C responsive without a third-party client. + const feed = new wickra.BinanceFeed(args.symbol, INTERVAL_CODES[args.interval]); + console.log( + `Listening for ${args.symbol.toLowerCase()}@kline_${args.interval} klines (Ctrl+C to stop)`, + ); - const ws = new WebSocket(url); - - ws.on('open', () => { - console.log(`Connected, listening for ${stream} klines (Ctrl+C to stop)`); + let running = true; + process.on('SIGINT', () => { + console.log('\nShutting down…'); + running = false; }); - ws.on('message', (raw) => { - let envelope; + while (running) { + let event; try { - envelope = JSON.parse(raw.toString()); + event = feed.next(1000); } catch (err) { - return; // ignore non-JSON frames + console.error(`feed error: ${err.message}`); + process.exitCode = 1; + break; } - const k = (envelope.data && envelope.data.k) || null; - if (!k || k.c === undefined) { - // Subscription acks, heartbeats and error frames carry no kline payload — - // skip them instead of crashing on Number(undefined). - return; + if (event === null) { + continue; // timeout — poll again } - const close = Number(k.c); - const isClosed = Boolean(k.x); + + const close = event.close; + const isClosed = event.isClosed; const rsiV = rsi.update(close); const macdV = macd.update(close); // { macd, signal, histogram } or null @@ -137,23 +130,10 @@ function main() { ); } } - }); + } - ws.on('error', (err) => { - console.error(`websocket error: ${err.message}`); - process.exitCode = 1; - }); - - ws.on('close', () => { - console.log('Connection closed.'); - }); - - // Translate Ctrl+C into a clean shutdown. - process.on('SIGINT', () => { - console.log('\nShutting down…'); - ws.close(); - process.exit(0); - }); + feed.close(); + console.log('Connection closed.'); } main(); diff --git a/examples/node/multi_timeframe.js b/examples/node/multi_timeframe.js index c903698b..2793563e 100644 --- a/examples/node/multi_timeframe.js +++ b/examples/node/multi_timeframe.js @@ -14,87 +14,51 @@ const path = require('node:path'); const wickra = require('wickra'); -const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume']; const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1m.csv'); const ONE_MINUTE_MS = 60_000; function readCsv(csvPath) { + // Native CandleReader: header validation, BOM/whitespace tolerance, throws on + // a malformed row. const text = fs.readFileSync(csvPath, 'utf8'); - const lines = text.split(/\r?\n/).filter((line) => line.length > 0); - if (lines.length === 0) { - throw new Error(`${csvPath}: file is empty`); - } - const header = lines[0].split(',').map((cell) => cell.trim()); - const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col)); - if (missing.length > 0) { - throw new Error( - `${csvPath}: missing required column(s): ${missing.join(', ')}; found: ${header.join(', ')}`, - ); - } - if (lines.length === 1) { + const candles = new wickra.CandleReader(text).read(); + if (candles.length === 0) { throw new Error(`${csvPath}: CSV has a header but no data rows`); } - const idx = {}; - for (const col of REQUIRED_COLUMNS) { - idx[col] = header.indexOf(col); - } const cols = { timestamp: [], open: [], high: [], low: [], close: [], volume: [] }; - for (let i = 1; i < lines.length; i++) { - const cells = lines[i].split(','); - for (const col of REQUIRED_COLUMNS) { - const value = Number(cells[idx[col]]); - if (!Number.isFinite(value)) { - throw new Error( - `${csvPath}: row ${i + 1} column '${col}' is not numeric: ${JSON.stringify(cells[idx[col]])}`, - ); - } - cols[col].push(value); - } + for (const k of candles) { + cols.timestamp.push(k.timestamp); + cols.open.push(k.open); + cols.high.push(k.high); + cols.low.push(k.low); + cols.close.push(k.close); + cols.volume.push(k.volume); } return cols; } -// Roll an OHLCV series up to `bucketMs`-sized buckets keyed on each bar's -// `floor(timestamp / bucketMs)`. Input timestamps must be monotonic -// non-decreasing (the bundled BTCUSDT-1m dataset is contiguous, so this -// holds by construction). function resample(cols, bucketMs) { if (cols.timestamp.length === 0) { throw new Error('resample: empty input series'); } + // Native Resampler — no hand-written bucketing. update() emits a closed candle + // when a bucket boundary is crossed; flush() yields the final partial bucket. + const r = new wickra.Resampler(bucketMs); const out = { timestamp: [], open: [], high: [], low: [], close: [], volume: [] }; - let bucketStart = Math.floor(cols.timestamp[0] / bucketMs) * bucketMs; - let [o, h, l, c, v] = [cols.open[0], cols.high[0], cols.low[0], cols.close[0], cols.volume[0]]; - - for (let i = 1; i < cols.timestamp.length; i++) { - const start = Math.floor(cols.timestamp[i] / bucketMs) * bucketMs; - if (start === bucketStart) { - if (cols.high[i] > h) h = cols.high[i]; - if (cols.low[i] < l) l = cols.low[i]; - c = cols.close[i]; - v += cols.volume[i]; - } else { - out.timestamp.push(bucketStart); - out.open.push(o); - out.high.push(h); - out.low.push(l); - out.close.push(c); - out.volume.push(v); - bucketStart = start; - o = cols.open[i]; - h = cols.high[i]; - l = cols.low[i]; - c = cols.close[i]; - v = cols.volume[i]; - } + const push = (k) => { + out.timestamp.push(k.timestamp); + out.open.push(k.open); + out.high.push(k.high); + out.low.push(k.low); + out.close.push(k.close); + out.volume.push(k.volume); + }; + for (let i = 0; i < cols.timestamp.length; i++) { + const k = r.update(cols.open[i], cols.high[i], cols.low[i], cols.close[i], cols.volume[i], cols.timestamp[i]); + if (k !== null) push(k); } - // Flush the final open bucket. - out.timestamp.push(bucketStart); - out.open.push(o); - out.high.push(h); - out.low.push(l); - out.close.push(c); - out.volume.push(v); + const last = r.flush(); + if (last !== null) push(last); return out; } diff --git a/examples/node/package-lock.json b/examples/node/package-lock.json index 1fb64b29..0937de93 100644 --- a/examples/node/package-lock.json +++ b/examples/node/package-lock.json @@ -10,9 +10,6 @@ "license": "SEE LICENSE IN ../../LICENSE", "dependencies": { "wickra": "file:../../bindings/node" - }, - "devDependencies": { - "ws": "^8.18.0" } }, "../../bindings/node": { @@ -23,7 +20,7 @@ "@napi-rs/cli": "^2.18.0" }, "engines": { - "node": ">= 18" + "node": ">= 20" }, "optionalDependencies": { "wickra-darwin-arm64": "0.9.2", @@ -37,28 +34,6 @@ "node_modules/wickra": { "resolved": "../../bindings/node", "link": true - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } } } } diff --git a/examples/node/package.json b/examples/node/package.json index cb469cef..779e607e 100644 --- a/examples/node/package.json +++ b/examples/node/package.json @@ -6,8 +6,5 @@ "license": "SEE LICENSE IN ../../LICENSE", "dependencies": { "wickra": "file:../../bindings/node" - }, - "devDependencies": { - "ws": "^8.18.0" } } diff --git a/examples/node/strategy_bollinger_squeeze.js b/examples/node/strategy_bollinger_squeeze.js index 1d1bc43e..6efeb175 100644 --- a/examples/node/strategy_bollinger_squeeze.js +++ b/examples/node/strategy_bollinger_squeeze.js @@ -31,48 +31,14 @@ const ATR_PERIOD = 14; const ATR_STOP_MULT = 2.0; const SQUEEZE_LOOKBACK = 180; -const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume']; const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1d.csv'); function loadCandles(csvPath) { + // Native CandleReader: validates the header, tolerates a UTF-8 BOM and field + // whitespace, and throws on a malformed row. Yields { open, high, low, close, + // volume, timestamp } objects. const text = fs.readFileSync(csvPath, 'utf8'); - const lines = text.split(/\r?\n/).filter((line) => line.length > 0); - if (lines.length === 0) { - throw new Error(`${csvPath}: file is empty`); - } - - const header = lines[0].split(',').map((cell) => cell.trim()); - const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col)); - if (missing.length > 0) { - throw new Error( - `${csvPath}: CSV header is missing required column(s): ${missing.join(', ')}; ` + - `found: ${header.join(', ')}`, - ); - } - if (lines.length === 1) { - throw new Error(`${csvPath}: CSV has a header but no data rows`); - } - - const idx = {}; - for (const col of REQUIRED_COLUMNS) idx[col] = header.indexOf(col); - - const candles = []; - for (let row = 1; row < lines.length; row++) { - const cells = lines[row].split(','); - const candle = {}; - for (const col of ['open', 'high', 'low', 'close', 'volume']) { - const raw = cells[idx[col]]; - const value = raw === undefined ? NaN : Number(raw.trim()); - if (raw === undefined || raw.trim() === '' || !Number.isFinite(value)) { - throw new Error( - `${csvPath}: row ${row + 1} column '${col}' is not numeric: ${JSON.stringify(raw)}`, - ); - } - candle[col] = value; - } - candles.push(candle); - } - return candles; + return new wickra.CandleReader(text).read(); } function signed(value, digits) { diff --git a/examples/node/strategy_macd_adx.js b/examples/node/strategy_macd_adx.js index 2f89bc57..2336dcc1 100644 --- a/examples/node/strategy_macd_adx.js +++ b/examples/node/strategy_macd_adx.js @@ -25,48 +25,14 @@ const wickra = require('wickra'); const FEE = 0.001; const ADX_FLOOR = 20.0; -const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume']; const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1h.csv'); function loadCandles(csvPath) { + // Native CandleReader: validates the header, tolerates a UTF-8 BOM and field + // whitespace, and throws on a malformed row. Yields { open, high, low, close, + // volume, timestamp } objects. const text = fs.readFileSync(csvPath, 'utf8'); - const lines = text.split(/\r?\n/).filter((line) => line.length > 0); - if (lines.length === 0) { - throw new Error(`${csvPath}: file is empty`); - } - - const header = lines[0].split(',').map((cell) => cell.trim()); - const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col)); - if (missing.length > 0) { - throw new Error( - `${csvPath}: CSV header is missing required column(s): ${missing.join(', ')}; ` + - `found: ${header.join(', ')}`, - ); - } - if (lines.length === 1) { - throw new Error(`${csvPath}: CSV has a header but no data rows`); - } - - const idx = {}; - for (const col of REQUIRED_COLUMNS) idx[col] = header.indexOf(col); - - const candles = []; - for (let row = 1; row < lines.length; row++) { - const cells = lines[row].split(','); - const candle = {}; - for (const col of ['open', 'high', 'low', 'close', 'volume']) { - const raw = cells[idx[col]]; - const value = raw === undefined ? NaN : Number(raw.trim()); - if (raw === undefined || raw.trim() === '' || !Number.isFinite(value)) { - throw new Error( - `${csvPath}: row ${row + 1} column '${col}' is not numeric: ${JSON.stringify(raw)}`, - ); - } - candle[col] = value; - } - candles.push(candle); - } - return candles; + return new wickra.CandleReader(text).read(); } function signed(value, digits) { diff --git a/examples/node/strategy_rsi_mean_reversion.js b/examples/node/strategy_rsi_mean_reversion.js index dd4f54e3..dbe9ec8c 100644 --- a/examples/node/strategy_rsi_mean_reversion.js +++ b/examples/node/strategy_rsi_mean_reversion.js @@ -29,51 +29,17 @@ const RSI_PERIOD = 14; const OVERSOLD = 30.0; const OVERBOUGHT = 70.0; -const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume']; const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1h.csv'); // Parse a plain OHLCV CSV into an array of candle objects. The Wickra CSV // layout is plain numeric — no quoted fields, no embedded commas — so splitting // on `,` is a complete and correct parse for it. function loadCandles(csvPath) { + // Native CandleReader: validates the header, tolerates a UTF-8 BOM and field + // whitespace, and throws on a malformed row. Yields { open, high, low, close, + // volume, timestamp } objects. const text = fs.readFileSync(csvPath, 'utf8'); - const lines = text.split(/\r?\n/).filter((line) => line.length > 0); - if (lines.length === 0) { - throw new Error(`${csvPath}: file is empty`); - } - - const header = lines[0].split(',').map((cell) => cell.trim()); - const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col)); - if (missing.length > 0) { - throw new Error( - `${csvPath}: CSV header is missing required column(s): ${missing.join(', ')}; ` + - `found: ${header.join(', ')}`, - ); - } - if (lines.length === 1) { - throw new Error(`${csvPath}: CSV has a header but no data rows`); - } - - const idx = {}; - for (const col of REQUIRED_COLUMNS) idx[col] = header.indexOf(col); - - const candles = []; - for (let row = 1; row < lines.length; row++) { - const cells = lines[row].split(','); - const candle = {}; - for (const col of ['open', 'high', 'low', 'close', 'volume']) { - const raw = cells[idx[col]]; - const value = raw === undefined ? NaN : Number(raw.trim()); - if (raw === undefined || raw.trim() === '' || !Number.isFinite(value)) { - throw new Error( - `${csvPath}: row ${row + 1} column '${col}' is not numeric: ${JSON.stringify(raw)}`, - ); - } - candle[col] = value; - } - candles.push(candle); - } - return candles; + return new wickra.CandleReader(text).read(); } // Forced-sign fixed-point (matches Python's `{:+.Nf}`): the value's own minus is diff --git a/examples/python/backtest.py b/examples/python/backtest.py index d45704df..2f7695a4 100644 --- a/examples/python/backtest.py +++ b/examples/python/backtest.py @@ -14,20 +14,14 @@ indicators are wired correctly without pulling in pandas or a charting stack. from __future__ import annotations import argparse -import csv import sys from dataclasses import dataclass -from typing import List import numpy as np import wickra as ta -# Columns the OHLCV layout requires; the CSV header must name every one. -REQUIRED_COLUMNS = ("timestamp", "open", "high", "low", "close", "volume") - - @dataclass class History: timestamp: np.ndarray @@ -39,51 +33,30 @@ class History: def read_history(path: str) -> History: - """Load an OHLCV CSV into typed NumPy columns. + """Load an OHLCV CSV into typed NumPy columns with Wickra's native + ``CandleReader`` — no manual CSV parsing. + + ``CandleReader`` validates the header (``timestamp,open,high,low,close,volume``), + tolerates a UTF-8 BOM and surrounding whitespace, and raises ``ValueError`` on a + missing column or a non-numeric / invalid OHLC row. Raises: - ValueError: if the file has no header, is missing a required column, - holds no data rows, or contains a non-numeric value (the message - pinpoints the offending row and column instead of surfacing an - opaque ``KeyError`` or NumPy ``ValueError``). + ValueError: if the CSV header or a data row is malformed. """ - rows: List[List[str]] = [] - with open(path, newline="") as f: - reader = csv.DictReader(f) - if reader.fieldnames is None: - raise ValueError(f"{path}: CSV has no header row") - missing = [c for c in REQUIRED_COLUMNS if c not in reader.fieldnames] - if missing: - raise ValueError( - f"{path}: CSV is missing required column(s): " - f"{', '.join(missing)}; found: {', '.join(reader.fieldnames)}" - ) - for row in reader: - rows.append([row[c] for c in REQUIRED_COLUMNS]) - if not rows: + with open(path, encoding="utf-8") as f: + candles = ta.CandleReader(f.read()).read() + if not candles: raise ValueError(f"{path}: CSV has a header but no data rows") - try: - arr = np.array(rows, dtype=np.float64) - except (TypeError, ValueError) as exc: - # NumPy's own message names neither the row nor the column — locate - # the first non-numeric cell and report it precisely. - for line_no, row in enumerate(rows, start=2): - for col, value in zip(REQUIRED_COLUMNS, row): - try: - float(value) - except (TypeError, ValueError): - raise ValueError( - f"{path}: row {line_no} column '{col}' is not " - f"numeric: {value!r}" - ) from exc - raise # could not localise — surface the original error + # CandleReader yields (open, high, low, close, volume, timestamp) tuples. + # Transpose into contiguous 1-D columns (batch() needs C-contiguous arrays). + o, h, l, c, v, ts = (np.array(col, dtype=np.float64) for col in zip(*candles)) return History( - timestamp=arr[:, 0].astype(np.int64), - open=arr[:, 1], - high=arr[:, 2], - low=arr[:, 3], - close=arr[:, 4], - volume=arr[:, 5], + timestamp=ts.astype(np.int64), + open=o, + high=h, + low=l, + close=c, + volume=v, ) diff --git a/examples/python/live_binance.py b/examples/python/live_binance.py index 433e9bbe..55e7f06b 100644 --- a/examples/python/live_binance.py +++ b/examples/python/live_binance.py @@ -1,61 +1,42 @@ -"""Live Binance feed: stream Binance kline ticks → incremental indicators → signals. +"""Live Binance feed: stream Binance kline ticks -> incremental indicators -> signals. -This example connects to Binance's public WebSocket feed (no API key needed) -and runs RSI / MACD / Bollinger Bands on the close prices coming in. When the -RSI crosses common overbought / oversold thresholds *and* the MACD histogram -confirms the direction, a `Signal` event is printed. No orders are placed. +This example streams Binance's public kline feed (no API key needed) through +Wickra's **native** ``BinanceFeed`` and runs RSI / MACD / Bollinger Bands on the +close prices coming in. When the RSI crosses common overbought / oversold +thresholds *and* the MACD histogram confirms the direction, a signal is printed. +No orders are placed. -Run with:: +There is **no third-party dependency** — the WebSocket client is built into +Wickra. Just:: python -m examples.python.live_binance --symbol BTCUSDT --interval 1m - -Dependencies:: - - pip install websockets """ from __future__ import annotations import argparse -import asyncio -import json import logging import re -import signal import sys from dataclasses import dataclass from typing import Optional import wickra as ta -try: - import websockets -except ImportError: - print("This example needs the `websockets` package: pip install websockets", file=sys.stderr) - raise - - -BINANCE_WS = "wss://stream.binance.com:9443/stream" - -# Kline intervals the public Binance WebSocket API recognises. -VALID_INTERVALS = frozenset( - { - "1s", "1m", "3m", "5m", "15m", "30m", - "1h", "2h", "4h", "6h", "8h", "12h", - "1d", "3d", "1w", "1M", - } -) +# Binance kline interval -> the integer code BinanceFeed expects (the same order +# in every Wickra binding). +INTERVAL_CODES = { + "1s": 0, "1m": 1, "3m": 2, "5m": 3, "15m": 4, "30m": 5, + "1h": 6, "2h": 7, "4h": 8, "6h": 9, "8h": 10, "12h": 11, + "1d": 12, "3d": 13, "1w": 14, "1M": 15, +} # A Binance symbol is strictly alphanumeric (e.g. BTCUSDT). _SYMBOL_RE = re.compile(r"^[A-Za-z0-9]+$") -def validate_args(symbol: str, interval: str) -> None: - """Reject a symbol or interval that is not safe to splice into the WS URL. - - Both values are interpolated straight into the stream name and URL, so an - unexpected value would otherwise produce a confusing connection failure. - Validating up front keeps the URL well-formed without needing to escape it. +def validate_args(symbol: str, interval: str) -> int: + """Validate the symbol/interval and return the interval's integer code. Raises: ValueError: if ``symbol`` is not strictly alphanumeric, or ``interval`` @@ -63,14 +44,14 @@ def validate_args(symbol: str, interval: str) -> None: """ if not _SYMBOL_RE.match(symbol): raise ValueError( - f"invalid --symbol {symbol!r}: expected only letters and digits, " - "e.g. BTCUSDT" + f"invalid --symbol {symbol!r}: expected only letters and digits, e.g. BTCUSDT" ) - if interval not in VALID_INTERVALS: + if interval not in INTERVAL_CODES: raise ValueError( f"invalid --interval {interval!r}: expected one of " - + ", ".join(sorted(VALID_INTERVALS)) + + ", ".join(INTERVAL_CODES) ) + return INTERVAL_CODES[interval] @dataclass @@ -120,38 +101,28 @@ def emit_signal(snap: Snapshot, log: logging.Logger) -> None: ) -async def run(symbol: str, interval: str) -> None: - stream = f"{symbol.lower()}@kline_{interval}" - url = f"{BINANCE_WS}?streams={stream}" +def run(symbol: str, interval_code: int) -> None: log = logging.getLogger("wickra-live") state = StrategyState() - log.info("Connecting to %s", url) - async with websockets.connect(url, ping_interval=20) as ws: - log.info("Connected, listening for %s klines", stream) - async for raw in ws: - envelope = json.loads(raw) - payload = envelope.get("data", {}) - k = payload.get("k", {}) - if not k or "c" not in k: - # Subscription acks, heartbeats and error frames carry no - # kline payload — skip them instead of crashing on - # float(None) the moment the stream opens. - log.debug("skipping non-kline frame: %s", raw) - continue - close = float(k.get("c")) - is_closed = bool(k.get("x")) - snap = state.update(close) - log.info( - "%s close=%.4f rsi=%s hist=%s bb=%s", - "BAR" if is_closed else "tick", - close, - f"{snap.rsi:.1f}" if snap.rsi is not None else "--", - f"{snap.macd_hist:+.4f}" if snap.macd_hist is not None else "--", - f"{snap.bb_lower:.2f}/{snap.bb_middle:.2f}/{snap.bb_upper:.2f}" - if snap.bb_upper is not None - else "--", - ) - emit_signal(snap, log) + feed = ta.BinanceFeed(symbol, interval_code) + log.info("Streaming %s klines from Binance", symbol) + while True: + event = feed.next(1000) # blocks up to 1s; None on timeout (Ctrl+C between polls) + if event is None: + continue + _symbol, _open, _high, _low, close, _volume, _open_time, is_closed = event + snap = state.update(close) + log.info( + "%s close=%.4f rsi=%s hist=%s bb=%s", + "BAR" if is_closed else "tick", + close, + f"{snap.rsi:.1f}" if snap.rsi is not None else "--", + f"{snap.macd_hist:+.4f}" if snap.macd_hist is not None else "--", + f"{snap.bb_lower:.2f}/{snap.bb_middle:.2f}/{snap.bb_upper:.2f}" + if snap.bb_upper is not None + else "--", + ) + emit_signal(snap, log) def parse_args() -> argparse.Namespace: @@ -165,7 +136,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() try: - validate_args(args.symbol, args.interval) + interval_code = validate_args(args.symbol, args.interval) except ValueError as exc: print(f"error: {exc}", file=sys.stderr) return 2 @@ -173,19 +144,10 @@ def main() -> int: level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(levelname)s %(message)s", ) - loop = asyncio.new_event_loop() - # Translate Ctrl+C into a clean loop stop. - for sig in (signal.SIGINT, signal.SIGTERM): - try: - loop.add_signal_handler(sig, loop.stop) - except NotImplementedError: - pass # Windows does not support add_signal_handler for SIGTERM. try: - loop.run_until_complete(run(args.symbol, args.interval)) + run(args.symbol, interval_code) except KeyboardInterrupt: pass - finally: - loop.close() return 0 diff --git a/examples/python/multi_timeframe.py b/examples/python/multi_timeframe.py index e6f19cf3..f8bb9356 100644 --- a/examples/python/multi_timeframe.py +++ b/examples/python/multi_timeframe.py @@ -1,9 +1,9 @@ """Compute indicators on multiple timeframes from a single 1-minute feed. -Wickra exposes resampling on the Rust side (in `wickra-data`); from Python we -roll up bars manually using NumPy because most users already have their data -in a NumPy array and don't need the streaming-resample infrastructure for -offline analysis. +Loads the 1-minute history with Wickra's native ``CandleReader`` and rolls it up +to coarser timeframes with the native ``Resampler`` — no manual CSV parsing and +no hand-written bucketing. NumPy is used only to run the batch indicators over +each resampled series. Run with:: @@ -13,104 +13,45 @@ Run with:: from __future__ import annotations import argparse -import csv -from typing import Iterable, List, Tuple +from typing import List, Tuple import numpy as np import wickra as ta - -# Columns the OHLCV layout requires; the CSV header must name every one. -REQUIRED_COLUMNS = ("timestamp", "open", "high", "low", "close", "volume") +# A candle as the data layer yields it: (open, high, low, close, volume, timestamp). +Candle = Tuple[float, float, float, float, float, int] -def read_csv(path: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - """Read an OHLCV CSV into typed columns. - - Raises: - ValueError: if the file has no header, is missing a required column, - holds no data rows, or contains a non-numeric value (the message - names the offending row). - """ - ts, o, h, l, c, v = [], [], [], [], [], [] - with open(path, newline="") as f: - reader = csv.DictReader(f) - if reader.fieldnames is None: - raise ValueError(f"{path}: CSV has no header row") - missing = [col for col in REQUIRED_COLUMNS if col not in reader.fieldnames] - if missing: - raise ValueError( - f"{path}: CSV is missing required column(s): " - f"{', '.join(missing)}; found: {', '.join(reader.fieldnames)}" - ) - for line_no, row in enumerate(reader, start=2): - try: - ts.append(int(row["timestamp"])) - o.append(float(row["open"])) - h.append(float(row["high"])) - l.append(float(row["low"])) - c.append(float(row["close"])) - v.append(float(row["volume"])) - except (TypeError, ValueError) as exc: - raise ValueError( - f"{path}: row {line_no} has a non-numeric value: {exc}" - ) from exc - if not ts: - raise ValueError(f"{path}: CSV has a header but no data rows") - return ( - np.asarray(ts, dtype=np.int64), - np.asarray(o, dtype=np.float64), - np.asarray(h, dtype=np.float64), - np.asarray(l, dtype=np.float64), - np.asarray(c, dtype=np.float64), - np.asarray(v, dtype=np.float64), - ) +def load(path: str) -> List[Candle]: + """Read an OHLCV CSV into candles with ``CandleReader`` (validates the + ``timestamp,open,high,low,close,volume`` header; raises ``ValueError`` on a + malformed header or row).""" + with open(path, encoding="utf-8") as f: + return ta.CandleReader(f.read()).read() -def resample( - ts: np.ndarray, - o: np.ndarray, - h: np.ndarray, - l: np.ndarray, - c: np.ndarray, - v: np.ndarray, - bucket: int, -) -> Tuple[np.ndarray, ...]: - """Aggregate bar-level columns into coarser buckets of size `bucket`. - - Raises: - ValueError: if the input series is empty. - """ - if ts.size == 0: - raise ValueError("resample received an empty series") - bucket_index = (ts // bucket).astype(np.int64) - boundaries = np.diff(bucket_index, prepend=bucket_index[0] - 1) != 0 - group_ids = np.cumsum(boundaries) - 1 - n_groups = int(group_ids.max()) + 1 - - new_ts = np.empty(n_groups, dtype=np.int64) - new_o = np.empty(n_groups, dtype=np.float64) - new_h = np.empty(n_groups, dtype=np.float64) - new_l = np.empty(n_groups, dtype=np.float64) - new_c = np.empty(n_groups, dtype=np.float64) - new_v = np.empty(n_groups, dtype=np.float64) - - for g in range(n_groups): - mask = group_ids == g - new_ts[g] = ts[mask][0] - new_o[g] = o[mask][0] - new_h[g] = h[mask].max() - new_l[g] = l[mask].min() - new_c[g] = c[mask][-1] - new_v[g] = v[mask].sum() - return new_ts, new_o, new_h, new_l, new_c, new_v +def resample(candles: List[Candle], bucket_ms: int) -> List[Candle]: + """Aggregate 1-minute candles into ``bucket_ms``-sized candles with the + native streaming ``Resampler``.""" + r = ta.Resampler(bucket_ms) + out: List[Candle] = [] + for o, h, l, c, v, ts in candles: + agg = r.update(o, h, l, c, v, ts) + if agg is not None: + out.append(agg) + tail = r.flush() + if tail is not None: + out.append(tail) + return out -def summarize(label: str, close: np.ndarray, high: np.ndarray, low: np.ndarray) -> None: - if close.size == 0: +def summarize(label: str, candles: List[Candle]) -> None: + if not candles: print(f" {label:<10} (empty series — nothing to summarize)") return + # Transpose into contiguous 1-D columns (batch() needs C-contiguous arrays). + _o, high, low, close, _v, _ts = (np.array(col, dtype=np.float64) for col in zip(*candles)) rsi = ta.RSI(14).batch(close) macd = ta.MACD().batch(close) adx = ta.ADX(14).batch(high, low, close) @@ -130,16 +71,15 @@ def main() -> int: p.add_argument("path", help="Path to a 1-minute OHLCV CSV (timestamps in milliseconds)") args = p.parse_args() - ts, o, h, l, c, v = read_csv(args.path) + candles = load(args.path) one_minute = 60_000 print(f"Multi-timeframe view of {args.path}") - summarize("1m", c, h, l) + summarize("1m", candles) for label, bucket in [("5m", 5), ("15m", 15), ("1h", 60), ("4h", 240), ("1d", 1440)]: - if ts.size < bucket: + if len(candles) < bucket: continue - rs_ts, rs_o, rs_h, rs_l, rs_c, rs_v = resample(ts, o, h, l, c, v, bucket * one_minute) - summarize(label, rs_c, rs_h, rs_l) + summarize(label, resample(candles, bucket * one_minute)) return 0 diff --git a/examples/python/strategy_bollinger_squeeze.py b/examples/python/strategy_bollinger_squeeze.py index 6f95155c..8374a831 100644 --- a/examples/python/strategy_bollinger_squeeze.py +++ b/examples/python/strategy_bollinger_squeeze.py @@ -18,7 +18,6 @@ daily bars give an interpretable 6-month-low lookback (~180 bars). from __future__ import annotations -import csv import math from collections import deque from pathlib import Path @@ -34,18 +33,14 @@ SQUEEZE_LOOKBACK = 180 def load_candles(path: Path) -> list[dict[str, float]]: - with path.open() as fh: - reader = csv.DictReader(fh) - return [ - { - "open": float(r["open"]), - "high": float(r["high"]), - "low": float(r["low"]), - "close": float(r["close"]), - "volume": float(r["volume"]), - } - for r in reader - ] + # Native CandleReader: validates the header, tolerates a UTF-8 BOM and field + # whitespace, and raises ValueError on a malformed row. No third-party CSV. + candles = ta.CandleReader(path.read_text(encoding="utf-8")).read() + # CandleReader yields (open, high, low, close, volume, timestamp) tuples. + return [ + {"open": o, "high": h, "low": l, "close": c, "volume": v} + for o, h, l, c, v, _ts in candles + ] def print_summary( diff --git a/examples/python/strategy_macd_adx.py b/examples/python/strategy_macd_adx.py index 4a119b4c..18185b12 100644 --- a/examples/python/strategy_macd_adx.py +++ b/examples/python/strategy_macd_adx.py @@ -15,7 +15,6 @@ Uses the checked-in ``examples/data/btcusdt-1h.csv`` dataset. from __future__ import annotations -import csv import math from pathlib import Path @@ -26,18 +25,14 @@ ADX_FLOOR = 20.0 def load_candles(path: Path) -> list[dict[str, float]]: - with path.open() as fh: - reader = csv.DictReader(fh) - return [ - { - "open": float(r["open"]), - "high": float(r["high"]), - "low": float(r["low"]), - "close": float(r["close"]), - "volume": float(r["volume"]), - } - for r in reader - ] + # Native CandleReader: validates the header, tolerates a UTF-8 BOM and field + # whitespace, and raises ValueError on a malformed row. No third-party CSV. + candles = ta.CandleReader(path.read_text(encoding="utf-8")).read() + # CandleReader yields (open, high, low, close, volume, timestamp) tuples. + return [ + {"open": o, "high": h, "low": l, "close": c, "volume": v} + for o, h, l, c, v, _ts in candles + ] def print_summary( diff --git a/examples/python/strategy_rsi_mean_reversion.py b/examples/python/strategy_rsi_mean_reversion.py index 6bb0bb9a..7172bde4 100644 --- a/examples/python/strategy_rsi_mean_reversion.py +++ b/examples/python/strategy_rsi_mean_reversion.py @@ -17,7 +17,6 @@ Uses the checked-in ``examples/data/btcusdt-1h.csv`` dataset. from __future__ import annotations -import csv import math from pathlib import Path @@ -30,18 +29,14 @@ OVERBOUGHT = 70.0 def load_candles(path: Path) -> list[dict[str, float]]: - with path.open() as fh: - reader = csv.DictReader(fh) - return [ - { - "open": float(r["open"]), - "high": float(r["high"]), - "low": float(r["low"]), - "close": float(r["close"]), - "volume": float(r["volume"]), - } - for r in reader - ] + # Native CandleReader: validates the header, tolerates a UTF-8 BOM and field + # whitespace, and raises ValueError on a malformed row. No third-party CSV. + candles = ta.CandleReader(path.read_text(encoding="utf-8")).read() + # CandleReader yields (open, high, low, close, volume, timestamp) tuples. + return [ + {"open": o, "high": h, "low": l, "close": c, "volume": v} + for o, h, l, c, v, _ts in candles + ] def print_summary( diff --git a/examples/r/README.md b/examples/r/README.md index a34aff8f..ca2eb87a 100644 --- a/examples/r/README.md +++ b/examples/r/README.md @@ -24,10 +24,10 @@ Rscript streaming.R | `strategy_rsi_mean_reversion.R` | RSI(14) mean-reversion with a PnL / Sharpe / max-DD summary. | `Rscript strategy_rsi_mean_reversion.R` | | `strategy_macd_adx.R` | MACD crossover entries gated by ADX(14) > 20. | `Rscript strategy_macd_adx.R` | | `strategy_bollinger_squeeze.R` | Bollinger-squeeze breakout with an ATR(14) trailing stop. | `Rscript strategy_bollinger_squeeze.R` | -| `fetch_btcusdt.R` | Download real BTCUSDT klines from the Binance REST API into a CSV (`jsonlite`). | `Rscript fetch_btcusdt.R` | -| `live_binance.R` | Stream live Binance klines through EMA(20) over a WebSocket (`websocket`). | `Rscript live_binance.R` | +| `fetch_btcusdt.R` | Download real BTCUSDT klines from the Binance REST API into a CSV (native `fetch_binance_klines`). | `Rscript fetch_btcusdt.R` | +| `live_binance.R` | Stream live Binance klines through EMA(20) via the native `BinanceFeed`. | `Rscript live_binance.R` | -`fetch_btcusdt.R` and `live_binance.R` require network access (and the -`jsonlite` / `websocket` packages); the rest run offline on deterministic -synthetic data. `parallel_assets.R` forks via `parallel::mclapply` on Unix and -runs serially on Windows. +`fetch_btcusdt.R` and `live_binance.R` require network access but no third-party +packages — they use Wickra's native REST fetcher and live feed; the rest run +offline on deterministic synthetic data. `parallel_assets.R` forks via +`parallel::mclapply` on Unix and runs serially on Windows. diff --git a/examples/r/_common.R b/examples/r/_common.R index 3c94576e..fd4805ee 100644 --- a/examples/r/_common.R +++ b/examples/r/_common.R @@ -24,14 +24,11 @@ synthetic_candles <- function(count, start_ts = 0, step_ms = 3600000) { } load_ohlcv_csv <- function(path) { - df <- utils::read.csv(path, header = TRUE, stringsAsFactors = FALSE) - if (ncol(df) >= 6) { - data.frame(open = df[[2]], high = df[[3]], low = df[[4]], - close = df[[5]], volume = df[[6]], timestamp = df[[1]]) - } else { - data.frame(open = df[[1]], high = df[[2]], low = df[[3]], - close = df[[4]], volume = df[[5]], timestamp = seq_len(nrow(df))) - } + # Native CandleReader: header validation, BOM and field-whitespace tolerance. + # read() returns an (n x 6) matrix of open, high, low, close, volume, timestamp. + m <- read(CandleReader(paste(readLines(path, warn = FALSE), collapse = "\n"))) + data.frame(open = m[, "open"], high = m[, "high"], low = m[, "low"], + close = m[, "close"], volume = m[, "volume"], timestamp = m[, "timestamp"]) } summarize_equity <- function(returns, trades, periods_per_year = 252) { diff --git a/examples/r/fetch_btcusdt.R b/examples/r/fetch_btcusdt.R index e37b72b3..d756ad69 100644 --- a/examples/r/fetch_btcusdt.R +++ b/examples/r/fetch_btcusdt.R @@ -1,13 +1,17 @@ # Download real BTCUSDT hourly klines from the Binance REST API into a CSV that the -# other examples can consume. Requires network access and the 'jsonlite' package. -if (!requireNamespace("jsonlite", quietly = TRUE)) stop("install 'jsonlite' to run this example") -url <- "https://api.binance.com/api/v3/klines?symbol=BTCUSDT&interval=1h&limit=500" -cat("Fetching", url, "\n") -klines <- jsonlite::fromJSON(url) +# other examples can consume, using Wickra's native fetcher — no third-party packages. +# Requires network access. +library(wickra) + +cat("Fetching 500 BTCUSDT 1h klines from Binance...\n") +# Interval code 6 == 1h. Returns an (n x 6) matrix with columns +# open, high, low, close, volume, timestamp. +m <- fetch_binance_klines("BTCUSDT", 6L, 500L) + dir.create("data", showWarnings = FALSE) df <- data.frame( - timestamp = as.numeric(klines[, 1]), open = klines[, 2], high = klines[, 3], - low = klines[, 4], close = klines[, 5], volume = klines[, 6] + timestamp = m[, "timestamp"], open = m[, "open"], high = m[, "high"], + low = m[, "low"], close = m[, "close"], volume = m[, "volume"] ) utils::write.csv(df, "data/btcusdt_1h.csv", row.names = FALSE, quote = FALSE) cat(sprintf("Wrote %d klines to data/btcusdt_1h.csv\n", nrow(df))) diff --git a/examples/r/live_binance.R b/examples/r/live_binance.R index 69587a22..8096f75d 100644 --- a/examples/r/live_binance.R +++ b/examples/r/live_binance.R @@ -1,17 +1,20 @@ # Stream live BTCUSDT 1-minute klines from Binance and feed each close through EMA(20). -# Requires network access and the 'websocket' + 'jsonlite' packages. Runs ~60s. +# Uses Wickra's native BinanceFeed — no third-party packages. Requires network +# access. Runs for up to ~60 seconds. library(wickra) -for (p in c("websocket", "jsonlite", "later")) { - if (!requireNamespace(p, quietly = TRUE)) stop(sprintf("install '%s' to run this example", p)) -} + ema <- Ema(20) -ws <- websocket::WebSocket$new("wss://stream.binance.com:9443/ws/btcusdt@kline_1m") -ws$onOpen(function(event) cat("Connected; streaming for up to 60s...\n")) -ws$onMessage(function(event) { - msg <- jsonlite::fromJSON(event$data) - close <- as.numeric(msg$k$c) - cat(sprintf("close=%.2f EMA(20)=%.2f\n", close, update(ema, close))) -}) -ws$connect() -later::later(function() ws$close(), 60) -while (ws$readyState() <= 1L) later::run_now(timeout = 1) +# Interval code 1 == 1m (the Interval declaration order shared by every binding). +feed <- BinanceFeed("BTCUSDT", 1L) +cat("Streaming for up to 60s...\n") + +deadline <- Sys.time() + 60 +repeat { + if (Sys.time() > deadline) break + # binance_next() returns a named list on an event, or NULL on timeout. + event <- binance_next(feed, 1000) + if (is.null(event)) next + cat(sprintf("close=%.2f EMA(20)=%.2f\n", event$close, update(ema, event$close))) +} +binance_close(feed) +cat("Done (time limit reached).\n") diff --git a/examples/r/multi_timeframe.R b/examples/r/multi_timeframe.R index 56da0776..d4f71a3e 100644 --- a/examples/r/multi_timeframe.R +++ b/examples/r/multi_timeframe.R @@ -4,13 +4,21 @@ source("_common.R") resample <- function(bars, factor) { if (factor <= 1) return(bars) - idx <- seq(1, nrow(bars), by = factor) - do.call(rbind, lapply(idx, function(i) { - j <- min(i + factor - 1, nrow(bars)) - data.frame(open = bars$open[i], high = max(bars$high[i:j]), - low = min(bars$low[i:j]), close = bars$close[j], - volume = sum(bars$volume[i:j]), timestamp = bars$timestamp[i]) - })) + # Native Resampler: bucket by an absolute timeframe (synthetic bars step 60000 ms, + # so factor minutes == factor*60000 ms). update() yields NA until a bucket closes; + # flush() returns the final partial bucket. No hand-written bucketing. + r <- Resampler(factor * 60000) + out <- list() + for (i in seq_len(nrow(bars))) { + c <- update(r, bars$open[i], bars$high[i], bars$low[i], bars$close[i], + bars$volume[i], bars$timestamp[i]) + if (!is.na(c[1])) out[[length(out) + 1L]] <- c + } + f <- flush(r) + if (!is.null(f)) out[[length(out) + 1L]] <- f + m <- do.call(rbind, out) + data.frame(open = m[, 1], high = m[, 2], low = m[, 3], close = m[, 4], + volume = m[, 5], timestamp = m[, 6]) } one_minute <- synthetic_candles(1200, step_ms = 60000) diff --git a/examples/wasm/backtest.html b/examples/wasm/backtest.html index 851a3586..cb9f1d7b 100644 --- a/examples/wasm/backtest.html +++ b/examples/wasm/backtest.html @@ -44,40 +44,26 @@