4e3c41ea80
* feat(family-15): add 17 risk/performance metrics Implements Family 15 pragmatically as standard `Indicator`s instead of a separate `wickra-metrics` crate. Input is scalar `f64` per bar — period return, equity sample, or per-trade P&L depending on the metric. Scalar `Indicator<f64>` (14): - SharpeRatio(period, risk_free) - SortinoRatio(period, mar) - CalmarRatio(period) - OmegaRatio(period, threshold) - MaxDrawdown(period) — rolling, peak-to-trough - AverageDrawdown(period) - DrawdownDuration — cumulative, bars under water (u32 output) - PainIndex(period) - ValueAtRisk(period, confidence) - ConditionalValueAtRisk(period, confidence) - ProfitFactor(period) - GainLossRatio(period) - RecoveryFactor — cumulative, net return / max drawdown - KellyCriterion(period) Two-series `Indicator<(f64, f64)>` for (asset, benchmark) returns (3): - TreynorRatio(period, risk_free) - InformationRatio(period) - Alpha(period, risk_free) — Jensen / CAPM Touchpoints: - 17 new files under `crates/wickra-core/src/indicators/`. - `mod.rs` + `lib.rs` re-exports. - Python bindings (`bindings/python/src/lib.rs`, `__init__.py`). - Node bindings (`bindings/node/src/lib.rs`, `index.js`). - WASM bindings (`bindings/wasm/src/lib.rs`). - Fuzz: scalar metrics appended to `indicator_update.rs`; new `indicator_update_pair.rs` fuzz target for `(f64, f64)` indicators. - Python tests: SCALAR + new PAIR parameter lists in `test_new_indicators.py`, reference-value cases in `test_known_values.py`. - Node tests: scalar factories + new pair-factory block in `bindings/node/__tests__/indicators.test.js`. - Benches: 5 Family-15 benches added in `crates/wickra/benches/indicators.rs`. - Docs: README family-table row + counter (71 -> 88), CHANGELOG entry under [Unreleased]. Note: Family 12 (statistik-regression, PR #51) introduces `node_pair_indicator!` and `wasm_pair_indicator!` macros for Pearson / Beta / Spearman. Family 15 needs the same pair-input pattern but Family 12 is not yet in main, so the three pair wrappers below are written by hand in this PR. When PR #51 lands, the trivial merge-conflict is resolved by keeping the macros from Family 12 and re-using them for Treynor / IR / Alpha (drop the three handwritten wrappers). cargo check --workspace --all-features: green. * fix(family-15): satisfy clippy doc_markdown / if_not_else / digit_grouping * fix(family-15): unused TreynorRatio import, duplicate pairFactories, _eq_nan inf handling * fix(family-15): node eq() handles matching infinities for ratio indicators * test(family-15): cover cold paths flagged by codecov patch
818 lines
47 KiB
JavaScript
818 lines
47 KiB
JavaScript
// Comprehensive tests for the Wickra Node bindings: streaming-vs-batch
|
|
// equivalence, reference values, and lifecycle methods across all 71
|
|
// indicators. Ported from the Python test_streaming_vs_batch / test_known_values
|
|
// suites.
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const wickra = require('..');
|
|
|
|
// Synthetic OHLCV series long enough to warm up every indicator.
|
|
const N = 120;
|
|
const close = Array.from({ length: N }, (_, i) => 100 + Math.sin(i * 0.2) * 10 + i * 0.1);
|
|
const high = close.map((c) => c + 1.5);
|
|
const low = close.map((c) => c - 1.5);
|
|
const volume = Array.from({ length: N }, (_, i) => 1000 + (i % 7) * 50);
|
|
const open = close.map((c) => c - 0.5);
|
|
|
|
function eq(a, b) {
|
|
if (Number.isNaN(a)) return Number.isNaN(b);
|
|
if (!Number.isFinite(a) || !Number.isFinite(b)) return a === b;
|
|
return Math.abs(a - b) < 1e-9;
|
|
}
|
|
|
|
function num(v) {
|
|
return v === null || v === undefined ? NaN : v;
|
|
}
|
|
|
|
// --- Scalar indicators: update(value) vs batch(prices) ---
|
|
|
|
const scalarFactories = {
|
|
SMA: () => new wickra.SMA(14),
|
|
EMA: () => new wickra.EMA(14),
|
|
WMA: () => new wickra.WMA(14),
|
|
RSI: () => new wickra.RSI(14),
|
|
DEMA: () => new wickra.DEMA(10),
|
|
TEMA: () => new wickra.TEMA(10),
|
|
HMA: () => new wickra.HMA(9),
|
|
ROC: () => new wickra.ROC(12),
|
|
TRIX: () => new wickra.TRIX(9),
|
|
KAMA: () => new wickra.KAMA(10, 2, 30),
|
|
ALMA: () => new wickra.ALMA(9, 0.85, 6.0),
|
|
McGinleyDynamic: () => new wickra.McGinleyDynamic(10),
|
|
FRAMA: () => new wickra.FRAMA(16),
|
|
VIDYA: () => new wickra.VIDYA(14, 9),
|
|
JMA: () => new wickra.JMA(14, 0, 2),
|
|
SMMA: () => new wickra.SMMA(14),
|
|
TRIMA: () => new wickra.TRIMA(20),
|
|
ZLEMA: () => new wickra.ZLEMA(14),
|
|
T3: () => new wickra.T3(5, 0.7),
|
|
MOM: () => new wickra.MOM(10),
|
|
CMO: () => new wickra.CMO(14),
|
|
TSI: () => new wickra.TSI(25, 13),
|
|
PMO: () => new wickra.PMO(35, 20),
|
|
TII: () => new wickra.TII(20, 10),
|
|
StochRSI: () => new wickra.StochRSI(14, 14),
|
|
PPO: () => new wickra.PPO(12, 26),
|
|
APO: () => new wickra.APO(12, 26),
|
|
CFO: () => new wickra.CFO(14),
|
|
ElderImpulse: () => new wickra.ElderImpulse(13, 12, 26, 9),
|
|
STC: () => new wickra.STC(23, 50, 10, 0.5),
|
|
DPO: () => new wickra.DPO(20),
|
|
Coppock: () => new wickra.Coppock(14, 11, 10),
|
|
StdDev: () => new wickra.StdDev(20),
|
|
UlcerIndex: () => new wickra.UlcerIndex(14),
|
|
HistoricalVolatility: () => new wickra.HistoricalVolatility(20, 252),
|
|
BollingerBandwidth: () => new wickra.BollingerBandwidth(20, 2),
|
|
PercentB: () => new wickra.PercentB(20, 2),
|
|
LinearRegression: () => new wickra.LinearRegression(14),
|
|
LinRegSlope: () => new wickra.LinRegSlope(14),
|
|
VerticalHorizontalFilter: () => new wickra.VerticalHorizontalFilter(28),
|
|
ZScore: () => new wickra.ZScore(20),
|
|
LinRegAngle: () => new wickra.LinRegAngle(14),
|
|
PercentageTrailingStop: () => new wickra.PercentageTrailingStop(5),
|
|
StepTrailingStop: () => new wickra.StepTrailingStop(1),
|
|
RenkoTrailingStop: () => new wickra.RenkoTrailingStop(1),
|
|
LaguerreRSI: () => new wickra.LaguerreRSI(0.5),
|
|
ConnorsRSI: () => new wickra.ConnorsRSI(3, 2, 100),
|
|
RVIVolatility: () => new wickra.RVIVolatility(10),
|
|
// Family 10 — Ehlers / Cycle
|
|
SuperSmoother: () => new wickra.SuperSmoother(10),
|
|
FisherTransform: () => new wickra.FisherTransform(10),
|
|
InverseFisherTransform: () => new wickra.InverseFisherTransform(1.0),
|
|
Decycler: () => new wickra.Decycler(20),
|
|
DecyclerOscillator: () => new wickra.DecyclerOscillator(10, 30),
|
|
RoofingFilter: () => new wickra.RoofingFilter(10, 48),
|
|
CenterOfGravity: () => new wickra.CenterOfGravity(10),
|
|
CyberneticCycle: () => new wickra.CyberneticCycle(10),
|
|
InstantaneousTrendline: () => new wickra.InstantaneousTrendline(20),
|
|
EhlersStochastic: () => new wickra.EhlersStochastic(20),
|
|
EmpiricalModeDecomposition: () => new wickra.EmpiricalModeDecomposition(20, 0.5),
|
|
HilbertDominantCycle: () => new wickra.HilbertDominantCycle(),
|
|
AdaptiveCycle: () => new wickra.AdaptiveCycle(),
|
|
SineWave: () => new wickra.SineWave(),
|
|
FAMA: () => new wickra.FAMA(0.5, 0.05),
|
|
// Family 12 — Statistik / Regression
|
|
Variance: () => new wickra.Variance(20),
|
|
CoefficientOfVariation: () => new wickra.CoefficientOfVariation(20),
|
|
Skewness: () => new wickra.Skewness(20),
|
|
Kurtosis: () => new wickra.Kurtosis(20),
|
|
StandardError: () => new wickra.StandardError(14),
|
|
DetrendedStdDev: () => new wickra.DetrendedStdDev(14),
|
|
RSquared: () => new wickra.RSquared(14),
|
|
MedianAbsoluteDeviation: () => new wickra.MedianAbsoluteDeviation(20),
|
|
Autocorrelation: () => new wickra.Autocorrelation(20, 1),
|
|
HurstExponent: () => new wickra.HurstExponent(40, 4),
|
|
// Family 15 — Risk / Performance metrics (scalar f64 input).
|
|
SharpeRatio: () => new wickra.SharpeRatio(20, 0),
|
|
SortinoRatio: () => new wickra.SortinoRatio(20, 0),
|
|
CalmarRatio: () => new wickra.CalmarRatio(20),
|
|
OmegaRatio: () => new wickra.OmegaRatio(20, 0),
|
|
MaxDrawdown: () => new wickra.MaxDrawdown(20),
|
|
AverageDrawdown: () => new wickra.AverageDrawdown(20),
|
|
DrawdownDuration: () => new wickra.DrawdownDuration(),
|
|
PainIndex: () => new wickra.PainIndex(20),
|
|
ValueAtRisk: () => new wickra.ValueAtRisk(20, 0.95),
|
|
ConditionalValueAtRisk: () => new wickra.ConditionalValueAtRisk(20, 0.95),
|
|
ProfitFactor: () => new wickra.ProfitFactor(20),
|
|
GainLossRatio: () => new wickra.GainLossRatio(20),
|
|
RecoveryFactor: () => new wickra.RecoveryFactor(),
|
|
KellyCriterion: () => new wickra.KellyCriterion(20),
|
|
};
|
|
|
|
// --- Two-series (asset, benchmark) ratio indicators ---
|
|
|
|
const ratioPairFactories = {
|
|
TreynorRatio: () => new wickra.TreynorRatio(20, 0),
|
|
InformationRatio: () => new wickra.InformationRatio(20),
|
|
Alpha: () => new wickra.Alpha(20, 0),
|
|
};
|
|
|
|
const asset = Array.from({ length: N }, (_, i) => 0.001 + Math.sin(i * 0.15) * 0.01);
|
|
const bench = Array.from({ length: N }, (_, i) => 0.001 + Math.sin(i * 0.15) * 0.007);
|
|
|
|
for (const [name, make] of Object.entries(ratioPairFactories)) {
|
|
test(`${name}: streaming update matches batch (pair)`, () => {
|
|
const batch = make().batch(asset, bench);
|
|
const streaming = make();
|
|
assert.equal(batch.length, N);
|
|
for (let i = 0; i < N; i++) {
|
|
const s = num(streaming.update(asset[i], bench[i]));
|
|
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
for (const [name, make] of Object.entries(scalarFactories)) {
|
|
test(`${name}: streaming update matches batch`, () => {
|
|
const batch = make().batch(close);
|
|
const streaming = make();
|
|
assert.equal(batch.length, N);
|
|
for (let i = 0; i < N; i++) {
|
|
const s = num(streaming.update(close[i]));
|
|
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Scalar-output candle indicators: update(...) vs batch(...) ---
|
|
|
|
const candleScalar = {
|
|
ATR: { make: () => new wickra.ATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
CCI: { make: () => new wickra.CCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
WilliamsR: { make: () => new wickra.WilliamsR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
PSAR: { make: () => new wickra.PSAR(0.02, 0.02, 0.2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
MFI: { make: () => new wickra.MFI(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
|
VWAP: { make: () => new wickra.VWAP(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
|
RollingVWAP: { make: () => new wickra.RollingVWAP(20), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
|
AwesomeOscillator: { make: () => new wickra.AwesomeOscillator(5, 34), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
OBV: { make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
|
VWMA: { make: () => new wickra.VWMA(20), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
|
RVI: { make: () => new wickra.RVI(10), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
Inertia: { make: () => new wickra.Inertia(14, 20), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
PGO: { make: () => new wickra.PGO(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
SMI: { make: () => new wickra.SMI(5, 3, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
EVWMA: { make: () => new wickra.EVWMA(20), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
|
UltimateOscillator: { make: () => new wickra.UltimateOscillator(7, 14, 28), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
AroonOscillator: { make: () => new wickra.AroonOscillator(14), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
NATR: { make: () => new wickra.NATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
MassIndex: { make: () => new wickra.MassIndex(9, 25), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
ADL: { make: () => new wickra.ADL(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
|
VolumePriceTrend: { make: () => new wickra.VolumePriceTrend(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
|
ChaikinMoneyFlow: { make: () => new wickra.ChaikinMoneyFlow(20), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
|
ChaikinOscillator: { make: () => new wickra.ChaikinOscillator(3, 10), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
|
ForceIndex: { make: () => new wickra.ForceIndex(13), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
|
EaseOfMovement: { make: () => new wickra.EaseOfMovement(14, 1e8), step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
|
KVO: { make: () => new wickra.KVO(34, 55), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
|
VolumeOscillator: { make: () => new wickra.VolumeOscillator(14, 28), step: (ind, i) => ind.update(volume[i]), batch: (ind) => ind.batch(volume) },
|
|
NVI: { make: () => new wickra.NVI(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
|
PVI: { make: () => new wickra.PVI(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
|
WilliamsAD: { make: () => new wickra.WilliamsAD(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
AnchoredVWAP: { make: () => new wickra.AnchoredVWAP(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
|
DemandIndex: { make: () => new wickra.DemandIndex(10), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
|
TSV: { make: () => new wickra.TSV(18), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
|
VZO: { make: () => new wickra.VZO(14), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
|
MarketFacilitationIndex: { make: () => new wickra.MarketFacilitationIndex(), step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
|
AtrTrailingStop: { make: () => new wickra.AtrTrailingStop(14, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
HiLoActivator: { make: () => new wickra.HiLoActivator(3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
VoltyStop: { make: () => new wickra.VoltyStop(14, 2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
YoyoExit: { make: () => new wickra.YoyoExit(14, 2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
TypicalPrice: { make: () => new wickra.TypicalPrice(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
MedianPrice: { make: () => new wickra.MedianPrice(), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
WeightedClose: { make: () => new wickra.WeightedClose(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
AcceleratorOscillator: { make: () => new wickra.AcceleratorOscillator(5, 34, 5), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
AwesomeOscillatorHistogram: { make: () => new wickra.AwesomeOscillatorHistogram(5, 34, 5), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
BalanceOfPower: { make: () => new wickra.BalanceOfPower(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
ChoppinessIndex: { make: () => new wickra.ChoppinessIndex(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
TrueRange: { make: () => new wickra.TrueRange(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
ChaikinVolatility: { make: () => new wickra.ChaikinVolatility(10, 10), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
ADXR: { make: () => new wickra.ADXR(7), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
ParkinsonVolatility: { make: () => new wickra.ParkinsonVolatility(20, 252), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
GarmanKlassVolatility: { make: () => new wickra.GarmanKlassVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
RogersSatchellVolatility: { make: () => new wickra.RogersSatchellVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
YangZhangVolatility: { make: () => new wickra.YangZhangVolatility(20, 252), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
TDSetup: { make: () => new wickra.TDSetup(4, 9), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
TDDeMarker: { make: () => new wickra.TDDeMarker(14), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
TDREI: { make: () => new wickra.TDREI(5), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
TDPressure: { make: () => new wickra.TDPressure(5), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(open, high, low, close, volume) },
|
|
TDCombo: { make: () => new wickra.TDCombo(4, 9, 2, 13), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
TDCountdown: { make: () => new wickra.TDCountdown(4, 9, 2, 13), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
TDDifferential: { make: () => new wickra.TDDifferential(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
TDOpen: { make: () => new wickra.TDOpen(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
// Family 14 — Candlestick patterns
|
|
Doji: { make: () => new wickra.Doji(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
Hammer: { make: () => new wickra.Hammer(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
InvertedHammer: { make: () => new wickra.InvertedHammer(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
HangingMan: { make: () => new wickra.HangingMan(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
ShootingStar: { make: () => new wickra.ShootingStar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
Engulfing: { make: () => new wickra.Engulfing(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
Harami: { make: () => new wickra.Harami(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
MorningEveningStar: { make: () => new wickra.MorningEveningStar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
ThreeSoldiersOrCrows: { make: () => new wickra.ThreeSoldiersOrCrows(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
PiercingDarkCloud: { make: () => new wickra.PiercingDarkCloud(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
Marubozu: { make: () => new wickra.Marubozu(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
Tweezer: { make: () => new wickra.Tweezer(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
SpinningTop: { make: () => new wickra.SpinningTop(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
ThreeInside: { make: () => new wickra.ThreeInside(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
ThreeOutside: { make: () => new wickra.ThreeOutside(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
};
|
|
|
|
for (const [name, d] of Object.entries(candleScalar)) {
|
|
test(`${name}: streaming update matches batch`, () => {
|
|
const batch = d.batch(d.make());
|
|
const streaming = d.make();
|
|
assert.equal(batch.length, N);
|
|
for (let i = 0; i < N; i++) {
|
|
const s = num(d.step(streaming, i));
|
|
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Multi-output indicators: object update vs interleaved batch ---
|
|
|
|
const multi = {
|
|
KST: { make: () => new wickra.KST(10, 15, 20, 30, 10, 10, 10, 15, 9), fields: ['kst', 'signal'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
|
Alligator: { make: () => new wickra.Alligator(13, 8, 5), fields: ['jaw', 'teeth', 'lips'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
ZeroLagMACD: { make: () => new wickra.ZeroLagMACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
|
MACD: { make: () => new wickra.MACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
|
KST: { make: () => wickra.KST.classic(), fields: ['kst', 'signal'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
|
BollingerBands: { make: () => new wickra.BollingerBands(20, 2), fields: ['upper', 'middle', 'lower', 'stddev'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
|
Stochastic: { make: () => new wickra.Stochastic(14, 3), fields: ['k', 'd'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
ADX: { make: () => new wickra.ADX(14), fields: ['plusDi', 'minusDi', 'adx'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
Keltner: { make: () => new wickra.Keltner(20, 10, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
Donchian: { make: () => new wickra.Donchian(20), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
Aroon: { make: () => new wickra.Aroon(14), fields: ['up', 'down'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
Vortex: { make: () => new wickra.Vortex(14), fields: ['plus', 'minus'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
RWI: { make: () => new wickra.RWI(14), fields: ['high', 'low'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
WaveTrend: { make: () => wickra.WaveTrend.classic(), fields: ['wt1', 'wt2'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
SuperTrend: { make: () => new wickra.SuperTrend(10, 3), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
ChandelierExit: { make: () => new wickra.ChandelierExit(22, 3), fields: ['longStop', 'shortStop'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
ChandeKrollStop: { make: () => new wickra.ChandeKrollStop(10, 1, 9), fields: ['stopLong', 'stopShort'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
// Family 16: Market Profile
|
|
ValueArea: { make: () => new wickra.ValueArea(20, 50, 0.70), fields: ['poc', 'vah', 'val'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
|
InitialBalance: { make: () => new wickra.InitialBalance(12), fields: ['high', 'low'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
OpeningRange: { make: () => new wickra.OpeningRange(6), fields: ['high', 'low', 'breakoutDistance'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
DonchianStop: { make: () => new wickra.DonchianStop(10), fields: ['stopLong', 'stopShort'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
// Family 05: bands & channels
|
|
MaEnvelope: { make: () => new wickra.MaEnvelope(20, 0.025), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
|
AccelerationBands: { make: () => new wickra.AccelerationBands(20, 0.001), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
StarcBands: { make: () => new wickra.StarcBands(6, 15, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
AtrBands: { make: () => new wickra.AtrBands(14, 3), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
HurstChannel: { make: () => new wickra.HurstChannel(10, 0.5), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
LinRegChannel: { make: () => new wickra.LinRegChannel(20, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
|
StandardErrorBands: { make: () => new wickra.StandardErrorBands(21, 2), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
|
DoubleBollinger: { make: () => new wickra.DoubleBollinger(20, 1, 2), fields: ['upperOuter', 'upperInner', 'middle', 'lowerInner', 'lowerOuter'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
|
TtmSqueeze: { make: () => new wickra.TtmSqueeze(20, 2, 1.5), fields: ['squeeze', 'momentum'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
FractalChaosBands: { make: () => new wickra.FractalChaosBands(2), fields: ['upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
VwapStdDevBands: { make: () => new wickra.VwapStdDevBands(2), fields: ['upper', 'middle', 'lower', 'stddev'], step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
|
// Family 08: Pivots & Support/Resistance
|
|
ClassicPivots: { make: () => new wickra.ClassicPivots(), fields: ['pp', 'r1', 'r2', 'r3', 's1', 's2', 's3'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
FibonacciPivots: { make: () => new wickra.FibonacciPivots(), fields: ['pp', 'r1', 'r2', 'r3', 's1', 's2', 's3'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
Camarilla: { make: () => new wickra.Camarilla(), fields: ['pp', 'r1', 'r2', 'r3', 'r4', 's1', 's2', 's3', 's4'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
WoodiePivots: { make: () => new wickra.WoodiePivots(), fields: ['pp', 'r1', 'r2', 's1', 's2'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
DemarkPivots: { make: () => new wickra.DemarkPivots(), fields: ['pp', 'r1', 's1'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
WilliamsFractals: { make: () => new wickra.WilliamsFractals(), fields: ['up', 'down'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
ZigZag: { make: () => new wickra.ZigZag(0.02), fields: ['swing', 'direction'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
|
// Family 11: DeMark
|
|
TDSequential: { make: () => new wickra.TDSequential(4, 9, 2, 13), fields: ['setup', 'countdown', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
TDLines: { make: () => new wickra.TDLines(4, 9), fields: ['resistance', 'support'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
TDRangeProjection: { make: () => new wickra.TDRangeProjection(), fields: ['high', 'low'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
TDRiskLevel: { make: () => new wickra.TDRiskLevel(4, 9), fields: ['buyRisk', 'sellRisk'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
// Family 10: Ehlers / Cycle (multi-output)
|
|
MAMA: { make: () => new wickra.MAMA(0.5, 0.05), fields: ['mama', 'fama'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
|
// Family 13: Ichimoku & alternative charts
|
|
Ichimoku: { make: () => new wickra.Ichimoku(9, 26, 52, 26), fields: ['tenkan', 'kijun', 'senkouA', 'senkouB', 'chikou'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
|
HeikinAshi: { make: () => new wickra.HeikinAshi(), fields: ['open', 'high', 'low', 'close'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
|
};
|
|
|
|
for (const [name, d] of Object.entries(multi)) {
|
|
test(`${name}: streaming update matches interleaved batch`, () => {
|
|
const k = d.fields.length;
|
|
const batch = d.batch(d.make());
|
|
const streaming = d.make();
|
|
assert.equal(batch.length, N * k);
|
|
for (let i = 0; i < N; i++) {
|
|
const o = d.step(streaming, i);
|
|
d.fields.forEach((field, j) => {
|
|
const s = o === null || o === undefined ? NaN : o[field];
|
|
assert.ok(eq(s, batch[i * k + j]), `${name}.${field} mismatch at ${i}`);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Lifecycle: every indicator exposes reset / isReady / warmupPeriod ---
|
|
|
|
test('every indicator exposes reset, isReady and warmupPeriod', () => {
|
|
const all = [
|
|
...Object.values(scalarFactories).map((f) => f()),
|
|
...Object.values(candleScalar).map((d) => d.make()),
|
|
...Object.values(multi).map((d) => d.make()),
|
|
];
|
|
for (const ind of all) {
|
|
assert.equal(typeof ind.reset, 'function');
|
|
assert.equal(typeof ind.isReady, 'function');
|
|
assert.equal(typeof ind.warmupPeriod, 'function');
|
|
assert.equal(ind.isReady(), false);
|
|
assert.ok(ind.warmupPeriod() >= 1);
|
|
}
|
|
});
|
|
|
|
test('reset returns an indicator to its un-warmed state', () => {
|
|
const sma = new wickra.SMA(5);
|
|
sma.batch([1, 2, 3, 4, 5]);
|
|
assert.equal(sma.isReady(), true);
|
|
sma.reset();
|
|
assert.equal(sma.isReady(), false);
|
|
assert.equal(sma.update(10), null);
|
|
});
|
|
|
|
// --- Reference values ---
|
|
|
|
test('SMA(3) reference values', () => {
|
|
const out = new wickra.SMA(3).batch([2, 4, 6, 8, 10]);
|
|
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
|
|
assert.equal(out[2], 4);
|
|
assert.equal(out[3], 6);
|
|
assert.equal(out[4], 8);
|
|
});
|
|
|
|
test('MFI(2) reference value equals 1200/23', () => {
|
|
// Candle 1 seeds; candle 2 (tp 12 > 10) +mf 1200; candle 3 (tp 11 < 12) -mf 1100.
|
|
const mfi = new wickra.MFI(2);
|
|
assert.equal(mfi.update(10, 10, 10, 100), null);
|
|
assert.equal(mfi.update(12, 12, 12, 100), null);
|
|
const v = mfi.update(11, 11, 11, 100);
|
|
assert.ok(Math.abs(v - 1200 / 23) < 1e-9);
|
|
});
|
|
|
|
test('RSI pure uptrend yields 100', () => {
|
|
const prices = Array.from({ length: 20 }, (_, i) => i + 1);
|
|
const out = new wickra.RSI(14).batch(prices);
|
|
for (let i = 14; i < out.length; i++) {
|
|
assert.equal(out[i], 100);
|
|
}
|
|
});
|
|
|
|
test('MACD histogram equals macd minus signal', () => {
|
|
const macd = new wickra.MACD(12, 26, 9);
|
|
let v = null;
|
|
for (let i = 1; i <= 60; i++) v = macd.update(i);
|
|
assert.ok(v);
|
|
assert.ok(Math.abs(v.histogram - (v.macd - v.signal)) < 1e-9);
|
|
});
|
|
|
|
test('TypicalPrice reference value', () => {
|
|
// (high + low + close) / 3 = (12 + 6 + 9) / 3 = 9.
|
|
assert.equal(new wickra.TypicalPrice().update(12, 6, 9), 9);
|
|
});
|
|
|
|
test('ChaikinMoneyFlow(2) reference value equals 0.5', () => {
|
|
// Bar 1 closes at the high (MFV +100); bar 2 closes mid-range (MFV 0).
|
|
const cmf = new wickra.ChaikinMoneyFlow(2);
|
|
assert.equal(cmf.update(10, 8, 10, 100), null);
|
|
assert.ok(Math.abs(cmf.update(12, 8, 10, 100) - 0.5) < 1e-9);
|
|
});
|
|
|
|
test('LinearRegression(3) reference values', () => {
|
|
// Least-squares line through [1, 2, 9] is y = 4x; endpoint 4·2 = 8.
|
|
const out = new wickra.LinearRegression(3).batch([1, 2, 9]);
|
|
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
|
|
assert.ok(Math.abs(out[2] - 8) < 1e-9);
|
|
});
|
|
|
|
test('SuperTrend flat market holds the lower band and an uptrend', () => {
|
|
// Flat candles: ATR 2, hl2 10, lower band 10 - 3·2 = 4.
|
|
const n = 20;
|
|
const out = new wickra.SuperTrend(5, 3).batch(
|
|
Array(n).fill(11),
|
|
Array(n).fill(9),
|
|
Array(n).fill(10),
|
|
);
|
|
assert.ok(Math.abs(out[2 * n - 2] - 4) < 1e-9); // value
|
|
assert.equal(out[2 * n - 1], 1); // direction
|
|
});
|
|
|
|
test('BalanceOfPower reference value', () => {
|
|
// (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
|
|
assert.ok(Math.abs(new wickra.BalanceOfPower().update(10, 14, 10, 12) - 0.5) < 1e-9);
|
|
});
|
|
|
|
test('TrueRange reference values', () => {
|
|
const tr = new wickra.TrueRange();
|
|
assert.equal(tr.update(12, 8, 11), 4); // no prev close -> high - low
|
|
assert.equal(tr.update(10, 9, 9.5), 2); // prev close 11 -> max(1, 1, 2)
|
|
});
|
|
|
|
test('LinRegAngle of a unit-slope series is 45 degrees', () => {
|
|
const out = new wickra.LinRegAngle(5).batch([1, 2, 3, 4, 5, 6]);
|
|
assert.ok(Math.abs(out[4] - 45) < 1e-9);
|
|
});
|
|
|
|
test('InitialBalance(2) locks after period and ignores subsequent bars', () => {
|
|
const ib = new wickra.InitialBalance(2);
|
|
let v = ib.update(102, 100);
|
|
assert.equal(v.high, 102);
|
|
assert.equal(v.low, 100);
|
|
v = ib.update(103, 99);
|
|
assert.equal(v.high, 103);
|
|
assert.equal(v.low, 99);
|
|
assert.equal(ib.isLocked(), true);
|
|
// Extreme bar after lock must not modify the IB.
|
|
v = ib.update(200, 50);
|
|
assert.equal(v.high, 103);
|
|
assert.equal(v.low, 99);
|
|
});
|
|
|
|
test('OpeningRange(2) breakout distance is signed close minus midpoint', () => {
|
|
const or = new wickra.OpeningRange(2);
|
|
or.update(102, 100, 101);
|
|
or.update(103, 101, 102);
|
|
// OR locked at high 103 / low 100 / mid 101.5. Close 105 -> +3.5.
|
|
const v = or.update(110, 102, 105);
|
|
assert.equal(v.high, 103);
|
|
assert.equal(v.low, 100);
|
|
assert.ok(Math.abs(v.breakoutDistance - 3.5) < 1e-9);
|
|
});
|
|
|
|
// --- Family 12: two-series indicators (Pearson / Beta / Spearman) ---
|
|
|
|
const pairFactories = {
|
|
PearsonCorrelation: () => new wickra.PearsonCorrelation(14),
|
|
Beta: () => new wickra.Beta(14),
|
|
SpearmanCorrelation: () => new wickra.SpearmanCorrelation(14),
|
|
};
|
|
|
|
for (const [name, make] of Object.entries(pairFactories)) {
|
|
test(`${name}: streaming update matches batch over a pair of series`, () => {
|
|
const xs = Array.from({ length: N }, (_, i) => Math.sin(i * 0.2) + 0.05 * i);
|
|
const ys = Array.from({ length: N }, (_, i) => Math.cos(i * 0.3) + 0.02 * i);
|
|
const batch = make().batch(xs, ys);
|
|
const streaming = make();
|
|
assert.equal(batch.length, N);
|
|
for (let i = 0; i < N; i++) {
|
|
const s = num(streaming.update(xs[i], ys[i]));
|
|
assert.ok(eq(s, batch[i]), `${name} mismatch at ${i}: ${s} vs ${batch[i]}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
test('PearsonCorrelation perfect positive is 1', () => {
|
|
const x = Array.from({ length: 10 }, (_, i) => i);
|
|
const y = x.map((v) => 2 * v + 3);
|
|
const out = new wickra.PearsonCorrelation(5).batch(x, y);
|
|
assert.ok(Math.abs(out[out.length - 1] - 1) < 1e-9);
|
|
});
|
|
|
|
test('Beta perfect two-to-one', () => {
|
|
const bench = Array.from({ length: 10 }, (_, i) => i);
|
|
const asset = bench.map((v) => 2 * v);
|
|
const out = new wickra.Beta(5).batch(asset, bench);
|
|
assert.ok(Math.abs(out[out.length - 1] - 2) < 1e-9);
|
|
});
|
|
|
|
test('SpearmanCorrelation monotone non-linear is 1', () => {
|
|
const x = Array.from({ length: 10 }, (_, i) => i + 1);
|
|
const y = x.map((v) => v ** 3);
|
|
const out = new wickra.SpearmanCorrelation(5).batch(x, y);
|
|
assert.ok(Math.abs(out[out.length - 1] - 1) < 1e-9);
|
|
});
|
|
|
|
test('Variance(3) of [2, 4, 6] equals 8/3', () => {
|
|
const out = new wickra.Variance(3).batch([2, 4, 6]);
|
|
assert.ok(Math.abs(out[2] - 8 / 3) < 1e-12);
|
|
});
|
|
|
|
test('RSquared on a perfect line is 1', () => {
|
|
const xs = Array.from({ length: 20 }, (_, i) => 2 * i + 5);
|
|
const out = new wickra.RSquared(5).batch(xs);
|
|
for (let i = 5; i < out.length; i++) {
|
|
assert.ok(Math.abs(out[i] - 1) < 1e-9);
|
|
}
|
|
});
|
|
|
|
test('MedianAbsoluteDeviation ignores a single huge outlier', () => {
|
|
const xs = Array(9).fill(5).concat([1000]);
|
|
const out = new wickra.MedianAbsoluteDeviation(10).batch(xs);
|
|
assert.ok(Math.abs(out[9]) < 1e-12);
|
|
});
|
|
|
|
test('Autocorrelation of an alternating series is strongly negative at lag 1', () => {
|
|
const xs = Array.from({ length: 20 }, (_, i) => (i % 2 === 0 ? -1 : 1));
|
|
const out = new wickra.Autocorrelation(10, 1).batch(xs);
|
|
assert.ok(out[out.length - 1] < -0.5);
|
|
});
|
|
|
|
test('HurstExponent of a monotone ramp is above 0.5', () => {
|
|
const xs = Array.from({ length: 200 }, (_, i) => i);
|
|
const out = new wickra.HurstExponent(100, 4).batch(xs);
|
|
assert.ok(out[out.length - 1] > 0.5);
|
|
});
|
|
|
|
test('Ichimoku classic warmup is 77 and tenkan emits at bar 9', () => {
|
|
const ichi = new wickra.Ichimoku(9, 26, 52, 26);
|
|
assert.equal(ichi.warmupPeriod(), 77);
|
|
const n = 30;
|
|
const h = Array.from({ length: n }, (_, i) => 100 + i + 2);
|
|
const l = Array.from({ length: n }, (_, i) => 100 + i - 2);
|
|
const c = Array.from({ length: n }, (_, i) => 100 + i + 1);
|
|
const out = ichi.batch(h, l, c);
|
|
for (let i = 0; i < 8; i++) {
|
|
assert.ok(Number.isNaN(out[i * 5]), `tenkan should be NaN at bar ${i}`);
|
|
}
|
|
assert.ok(!Number.isNaN(out[8 * 5]), 'tenkan should be defined at bar 9');
|
|
});
|
|
|
|
test('HeikinAshi first bar seeds from real open and close', () => {
|
|
const ha = new wickra.HeikinAshi();
|
|
const out = ha.update(10, 12, 9, 11);
|
|
assert.ok(Math.abs(out.open - (10 + 11) / 2) < 1e-12);
|
|
assert.ok(Math.abs(out.close - (10 + 12 + 9 + 11) / 4) < 1e-12);
|
|
});
|
|
|
|
test('PercentageTrailingStop seeds and ratchets', () => {
|
|
const s = new wickra.PercentageTrailingStop(10);
|
|
assert.ok(Math.abs(s.update(100) - 90) < 1e-9);
|
|
assert.ok(Math.abs(s.update(110) - 99) < 1e-9);
|
|
});
|
|
|
|
test('RenkoTrailingStop only advances after a full block', () => {
|
|
const s = new wickra.RenkoTrailingStop(1);
|
|
assert.ok(Math.abs(s.update(100) - 99) < 1e-9);
|
|
assert.ok(Math.abs(s.update(100.5) - 99) < 1e-9);
|
|
assert.ok(Math.abs(s.update(101) - 100) < 1e-9);
|
|
});
|
|
|
|
test('DonchianStop window extremes', () => {
|
|
const out = new wickra.DonchianStop(5).batch([1, 2, 3, 4, 5], [0, 1, 2, 3, 4]);
|
|
// [long0, short0, long1, short1, ...]: idx 8 (=4*2) = long_5th, idx 9 = short_5th.
|
|
assert.ok(Math.abs(out[8] - 0) < 1e-9);
|
|
assert.ok(Math.abs(out[9] - 5) < 1e-9);
|
|
});
|
|
|
|
test('MaEnvelope reference values', () => {
|
|
// SMA([10, 20, 30]) = 20; with percent 0.10: upper=22, lower=18.
|
|
const out = new wickra.MaEnvelope(3, 0.10).batch([10, 20, 30]);
|
|
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[3]));
|
|
assert.ok(Math.abs(out[2 * 3 + 0] - 22) < 1e-9); // upper
|
|
assert.ok(Math.abs(out[2 * 3 + 1] - 20) < 1e-9); // middle
|
|
assert.ok(Math.abs(out[2 * 3 + 2] - 18) < 1e-9); // lower
|
|
});
|
|
|
|
test('AccelerationBands single-bar reference', () => {
|
|
// high=12, low=8, close=10, factor=0.5, period=1.
|
|
// ratio=0.2, raw_up=13.2, raw_lo=7.2.
|
|
const v = new wickra.AccelerationBands(1, 0.5).update(12, 8, 10);
|
|
assert.ok(Math.abs(v.upper - 13.2) < 1e-9);
|
|
assert.ok(Math.abs(v.middle - 10) < 1e-9);
|
|
assert.ok(Math.abs(v.lower - 7.2) < 1e-9);
|
|
});
|
|
|
|
test('LinRegChannel reference values for [1, 2, 9]', () => {
|
|
// Line y=4x, endpoint=8, residuals=[1,-2,1], sigma=sqrt(2).
|
|
const out = new wickra.LinRegChannel(3, 2).batch([1, 2, 9]);
|
|
const s = Math.sqrt(2);
|
|
const i = 2;
|
|
assert.ok(Math.abs(out[i * 3 + 0] - (8 + 2 * s)) < 1e-9);
|
|
assert.ok(Math.abs(out[i * 3 + 1] - 8) < 1e-9);
|
|
assert.ok(Math.abs(out[i * 3 + 2] - (8 - 2 * s)) < 1e-9);
|
|
});
|
|
|
|
test('VwapStdDevBands two-bar reference', () => {
|
|
const v = new wickra.VwapStdDevBands(1.5);
|
|
v.update(8, 8, 8, 1);
|
|
const o = v.update(12, 12, 12, 1);
|
|
assert.ok(Math.abs(o.upper - 13) < 1e-9);
|
|
assert.ok(Math.abs(o.middle - 10) < 1e-9);
|
|
assert.ok(Math.abs(o.lower - 7) < 1e-9);
|
|
assert.ok(Math.abs(o.stddev - 2) < 1e-9);
|
|
});
|
|
|
|
test('RVIVolatility pure uptrend saturates at 100', () => {
|
|
const prices = Array.from({ length: 40 }, (_, i) => i + 1);
|
|
const out = new wickra.RVIVolatility(5).batch(prices);
|
|
for (let i = 9; i < out.length; i++) {
|
|
assert.ok(Math.abs(out[i] - 100) < 1e-9, `RVIVolatility[${i}] = ${out[i]}`);
|
|
}
|
|
});
|
|
|
|
test('ParkinsonVolatility zero-range bars yield zero', () => {
|
|
const n = 30;
|
|
const h = Array(n).fill(10);
|
|
const l = Array(n).fill(10);
|
|
const out = new wickra.ParkinsonVolatility(14, 252).batch(h, l);
|
|
for (let i = 13; i < n; i++) {
|
|
assert.ok(Math.abs(out[i]) < 1e-12, `Parkinson[${i}] = ${out[i]}`);
|
|
}
|
|
});
|
|
|
|
test('GarmanKlassVolatility zero-movement bars yield zero', () => {
|
|
const n = 30;
|
|
const flat = Array(n).fill(10);
|
|
const out = new wickra.GarmanKlassVolatility(14, 252).batch(flat, flat, flat, flat);
|
|
for (let i = 13; i < n; i++) {
|
|
assert.ok(Math.abs(out[i]) < 1e-12, `GK[${i}] = ${out[i]}`);
|
|
}
|
|
});
|
|
|
|
test('RogersSatchellVolatility zero-movement bars yield zero', () => {
|
|
const n = 30;
|
|
const flat = Array(n).fill(10);
|
|
const out = new wickra.RogersSatchellVolatility(14, 252).batch(flat, flat, flat, flat);
|
|
for (let i = 13; i < n; i++) {
|
|
assert.ok(Math.abs(out[i]) < 1e-12, `RS[${i}] = ${out[i]}`);
|
|
}
|
|
});
|
|
|
|
test('YangZhangVolatility zero-movement bars yield zero', () => {
|
|
const n = 30;
|
|
const flat = Array(n).fill(10);
|
|
const out = new wickra.YangZhangVolatility(14, 252).batch(flat, flat, flat, flat);
|
|
for (let i = 14; i < n; i++) {
|
|
assert.ok(Math.abs(out[i]) < 1e-12, `YZ[${i}] = ${out[i]}`);
|
|
}
|
|
});
|
|
|
|
test('ZeroLagMACD on a flat series converges to zero', () => {
|
|
const out = new wickra.ZeroLagMACD(3, 5, 3).batch(Array(60).fill(42));
|
|
// Last interleaved row: macd, signal, histogram all 0.
|
|
const n = 60;
|
|
assert.ok(Math.abs(out[(n - 1) * 3]) < 1e-12);
|
|
assert.ok(Math.abs(out[(n - 1) * 3 + 1]) < 1e-12);
|
|
assert.ok(Math.abs(out[(n - 1) * 3 + 2]) < 1e-12);
|
|
});
|
|
|
|
test('AwesomeOscillatorHistogram on a flat median converges to zero', () => {
|
|
const n = 50;
|
|
const out = new wickra.AwesomeOscillatorHistogram(3, 5, 3).batch(
|
|
Array(n).fill(11),
|
|
Array(n).fill(9),
|
|
);
|
|
// warmup = 5 + 3 - 1 = 7.
|
|
for (let i = 6; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12);
|
|
});
|
|
|
|
test('STC on a flat series stays at zero', () => {
|
|
const out = new wickra.STC(3, 5, 4, 0.5).batch(Array(60).fill(42));
|
|
// Latest values must be exactly zero.
|
|
for (let i = out.length - 5; i < out.length; i++) {
|
|
if (Number.isNaN(out[i])) continue;
|
|
assert.equal(out[i], 0);
|
|
}
|
|
});
|
|
|
|
test('ElderImpulse on a flat series stays neutral (0)', () => {
|
|
const out = new wickra.ElderImpulse(13, 12, 26, 9).batch(Array(120).fill(42));
|
|
for (let i = 0; i < out.length; i++) {
|
|
if (Number.isNaN(out[i])) continue;
|
|
assert.equal(out[i], 0);
|
|
}
|
|
});
|
|
|
|
test('CFO(5) on a perfectly linear series yields zero', () => {
|
|
const prices = Array.from({ length: 20 }, (_, i) => (i + 1) * 2);
|
|
const out = new wickra.CFO(5).batch(prices);
|
|
for (let i = 4; i < 20; i++) assert.ok(Math.abs(out[i]) < 1e-9);
|
|
});
|
|
|
|
test('APO(3, 5) on a flat series converges to zero', () => {
|
|
const out = new wickra.APO(3, 5).batch(Array(30).fill(42));
|
|
for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i]));
|
|
for (let i = 4; i < 30; i++) assert.ok(Math.abs(out[i]) < 1e-12);
|
|
});
|
|
|
|
test('Inertia(3, 4) on a constant RVI series equals that RVI', () => {
|
|
const n = 60;
|
|
// Every bar (open, high, low, close) = (10, 11, 9, 10.5) -> RVI = 0.25.
|
|
const out = new wickra.Inertia(3, 4).batch(
|
|
Array(n).fill(10),
|
|
Array(n).fill(11),
|
|
Array(n).fill(9),
|
|
Array(n).fill(10.5),
|
|
);
|
|
for (let i = 5; i < n; i++) assert.ok(Math.abs(out[i] - 0.25) < 1e-12);
|
|
});
|
|
|
|
test('ConnorsRSI stays bounded in [0, 100]', () => {
|
|
const prices = Array.from({ length: 250 }, (_, i) => 100 + 20 * Math.sin(i * 0.12));
|
|
const out = new wickra.ConnorsRSI(3, 2, 100).batch(prices);
|
|
for (let i = 0; i < out.length; i++) {
|
|
if (Number.isNaN(out[i])) continue;
|
|
assert.ok(out[i] >= 0 && out[i] <= 100, `out[${i}] = ${out[i]}`);
|
|
}
|
|
});
|
|
|
|
test('LaguerreRSI on a flat series stays at the neutral 50', () => {
|
|
const out = new wickra.LaguerreRSI(0.5).batch(Array(40).fill(42));
|
|
for (let i = 0; i < out.length; i++) assert.ok(Math.abs(out[i] - 50) < 1e-12);
|
|
});
|
|
|
|
test('SMI with close at range centre emits zero after warmup', () => {
|
|
const n = 60;
|
|
const out = new wickra.SMI(5, 3, 3).batch(Array(n).fill(11), Array(n).fill(9), Array(n).fill(10));
|
|
// warmup_period = 5 + 3 + 3 - 2 = 9.
|
|
for (let i = 8; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12);
|
|
});
|
|
|
|
test('KST on a flat series emits zero after warmup', () => {
|
|
const kst = new wickra.KST(10, 15, 20, 30, 10, 10, 10, 15, 9);
|
|
const n = 80;
|
|
const out = kst.batch(Array(n).fill(42));
|
|
const warmup = kst.warmupPeriod();
|
|
for (let i = warmup - 1; i < n; i++) {
|
|
assert.ok(Math.abs(out[i * 2]) < 1e-12, `kst[${i}] = ${out[i * 2]}`);
|
|
assert.ok(Math.abs(out[i * 2 + 1]) < 1e-12, `signal[${i}] = ${out[i * 2 + 1]}`);
|
|
}
|
|
});
|
|
|
|
test('PGO(5) on a flat close emits zero after warmup', () => {
|
|
const n = 20;
|
|
const out = new wickra.PGO(5).batch(Array(n).fill(11), Array(n).fill(9), Array(n).fill(10));
|
|
for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i]));
|
|
for (let i = 4; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12, `out[${i}] = ${out[i]}`);
|
|
});
|
|
|
|
test('RVI(2) reference value on two bars', () => {
|
|
// Bars (open, high, low, close): (10, 11, 9, 10.5), (10.5, 11.5, 10, 11).
|
|
const out = new wickra.RVI(2).batch([10, 10.5], [11, 11.5], [9, 10], [10.5, 11]);
|
|
assert.ok(Number.isNaN(out[0]));
|
|
assert.ok(Math.abs(out[1] - 1 / 3.5) < 1e-12);
|
|
});
|
|
|
|
test('EVWMA(2) reference values on [10, 20, 30] with volumes [1, 3, 1]', () => {
|
|
const out = new wickra.EVWMA(2).batch([10, 20, 30], [1, 3, 1]);
|
|
assert.ok(Number.isNaN(out[0]));
|
|
assert.ok(Math.abs(out[1] - 20) < 1e-12);
|
|
assert.ok(Math.abs(out[2] - 22.5) < 1e-12);
|
|
});
|
|
|
|
test('Alligator on a flat median price seeds to that median', () => {
|
|
const n = 30;
|
|
const out = new wickra.Alligator(13, 8, 5).batch(Array(n).fill(11), Array(n).fill(9));
|
|
// All three SMMAs see median (11 + 9) / 2 = 10 every bar.
|
|
for (let i = 12; i < n; i++) {
|
|
assert.ok(Math.abs(out[i * 3] - 10) < 1e-12, `jaw at ${i}: ${out[i * 3]}`);
|
|
assert.ok(Math.abs(out[i * 3 + 1] - 10) < 1e-12);
|
|
assert.ok(Math.abs(out[i * 3 + 2] - 10) < 1e-12);
|
|
}
|
|
});
|
|
|
|
test('JMA on a flat series reproduces the constant', () => {
|
|
const out = new wickra.JMA(14, 0, 2).batch(Array(30).fill(42));
|
|
for (let i = 0; i < 30; i++) assert.ok(Math.abs(out[i] - 42) < 1e-12);
|
|
});
|
|
|
|
test('VIDYA on a flat series holds the seed', () => {
|
|
const out = new wickra.VIDYA(14, 4).batch(Array(20).fill(42));
|
|
for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i]));
|
|
for (let i = 4; i < 20; i++) assert.ok(Math.abs(out[i] - 42) < 1e-12);
|
|
});
|
|
|
|
test('FRAMA pure uptrend hugs the latest close', () => {
|
|
const out = new wickra.FRAMA(4).batch([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
assert.ok(Math.abs(out[out.length - 1] - 8) < 0.05);
|
|
});
|
|
|
|
test('McGinleyDynamic(3) seeds with SMA and recurses on the next price', () => {
|
|
// Seed = SMA([10, 20, 30]) = 20. On 40: ratio = 2, divisor = 0.6*3*16 = 28.8.
|
|
const out = new wickra.McGinleyDynamic(3).batch([10, 20, 30, 40]);
|
|
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
|
|
assert.ok(Math.abs(out[2] - 20) < 1e-12);
|
|
const expected = 20 + 20 / (0.6 * 3 * 16);
|
|
assert.ok(Math.abs(out[3] - expected) < 1e-12);
|
|
});
|
|
|
|
test('ALMA(3, 0.85, 6) reference value on [10, 20, 30]', () => {
|
|
// m = 0.85 * 2 = 1.7; s = 3 / 6 = 0.5; 2*s^2 = 0.5.
|
|
const out = new wickra.ALMA(3, 0.85, 6).batch([10, 20, 30]);
|
|
assert.ok(Number.isNaN(out[0]) && Number.isNaN(out[1]));
|
|
const w = [0, 1, 2].map((i) => Math.exp(-Math.pow(i - 1.7, 2) / 0.5));
|
|
const s = w[0] + w[1] + w[2];
|
|
const expected = (10 * w[0] + 20 * w[1] + 30 * w[2]) / s;
|
|
assert.ok(Math.abs(out[2] - expected) < 1e-12);
|
|
// The heavy offset toward the newest sample lifts the average above the
|
|
// simple mean of 20.
|
|
assert.ok(out[2] > 20);
|
|
});
|