213 lines
7.1 KiB
JavaScript
213 lines
7.1 KiB
JavaScript
|
|
// Strategy example: Bollinger-Squeeze breakout with ATR-based stop.
|
||
|
|
//
|
||
|
|
// Enters long when the Bollinger Bandwidth has just printed a fresh 6-month low
|
||
|
|
// (the squeeze) and price closes above the upper band (the release). Exits when
|
||
|
|
// price closes below entry minus 2 * ATR(14), or when the upper band rolls back
|
||
|
|
// under the entry price. 0.1% fees per trade.
|
||
|
|
//
|
||
|
|
// Educational example. NOT a live trading recommendation. The Node counterpart
|
||
|
|
// of `examples/python/strategy_bollinger_squeeze.py` and the Rust
|
||
|
|
// `examples/rust/src/bin/strategy_bollinger_squeeze.rs`, printing the same
|
||
|
|
// summary.
|
||
|
|
//
|
||
|
|
// Build the native binding once, then run it:
|
||
|
|
//
|
||
|
|
// cd bindings/node && npm install && npx napi build --platform --release
|
||
|
|
// cd ../../examples/node && npm install
|
||
|
|
// node strategy_bollinger_squeeze.js
|
||
|
|
//
|
||
|
|
// Uses the checked-in `examples/data/btcusdt-1d.csv` dataset because daily bars
|
||
|
|
// give an interpretable 6-month-low lookback (~180 bars).
|
||
|
|
|
||
|
|
const fs = require('node:fs');
|
||
|
|
const path = require('node:path');
|
||
|
|
|
||
|
|
const wickra = require('wickra');
|
||
|
|
|
||
|
|
const FEE = 0.001;
|
||
|
|
const BB_PERIOD = 20;
|
||
|
|
const BB_K = 2.0;
|
||
|
|
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) {
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
function signed(value, digits) {
|
||
|
|
return (value >= 0 ? '+' : '') + value.toFixed(digits);
|
||
|
|
}
|
||
|
|
|
||
|
|
function printSummary(name, firstPrice, lastPrice, bars, closedTrades, finalEquity, equityCurve) {
|
||
|
|
const buyHold = lastPrice / firstPrice;
|
||
|
|
const stratReturn = finalEquity - 1.0;
|
||
|
|
const bhReturn = buyHold - 1.0;
|
||
|
|
const wins = closedTrades.filter((r) => r > 0).length;
|
||
|
|
const losses = closedTrades.filter((r) => r < 0).length;
|
||
|
|
const best = closedTrades.length ? Math.max(...closedTrades) : 0.0;
|
||
|
|
const worst = closedTrades.length ? Math.min(...closedTrades) : 0.0;
|
||
|
|
const n = closedTrades.length;
|
||
|
|
const meanRet = n ? closedTrades.reduce((a, r) => a + r, 0) / n : 0.0;
|
||
|
|
const varRet =
|
||
|
|
n > 1 ? closedTrades.reduce((a, r) => a + (r - meanRet) ** 2, 0) / (n - 1) : 0.0;
|
||
|
|
const stddev = Math.sqrt(varRet);
|
||
|
|
const sharpe = varRet > 0 ? meanRet / stddev : 0.0;
|
||
|
|
|
||
|
|
let peak = equityCurve.length ? equityCurve[0] : 1.0;
|
||
|
|
let maxDd = 0.0;
|
||
|
|
for (const eq of equityCurve) {
|
||
|
|
if (eq > peak) peak = eq;
|
||
|
|
const dd = (peak - eq) / peak;
|
||
|
|
if (dd > maxDd) maxDd = dd;
|
||
|
|
}
|
||
|
|
|
||
|
|
const label = (s) => s.padEnd(23);
|
||
|
|
console.log(`=== ${name} ===`);
|
||
|
|
console.log(`${label('Bars:')}${bars}`);
|
||
|
|
console.log(`${label('Trades:')}${n} (W${wins} / L${losses})`);
|
||
|
|
console.log(`${label('Strategy return:')}${signed(stratReturn * 100, 2)}%`);
|
||
|
|
console.log(`${label('Buy & Hold return:')}${signed(bhReturn * 100, 2)}%`);
|
||
|
|
console.log(`${label('Excess over BH:')}${signed((stratReturn - bhReturn) * 100, 2)}%`);
|
||
|
|
console.log(`${label('Max drawdown:')}${(maxDd * 100).toFixed(2)}%`);
|
||
|
|
console.log(
|
||
|
|
`${label('Per-trade Sharpe:')}${sharpe.toFixed(2)} ` +
|
||
|
|
`(mean ${signed(meanRet, 4)}, stddev ${stddev.toFixed(4)})`,
|
||
|
|
);
|
||
|
|
console.log(`${label('Best / worst trade:')}${signed(best * 100, 2)}% / ${signed(worst * 100, 2)}%`);
|
||
|
|
console.log();
|
||
|
|
console.log(
|
||
|
|
'NOTE: Educational example — fees, slippage, funding costs and tax effects ' +
|
||
|
|
'are simplified or omitted. Past performance is not indicative of future results.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function main() {
|
||
|
|
const csvPath = process.argv[2] || DEFAULT_CSV;
|
||
|
|
|
||
|
|
let candles;
|
||
|
|
try {
|
||
|
|
candles = loadCandles(csvPath);
|
||
|
|
} catch (err) {
|
||
|
|
console.error(`error: ${err.message}`);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
if (candles.length < SQUEEZE_LOOKBACK + BB_PERIOD) {
|
||
|
|
console.error(
|
||
|
|
`error: dataset has only ${candles.length} bars; need at least ` +
|
||
|
|
`${SQUEEZE_LOOKBACK + BB_PERIOD}`,
|
||
|
|
);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
const bb = new wickra.BollingerBands(BB_PERIOD, BB_K);
|
||
|
|
const atr = new wickra.ATR(ATR_PERIOD);
|
||
|
|
// Rolling window of the last SQUEEZE_LOOKBACK bandwidths (Python deque(maxlen)).
|
||
|
|
const bwWindow = [];
|
||
|
|
|
||
|
|
let inPosition = false;
|
||
|
|
let entryPrice = 0.0;
|
||
|
|
let stopLevel = 0.0;
|
||
|
|
const closedTrades = [];
|
||
|
|
let equity = 1.0;
|
||
|
|
const equityCurve = [];
|
||
|
|
|
||
|
|
for (const c of candles) {
|
||
|
|
const bbOut = bb.update(c.close);
|
||
|
|
const atrVal = atr.update(c.high, c.low, c.close);
|
||
|
|
const price = c.close;
|
||
|
|
const mtm = inPosition ? equity * (price / entryPrice) : equity;
|
||
|
|
equityCurve.push(mtm);
|
||
|
|
|
||
|
|
if (bbOut == null || atrVal == null) continue;
|
||
|
|
|
||
|
|
const { upper, middle, lower } = bbOut;
|
||
|
|
const bandwidth = Math.abs(middle) > 1e-12 ? (upper - lower) / middle : NaN;
|
||
|
|
|
||
|
|
if (Number.isNaN(bandwidth)) continue;
|
||
|
|
bwWindow.push(bandwidth);
|
||
|
|
if (bwWindow.length > SQUEEZE_LOOKBACK) bwWindow.shift();
|
||
|
|
if (bwWindow.length < SQUEEZE_LOOKBACK) continue;
|
||
|
|
const minBw = bwWindow.reduce((m, v) => (v < m ? v : m), Infinity);
|
||
|
|
|
||
|
|
if (inPosition) {
|
||
|
|
const stopHit = price < stopLevel;
|
||
|
|
const upperCollapse = upper < entryPrice;
|
||
|
|
if (stopHit || upperCollapse) {
|
||
|
|
const tradeRet = price / entryPrice - 1.0;
|
||
|
|
closedTrades.push(tradeRet);
|
||
|
|
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||
|
|
inPosition = false;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
const isNewLow = Math.abs(bandwidth - minBw) < 1e-12;
|
||
|
|
const breakout = price > upper;
|
||
|
|
if (isNewLow && breakout) {
|
||
|
|
entryPrice = price;
|
||
|
|
stopLevel = price - ATR_STOP_MULT * atrVal;
|
||
|
|
equity *= 1.0 - FEE;
|
||
|
|
inPosition = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (inPosition) {
|
||
|
|
const lastPrice = candles[candles.length - 1].close;
|
||
|
|
const tradeRet = lastPrice / entryPrice - 1.0;
|
||
|
|
closedTrades.push(tradeRet);
|
||
|
|
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||
|
|
}
|
||
|
|
|
||
|
|
printSummary(
|
||
|
|
'Bollinger Squeeze Breakout (1d, BTCUSDT)',
|
||
|
|
candles[0].close,
|
||
|
|
candles[candles.length - 1].close,
|
||
|
|
candles.length,
|
||
|
|
closedTrades,
|
||
|
|
equity,
|
||
|
|
equityCurve,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
main();
|