examples: migrate to the native data layer (drop ws/coder-websocket/jackson/jsonlite) (#316)

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.
This commit is contained in:
kingchenc
2026-06-17 01:49:11 +02:00
committed by GitHub
parent 2ae76bb90e
commit 677ea37402
40 changed files with 576 additions and 1102 deletions
+26 -23
View File
@@ -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;
}
+55 -44
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#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 */
+9 -26
View File
@@ -45,34 +45,17 @@ public static class MarketData
/// </summary>
public static Bar[] LoadOhlcvCsv(string path)
{
var bars = new List<Bar>();
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;
}
}
+9 -19
View File
@@ -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}");
+14 -30
View File
@@ -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).");
+14 -8
View File
@@ -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<Bar>();
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);
-2
View File
@@ -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
-2
View File
@@ -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=
+16 -31
View File
@@ -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.
+16 -28
View File
@@ -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).")
}
+13 -19
View File
@@ -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
}
-6
View File
@@ -23,12 +23,6 @@
<artifactId>wickra</artifactId>
<version>0.9.2</version>
</dependency>
<!-- Only the network examples (fetch_btcusdt) parse JSON. -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.22.0</version>
</dependency>
</dependencies>
<build>
@@ -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<String> 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());
}
}
@@ -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).");
}
}
@@ -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<Bar> 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;
}
}
}
@@ -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<Bar> 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());
}
}
+12 -35
View File
@@ -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;
}
+41 -61
View File
@@ -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();
+27 -63
View File
@@ -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;
}
+1 -26
View File
@@ -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
}
}
}
}
}
-3
View File
@@ -6,8 +6,5 @@
"license": "SEE LICENSE IN ../../LICENSE",
"dependencies": {
"wickra": "file:../../bindings/node"
},
"devDependencies": {
"ws": "^8.18.0"
}
}
+4 -38
View File
@@ -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) {
+4 -38
View File
@@ -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) {
+4 -38
View File
@@ -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
+19 -46
View File
@@ -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,
)
+43 -81
View File
@@ -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
+34 -94
View File
@@ -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
+8 -13
View File
@@ -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(
+8 -13
View File
@@ -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(
+8 -13
View File
@@ -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(
+6 -6
View File
@@ -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.
+5 -8
View File
@@ -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) {
+11 -7
View File
@@ -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)))
+17 -14
View File
@@ -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")
+15 -7
View File
@@ -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)
+11 -25
View File
@@ -44,40 +44,26 @@
<script type="module">
import init, {
CandleReader,
version, installPanicHook,
SMA, EMA, RSI, MACD, BollingerBands, ATR, ADX, OBV,
} from "../../bindings/wasm/pkg/wickra_wasm.js";
const REQUIRED = ["timestamp", "open", "high", "low", "close", "volume"];
// Parse a plain OHLCV CSV — the Wickra layout never quotes values nor
// embeds commas in fields, so split-on-comma is a complete parse.
function parseCsv(text) {
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
if (lines.length === 0) throw new Error("file is empty");
const header = lines[0].split(",").map((s) => s.trim());
const missing = REQUIRED.filter((c) => !header.includes(c));
if (missing.length > 0) {
throw new Error(
`missing required column(s): ${missing.join(", ")}; found: ${header.join(", ")}`,
);
}
if (lines.length === 1) throw new Error("CSV has a header but no data rows");
const idx = {};
for (const c of REQUIRED) idx[c] = header.indexOf(c);
// Native CandleReader: header validation, BOM/whitespace tolerance. No
// manual CSV parsing.
const candles = new CandleReader(text).read();
const cols = { timestamp: [], open: [], high: [], low: [], close: [], volume: [] };
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(",");
for (const c of REQUIRED) {
const v = Number(cells[idx[c]]);
if (!Number.isFinite(v)) {
throw new Error(
`row ${i + 1} column '${c}' is not numeric: ${JSON.stringify(cells[idx[c]])}`,
);
}
cols[c].push(v);
}
for (const c of candles) {
cols.timestamp.push(c.timestamp);
cols.open.push(c.open);
cols.high.push(c.high);
cols.low.push(c.low);
cols.close.push(c.close);
cols.volume.push(c.volume);
}
return cols;
}
+26 -45
View File
@@ -46,38 +46,25 @@
<script type="module">
import init, {
CandleReader, Resampler,
version, installPanicHook,
RSI, MACD, ADX,
} from "../../bindings/wasm/pkg/wickra_wasm.js";
const REQUIRED = ["timestamp", "open", "high", "low", "close", "volume"];
const ONE_MINUTE_MS = 60_000;
function parseCsv(text) {
const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
if (lines.length === 0) throw new Error("file is empty");
const header = lines[0].split(",").map((s) => s.trim());
const missing = REQUIRED.filter((c) => !header.includes(c));
if (missing.length > 0) {
throw new Error(
`missing required column(s): ${missing.join(", ")}; found: ${header.join(", ")}`,
);
}
if (lines.length === 1) throw new Error("CSV has a header but no data rows");
const idx = {};
for (const c of REQUIRED) idx[c] = header.indexOf(c);
// Native CandleReader: header validation, BOM/whitespace tolerance. No
// manual CSV parsing.
const candles = new CandleReader(text).read();
const cols = { timestamp: [], open: [], high: [], low: [], close: [], volume: [] };
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(",");
for (const c of REQUIRED) {
const v = Number(cells[idx[c]]);
if (!Number.isFinite(v)) {
throw new Error(
`row ${i + 1} column '${c}' is not numeric: ${JSON.stringify(cells[idx[c]])}`,
);
}
cols[c].push(v);
}
for (const c of candles) {
cols.timestamp.push(c.timestamp);
cols.open.push(c.open);
cols.high.push(c.high);
cols.low.push(c.low);
cols.close.push(c.close);
cols.volume.push(c.volume);
}
return cols;
}
@@ -88,29 +75,23 @@
// construction).
function resample(cols, bucketMs) {
if (cols.timestamp.length === 0) throw new Error("resample: empty series");
// Native Resampler — no hand-written bucketing. update() returns a closed
// candle on a bucket boundary; flush() yields the final partial bucket.
const r = new Resampler(bucketMs);
const out = { timestamp: [], open: [], high: [], low: [], close: [], volume: [] };
let bucketStart = Math.floor(cols.timestamp[0] / bucketMs) * bucketMs;
let o = cols.open[0], h = cols.high[0], l = cols.low[0],
c = cols.close[0], v = 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);
}
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);
r.free();
return out;
}
+13 -22
View File
@@ -48,7 +48,8 @@
</p>
<script type="module">
import init, { version, installPanicHook, BollingerBands, ATR } from "../../bindings/wasm/pkg/wickra_wasm.js";
import init, {
CandleReader, version, installPanicHook, BollingerBands, ATR } from "../../bindings/wasm/pkg/wickra_wasm.js";
const FEE = 0.001;
const BB_PERIOD = 20;
@@ -56,29 +57,19 @@
const ATR_PERIOD = 14;
const ATR_STOP_MULT = 2.0;
const SQUEEZE_LOOKBACK = 180;
const REQUIRED = ["timestamp", "open", "high", "low", "close", "volume"];
function parseCsv(text) {
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
if (lines.length === 0) throw new Error("file is empty");
const header = lines[0].split(",").map((s) => s.trim());
const missing = REQUIRED.filter((c) => !header.includes(c));
if (missing.length > 0) {
throw new Error(`missing required column(s): ${missing.join(", ")}; found: ${header.join(", ")}`);
}
if (lines.length === 1) throw new Error("CSV has a header but no data rows");
const idx = {};
for (const c of REQUIRED) idx[c] = header.indexOf(c);
const cols = { open: [], high: [], low: [], close: [], volume: [] };
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(",");
for (const c of ["open", "high", "low", "close", "volume"]) {
const v = Number(cells[idx[c]]);
if (!Number.isFinite(v)) {
throw new Error(`row ${i + 1} column '${c}' is not numeric: ${JSON.stringify(cells[idx[c]])}`);
}
cols[c].push(v);
}
// Native CandleReader: header validation, BOM/whitespace tolerance. No
// manual CSV parsing.
const candles = new CandleReader(text).read();
const cols = { timestamp: [], open: [], high: [], low: [], close: [], volume: [] };
for (const c of candles) {
cols.timestamp.push(c.timestamp);
cols.open.push(c.open);
cols.high.push(c.high);
cols.low.push(c.low);
cols.close.push(c.close);
cols.volume.push(c.volume);
}
return cols;
}
+13 -22
View File
@@ -47,33 +47,24 @@
</p>
<script type="module">
import init, { version, installPanicHook, MACD, ADX } from "../../bindings/wasm/pkg/wickra_wasm.js";
import init, {
CandleReader, version, installPanicHook, MACD, ADX } from "../../bindings/wasm/pkg/wickra_wasm.js";
const FEE = 0.001;
const ADX_FLOOR = 20.0;
const REQUIRED = ["timestamp", "open", "high", "low", "close", "volume"];
function parseCsv(text) {
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
if (lines.length === 0) throw new Error("file is empty");
const header = lines[0].split(",").map((s) => s.trim());
const missing = REQUIRED.filter((c) => !header.includes(c));
if (missing.length > 0) {
throw new Error(`missing required column(s): ${missing.join(", ")}; found: ${header.join(", ")}`);
}
if (lines.length === 1) throw new Error("CSV has a header but no data rows");
const idx = {};
for (const c of REQUIRED) idx[c] = header.indexOf(c);
const cols = { open: [], high: [], low: [], close: [], volume: [] };
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(",");
for (const c of ["open", "high", "low", "close", "volume"]) {
const v = Number(cells[idx[c]]);
if (!Number.isFinite(v)) {
throw new Error(`row ${i + 1} column '${c}' is not numeric: ${JSON.stringify(cells[idx[c]])}`);
}
cols[c].push(v);
}
// Native CandleReader: header validation, BOM/whitespace tolerance. No
// manual CSV parsing.
const candles = new CandleReader(text).read();
const cols = { timestamp: [], open: [], high: [], low: [], close: [], volume: [] };
for (const c of candles) {
cols.timestamp.push(c.timestamp);
cols.open.push(c.open);
cols.high.push(c.high);
cols.low.push(c.low);
cols.close.push(c.close);
cols.volume.push(c.volume);
}
return cols;
}
+13 -22
View File
@@ -48,37 +48,28 @@
</p>
<script type="module">
import init, { version, installPanicHook, RSI } from "../../bindings/wasm/pkg/wickra_wasm.js";
import init, {
CandleReader, version, installPanicHook, RSI } from "../../bindings/wasm/pkg/wickra_wasm.js";
const FEE = 0.001;
const RSI_PERIOD = 14;
const OVERSOLD = 30.0;
const OVERBOUGHT = 70.0;
const REQUIRED = ["timestamp", "open", "high", "low", "close", "volume"];
// Plain OHLCV CSV — the Wickra layout never quotes values nor embeds commas,
// so split-on-comma is a complete parse.
function parseCsv(text) {
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
if (lines.length === 0) throw new Error("file is empty");
const header = lines[0].split(",").map((s) => s.trim());
const missing = REQUIRED.filter((c) => !header.includes(c));
if (missing.length > 0) {
throw new Error(`missing required column(s): ${missing.join(", ")}; found: ${header.join(", ")}`);
}
if (lines.length === 1) throw new Error("CSV has a header but no data rows");
const idx = {};
for (const c of REQUIRED) idx[c] = header.indexOf(c);
const cols = { open: [], high: [], low: [], close: [], volume: [] };
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(",");
for (const c of ["open", "high", "low", "close", "volume"]) {
const v = Number(cells[idx[c]]);
if (!Number.isFinite(v)) {
throw new Error(`row ${i + 1} column '${c}' is not numeric: ${JSON.stringify(cells[idx[c]])}`);
}
cols[c].push(v);
}
// Native CandleReader: header validation, BOM/whitespace tolerance. No
// manual CSV parsing.
const candles = new CandleReader(text).read();
const cols = { timestamp: [], open: [], high: [], low: [], close: [], volume: [] };
for (const c of candles) {
cols.timestamp.push(c.timestamp);
cols.open.push(c.open);
cols.high.push(c.high);
cols.low.push(c.low);
cols.close.push(c.close);
cols.volume.push(c.volume);
}
return cols;
}