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:
+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
|
||||
|
||||
Reference in New Issue
Block a user