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

Stacked on #315 (the native Binance REST fetcher). Retarget to `main` once #315 merges.

Migrates the runnable examples off third-party data-I/O packages onto Wickra's
native data layer (`CandleReader`, `Resampler`, `BinanceFeed`, `fetch_*klines`).

## Third-party packages removed (the zero-dep selling point)
- **Node**: `ws` (live feed → BinanceFeed) — dropped from package.json + lockfile
- **Go**: `github.com/coder/websocket` — dropped from go.mod / go.sum (`go mod tidy`)
- **Java**: `jackson-databind` (live feed + REST fetch) — dropped from pom.xml
- **R**: `jsonlite` + `websocket` + `later` — dropped from the README notes

Each language's CSV loading now goes through `CandleReader`, manual resampling
through `Resampler`, the live feed through `BinanceFeed`, and (Java/R) the REST
download through the native fetcher.

## Verification
Ran the offline examples per language against the bundled data — backtest and
multi_timeframe produce identical output across Python / Node / Go / Java / R
(e.g. ATR(14) last 345.1010; 1h→5m resamples to 240 bars, →15m to 80 bars).

C# / C / WASM (stdlib-only, no third-party deps to remove) follow in this branch.

Note: the streaming `strategy_*` examples have pre-existing candle-indicator
runtime bugs (CI only syntax-smokes them); the CSV migration preserves their
shape and leaves those bugs for a separate fix.
This commit is contained in:
kingchenc
2026-06-17 01:49:11 +02:00
committed by GitHub
parent 2ae76bb90e
commit 677ea37402
40 changed files with 576 additions and 1102 deletions
+12 -35
View File
@@ -18,9 +18,6 @@ const path = require('node:path');
const wickra = require('wickra');
// The OHLCV columns the default layout requires; the CSV header must name
// every one of them (any extra columns are ignored).
const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume'];
// Default dataset: the checked-in BTCUSDT daily candles under the workspace
// `examples/data/` directory, resolved relative to this file.
@@ -31,42 +28,22 @@ const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1d.csv');
// The Wickra CSV layout is plain numeric — no quoted fields, no embedded
// commas — so splitting on `,` is a complete and correct parse for it.
function readHistory(csvPath) {
// Native CandleReader: validates the header (timestamp,open,high,low,close,
// volume), tolerates a UTF-8 BOM and field whitespace, and throws on a
// malformed row. No manual CSV parsing.
const text = fs.readFileSync(csvPath, 'utf8');
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
if (lines.length === 0) {
throw new Error(`${csvPath}: file is empty`);
}
const header = lines[0].split(',').map((cell) => cell.trim());
const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col));
if (missing.length > 0) {
throw new Error(
`${csvPath}: CSV header is missing required column(s): ${missing.join(', ')}; ` +
`found: ${header.join(', ')}`,
);
}
if (lines.length === 1) {
const candles = new wickra.CandleReader(text).read();
if (candles.length === 0) {
throw new Error(`${csvPath}: CSV has a header but no data rows`);
}
const columnIndex = {};
for (const col of REQUIRED_COLUMNS) {
columnIndex[col] = header.indexOf(col);
}
const cols = { timestamp: [], open: [], high: [], low: [], close: [], volume: [] };
for (let row = 1; row < lines.length; row++) {
const cells = lines[row].split(',');
for (const col of REQUIRED_COLUMNS) {
const raw = cells[columnIndex[col]];
const value = raw === undefined ? NaN : Number(raw.trim());
if (raw === undefined || raw.trim() === '' || !Number.isFinite(value)) {
throw new Error(
`${csvPath}: row ${row + 1} column '${col}' is not numeric: ${JSON.stringify(raw)}`,
);
}
cols[col].push(value);
}
for (const k of candles) {
cols.timestamp.push(k.timestamp);
cols.open.push(k.open);
cols.high.push(k.high);
cols.low.push(k.low);
cols.close.push(k.close);
cols.volume.push(k.volume);
}
return cols;
}
+41 -61
View File
@@ -1,37 +1,28 @@
// Live Binance feed example for the Wickra Node binding.
//
// Connects to Binance's public WebSocket feed (no API key needed) and runs
// RSI / MACD / Bollinger Bands on the incoming close prices. When RSI crosses
// the common overbought / oversold thresholds *and* the MACD histogram
// confirms the direction *and* price pierces the matching Bollinger band, a
// signal line is printed. No orders are placed. It is the Node counterpart of
// Connects to Binance's public kline feed (no API key needed) through Wickra's
// native `BinanceFeed` — no third-party WebSocket client — and runs RSI / MACD /
// Bollinger Bands on the incoming close prices. When RSI crosses the common
// overbought / oversold thresholds *and* the MACD histogram confirms the
// direction *and* price pierces the matching Bollinger band, a signal line is
// printed. No orders are placed. It is the Node counterpart of
// examples/python/live_binance.py.
//
// Run it from the repository after building the native binding:
//
// cd bindings/node && npm install && npx napi build --platform --release
// cd ../../examples/node && npm install # pulls `ws` for this example
// node live_binance.js --symbol BTCUSDT --interval 1m
// cd ../../examples/node && node live_binance.js --symbol BTCUSDT --interval 1m
//
// Stop it with Ctrl+C.
const wickra = require('wickra');
let WebSocket;
try {
WebSocket = require('ws');
} catch (err) {
console.error('This example needs the `ws` package — run `npm install` in examples/node.');
process.exit(1);
}
const BINANCE_WS = 'wss://stream.binance.com:9443/stream';
// Kline intervals the public Binance WebSocket API recognises.
const VALID_INTERVALS = new Set([
'1s', '1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '6h', '8h', '12h',
'1d', '3d', '1w', '1M',
]);
// Kline interval string -> BinanceFeed interval code (the Interval declaration
// order shared by every binding).
const INTERVAL_CODES = {
'1s': 0, '1m': 1, '3m': 2, '5m': 3, '15m': 4, '30m': 5, '1h': 6, '2h': 7,
'4h': 8, '6h': 9, '8h': 10, '12h': 11, '1d': 12, '3d': 13, '1w': 14, '1M': 15,
};
// A Binance symbol is strictly alphanumeric (e.g. BTCUSDT).
const SYMBOL_RE = /^[A-Za-z0-9]+$/;
@@ -53,19 +44,17 @@ function parseArgs(argv) {
return args;
}
// Reject a symbol or interval that is not safe to splice into the WS URL.
// Both are interpolated straight into the stream name, so validating up front
// keeps the URL well-formed without needing to escape it.
// Reject a symbol or interval the feed cannot use.
function validateArgs(symbol, interval) {
if (typeof symbol !== 'string' || !SYMBOL_RE.test(symbol)) {
throw new Error(
`invalid --symbol ${JSON.stringify(symbol)}: expected only letters and digits, e.g. BTCUSDT`,
);
}
if (!VALID_INTERVALS.has(interval)) {
if (!(interval in INTERVAL_CODES)) {
throw new Error(
`invalid --interval ${JSON.stringify(interval)}: expected one of ` +
[...VALID_INTERVALS].join(', '),
Object.keys(INTERVAL_CODES).join(', '),
);
}
}
@@ -88,31 +77,35 @@ function main() {
const macd = new wickra.MACD(12, 26, 9);
const bb = new wickra.BollingerBands(20, 2.0);
const stream = `${args.symbol.toLowerCase()}@kline_${args.interval}`;
const url = `${BINANCE_WS}?streams=${stream}`;
console.log(`Connecting to ${url}`);
// Native feed: a blocking poll driving the same tested WebSocket stream as the
// Rust core. `next(timeoutMs)` returns the next event or null on timeout, so a
// short timeout in a loop keeps Ctrl+C responsive without a third-party client.
const feed = new wickra.BinanceFeed(args.symbol, INTERVAL_CODES[args.interval]);
console.log(
`Listening for ${args.symbol.toLowerCase()}@kline_${args.interval} klines (Ctrl+C to stop)`,
);
const ws = new WebSocket(url);
ws.on('open', () => {
console.log(`Connected, listening for ${stream} klines (Ctrl+C to stop)`);
let running = true;
process.on('SIGINT', () => {
console.log('\nShutting down…');
running = false;
});
ws.on('message', (raw) => {
let envelope;
while (running) {
let event;
try {
envelope = JSON.parse(raw.toString());
event = feed.next(1000);
} catch (err) {
return; // ignore non-JSON frames
console.error(`feed error: ${err.message}`);
process.exitCode = 1;
break;
}
const k = (envelope.data && envelope.data.k) || null;
if (!k || k.c === undefined) {
// Subscription acks, heartbeats and error frames carry no kline payload —
// skip them instead of crashing on Number(undefined).
return;
if (event === null) {
continue; // timeout — poll again
}
const close = Number(k.c);
const isClosed = Boolean(k.x);
const close = event.close;
const isClosed = event.isClosed;
const rsiV = rsi.update(close);
const macdV = macd.update(close); // { macd, signal, histogram } or null
@@ -137,23 +130,10 @@ function main() {
);
}
}
});
}
ws.on('error', (err) => {
console.error(`websocket error: ${err.message}`);
process.exitCode = 1;
});
ws.on('close', () => {
console.log('Connection closed.');
});
// Translate Ctrl+C into a clean shutdown.
process.on('SIGINT', () => {
console.log('\nShutting down…');
ws.close();
process.exit(0);
});
feed.close();
console.log('Connection closed.');
}
main();
+27 -63
View File
@@ -14,87 +14,51 @@ const path = require('node:path');
const wickra = require('wickra');
const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume'];
const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1m.csv');
const ONE_MINUTE_MS = 60_000;
function readCsv(csvPath) {
// Native CandleReader: header validation, BOM/whitespace tolerance, throws on
// a malformed row.
const text = fs.readFileSync(csvPath, 'utf8');
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
if (lines.length === 0) {
throw new Error(`${csvPath}: file is empty`);
}
const header = lines[0].split(',').map((cell) => cell.trim());
const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col));
if (missing.length > 0) {
throw new Error(
`${csvPath}: missing required column(s): ${missing.join(', ')}; found: ${header.join(', ')}`,
);
}
if (lines.length === 1) {
const candles = new wickra.CandleReader(text).read();
if (candles.length === 0) {
throw new Error(`${csvPath}: CSV has a header but no data rows`);
}
const idx = {};
for (const col of REQUIRED_COLUMNS) {
idx[col] = header.indexOf(col);
}
const cols = { timestamp: [], open: [], high: [], low: [], close: [], volume: [] };
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(',');
for (const col of REQUIRED_COLUMNS) {
const value = Number(cells[idx[col]]);
if (!Number.isFinite(value)) {
throw new Error(
`${csvPath}: row ${i + 1} column '${col}' is not numeric: ${JSON.stringify(cells[idx[col]])}`,
);
}
cols[col].push(value);
}
for (const k of candles) {
cols.timestamp.push(k.timestamp);
cols.open.push(k.open);
cols.high.push(k.high);
cols.low.push(k.low);
cols.close.push(k.close);
cols.volume.push(k.volume);
}
return cols;
}
// Roll an OHLCV series up to `bucketMs`-sized buckets keyed on each bar's
// `floor(timestamp / bucketMs)`. Input timestamps must be monotonic
// non-decreasing (the bundled BTCUSDT-1m dataset is contiguous, so this
// holds by construction).
function resample(cols, bucketMs) {
if (cols.timestamp.length === 0) {
throw new Error('resample: empty input series');
}
// Native Resampler — no hand-written bucketing. update() emits a closed candle
// when a bucket boundary is crossed; flush() yields the final partial bucket.
const r = new wickra.Resampler(bucketMs);
const out = { timestamp: [], open: [], high: [], low: [], close: [], volume: [] };
let bucketStart = Math.floor(cols.timestamp[0] / bucketMs) * bucketMs;
let [o, h, l, c, v] = [cols.open[0], cols.high[0], cols.low[0], cols.close[0], cols.volume[0]];
for (let i = 1; i < cols.timestamp.length; i++) {
const start = Math.floor(cols.timestamp[i] / bucketMs) * bucketMs;
if (start === bucketStart) {
if (cols.high[i] > h) h = cols.high[i];
if (cols.low[i] < l) l = cols.low[i];
c = cols.close[i];
v += cols.volume[i];
} else {
out.timestamp.push(bucketStart);
out.open.push(o);
out.high.push(h);
out.low.push(l);
out.close.push(c);
out.volume.push(v);
bucketStart = start;
o = cols.open[i];
h = cols.high[i];
l = cols.low[i];
c = cols.close[i];
v = cols.volume[i];
}
const push = (k) => {
out.timestamp.push(k.timestamp);
out.open.push(k.open);
out.high.push(k.high);
out.low.push(k.low);
out.close.push(k.close);
out.volume.push(k.volume);
};
for (let i = 0; i < cols.timestamp.length; i++) {
const k = r.update(cols.open[i], cols.high[i], cols.low[i], cols.close[i], cols.volume[i], cols.timestamp[i]);
if (k !== null) push(k);
}
// Flush the final open bucket.
out.timestamp.push(bucketStart);
out.open.push(o);
out.high.push(h);
out.low.push(l);
out.close.push(c);
out.volume.push(v);
const last = r.flush();
if (last !== null) push(last);
return out;
}
+1 -26
View File
@@ -10,9 +10,6 @@
"license": "SEE LICENSE IN ../../LICENSE",
"dependencies": {
"wickra": "file:../../bindings/node"
},
"devDependencies": {
"ws": "^8.18.0"
}
},
"../../bindings/node": {
@@ -23,7 +20,7 @@
"@napi-rs/cli": "^2.18.0"
},
"engines": {
"node": ">= 18"
"node": ">= 20"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.9.2",
@@ -37,28 +34,6 @@
"node_modules/wickra": {
"resolved": "../../bindings/node",
"link": true
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
-3
View File
@@ -6,8 +6,5 @@
"license": "SEE LICENSE IN ../../LICENSE",
"dependencies": {
"wickra": "file:../../bindings/node"
},
"devDependencies": {
"ws": "^8.18.0"
}
}
+4 -38
View File
@@ -31,48 +31,14 @@ const ATR_PERIOD = 14;
const ATR_STOP_MULT = 2.0;
const SQUEEZE_LOOKBACK = 180;
const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume'];
const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1d.csv');
function loadCandles(csvPath) {
// Native CandleReader: validates the header, tolerates a UTF-8 BOM and field
// whitespace, and throws on a malformed row. Yields { open, high, low, close,
// volume, timestamp } objects.
const text = fs.readFileSync(csvPath, 'utf8');
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
if (lines.length === 0) {
throw new Error(`${csvPath}: file is empty`);
}
const header = lines[0].split(',').map((cell) => cell.trim());
const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col));
if (missing.length > 0) {
throw new Error(
`${csvPath}: CSV header is missing required column(s): ${missing.join(', ')}; ` +
`found: ${header.join(', ')}`,
);
}
if (lines.length === 1) {
throw new Error(`${csvPath}: CSV has a header but no data rows`);
}
const idx = {};
for (const col of REQUIRED_COLUMNS) idx[col] = header.indexOf(col);
const candles = [];
for (let row = 1; row < lines.length; row++) {
const cells = lines[row].split(',');
const candle = {};
for (const col of ['open', 'high', 'low', 'close', 'volume']) {
const raw = cells[idx[col]];
const value = raw === undefined ? NaN : Number(raw.trim());
if (raw === undefined || raw.trim() === '' || !Number.isFinite(value)) {
throw new Error(
`${csvPath}: row ${row + 1} column '${col}' is not numeric: ${JSON.stringify(raw)}`,
);
}
candle[col] = value;
}
candles.push(candle);
}
return candles;
return new wickra.CandleReader(text).read();
}
function signed(value, digits) {
+4 -38
View File
@@ -25,48 +25,14 @@ const wickra = require('wickra');
const FEE = 0.001;
const ADX_FLOOR = 20.0;
const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume'];
const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1h.csv');
function loadCandles(csvPath) {
// Native CandleReader: validates the header, tolerates a UTF-8 BOM and field
// whitespace, and throws on a malformed row. Yields { open, high, low, close,
// volume, timestamp } objects.
const text = fs.readFileSync(csvPath, 'utf8');
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
if (lines.length === 0) {
throw new Error(`${csvPath}: file is empty`);
}
const header = lines[0].split(',').map((cell) => cell.trim());
const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col));
if (missing.length > 0) {
throw new Error(
`${csvPath}: CSV header is missing required column(s): ${missing.join(', ')}; ` +
`found: ${header.join(', ')}`,
);
}
if (lines.length === 1) {
throw new Error(`${csvPath}: CSV has a header but no data rows`);
}
const idx = {};
for (const col of REQUIRED_COLUMNS) idx[col] = header.indexOf(col);
const candles = [];
for (let row = 1; row < lines.length; row++) {
const cells = lines[row].split(',');
const candle = {};
for (const col of ['open', 'high', 'low', 'close', 'volume']) {
const raw = cells[idx[col]];
const value = raw === undefined ? NaN : Number(raw.trim());
if (raw === undefined || raw.trim() === '' || !Number.isFinite(value)) {
throw new Error(
`${csvPath}: row ${row + 1} column '${col}' is not numeric: ${JSON.stringify(raw)}`,
);
}
candle[col] = value;
}
candles.push(candle);
}
return candles;
return new wickra.CandleReader(text).read();
}
function signed(value, digits) {
+4 -38
View File
@@ -29,51 +29,17 @@ const RSI_PERIOD = 14;
const OVERSOLD = 30.0;
const OVERBOUGHT = 70.0;
const REQUIRED_COLUMNS = ['timestamp', 'open', 'high', 'low', 'close', 'volume'];
const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1h.csv');
// Parse a plain OHLCV CSV into an array of candle objects. The Wickra CSV
// layout is plain numeric — no quoted fields, no embedded commas — so splitting
// on `,` is a complete and correct parse for it.
function loadCandles(csvPath) {
// Native CandleReader: validates the header, tolerates a UTF-8 BOM and field
// whitespace, and throws on a malformed row. Yields { open, high, low, close,
// volume, timestamp } objects.
const text = fs.readFileSync(csvPath, 'utf8');
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
if (lines.length === 0) {
throw new Error(`${csvPath}: file is empty`);
}
const header = lines[0].split(',').map((cell) => cell.trim());
const missing = REQUIRED_COLUMNS.filter((col) => !header.includes(col));
if (missing.length > 0) {
throw new Error(
`${csvPath}: CSV header is missing required column(s): ${missing.join(', ')}; ` +
`found: ${header.join(', ')}`,
);
}
if (lines.length === 1) {
throw new Error(`${csvPath}: CSV has a header but no data rows`);
}
const idx = {};
for (const col of REQUIRED_COLUMNS) idx[col] = header.indexOf(col);
const candles = [];
for (let row = 1; row < lines.length; row++) {
const cells = lines[row].split(',');
const candle = {};
for (const col of ['open', 'high', 'low', 'close', 'volume']) {
const raw = cells[idx[col]];
const value = raw === undefined ? NaN : Number(raw.trim());
if (raw === undefined || raw.trim() === '' || !Number.isFinite(value)) {
throw new Error(
`${csvPath}: row ${row + 1} column '${col}' is not numeric: ${JSON.stringify(raw)}`,
);
}
candle[col] = value;
}
candles.push(candle);
}
return candles;
return new wickra.CandleReader(text).read();
}
// Forced-sign fixed-point (matches Python's `{:+.Nf}`): the value's own minus is