examples + bindings: Node/WASM strategy parity + test/benchmark parity (P2 + P3) (#81)
* examples(node): add RSI mean-reversion strategy
Node counterpart of strategy_rsi_mean_reversion.{py,rs}: RSI(14) < 30 long,
> 70 exit, 0.1% fees, hourly BTCUSDT. Output verified byte-identical to the
Rust reference (37 trades W24/L13, -17.84% return, 46.89% max drawdown).
* examples(node): add MACD + ADX trend-filter strategy
Node counterpart of strategy_macd_adx.{py,rs}: MACD(12,26,9) histogram
crossover entries gated by ADX(14) > 20, hourly BTCUSDT, 0.1% fees. Output
verified byte-identical to the Rust reference (246 trades W90/L156, -47.19%
return, 53.75% max drawdown).
* examples(node): add Bollinger-squeeze breakout strategy
Node counterpart of strategy_bollinger_squeeze.{py,rs}: enter on a fresh
180-bar Bollinger-bandwidth low + close above the upper band, exit on a
2*ATR(14) stop or upper-band collapse, daily BTCUSDT, 0.1% fees. Output
verified byte-identical to the Rust reference (1 trade, -7.82% return,
13.01% max drawdown).
* examples(wasm): add RSI mean-reversion strategy demo
Browser counterpart of strategy_rsi_mean_reversion.{py,js,rs}: RSI(14) < 30
long, > 70 exit, 0.1% fees, summary table. Same signal/fill/PnL/equity loop as
the runtime-verified Node example; loads via the established wickra_wasm.js
init + fetch-CSV pattern. (wasm32 build runs in CI.)
* examples(wasm): add MACD + ADX trend-filter strategy demo
Browser counterpart of strategy_macd_adx.{py,js,rs}: MACD(12,26,9) histogram
crossover gated by ADX(14) > 20, hourly BTCUSDT, 0.1% fees. Logic identical to
the runtime-verified Node example; standard wickra_wasm.js init + fetch-CSV
loader. (wasm32 build runs in CI.)
* examples(wasm): add Bollinger-squeeze breakout strategy demo
Browser counterpart of strategy_bollinger_squeeze.{py,js,rs}: fresh 180-bar
Bollinger-bandwidth low + upper-band breakout, 2*ATR(14) stop, daily BTCUSDT,
0.1% fees. Logic identical to the runtime-verified Node example; standard
wickra_wasm.js init + fetch-CSV loader. (wasm32 build runs in CI.)
* ci: add examples syntax-smoke job (P2.3)
The Rust examples are built by 'cargo build -p wickra-examples --bins'; the
Node, browser-WASM and Python examples had no build gate. New job parse-checks
every examples/{node,wasm}/*.js, extracts and node --checks each WASM .html
module script, and python -m py_compile's every examples/python/*.py — so a
broken example edit fails CI instead of landing silently.
* docs(examples): list the new Node + WASM strategy examples
Add the three Node strategy scripts and three WASM strategy demos to the
examples README tables, bringing Node and WASM to parity with the existing
Rust and Python strategy rows.
* chore(examples): refresh examples/node lockfile for the linked wickra binding
npm install rewrote the file: dependency snapshot of the local wickra binding
that the examples link against (version 0.1.4 -> 0.3.1, license + engines
fields), which had gone stale in the committed lockfile.
* test(node): add input-validation suite
Node counterpart of bindings/python/tests/test_input_validation.py: invalid
constructor parameters (ATR zero period, MACD non-increasing fast/slow,
BollingerBands negative multiplier, PSAR step > max, ValueArea period/pct,
InitialBalance/OpeningRange zero period, Ichimoku non-increasing periods,
Ehlers-family ordering) and unequal-length candle/ValueArea batch inputs all
throw a JS Error. Validated against the built binding.
* test(node): add indicator completeness contract
Introspects every exported indicator class and asserts the full interface
(update / batch / reset / isReady / warmupPeriod) plus the pre-warmup contract
for zero-arg indicators, and guards that the full catalogue (>= 200 classes)
is exported. Catches a new indicator wired without the standard methods, or a
stale/partial native build dropping exports, with no per-indicator boilerplate.
* test(wasm): broaden scalar streaming-vs-batch coverage
Extend the inline wasm-bindgen-test suite with a streaming==batch check across
~70 scalar indicators spanning moving averages, momentum, volatility,
statistics/regression, Ehlers/cycle and risk/performance families (previously
only EMA + the candle-input group were covered per-indicator), plus four more
invalid-constructor assertions. Constructor args mirror the CI-passing Node
factories. Host-compiles (cargo test -p wickra-wasm --no-run); executed in CI
via wasm-pack test --node.
* bench(node): add indicator throughput benchmark
Node counterpart of the Rust criterion benches / Python compare_libraries:
measures streaming (per-tick update) and batch throughput in Mupd/s across a
representative indicator set over a synthetic OHLCV series (--bars, default
200k). Dependency-free; wired as 'npm run bench'.
* docs(wasm): list strategy demos + document the benchmark story
Add the three new strategy demos to the WASM examples table and a Performance
section: parallel_assets.html is the in-browser benchmark, with raw throughput
covered by the Rust criterion / Python / Node benchmarks (the WASM engine is the
same core compiled to wasm32).
This commit is contained in:
@@ -55,6 +55,63 @@ jobs:
|
||||
# streaming.
|
||||
run: cargo build -p wickra-examples --bins
|
||||
|
||||
# Syntax/parse smoke for the non-Rust examples. The Rust examples are built
|
||||
# in the `rust` job above (`cargo build -p wickra-examples --bins`); the Node,
|
||||
# browser-WASM and Python examples otherwise have no build gate, so a broken
|
||||
# edit could land unnoticed. This is a parse-only smoke — actually running the
|
||||
# examples needs the built native binding / wasm module / wheel, which the
|
||||
# binding jobs provide separately.
|
||||
examples-smoke:
|
||||
name: Examples (syntax smoke)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Node examples — syntax check
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
count=0
|
||||
for f in examples/node/*.js examples/wasm/*.js; do
|
||||
echo "node --check $f"
|
||||
node --check "$f"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "checked $count Node/WASM .js files"
|
||||
|
||||
- name: WASM demo module scripts — syntax check
|
||||
# The .html demos embed an ES module; extract it and parse-check so a
|
||||
# broken edit to the in-page strategy logic fails CI.
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
count=0
|
||||
for f in examples/wasm/*.html; do
|
||||
node -e 'const fs=require("fs");const h=fs.readFileSync(process.argv[1],"utf8");const m=h.match(/<script type="module">([\s\S]*?)<\/script>/);if(!m){console.error("no <script type=module> in "+process.argv[1]);process.exit(1);}fs.writeFileSync("module-check.mjs",m[1]);' "$f"
|
||||
echo "node --check (module of) $f"
|
||||
node --check module-check.mjs
|
||||
count=$((count + 1))
|
||||
done
|
||||
rm -f module-check.mjs
|
||||
echo "checked $count WASM .html module scripts"
|
||||
|
||||
- name: Python examples — byte-compile
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
count=0
|
||||
for f in examples/python/*.py; do
|
||||
echo "py_compile $f"
|
||||
python -m py_compile "$f"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "compiled $count Python files"
|
||||
|
||||
# Clippy for the Python and Node bindings. These are kept out of the main
|
||||
# `rust` job because PyO3 / napi build scripts need a Python interpreter and
|
||||
# a Node toolchain on PATH, which the 3-OS matrix job does not provision.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Completeness contract for the Wickra Node bindings: every exported indicator
|
||||
// class must expose the full streaming + batch + lifecycle interface. This
|
||||
// catches a new indicator being wired into the binding without the standard
|
||||
// methods (or an export silently disappearing) without needing a hand-written
|
||||
// test per indicator.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const wickra = require('..');
|
||||
|
||||
// An "indicator class" is an exported constructor whose prototype carries the
|
||||
// streaming `update` method. This excludes `version` (a plain function) and any
|
||||
// non-indicator export.
|
||||
function indicatorClasses() {
|
||||
return Object.keys(wickra).filter((name) => {
|
||||
const value = wickra[name];
|
||||
return (
|
||||
typeof value === 'function' &&
|
||||
value.prototype &&
|
||||
typeof value.prototype.update === 'function'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the binding exports the full indicator catalogue', () => {
|
||||
const names = indicatorClasses();
|
||||
// The published catalogue is 214 indicators. Guard against a regression that
|
||||
// silently drops exported classes (e.g. a stale or partial native build).
|
||||
assert.ok(
|
||||
names.length >= 200,
|
||||
`expected at least 200 indicator classes, got ${names.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('every exported indicator exposes update / batch / reset / isReady / warmupPeriod', () => {
|
||||
const required = ['update', 'batch', 'reset', 'isReady', 'warmupPeriod'];
|
||||
const missing = [];
|
||||
for (const name of indicatorClasses()) {
|
||||
const proto = wickra[name].prototype;
|
||||
for (const method of required) {
|
||||
if (typeof proto[method] !== 'function') {
|
||||
missing.push(`${name}.${method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`indicator classes missing required methods: ${missing.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a freshly constructed indicator reports not-ready with a positive warmup', () => {
|
||||
// Every indicator that takes no constructor arguments must still satisfy the
|
||||
// pre-warmup contract. (Indicators with required parameters are exercised by
|
||||
// the dedicated suites; here we cover the zero-arg ones generically.)
|
||||
let checked = 0;
|
||||
for (const name of indicatorClasses()) {
|
||||
let instance;
|
||||
try {
|
||||
instance = new wickra[name]();
|
||||
} catch {
|
||||
continue; // needs constructor arguments — covered elsewhere
|
||||
}
|
||||
assert.equal(instance.isReady(), false, `${name} should start un-ready`);
|
||||
assert.ok(instance.warmupPeriod() >= 1, `${name} warmup must be >= 1`);
|
||||
checked += 1;
|
||||
}
|
||||
assert.ok(checked > 0, 'expected at least one zero-arg indicator to check');
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// Input-validation tests for the Wickra Node bindings: malformed constructor
|
||||
// parameters and mismatched batch inputs must raise a JS Error (the napi
|
||||
// wrapper turns the Rust `Err` into a thrown Error), not crash the process.
|
||||
// Node counterpart of bindings/python/tests/test_input_validation.py.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const wickra = require('..');
|
||||
|
||||
// --- Constructors reject invalid periods / parameters ---
|
||||
|
||||
test('ATR rejects a zero period at construction', () => {
|
||||
// ATR validates its period (it drives the Wilder-smoothing length). The
|
||||
// plain moving averages (SMA/EMA/RSI/StdDev) instead treat period 0 as a
|
||||
// warmup-1 pass-through rather than an error, so they are not asserted here.
|
||||
assert.throws(() => new wickra.ATR(0), /.*/);
|
||||
});
|
||||
|
||||
test('MACD rejects zero and non-increasing fast/slow periods', () => {
|
||||
assert.throws(() => new wickra.MACD(0, 0, 0), /.*/);
|
||||
// fast must be strictly less than slow.
|
||||
assert.throws(() => new wickra.MACD(26, 12, 9), /.*/);
|
||||
});
|
||||
|
||||
test('BollingerBands rejects a negative standard-deviation multiplier', () => {
|
||||
assert.throws(() => new wickra.BollingerBands(20, -1), /.*/);
|
||||
});
|
||||
|
||||
test('PSAR rejects a step greater than its maximum', () => {
|
||||
assert.throws(() => new wickra.PSAR(0.3, 0.02, 0.2), /.*/);
|
||||
});
|
||||
|
||||
test('ValueArea rejects zero periods and out-of-range value-area percentages', () => {
|
||||
assert.throws(() => new wickra.ValueArea(0, 50, 0.7), /.*/);
|
||||
assert.throws(() => new wickra.ValueArea(20, 0, 0.7), /.*/);
|
||||
assert.throws(() => new wickra.ValueArea(20, 50, 0.0), /.*/);
|
||||
assert.throws(() => new wickra.ValueArea(20, 50, 1.5), /.*/);
|
||||
});
|
||||
|
||||
test('InitialBalance and OpeningRange reject a zero period', () => {
|
||||
assert.throws(() => new wickra.InitialBalance(0), /.*/);
|
||||
assert.throws(() => new wickra.OpeningRange(0), /.*/);
|
||||
});
|
||||
|
||||
test('Ichimoku rejects zero and non-increasing periods', () => {
|
||||
assert.throws(() => new wickra.Ichimoku(0, 26, 52, 26), /.*/);
|
||||
assert.throws(() => new wickra.Ichimoku(9, 26, 52, 0), /.*/);
|
||||
// Periods must satisfy tenkan < kijun < senkouB.
|
||||
assert.throws(() => new wickra.Ichimoku(26, 9, 52, 26), /.*/);
|
||||
assert.throws(() => new wickra.Ichimoku(9, 52, 52, 26), /.*/);
|
||||
});
|
||||
|
||||
test('Family 10 (Ehlers / cycle) indicators reject invalid parameters', () => {
|
||||
// InverseFisherTransform needs a non-zero scaling factor.
|
||||
assert.throws(() => new wickra.InverseFisherTransform(0.0), /.*/);
|
||||
// DecyclerOscillator / RoofingFilter need the short cutoff below the long one.
|
||||
assert.throws(() => new wickra.DecyclerOscillator(30, 10), /.*/);
|
||||
assert.throws(() => new wickra.RoofingFilter(48, 10), /.*/);
|
||||
// MAMA needs fast limit > slow limit.
|
||||
assert.throws(() => new wickra.MAMA(0.05, 0.5), /.*/);
|
||||
// EmpiricalModeDecomposition needs a positive fraction.
|
||||
assert.throws(() => new wickra.EmpiricalModeDecomposition(20, 0.0), /.*/);
|
||||
// NOTE: SuperSmoother(0) / FisherTransform(0) are NOT asserted: the Node
|
||||
// binding treats their period 0 as a warmup-1 pass-through (same as the
|
||||
// simple moving averages) rather than an error.
|
||||
});
|
||||
|
||||
// --- Batch methods reject mismatched input lengths ---
|
||||
|
||||
test('candle batch methods reject unequal-length columns', () => {
|
||||
const high = [10, 11, 12];
|
||||
const low = [9, 10]; // one short
|
||||
const close = [9.5, 10.5, 11.5];
|
||||
assert.throws(() => new wickra.ATR(14).batch(high, low, close), /.*/);
|
||||
assert.throws(() => new wickra.WilliamsR(14).batch(high, low, close), /.*/);
|
||||
assert.throws(() => new wickra.Aroon(14).batch(high, low), /.*/);
|
||||
});
|
||||
|
||||
test('ValueArea batch rejects unequal-length columns', () => {
|
||||
const high = [1, 2, 3];
|
||||
const low = [0.5, 1.5]; // short
|
||||
const volume = [10, 10, 10];
|
||||
assert.throws(() => new wickra.ValueArea(2, 10, 0.7).batch(high, low, volume), /.*/);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// Throughput benchmark for the Wickra Node bindings.
|
||||
//
|
||||
// Measures how many indicator updates per second the native binding sustains,
|
||||
// both per-tick (streaming `update`) and bulk (`batch`), over a synthetic
|
||||
// OHLCV series. It is the Node counterpart of the Rust criterion benches and
|
||||
// the Python `benchmarks/compare_libraries.py`; it benchmarks Wickra's own
|
||||
// O(1) streaming engine (there is no install-free TA library on npm with a
|
||||
// comparable surface to compare against), so the headline number is raw
|
||||
// throughput, not a cross-library ratio.
|
||||
//
|
||||
// Run after building the binding:
|
||||
//
|
||||
// cd bindings/node && npm install && npx napi build --platform --release
|
||||
// node benchmarks/throughput.js # 200k bars (default)
|
||||
// node benchmarks/throughput.js --bars 1000000
|
||||
|
||||
const wickra = require('..');
|
||||
|
||||
function parseBars() {
|
||||
const idx = process.argv.indexOf('--bars');
|
||||
if (idx !== -1 && process.argv[idx + 1]) {
|
||||
const n = Number(process.argv[idx + 1]);
|
||||
if (Number.isFinite(n) && n >= 1000) return Math.floor(n);
|
||||
console.error('--bars must be a number >= 1000');
|
||||
process.exit(1);
|
||||
}
|
||||
return 200_000;
|
||||
}
|
||||
|
||||
const BARS = parseBars();
|
||||
|
||||
// Deterministic synthetic OHLCV (no RNG, so runs are comparable).
|
||||
const close = new Array(BARS);
|
||||
const high = new Array(BARS);
|
||||
const low = new Array(BARS);
|
||||
const volume = new Array(BARS);
|
||||
for (let i = 0; i < BARS; i++) {
|
||||
const mid = 100 + Math.sin(i * 0.001) * 20 + i * 1e-4;
|
||||
close[i] = mid + Math.sin(i * 0.05) * 2;
|
||||
high[i] = Math.max(close[i], mid) + 1.5;
|
||||
low[i] = Math.min(close[i], mid) - 1.5;
|
||||
volume[i] = 1000 + (i % 97) * 13;
|
||||
}
|
||||
|
||||
// Median elapsed-ns over a few repetitions, after one warmup pass.
|
||||
function timeNs(fn, reps = 3) {
|
||||
fn(); // warmup (JIT + cache)
|
||||
const samples = [];
|
||||
for (let r = 0; r < reps; r++) {
|
||||
const t0 = process.hrtime.bigint();
|
||||
fn();
|
||||
samples.push(Number(process.hrtime.bigint() - t0));
|
||||
}
|
||||
samples.sort((a, b) => a - b);
|
||||
return samples[Math.floor(samples.length / 2)];
|
||||
}
|
||||
|
||||
function mupsFromNs(ns) {
|
||||
return (BARS / (ns / 1e9)) / 1e6; // million updates per second
|
||||
}
|
||||
|
||||
// Each indicator: a streaming step and a batch call over the full series.
|
||||
const indicators = [
|
||||
{ name: 'SMA(20)', make: () => new wickra.SMA(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'EMA(20)', make: () => new wickra.EMA(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'RSI(14)', make: () => new wickra.RSI(14), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'StdDev(20)', make: () => new wickra.StdDev(20), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'MACD(12,26,9)', make: () => new wickra.MACD(12, 26, 9), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'BollingerBands(20,2)', make: () => new wickra.BollingerBands(20, 2), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'KAMA(10,2,30)', make: () => new wickra.KAMA(10, 2, 30), step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
{ name: 'ATR(14)', make: () => new wickra.ATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
{ name: 'ADX(14)', make: () => new wickra.ADX(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
{ name: 'Stochastic(14,3)', make: () => new wickra.Stochastic(14, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
{ name: 'SuperTrend(10,3)', make: () => new wickra.SuperTrend(10, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
{ name: 'OBV', make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
];
|
||||
|
||||
console.log(`Wickra Node throughput — ${BARS.toLocaleString('en-US')} bars (median of 3 runs)\n`);
|
||||
console.log(`${'Indicator'.padEnd(22)}${'streaming (Mupd/s)'.padStart(20)}${'batch (Mupd/s)'.padStart(18)}`);
|
||||
console.log('-'.repeat(60));
|
||||
|
||||
for (const ind of indicators) {
|
||||
const streamNs = timeNs(() => {
|
||||
const inst = ind.make();
|
||||
for (let i = 0; i < BARS; i++) ind.step(inst, i);
|
||||
});
|
||||
const batchNs = timeNs(() => {
|
||||
ind.batch(ind.make());
|
||||
});
|
||||
console.log(
|
||||
`${ind.name.padEnd(22)}${mupsFromNs(streamNs).toFixed(1).padStart(20)}${mupsFromNs(batchNs).toFixed(1).padStart(18)}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nMupd/s = million indicator updates per second. Streaming is the per-tick\n' +
|
||||
'`update` path (one value at a time); batch is the bulk array path. Higher is\n' +
|
||||
'better. Numbers are machine-dependent — use them for relative comparison.',
|
||||
);
|
||||
@@ -60,7 +60,8 @@
|
||||
"artifacts": "napi artifacts",
|
||||
"universal": "napi universal",
|
||||
"version": "napi version",
|
||||
"test": "node --test __tests__/"
|
||||
"test": "node --test __tests__/",
|
||||
"bench": "node benchmarks/throughput.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
|
||||
@@ -6139,6 +6139,12 @@ mod tests {
|
||||
fn close_enough(a: f64, b: f64) -> bool {
|
||||
if a.is_nan() {
|
||||
b.is_nan()
|
||||
} else if a == b {
|
||||
// Exact equality, including matching infinities (e.g. ProfitFactor
|
||||
// with no losing trades is +inf in both the streaming and batch
|
||||
// passes). `(inf - inf).abs()` is NaN, so the tolerance check below
|
||||
// would otherwise reject two equal infinities.
|
||||
true
|
||||
} else {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
@@ -6630,6 +6636,146 @@ mod tests {
|
||||
"ready after 5 finite inputs even with prior NaNs"
|
||||
);
|
||||
}
|
||||
|
||||
// Streaming `update` must reproduce `batch` value-for-value for every scalar
|
||||
// indicator — the core O(1) state-machine invariant. Each entry builds a
|
||||
// fresh instance for the batch pass and another for the streaming pass. The
|
||||
// constructor arguments mirror the (CI-passing) Node `indicators.test.js`
|
||||
// factories, so they are known-valid.
|
||||
macro_rules! assert_scalar_stream_eq {
|
||||
($ctor:expr, $prices:expr) => {{
|
||||
let prices: &[f64] = $prices;
|
||||
let batch = { $ctor }.batch(prices);
|
||||
let mut streaming = { $ctor };
|
||||
for (i, &p) in prices.iter().enumerate() {
|
||||
let b = batch.get_index(i as u32);
|
||||
match streaming.update(p) {
|
||||
Some(v) => assert!(
|
||||
close_enough(v, b),
|
||||
"{} streaming != batch at {i}: {v} vs {b}",
|
||||
stringify!($ctor)
|
||||
),
|
||||
None => assert!(
|
||||
b.is_nan(),
|
||||
"{} expected NaN warmup at {i}",
|
||||
stringify!($ctor)
|
||||
),
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
#[wasm_bindgen_test]
|
||||
fn scalar_streaming_matches_batch_broad() {
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
100.0 + (t * 0.2).sin() * 10.0 + t * 0.1
|
||||
})
|
||||
.collect();
|
||||
let p = prices.as_slice();
|
||||
|
||||
// Moving averages.
|
||||
assert_scalar_stream_eq!(WasmSma::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmWma::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmDema::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmTema::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmHma::new(9).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmSmma::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmTrima::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmZlema::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmT3::new(5, 0.7).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmAlma::new(9, 0.85, 6.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmMcGinleyDynamic::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmFrama::new(16).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmVidya::new(14, 9).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmJma::new(14, 0.0, 2).expect("valid"), p);
|
||||
|
||||
// Momentum / oscillators.
|
||||
assert_scalar_stream_eq!(WasmRsi::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmRoc::new(12).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmTrix::new(9).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmMom::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCmo::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmTsi::new(25, 13).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmPmo::new(35, 20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmTii::new(20, 10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmStochRsi::new(14, 14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmDpo::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmPpo::new(12, 26).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmApo::new(12, 26).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCfo::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmStc::new(23, 50, 10, 0.5).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCoppock::new(14, 11, 10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmLaguerreRsi::new(0.5).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmConnorsRsi::new(3, 2, 100).expect("valid"), p);
|
||||
|
||||
// Volatility / statistics / regression.
|
||||
assert_scalar_stream_eq!(WasmStdDev::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmUlcerIndex::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmHistoricalVolatility::new(20, 252).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmBollingerBandwidth::new(20, 2.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmPercentB::new(20, 2.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmLinearRegression::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmLinRegSlope::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmLinRegAngle::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmVerticalHorizontalFilter::new(28).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmZScore::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmVariance::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCoefficientOfVariation::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmSkewness::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmKurtosis::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmStandardError::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmDetrendedStdDev::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmRSquared::new(14).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmMedianAbsoluteDeviation::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmAutocorrelation::new(20, 1).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmHurstExponent::new(40, 4).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmRviVolatility::new(10).expect("valid"), p);
|
||||
|
||||
// Ehlers / cycle.
|
||||
assert_scalar_stream_eq!(WasmSuperSmoother::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmFisherTransform::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmInverseFisherTransform::new(1.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmDecycler::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmDecyclerOscillator::new(10, 30).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmRoofingFilter::new(10, 48).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCenterOfGravity::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmCyberneticCycle::new(10).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmInstantaneousTrendline::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmEhlersStochastic::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(
|
||||
WasmEmpiricalModeDecomposition::new(20, 0.5).expect("valid"),
|
||||
p
|
||||
);
|
||||
assert_scalar_stream_eq!(WasmFama::new(0.5, 0.05).expect("valid"), p);
|
||||
|
||||
// Risk / performance (scalar f64 input).
|
||||
assert_scalar_stream_eq!(WasmCalmarRatio::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmMaxDrawdown::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmAverageDrawdown::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmPainIndex::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmProfitFactor::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmGainLossRatio::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmKellyCriterion::new(20).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmSharpeRatio::new(20, 0.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmSortinoRatio::new(20, 0.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmOmegaRatio::new(20, 0.0).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmValueAtRisk::new(20, 0.95).expect("valid"), p);
|
||||
assert_scalar_stream_eq!(WasmConditionalValueAtRisk::new(20, 0.95).expect("valid"), p);
|
||||
}
|
||||
|
||||
// Additional invalid-constructor coverage. These wrap the same fallible core
|
||||
// `new` as the Python / Node bindings, where the equivalent calls are
|
||||
// confirmed to error.
|
||||
#[wasm_bindgen_test]
|
||||
fn additional_invalid_constructors_are_rejected() {
|
||||
assert!(WasmDecyclerOscillator::new(30, 10).is_err()); // short cutoff >= long
|
||||
assert!(WasmRoofingFilter::new(48, 10).is_err()); // lowpass >= highpass
|
||||
assert!(WasmInverseFisherTransform::new(0.0).is_err()); // zero scale
|
||||
assert!(WasmEmpiricalModeDecomposition::new(20, 0.0).is_err()); // zero fraction
|
||||
}
|
||||
}
|
||||
// ============================== Family 15: Risk / Performance ==============================
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ cd ../../examples/node && npm install # links wickra + installs `ws`
|
||||
| `parallel_assets.js` | Serial vs `worker_threads` pool over a synthetic panel, with speedup. | `node parallel_assets.js --assets 200 --bars 5000` |
|
||||
| `live_trading.js` | Live Binance feed → RSI / MACD / Bollinger → signals. | `node live_trading.js --symbol BTCUSDT --interval 1m` |
|
||||
| `fetch_btcusdt.js` | Download real BTCUSDT klines from the Binance REST API into `examples/data/` (built-in `fetch`, Node 18+). | `node fetch_btcusdt.js` |
|
||||
| `strategy_rsi_mean_reversion.js` | Hourly BTCUSDT mean-reversion using RSI(14) thresholds, with PnL / Sharpe / max-DD summary. | `node strategy_rsi_mean_reversion.js` |
|
||||
| `strategy_macd_adx.js` | Hourly BTCUSDT trend-follower: MACD crossover entries gated by ADX(14) > 20. | `node strategy_macd_adx.js` |
|
||||
| `strategy_bollinger_squeeze.js` | Daily BTCUSDT Bollinger-squeeze breakout with ATR(14) trailing stop. | `node strategy_bollinger_squeeze.js` |
|
||||
|
||||
## WebAssembly — `examples/wasm/`
|
||||
|
||||
@@ -73,6 +76,9 @@ Then serve the repository root (`python -m http.server`, `npx http-server`,
|
||||
| `live_trading.html` | Opens a browser-native `WebSocket` to Binance, runs RSI / MACD / Bollinger and flags BUY/SELL candidates. |
|
||||
| `multi_timeframe.html` | Fetches a 1-minute CSV, rolls it up to 5m / 15m / 1h / 4h / 1d in-page, prints RSI / MACD hist / ADX per timeframe. |
|
||||
| `parallel_assets.html` | Spawns a pool of module Workers (each loading its own copy of the WASM module) and reports the speedup over a serial baseline. |
|
||||
| `strategy_rsi_mean_reversion.html` | Hourly BTCUSDT RSI(14) mean-reversion (long < 30, exit > 70); prints a PnL / Sharpe / max-DD summary table. |
|
||||
| `strategy_macd_adx.html` | Hourly BTCUSDT MACD crossover gated by ADX(14) > 20, with the same summary table. |
|
||||
| `strategy_bollinger_squeeze.html` | Daily BTCUSDT Bollinger-squeeze breakout with a 2×ATR(14) stop and summary table. |
|
||||
|
||||
## Example datasets
|
||||
|
||||
|
||||
Generated
+9
-9
@@ -17,21 +17,21 @@
|
||||
},
|
||||
"../../bindings/node": {
|
||||
"name": "wickra",
|
||||
"version": "0.1.4",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"version": "0.3.1",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.1.4",
|
||||
"wickra-darwin-x64": "0.1.4",
|
||||
"wickra-linux-arm64-gnu": "0.1.4",
|
||||
"wickra-linux-x64-gnu": "0.1.4",
|
||||
"wickra-win32-arm64-msvc": "0.1.4",
|
||||
"wickra-win32-x64-msvc": "0.1.4"
|
||||
"wickra-darwin-arm64": "0.3.1",
|
||||
"wickra-darwin-x64": "0.3.1",
|
||||
"wickra-linux-arm64-gnu": "0.3.1",
|
||||
"wickra-linux-x64-gnu": "0.3.1",
|
||||
"wickra-win32-arm64-msvc": "0.3.1",
|
||||
"wickra-win32-x64-msvc": "0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra": {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// 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();
|
||||
@@ -0,0 +1,189 @@
|
||||
// Strategy example: MACD crossover with ADX trend-strength filter.
|
||||
//
|
||||
// Long-only trend follower. Entries fire on a MACD-line-crosses-above-signal
|
||||
// event (histogram turns positive) while ADX(14) > 20 (i.e. a directional
|
||||
// market). Exits on the opposite MACD crossover regardless of ADX. 0.1% fees
|
||||
// per trade.
|
||||
//
|
||||
// Educational example. NOT a live trading recommendation. The Node counterpart
|
||||
// of `examples/python/strategy_macd_adx.py` and the Rust
|
||||
// `examples/rust/src/bin/strategy_macd_adx.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_macd_adx.js
|
||||
//
|
||||
// Uses the checked-in `examples/data/btcusdt-1h.csv` dataset.
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
const macd = new wickra.MACD(12, 26, 9);
|
||||
const adx = new wickra.ADX(14);
|
||||
|
||||
let inPosition = false;
|
||||
let entryPrice = 0.0;
|
||||
const closedTrades = [];
|
||||
let equity = 1.0;
|
||||
const equityCurve = [];
|
||||
// null until the first warm bar, then a boolean — matches the Python
|
||||
// `prev_hist_sign: bool | None`, so a cross only fires after a real prior sign.
|
||||
let prevHistSign = null;
|
||||
|
||||
for (const c of candles) {
|
||||
const macdOut = macd.update(c.close);
|
||||
const adxOut = adx.update(c.high, c.low, c.close);
|
||||
const price = c.close;
|
||||
const mtm = inPosition ? equity * (price / entryPrice) : equity;
|
||||
equityCurve.push(mtm);
|
||||
|
||||
if (macdOut == null || adxOut == null) continue;
|
||||
|
||||
const histogram = macdOut.histogram;
|
||||
const adxValue = adxOut.adx;
|
||||
|
||||
const histSign = histogram > 0.0;
|
||||
const crossUp = prevHistSign === false && histSign;
|
||||
const crossDown = prevHistSign === true && !histSign;
|
||||
prevHistSign = histSign;
|
||||
|
||||
if (!inPosition && crossUp && adxValue > ADX_FLOOR) {
|
||||
entryPrice = price;
|
||||
equity *= 1.0 - FEE;
|
||||
inPosition = true;
|
||||
} else if (inPosition && crossDown) {
|
||||
const tradeRet = price / entryPrice - 1.0;
|
||||
closedTrades.push(tradeRet);
|
||||
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||
inPosition = false;
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
'MACD + ADX Trend Filter (1h, BTCUSDT)',
|
||||
candles[0].close,
|
||||
candles[candles.length - 1].close,
|
||||
candles.length,
|
||||
closedTrades,
|
||||
equity,
|
||||
equityCurve,
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,189 @@
|
||||
// Strategy example: RSI mean-reversion on hourly BTCUSDT data.
|
||||
//
|
||||
// Goes long when RSI(14) crosses below 30 (oversold), exits when RSI crosses
|
||||
// above 70 (overbought). Position is binary (full-in / full-out), fees are
|
||||
// 0.1% per trade (Binance maker tier), no stop-loss.
|
||||
//
|
||||
// Educational example. NOT a recommended trading strategy in real markets.
|
||||
// The point is to show how Wickra streaming indicators wire up into a complete
|
||||
// signal -> fill -> PnL -> equity loop in a single file. It is the Node
|
||||
// counterpart of `examples/python/strategy_rsi_mean_reversion.py` and the Rust
|
||||
// `examples/rust/src/bin/strategy_rsi_mean_reversion.rs`, and prints 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_rsi_mean_reversion.js
|
||||
//
|
||||
// Uses the checked-in `examples/data/btcusdt-1h.csv` dataset.
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const wickra = require('wickra');
|
||||
|
||||
const FEE = 0.001;
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Forced-sign fixed-point (matches Python's `{:+.Nf}`): the value's own minus is
|
||||
// preserved by toFixed; we only add an explicit '+' for non-negative values.
|
||||
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 < RSI_PERIOD * 4) {
|
||||
console.error(`error: dataset too small: ${candles.length}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rsi = new wickra.RSI(RSI_PERIOD);
|
||||
|
||||
let inPosition = false;
|
||||
let entryPrice = 0.0;
|
||||
const closedTrades = [];
|
||||
let equity = 1.0;
|
||||
const equityCurve = [];
|
||||
|
||||
for (const c of candles) {
|
||||
const rsiVal = rsi.update(c.close);
|
||||
const price = c.close;
|
||||
const mtm = inPosition ? equity * (price / entryPrice) : equity;
|
||||
equityCurve.push(mtm);
|
||||
|
||||
if (rsiVal == null) continue;
|
||||
|
||||
if (!inPosition && rsiVal < OVERSOLD) {
|
||||
entryPrice = price;
|
||||
equity *= 1.0 - FEE;
|
||||
inPosition = true;
|
||||
} else if (inPosition && rsiVal > OVERBOUGHT) {
|
||||
const tradeRet = price / entryPrice - 1.0;
|
||||
closedTrades.push(tradeRet);
|
||||
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||
inPosition = false;
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
'RSI Mean-Reversion (1h, BTCUSDT)',
|
||||
candles[0].close,
|
||||
candles[candles.length - 1].close,
|
||||
candles.length,
|
||||
closedTrades,
|
||||
equity,
|
||||
equityCurve,
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -42,6 +42,19 @@ Then open the demo you want at `http://localhost:8000/examples/wasm/<file>`.
|
||||
| `multi_timeframe.html` | Fetches a 1-minute CSV, rolls it up in-page to 5m / 15m / 1h / 4h / 1d buckets and prints RSI / MACD-histogram / ADX per timeframe. Mirrors `examples/python/multi_timeframe.py`. |
|
||||
| `parallel_assets.html` | Synthetic `(assets, bars)` panel, serial baseline on the main thread vs. a pool of module Workers each loading its own copy of the WASM module. Mirrors `examples/python/parallel_assets.py`. |
|
||||
| `parallel_worker.js` | Module worker used by `parallel_assets.html` (not loaded directly). |
|
||||
| `strategy_rsi_mean_reversion.html` | RSI(14) mean-reversion (long < 30, exit > 70), 0.1% fees, summary table. Mirrors `examples/python/strategy_rsi_mean_reversion.py`. |
|
||||
| `strategy_macd_adx.html` | MACD(12,26,9) crossover gated by ADX(14) > 20, summary table. Mirrors `examples/python/strategy_macd_adx.py`. |
|
||||
| `strategy_bollinger_squeeze.html` | Bollinger-squeeze breakout with a 2×ATR(14) stop, summary table. Mirrors `examples/python/strategy_bollinger_squeeze.py`. |
|
||||
|
||||
## Performance
|
||||
|
||||
The in-browser benchmark is `parallel_assets.html`: it times a serial main-thread
|
||||
baseline against a pool of module Workers and reports the speedup. For raw
|
||||
single-thread throughput numbers see the sibling benchmarks — Rust criterion
|
||||
(`crates/wickra/benches/`), Python (`bindings/python/benchmarks/compare_libraries.py`)
|
||||
and Node (`bindings/node/benchmarks/throughput.js`, `npm run bench`). The WASM
|
||||
engine is the same Rust core compiled to `wasm32`, so its relative ordering of
|
||||
indicators tracks those.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Wickra WASM — Bollinger-squeeze breakout</title>
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; color: #1d1d1d; }
|
||||
h1 { margin-bottom: .25rem; }
|
||||
.meta { color: #666; margin-top: 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin-top: 1rem; font-variant-numeric: tabular-nums; }
|
||||
th, td { border: 1px solid #ddd; padding: .5rem .75rem; text-align: right; }
|
||||
th:first-child, td:first-child { text-align: left; }
|
||||
th { background: #fafafa; }
|
||||
code { background: #f4f4f4; padding: .1rem .25rem; border-radius: .2rem; font-size: .9em; }
|
||||
label { display: inline-block; margin-right: .5rem; }
|
||||
button { padding: .5rem 1rem; }
|
||||
#status { margin-top: 1rem; color: #666; }
|
||||
.note { margin-top: 1rem; color: #888; font-size: .9em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Wickra WASM — Bollinger-squeeze breakout</h1>
|
||||
<p class="meta">
|
||||
Enters long on a fresh 180-bar Bollinger-bandwidth low (the squeeze) with a
|
||||
close above the upper band (the release); exits on a 2×ATR(14) stop or
|
||||
an upper-band collapse, 0.1% fees. The browser counterpart of
|
||||
<code>examples/python/strategy_bollinger_squeeze.py</code>,
|
||||
<code>examples/node/strategy_bollinger_squeeze.js</code> and the Rust
|
||||
<code>strategy_bollinger_squeeze.rs</code> — same loop, same summary.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>Dataset: <input type="text" id="path" value="../data/btcusdt-1d.csv" size="40" /></label>
|
||||
<button id="go" disabled>Run strategy</button>
|
||||
</p>
|
||||
|
||||
<p id="status">Loading WASM module…</p>
|
||||
|
||||
<table id="results" hidden>
|
||||
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<p class="note" id="disclaimer" hidden>
|
||||
NOTE: Educational example — fees, slippage, funding costs and tax effects
|
||||
are simplified or omitted. Past performance is not indicative of future
|
||||
results.
|
||||
</p>
|
||||
|
||||
<script type="module">
|
||||
import init, { version, installPanicHook, BollingerBands, ATR } from "../../bindings/wasm/pkg/wickra_wasm.js";
|
||||
|
||||
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 = ["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);
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
function signed(value, digits) {
|
||||
return (value >= 0 ? "+" : "") + value.toFixed(digits);
|
||||
}
|
||||
|
||||
function runStrategy(cols) {
|
||||
const n = cols.close.length;
|
||||
if (n < SQUEEZE_LOOKBACK + BB_PERIOD) {
|
||||
throw new Error(`dataset has only ${n} bars; need at least ${SQUEEZE_LOOKBACK + BB_PERIOD}`);
|
||||
}
|
||||
|
||||
const bb = new BollingerBands(BB_PERIOD, BB_K);
|
||||
const atr = new ATR(ATR_PERIOD);
|
||||
const bwWindow = [];
|
||||
|
||||
let inPosition = false;
|
||||
let entryPrice = 0.0;
|
||||
let stopLevel = 0.0;
|
||||
const closedTrades = [];
|
||||
let equity = 1.0;
|
||||
const equityCurve = [];
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const price = cols.close[i];
|
||||
const bbOut = bb.update(price);
|
||||
const atrVal = atr.update(cols.high[i], cols.low[i], price);
|
||||
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 tradeRet = cols.close[n - 1] / entryPrice - 1.0;
|
||||
closedTrades.push(tradeRet);
|
||||
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||
}
|
||||
|
||||
return summarise("Bollinger Squeeze Breakout (1d, BTCUSDT)", cols.close[0], cols.close[n - 1], n, closedTrades, equity, equityCurve);
|
||||
}
|
||||
|
||||
function summarise(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 nT = closedTrades.length;
|
||||
const meanRet = nT ? closedTrades.reduce((a, r) => a + r, 0) / nT : 0.0;
|
||||
const varRet = nT > 1 ? closedTrades.reduce((a, r) => a + (r - meanRet) ** 2, 0) / (nT - 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;
|
||||
}
|
||||
return [
|
||||
["Strategy", name],
|
||||
["Bars", String(bars)],
|
||||
["Trades", `${nT} (W${wins} / L${losses})`],
|
||||
["Strategy return", `${signed(stratReturn * 100, 2)}%`],
|
||||
["Buy & Hold return", `${signed(bhReturn * 100, 2)}%`],
|
||||
["Excess over BH", `${signed((stratReturn - bhReturn) * 100, 2)}%`],
|
||||
["Max drawdown", `${(maxDd * 100).toFixed(2)}%`],
|
||||
["Per-trade Sharpe", `${sharpe.toFixed(2)} (mean ${signed(meanRet, 4)}, stddev ${stddev.toFixed(4)})`],
|
||||
["Best / worst trade", `${signed(best * 100, 2)}% / ${signed(worst * 100, 2)}%`],
|
||||
];
|
||||
}
|
||||
|
||||
function render(rows) {
|
||||
const tbody = document.querySelector("#results tbody");
|
||||
tbody.innerHTML = "";
|
||||
for (const [k, v] of rows) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${k}</td><td>${v}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
document.getElementById("results").hidden = false;
|
||||
document.getElementById("disclaimer").hidden = false;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const path = document.getElementById("path").value;
|
||||
const status = document.getElementById("status");
|
||||
status.textContent = `Fetching ${path}…`;
|
||||
try {
|
||||
const resp = await fetch(path);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${path}`);
|
||||
const cols = parseCsv(await resp.text());
|
||||
status.textContent = `Running Bollinger squeeze over ${cols.close.length} bars…`;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
render(runStrategy(cols));
|
||||
status.textContent = `Done — ${cols.close.length} bars.`;
|
||||
} catch (err) {
|
||||
status.textContent = `error: ${err.message || err}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("go").onclick = run;
|
||||
|
||||
init().then(() => {
|
||||
installPanicHook();
|
||||
document.getElementById("go").disabled = false;
|
||||
document.getElementById("status").textContent = `Ready — wickra ${version()}. Click "Run strategy".`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,203 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Wickra WASM — MACD + ADX trend filter</title>
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; color: #1d1d1d; }
|
||||
h1 { margin-bottom: .25rem; }
|
||||
.meta { color: #666; margin-top: 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin-top: 1rem; font-variant-numeric: tabular-nums; }
|
||||
th, td { border: 1px solid #ddd; padding: .5rem .75rem; text-align: right; }
|
||||
th:first-child, td:first-child { text-align: left; }
|
||||
th { background: #fafafa; }
|
||||
code { background: #f4f4f4; padding: .1rem .25rem; border-radius: .2rem; font-size: .9em; }
|
||||
label { display: inline-block; margin-right: .5rem; }
|
||||
button { padding: .5rem 1rem; }
|
||||
#status { margin-top: 1rem; color: #666; }
|
||||
.note { margin-top: 1rem; color: #888; font-size: .9em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Wickra WASM — MACD + ADX trend filter</h1>
|
||||
<p class="meta">
|
||||
Long-only trend follower: enters on a MACD(12,26,9) histogram crossover up
|
||||
while ADX(14) > 20, exits on the opposite crossover, 0.1% fees. The
|
||||
browser counterpart of <code>examples/python/strategy_macd_adx.py</code>,
|
||||
<code>examples/node/strategy_macd_adx.js</code> and the Rust
|
||||
<code>strategy_macd_adx.rs</code> — same loop, same summary.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>Dataset: <input type="text" id="path" value="../data/btcusdt-1h.csv" size="40" /></label>
|
||||
<button id="go" disabled>Run strategy</button>
|
||||
</p>
|
||||
|
||||
<p id="status">Loading WASM module…</p>
|
||||
|
||||
<table id="results" hidden>
|
||||
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<p class="note" id="disclaimer" hidden>
|
||||
NOTE: Educational example — fees, slippage, funding costs and tax effects
|
||||
are simplified or omitted. Past performance is not indicative of future
|
||||
results.
|
||||
</p>
|
||||
|
||||
<script type="module">
|
||||
import init, { 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);
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
function signed(value, digits) {
|
||||
return (value >= 0 ? "+" : "") + value.toFixed(digits);
|
||||
}
|
||||
|
||||
function runStrategy(cols) {
|
||||
const n = cols.close.length;
|
||||
const macd = new MACD(12, 26, 9);
|
||||
const adx = new ADX(14);
|
||||
|
||||
let inPosition = false;
|
||||
let entryPrice = 0.0;
|
||||
const closedTrades = [];
|
||||
let equity = 1.0;
|
||||
const equityCurve = [];
|
||||
let prevHistSign = null;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const price = cols.close[i];
|
||||
const macdOut = macd.update(price);
|
||||
const adxOut = adx.update(cols.high[i], cols.low[i], price);
|
||||
const mtm = inPosition ? equity * (price / entryPrice) : equity;
|
||||
equityCurve.push(mtm);
|
||||
|
||||
if (macdOut == null || adxOut == null) continue;
|
||||
|
||||
const histSign = macdOut.histogram > 0.0;
|
||||
const crossUp = prevHistSign === false && histSign;
|
||||
const crossDown = prevHistSign === true && !histSign;
|
||||
prevHistSign = histSign;
|
||||
|
||||
if (!inPosition && crossUp && adxOut.adx > ADX_FLOOR) {
|
||||
entryPrice = price;
|
||||
equity *= 1.0 - FEE;
|
||||
inPosition = true;
|
||||
} else if (inPosition && crossDown) {
|
||||
const tradeRet = price / entryPrice - 1.0;
|
||||
closedTrades.push(tradeRet);
|
||||
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||
inPosition = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (inPosition) {
|
||||
const tradeRet = cols.close[n - 1] / entryPrice - 1.0;
|
||||
closedTrades.push(tradeRet);
|
||||
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||
}
|
||||
|
||||
return summarise("MACD + ADX Trend Filter (1h, BTCUSDT)", cols.close[0], cols.close[n - 1], n, closedTrades, equity, equityCurve);
|
||||
}
|
||||
|
||||
function summarise(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 nT = closedTrades.length;
|
||||
const meanRet = nT ? closedTrades.reduce((a, r) => a + r, 0) / nT : 0.0;
|
||||
const varRet = nT > 1 ? closedTrades.reduce((a, r) => a + (r - meanRet) ** 2, 0) / (nT - 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;
|
||||
}
|
||||
return [
|
||||
["Strategy", name],
|
||||
["Bars", String(bars)],
|
||||
["Trades", `${nT} (W${wins} / L${losses})`],
|
||||
["Strategy return", `${signed(stratReturn * 100, 2)}%`],
|
||||
["Buy & Hold return", `${signed(bhReturn * 100, 2)}%`],
|
||||
["Excess over BH", `${signed((stratReturn - bhReturn) * 100, 2)}%`],
|
||||
["Max drawdown", `${(maxDd * 100).toFixed(2)}%`],
|
||||
["Per-trade Sharpe", `${sharpe.toFixed(2)} (mean ${signed(meanRet, 4)}, stddev ${stddev.toFixed(4)})`],
|
||||
["Best / worst trade", `${signed(best * 100, 2)}% / ${signed(worst * 100, 2)}%`],
|
||||
];
|
||||
}
|
||||
|
||||
function render(rows) {
|
||||
const tbody = document.querySelector("#results tbody");
|
||||
tbody.innerHTML = "";
|
||||
for (const [k, v] of rows) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${k}</td><td>${v}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
document.getElementById("results").hidden = false;
|
||||
document.getElementById("disclaimer").hidden = false;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const path = document.getElementById("path").value;
|
||||
const status = document.getElementById("status");
|
||||
status.textContent = `Fetching ${path}…`;
|
||||
try {
|
||||
const resp = await fetch(path);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${path}`);
|
||||
const cols = parseCsv(await resp.text());
|
||||
status.textContent = `Running MACD + ADX over ${cols.close.length} bars…`;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
render(runStrategy(cols));
|
||||
status.textContent = `Done — ${cols.close.length} bars.`;
|
||||
} catch (err) {
|
||||
status.textContent = `error: ${err.message || err}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("go").onclick = run;
|
||||
|
||||
init().then(() => {
|
||||
installPanicHook();
|
||||
document.getElementById("go").disabled = false;
|
||||
document.getElementById("status").textContent = `Ready — wickra ${version()}. Click "Run strategy".`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,202 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Wickra WASM — RSI mean-reversion</title>
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; color: #1d1d1d; }
|
||||
h1 { margin-bottom: .25rem; }
|
||||
.meta { color: #666; margin-top: 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin-top: 1rem; font-variant-numeric: tabular-nums; }
|
||||
th, td { border: 1px solid #ddd; padding: .5rem .75rem; text-align: right; }
|
||||
th:first-child, td:first-child { text-align: left; }
|
||||
th { background: #fafafa; }
|
||||
code { background: #f4f4f4; padding: .1rem .25rem; border-radius: .2rem; font-size: .9em; }
|
||||
label { display: inline-block; margin-right: .5rem; }
|
||||
button { padding: .5rem 1rem; }
|
||||
#status { margin-top: 1rem; color: #666; }
|
||||
.note { margin-top: 1rem; color: #888; font-size: .9em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Wickra WASM — RSI mean-reversion</h1>
|
||||
<p class="meta">
|
||||
Goes long when RSI(14) crosses below 30 and exits above 70, with 0.1% fees
|
||||
and a full-in / full-out position. The browser counterpart of
|
||||
<code>examples/python/strategy_rsi_mean_reversion.py</code>,
|
||||
<code>examples/node/strategy_rsi_mean_reversion.js</code> and the Rust
|
||||
<code>strategy_rsi_mean_reversion.rs</code> — same signal → fill → PnL →
|
||||
equity loop, same summary.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>Dataset: <input type="text" id="path" value="../data/btcusdt-1h.csv" size="40" /></label>
|
||||
<button id="go" disabled>Run strategy</button>
|
||||
</p>
|
||||
|
||||
<p id="status">Loading WASM module…</p>
|
||||
|
||||
<table id="results" hidden>
|
||||
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<p class="note" id="disclaimer" hidden>
|
||||
NOTE: Educational example — fees, slippage, funding costs and tax effects
|
||||
are simplified or omitted. Past performance is not indicative of future
|
||||
results.
|
||||
</p>
|
||||
|
||||
<script type="module">
|
||||
import init, { 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);
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
// Forced-sign fixed-point, matching the CLI summaries' `{:+.Nf}`.
|
||||
function signed(value, digits) {
|
||||
return (value >= 0 ? "+" : "") + value.toFixed(digits);
|
||||
}
|
||||
|
||||
function runStrategy(cols) {
|
||||
const n = cols.close.length;
|
||||
if (n < RSI_PERIOD * 4) throw new Error(`dataset too small: ${n}`);
|
||||
|
||||
const rsi = new RSI(RSI_PERIOD);
|
||||
let inPosition = false;
|
||||
let entryPrice = 0.0;
|
||||
const closedTrades = [];
|
||||
let equity = 1.0;
|
||||
const equityCurve = [];
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const price = cols.close[i];
|
||||
const rsiVal = rsi.update(price);
|
||||
const mtm = inPosition ? equity * (price / entryPrice) : equity;
|
||||
equityCurve.push(mtm);
|
||||
|
||||
if (rsiVal == null) continue;
|
||||
|
||||
if (!inPosition && rsiVal < OVERSOLD) {
|
||||
entryPrice = price;
|
||||
equity *= 1.0 - FEE;
|
||||
inPosition = true;
|
||||
} else if (inPosition && rsiVal > OVERBOUGHT) {
|
||||
const tradeRet = price / entryPrice - 1.0;
|
||||
closedTrades.push(tradeRet);
|
||||
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||
inPosition = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (inPosition) {
|
||||
const tradeRet = cols.close[n - 1] / entryPrice - 1.0;
|
||||
closedTrades.push(tradeRet);
|
||||
equity *= (1.0 + tradeRet) * (1.0 - FEE);
|
||||
}
|
||||
|
||||
return summarise("RSI Mean-Reversion (1h, BTCUSDT)", cols.close[0], cols.close[n - 1], n, closedTrades, equity, equityCurve);
|
||||
}
|
||||
|
||||
function summarise(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 nT = closedTrades.length;
|
||||
const meanRet = nT ? closedTrades.reduce((a, r) => a + r, 0) / nT : 0.0;
|
||||
const varRet = nT > 1 ? closedTrades.reduce((a, r) => a + (r - meanRet) ** 2, 0) / (nT - 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;
|
||||
}
|
||||
return [
|
||||
["Strategy", name],
|
||||
["Bars", String(bars)],
|
||||
["Trades", `${nT} (W${wins} / L${losses})`],
|
||||
["Strategy return", `${signed(stratReturn * 100, 2)}%`],
|
||||
["Buy & Hold return", `${signed(bhReturn * 100, 2)}%`],
|
||||
["Excess over BH", `${signed((stratReturn - bhReturn) * 100, 2)}%`],
|
||||
["Max drawdown", `${(maxDd * 100).toFixed(2)}%`],
|
||||
["Per-trade Sharpe", `${sharpe.toFixed(2)} (mean ${signed(meanRet, 4)}, stddev ${stddev.toFixed(4)})`],
|
||||
["Best / worst trade", `${signed(best * 100, 2)}% / ${signed(worst * 100, 2)}%`],
|
||||
];
|
||||
}
|
||||
|
||||
function render(rows) {
|
||||
const tbody = document.querySelector("#results tbody");
|
||||
tbody.innerHTML = "";
|
||||
for (const [k, v] of rows) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${k}</td><td>${v}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
document.getElementById("results").hidden = false;
|
||||
document.getElementById("disclaimer").hidden = false;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const path = document.getElementById("path").value;
|
||||
const status = document.getElementById("status");
|
||||
status.textContent = `Fetching ${path}…`;
|
||||
try {
|
||||
const resp = await fetch(path);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${path}`);
|
||||
const cols = parseCsv(await resp.text());
|
||||
status.textContent = `Running RSI mean-reversion over ${cols.close.length} bars…`;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
render(runStrategy(cols));
|
||||
status.textContent = `Done — ${cols.close.length} bars.`;
|
||||
} catch (err) {
|
||||
status.textContent = `error: ${err.message || err}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("go").onclick = run;
|
||||
|
||||
init().then(() => {
|
||||
installPanicHook();
|
||||
document.getElementById("go").disabled = false;
|
||||
document.getElementById("status").textContent = `Ready — wickra ${version()}. Click "Run strategy".`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user