examples: move Node examples into a top-level examples/node/
Continue the per-language `examples/<lang>/` restructure: move the three
Node example files (streaming.js, backtest.js, live_trading.js) out of
bindings/node/examples/ and into a top-level examples/node/ directory.
* `examples/node/package.json` is a `private` package that pulls the
native binding via `file:../../bindings/node` and lists `ws` as a
dev-dependency for the live-trading example. `require('..')` in each
file becomes `require('wickra')` — exactly what a downstream user would
write — and the file-header run instructions are updated to the new
two-step workflow (`npm install` in bindings/node, then in
examples/node).
* `backtest.js`'s default-CSV path becomes the much shorter
`__dirname/../data/btcusdt-1d.csv` from the new location.
* `bindings/node/package.json` drops the now-unused `ws` devDependency.
* `.gitignore` is broadened from `bindings/node/node_modules/` to
`**/node_modules/` so the new `examples/node/node_modules/` directory is
not tracked.
* The README "Languages" table, project-layout block and
`examples/README.md` Node section are updated for the new paths and run
commands.
Verified by running `node backtest.js` (3200 BTCUSDT daily bars, matching
output), `node streaming.js`, and `node --check live_trading.js` from the
new location.
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
// Offline backtest example for the Wickra Node binding.
|
||||
//
|
||||
// Reads an OHLCV CSV, streams every candle through a basket of indicators
|
||||
// (SMA, EMA, RSI, MACD, Bollinger Bands, ATR, ADX, OBV) with the O(1)
|
||||
// `update` call, and prints a summary of each resulting series. It is the
|
||||
// Node counterpart of `examples/python/backtest.py` and the Rust
|
||||
// `examples/rust/src/bin/backtest.rs`.
|
||||
//
|
||||
// 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
|
||||
// node backtest.js # uses the bundled BTCUSDT 1d data
|
||||
// node backtest.js path/to/ohlcv.csv # or any OHLCV CSV
|
||||
|
||||
const fs = require('node:fs');
|
||||
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.
|
||||
const DEFAULT_CSV = path.join(__dirname, '..', 'data', 'btcusdt-1d.csv');
|
||||
|
||||
// Parse an OHLCV CSV into column arrays of numbers.
|
||||
//
|
||||
// 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) {
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
// Running min / max / mean / last of one indicator output series. Null and
|
||||
// non-finite values (the warmup phase) are skipped.
|
||||
class Series {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.count = 0;
|
||||
this.sum = 0;
|
||||
this.min = Infinity;
|
||||
this.max = -Infinity;
|
||||
this.last = NaN;
|
||||
}
|
||||
|
||||
add(value) {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) {
|
||||
return;
|
||||
}
|
||||
this.count += 1;
|
||||
this.sum += value;
|
||||
if (value < this.min) this.min = value;
|
||||
if (value > this.max) this.max = value;
|
||||
this.last = value;
|
||||
}
|
||||
|
||||
print() {
|
||||
if (this.count === 0) {
|
||||
console.log(` ${this.name.padEnd(12)} (no valid samples — series too short)`);
|
||||
return;
|
||||
}
|
||||
const cell = (v) => v.toFixed(4).padStart(14);
|
||||
console.log(
|
||||
` ${this.name.padEnd(12)} mean=${cell(this.sum / this.count)} ` +
|
||||
`min=${cell(this.min)} max=${cell(this.max)} last=${cell(this.last)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const csvPath = process.argv[2] || DEFAULT_CSV;
|
||||
|
||||
let history;
|
||||
try {
|
||||
history = readHistory(csvPath);
|
||||
} catch (err) {
|
||||
console.error(`error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const bars = history.close.length;
|
||||
|
||||
const sma = new wickra.SMA(20);
|
||||
const ema = new wickra.EMA(20);
|
||||
const rsi = new wickra.RSI(14);
|
||||
const macd = new wickra.MACD(12, 26, 9);
|
||||
const bb = new wickra.BollingerBands(20, 2.0);
|
||||
const atr = new wickra.ATR(14);
|
||||
const adx = new wickra.ADX(14);
|
||||
const obv = new wickra.OBV();
|
||||
|
||||
const series = {
|
||||
sma: new Series('SMA(20)'),
|
||||
ema: new Series('EMA(20)'),
|
||||
rsi: new Series('RSI(14)'),
|
||||
macdLine: new Series('MACD line'),
|
||||
macdHist: new Series('MACD hist'),
|
||||
bbUpper: new Series('BB upper'),
|
||||
bbLower: new Series('BB lower'),
|
||||
atr: new Series('ATR(14)'),
|
||||
adx: new Series('ADX(14)'),
|
||||
obv: new Series('OBV'),
|
||||
};
|
||||
|
||||
for (let i = 0; i < bars; i++) {
|
||||
const close = history.close[i];
|
||||
series.sma.add(sma.update(close));
|
||||
series.ema.add(ema.update(close));
|
||||
series.rsi.add(rsi.update(close));
|
||||
|
||||
const m = macd.update(close); // { macd, signal, histogram } or null
|
||||
if (m) {
|
||||
series.macdLine.add(m.macd);
|
||||
series.macdHist.add(m.histogram);
|
||||
}
|
||||
|
||||
const b = bb.update(close); // { upper, middle, lower, stddev } or null
|
||||
if (b) {
|
||||
series.bbUpper.add(b.upper);
|
||||
series.bbLower.add(b.lower);
|
||||
}
|
||||
|
||||
series.atr.add(atr.update(history.high[i], history.low[i], close));
|
||||
|
||||
const a = adx.update(history.high[i], history.low[i], close); // { plusDi, minusDi, adx } or null
|
||||
if (a) {
|
||||
series.adx.add(a.adx);
|
||||
}
|
||||
|
||||
series.obv.add(obv.update(close, history.volume[i]));
|
||||
}
|
||||
|
||||
console.log(`Backtest summary for ${csvPath} (${bars} bars)`);
|
||||
for (const s of Object.values(series)) {
|
||||
s.print();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,159 @@
|
||||
// Live trading skeleton 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
|
||||
// examples/python/live_trading.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_trading.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',
|
||||
]);
|
||||
|
||||
// A Binance symbol is strictly alphanumeric (e.g. BTCUSDT).
|
||||
const SYMBOL_RE = /^[A-Za-z0-9]+$/;
|
||||
|
||||
// Parse `--symbol` / `--interval` flags, defaulting to BTCUSDT 1m.
|
||||
function parseArgs(argv) {
|
||||
const args = { symbol: 'BTCUSDT', interval: '1m' };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--symbol') {
|
||||
args.symbol = argv[i + 1];
|
||||
i += 1;
|
||||
} else if (argv[i] === '--interval') {
|
||||
args.interval = argv[i + 1];
|
||||
i += 1;
|
||||
} else {
|
||||
throw new Error(`unexpected argument: ${argv[i]}`);
|
||||
}
|
||||
}
|
||||
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.
|
||||
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)) {
|
||||
throw new Error(
|
||||
`invalid --interval ${JSON.stringify(interval)}: expected one of ` +
|
||||
[...VALID_INTERVALS].join(', '),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fmt = (v) => (v === null || v === undefined || Number.isNaN(v) ? '--' : v.toFixed(2));
|
||||
|
||||
function main() {
|
||||
let args;
|
||||
try {
|
||||
args = parseArgs(process.argv.slice(2));
|
||||
validateArgs(args.symbol, args.interval);
|
||||
} catch (err) {
|
||||
console.error(`error: ${err.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// One streaming instance of each indicator — the same O(1) update model a
|
||||
// real bot would use.
|
||||
const rsi = new wickra.RSI(14);
|
||||
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}`);
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log(`Connected, listening for ${stream} klines (Ctrl+C to stop)`);
|
||||
});
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
let envelope;
|
||||
try {
|
||||
envelope = JSON.parse(raw.toString());
|
||||
} catch (err) {
|
||||
return; // ignore non-JSON frames
|
||||
}
|
||||
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;
|
||||
}
|
||||
const close = Number(k.c);
|
||||
const isClosed = Boolean(k.x);
|
||||
|
||||
const rsiV = rsi.update(close);
|
||||
const macdV = macd.update(close); // { macd, signal, histogram } or null
|
||||
const bbV = bb.update(close); // { upper, middle, lower, stddev } or null
|
||||
const hist = macdV ? macdV.histogram : null;
|
||||
|
||||
console.log(
|
||||
`${isClosed ? 'BAR ' : 'tick'} close=${fmt(close)} rsi=${fmt(rsiV)} ` +
|
||||
`hist=${fmt(hist)} ` +
|
||||
`bb=${bbV ? `${fmt(bbV.lower)}/${fmt(bbV.middle)}/${fmt(bbV.upper)}` : '--'}`,
|
||||
);
|
||||
|
||||
// Only act once every indicator has warmed up.
|
||||
if (rsiV !== null && macdV !== null && bbV !== null) {
|
||||
if (rsiV > 70 && hist < 0 && close >= bbV.upper) {
|
||||
console.log(
|
||||
` SELL candidate: rsi=${fmt(rsiV)} hist=${fmt(hist)} close >= bb_upper=${fmt(bbV.upper)}`,
|
||||
);
|
||||
} else if (rsiV < 30 && hist > 0 && close <= bbV.lower) {
|
||||
console.log(
|
||||
` BUY candidate: rsi=${fmt(rsiV)} hist=${fmt(hist)} close <= bb_lower=${fmt(bbV.lower)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "wickra-examples-node",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Runnable Node.js examples for the Wickra technical-analysis library.",
|
||||
"license": "SEE LICENSE IN ../../LICENSE",
|
||||
"dependencies": {
|
||||
"wickra": "file:../../bindings/node"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Streaming indicators with the Wickra Node binding.
|
||||
//
|
||||
// Feeds a synthetic price series through several indicators tick by tick —
|
||||
// the same O(1)-per-update model a live trading bot would use — and prints a
|
||||
// status line whenever every indicator has warmed up.
|
||||
//
|
||||
// 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
|
||||
// node streaming.js
|
||||
|
||||
const wickra = require('wickra');
|
||||
|
||||
// A deterministic synthetic series: a slow trend with two oscillations and a
|
||||
// little noise from a seeded generator (so every run prints the same thing).
|
||||
function makeSeries(n) {
|
||||
let seed = 1234567;
|
||||
const rand = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
const prices = [];
|
||||
for (let t = 0; t < n; t++) {
|
||||
const price =
|
||||
100 +
|
||||
t * 0.05 +
|
||||
Math.sin(t * 0.07) * 8 +
|
||||
Math.cos(t * 0.21) * 3 +
|
||||
(rand() - 0.5);
|
||||
prices.push(price);
|
||||
}
|
||||
return prices;
|
||||
}
|
||||
|
||||
const fmt = (v) =>
|
||||
v === null || v === undefined || Number.isNaN(v) ? ' -- ' : v.toFixed(2);
|
||||
|
||||
function main() {
|
||||
console.log(`Wickra ${wickra.version()} — streaming indicator demo\n`);
|
||||
|
||||
const sma = new wickra.SMA(20);
|
||||
const ema = new wickra.EMA(20);
|
||||
const rsi = new wickra.RSI(14);
|
||||
const macd = new wickra.MACD(12, 26, 9);
|
||||
|
||||
const prices = makeSeries(120);
|
||||
let signals = 0;
|
||||
|
||||
for (let t = 0; t < prices.length; t++) {
|
||||
const price = prices[t];
|
||||
const smaV = sma.update(price);
|
||||
const emaV = ema.update(price);
|
||||
const rsiV = rsi.update(price);
|
||||
const macdV = macd.update(price); // { macd, signal, histogram } or null
|
||||
|
||||
// Only act once every indicator has produced a value.
|
||||
if (smaV == null || emaV == null || rsiV == null || macdV == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overbought = rsiV > 70 && macdV.histogram < 0;
|
||||
const oversold = rsiV < 30 && macdV.histogram > 0;
|
||||
const tag = overbought ? 'SELL?' : oversold ? 'BUY? ' : ' ';
|
||||
if (overbought || oversold) signals++;
|
||||
|
||||
console.log(
|
||||
`t=${String(t).padStart(3)} price=${fmt(price)} ` +
|
||||
`sma=${fmt(smaV)} ema=${fmt(emaV)} rsi=${fmt(rsiV)} ` +
|
||||
`macd_hist=${fmt(macdV.histogram)} ${tag}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\nDone — ${signals} candidate signal(s) over ${prices.length} ticks.`);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user