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:
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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).");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Generated
+1
-26
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,5 @@
|
||||
"license": "SEE LICENSE IN ../../LICENSE",
|
||||
"dependencies": {
|
||||
"wickra": "file:../../bindings/node"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user