ohlcv-indicators 3.5.78 → 3.5.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/AGENTS.md +113 -0
  2. package/README.md +456 -0
  3. package/dist/ohlcv-indicators.min.js +2 -1
  4. package/dist/ohlcv-indicators.min.js.LICENSE.txt +22 -0
  5. package/index.js +112 -84
  6. package/package.json +2 -5
  7. package/src/core-functions/initializeColumns.js +40 -0
  8. package/src/core-functions/mainLoop.js +35 -30
  9. package/src/core-functions/pushToMain.js +8 -27
  10. package/src/core-indicators/README.md +60 -0
  11. package/src/core-indicators/composites.js +136 -0
  12. package/src/core-indicators/index.js +5 -0
  13. package/src/core-indicators/indicator.js +81 -0
  14. package/src/core-indicators/movingAverages.js +88 -0
  15. package/src/core-indicators/rsi.js +41 -0
  16. package/src/core-indicators/volatility.js +107 -0
  17. package/src/moving-averages/bollingerBands.js +39 -31
  18. package/src/moving-averages/donchianChannel.js +62 -36
  19. package/src/moving-averages/heikenAshi.js +33 -52
  20. package/src/moving-averages/macd.js +10 -26
  21. package/src/moving-averages/movingAverages.js +19 -25
  22. package/src/moving-averages/relativeVolume.js +30 -24
  23. package/src/oscillators/mfi.js +56 -0
  24. package/src/oscillators/rsi.js +7 -26
  25. package/src/oscillators/stochastic.js +6 -19
  26. package/src/oscillators/volumeDelta.js +36 -28
  27. package/src/oscillators/volumeOscillator.js +24 -32
  28. package/src/studies/candleFeatures.js +40 -28
  29. package/src/studies/dateTime.js +54 -56
  30. package/src/studies/findCrosses.js +18 -16
  31. package/src/studies/lag.js +32 -20
  32. package/src/utilities/assignTypes.js +1 -1
  33. package/src/utilities/config.js +31 -0
  34. package/src/utilities/numberUtilities.js +5 -3
  35. package/src/utilities/precisionMultiplier.js +11 -6
  36. package/src/utilities/validators.js +40 -1
  37. package/src/utilities/verticalToHorizontal.js +26 -16
  38. package/src/volatility/adx.js +7 -20
  39. package/src/volatility/atr.js +10 -23
  40. package/test/test.js +1230 -0
  41. package/webpack.config.js +0 -11
  42. package/src/moving-averages/vidya.js +0 -181
  43. package/src/studies/mapCols.js +0 -46
package/test/test.js ADDED
@@ -0,0 +1,1230 @@
1
+ import assert from 'node:assert/strict'
2
+ import { performance } from 'node:perf_hooks'
3
+ import { resolve } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+ import { createHash } from 'node:crypto'
6
+ import OHLCV_INDICATORS from '../index.js'
7
+ import * as Core from '../src/core-indicators/index.js'
8
+ import { addPrecisionAsNumber, revertPrecisionAsNumber } from '../src/utilities/precisionMultiplier.js'
9
+
10
+ // npm test: offline deterministic regressions.
11
+ // npm test -- --baseline /path/to/pre-change/index.js --benchmark --rows 10000 --repeats 7
12
+ // npm test -- --live: original Nasdaq smoke test (requires its market-data service).
13
+ const args = process.argv.slice(2)
14
+ const option = name => {
15
+ const index = args.indexOf(name)
16
+ if (index === -1) return undefined
17
+ assert.ok(args[index + 1] && !args[index + 1].startsWith('--'), `${name} requires a value`)
18
+ return args[index + 1]
19
+ }
20
+ const makeInput = (length = 320, precision = false) => Array.from({ length }, (_, index) => {
21
+ const open = 100 + index / 100 + Math.sin(index / 7) * 3
22
+ const close = open + Math.cos(index / 3) * 1.5
23
+ const price = value => precision ? value.toFixed(4) : Number(value.toFixed(4))
24
+ return {
25
+ date: new Date(Date.UTC(2024, 0, 1, 1, 15) + index * 3600000).toISOString(),
26
+ open: price(open), high: price(Math.max(open, close) + 2),
27
+ low: price(Math.min(open, close) - 2), close: price(close),
28
+ volume: index > 0 && index % 37 === 0 ? 0 : 1000 + index % 29 * 17,
29
+ note: `row-${index}`, flag: index % 2 === 0, extra: index / 10
30
+ }
31
+ })
32
+ const configure = (Ctor, input, { precision = false, retLogs = false, chunkProcess = 100, includeMfi = true } = {}) => {
33
+ const main = new Ctor({ input, chunkProcess, config: { precision, useFullNames: retLogs, timeZone: 'America/Panama', dateFormat: 'iso', skipNull: false } })
34
+ .ema(5, { lag: 2 }).ema(9, { target: 'open' }).sma(7, { lag: 1 })
35
+ .bollingerBands(8, 2, { lag: 1 }).macd(4, 9, 3, { lag: 1 })
36
+ .relativeVolume(6, { lag: 2 }).volumeDelta({ lag: 1 }).volumeOscillator(3, 8, { retLogs, lag: 2 })
37
+ .rsi(7, { retLogs, lag: 1 }).stochastic(7, 3, 3, { retLogs, lag: 1 })
38
+ .atr(7, { retLogs, lag: 1 }).adx(7, { retLogs, lag: 1 })
39
+ .heikenAshi(null, null, { retLogs, lag: 1 }).heikenAshi(3, 4, { retLogs })
40
+ .donchianChannels(1, 0, { lag: 1 }).donchianChannels(8, 2, { lag: 2 }).donchianChannels(31, 9)
41
+ .dateTime({ oneHot: retLogs, lag: 1 }).candleFeatures({ colKeys: ['close'], retLogs, lag: 1 })
42
+ .crossPairs([{ fast: 'close', slow: 'open' }, { fast: 'price', slow: 100 }], { limit: 5, oneHot: true })
43
+ .lag(['close', 'volume'], 2).lag(['close_lag_1'], 2).lag(['one_hot_close_x_open'], 1)
44
+ if (includeMfi) main.mfi(7, { retLogs, lag: 1 })
45
+ return main
46
+ }
47
+ const deepFreeze = value => {
48
+ if (value && typeof value === 'object') { Object.values(value).forEach(deepFreeze); Object.freeze(value) }
49
+ return value
50
+ }
51
+ // Observe results/metadata/types, not the intentionally changed runtime cache layout.
52
+ const snapshot = main => ({
53
+ columns: structuredClone(main.verticalOhlcv),
54
+ types: Object.entries(main.verticalOhlcv).map(([key, values]) => [key, values.constructor.name]),
55
+ keys: [...main.verticalOhlcvKeyNames], temp: [...main.verticalOhlcvTempCols],
56
+ priceBased: [...main.priceBased], scaledGroups: structuredClone(main.scaledGroups),
57
+ invalidValueIndex: main.invalidValueIndex, config: main.exportConfig(),
58
+ rows: main.getData({ skipNull: false, dateFormat: 'object' }),
59
+ validRows: main.getData({ skipNull: true, dateFormat: 'iso' }), last: main.getLastValues({ dateFormat: 'seconds' })
60
+ })
61
+ // Keep the old shift-based algorithm as an independent oracle, including its
62
+ // NaN comparisons (Math.max/Math.min would have different invalid-value behavior).
63
+ const referenceDonchian = (highs, lows, size, offset) => {
64
+ const max = [], min = [], upper = [], lower = [], basis = []
65
+ for (let index = 0; index < highs.length; index++) {
66
+ upper.push(NaN); lower.push(NaN); basis.push(NaN)
67
+ const current = index - offset, start = current - size + 1
68
+ if (current < 0 || current >= highs.length) continue
69
+ while (max.length && max[0] < start) max.shift()
70
+ while (max.length && highs[max.at(-1)] <= highs[current]) max.pop()
71
+ max.push(current)
72
+ while (min.length && min[0] < start) min.shift()
73
+ while (min.length && lows[min.at(-1)] >= lows[current]) min.pop()
74
+ min.push(current)
75
+ if (start < 0) continue
76
+ upper[index] = highs[max[0]]; lower[index] = lows[min[0]]; basis[index] = (upper[index] + lower[index]) / 2
77
+ }
78
+ return { upper, lower, basis }
79
+ }
80
+
81
+ // Use independent full windows and the original three-log geometric definition.
82
+ const referenceBollinger = (closes, size, stdDev) => Array.from(closes, (_, index) => {
83
+ if (index < size) return { upper: NaN, middle: NaN, lower: NaN }
84
+ const window = Array.from(closes.slice(index - size + 1, index + 1))
85
+ const middle = window.reduce((sum, value) => sum + value, 0) / size
86
+ const deviation = Math.sqrt(window.reduce((sum, value) => sum + (value - middle) ** 2, 0) / size) * stdDev
87
+ return { upper: middle + deviation, middle, lower: middle - deviation }
88
+ })
89
+ const referenceChannelLogs = (upper, lower, close) => {
90
+ if (!Number.isFinite(upper) || !Number.isFinite(lower) || lower <= 0 || upper < lower || !Number.isFinite(upper / lower)) {
91
+ return { width: NaN, position: NaN }
92
+ }
93
+ const logUpper = Math.log(upper), logLower = Math.log(lower)
94
+ const width = (logUpper - logLower) / 2
95
+ const ratio = close / lower
96
+ const position = width > 0 && Number.isFinite(close) && close > 0 && Number.isFinite(ratio) && ratio > 0
97
+ ? (Math.log(close) - (logUpper + logLower) / 2) / width : NaN
98
+ return { width, position: Number.isFinite(position) ? position : NaN }
99
+ }
100
+ const assertNear = (actual, expected, label, tolerance = 1e-10) => {
101
+ if (Number.isNaN(expected)) assert.ok(Number.isNaN(actual), `${label}: expected NaN, received ${actual}`)
102
+ else assert.ok(Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance * Math.max(1, Math.abs(expected)),
103
+ `${label}: expected ${expected}, received ${actual}`)
104
+ }
105
+
106
+ // Independent full-history/window oracle: do not share the handler's ring state.
107
+ const referenceMfi = (columns, size, retLogs = false) => {
108
+ const typical = Array.from(columns.close, (_, index) =>
109
+ (columns.high[index] + columns.low[index] + columns.close[index]) / 3)
110
+ const flows = typical.map((value, index) => {
111
+ if (index === 0) return NaN
112
+ const difference = value - typical[index - 1], money = value * columns.volume[index]
113
+ if (!Number.isFinite(difference) || !Number.isFinite(money) || money < 0) return NaN
114
+ return difference > 0 ? money : difference < 0 ? -money : 0
115
+ })
116
+ return flows.map((_, index) => {
117
+ if (index < size) return NaN
118
+ const window = flows.slice(index - size + 1, index + 1)
119
+ if (window.some(Number.isNaN)) return NaN
120
+ let positive = 0, negative = 0
121
+ for (const value of window) {
122
+ if (value > 0) positive += value
123
+ else if (value < 0) negative -= value
124
+ }
125
+ if (!Number.isFinite(positive) || !Number.isFinite(negative)) return NaN
126
+ if (positive === 0 && negative === 0) return NaN
127
+ const value = negative === 0 ? 100 : positive === 0 ? 0 : 100 - 100 / (1 + positive / negative)
128
+ return retLogs ? Math.log((value === 0 ? 0.001 : value) / 50) : value
129
+ })
130
+ }
131
+ const mfiInput = (prices, volumes = prices.map(() => 1), precision = false) => prices.map((price, index) => {
132
+ const value = precision ? String(price) : price
133
+ return { open: value, high: value, low: value, close: value, volume: volumes[index] }
134
+ })
135
+
136
+ // One independent registration per public indicator/study. The combined chain
137
+ // above remains useful for detecting interactions between different handlers.
138
+ const indicatorCases = [
139
+ { name: 'ema', register: (m, o) => m.ema(5, o), keys: logs => [`${logs ? 'ret_log_' : ''}ema_5`], warmup: 4 },
140
+ { name: 'sma', register: (m, o) => m.sma(5, o), keys: logs => [`${logs ? 'ret_log_' : ''}sma_5`], warmup: 4 },
141
+ { name: 'bollingerBands', register: (m, o) => m.bollingerBands(8, 2, o),
142
+ // Preserve the existing first bands after size + 1 updates.
143
+ keys: logs => logs ? ['width', 'position'].map(k => `ret_log_bollinger_bands_${k}`)
144
+ : ['upper', 'middle', 'lower'].map(k => `bollinger_bands_${k}`), warmup: 8 },
145
+ { name: 'macd', register: (m, o) => m.macd(4, 9, 3, o),
146
+ keys: () => ['diff', 'dea', 'histogram'].map(k => `macd_${k}`) },
147
+ { name: 'relativeVolume', register: (m, o) => m.relativeVolume(6, o),
148
+ keys: logs => [`${logs ? 'ret_log_' : ''}relative_volume_6`], warmup: 6 },
149
+ { name: 'volumeDelta', register: (m, o) => m.volumeDelta(o),
150
+ keys: () => ['high', 'low', 'close', 'cross'].map(k => `volume_delta_${k}`) },
151
+ { name: 'volumeOscillator', register: (m, o) => m.volumeOscillator(3, 8, o),
152
+ keys: logs => [`${logs ? 'ret_log_' : ''}volume_oscillator_3_8`], warmup: 7 },
153
+ { name: 'rsi', register: (m, o) => m.rsi(7, o),
154
+ keys: logs => ['rsi_7', 'rsi_sma_7'].map(k => `${logs ? 'ret_log_' : ''}${k}`) },
155
+ { name: 'mfi', register: (m, o) => m.mfi(7, o), keys: logs => [`${logs ? 'ret_log_' : ''}mfi_7`], warmup: 7 },
156
+ { name: 'stochastic', register: (m, o) => m.stochastic(7, 3, 3, o),
157
+ keys: logs => ['d', 'k'].map(k => `${logs ? 'ret_log_' : ''}stochastic_${k}_7_3_3`) },
158
+ { name: 'atr', register: (m, o) => m.atr(7, o), keys: logs => [`${logs ? 'ret_log_' : ''}atr_7`] },
159
+ { name: 'adx', register: (m, o) => m.adx(7, o), keys: logs => [`${logs ? 'ret_log_' : ''}adx_7`] },
160
+ { name: 'heikenAshi', register: (m, o) => m.heikenAshi(null, null, o),
161
+ keys: logs => [...['body', 'upper_wick', 'lower_wick', 'range'].map(k => `${logs ? 'ret_log_' : 'ret_'}heiken_ashi_${k}`), 'heiken_ashi_cross'] },
162
+ { name: 'donchianChannels', register: (m, o) => m.donchianChannels(8, 2, o),
163
+ keys: logs => logs ? ['width', 'position'].map(k => `ret_log_donchian_channel_${k}`)
164
+ : ['upper', 'basis', 'lower'].map(k => `donchian_channel_${k}`), warmup: 9 },
165
+ { name: 'dateTime', register: (m, o) => m.dateTime({ lag: o.lag, oneHot: o.retLogs }),
166
+ keys: oneHot => oneHot
167
+ ? ['month', 'day_of_the_month', 'day_of_the_week', 'hour', 'minute'].map(k => `one_hot_${k}`)
168
+ : ['month', 'day_of_the_month', 'day_of_the_week', 'hour', 'minute', 'year'] },
169
+ { name: 'candleFeatures', register: (m, o) => m.candleFeatures({ ...o, colKeys: ['close'] }),
170
+ keys: logs => ['change', 'mid_price_change', 'upper_wick', 'lower_wick', 'gap', 'body', 'range', 'close'].map(k => `${logs ? 'ret_log_' : 'ret_'}${k}`), warmup: 1 },
171
+ { name: 'crossPairs', register: (m, o) => m.crossPairs([{ fast: 'close', slow: 'open' }], { limit: 5, oneHot: o.retLogs }),
172
+ keys: oneHot => ['close_x_open', ...(oneHot ? ['one_hot_close_x_open'] : [])], autoLag: false },
173
+ { name: 'lag', register: m => m.lag(['close', 'volume'], 2),
174
+ keys: () => ['close_lag_1', 'close_lag_2', 'volume_lag_1', 'volume_lag_2'], autoLag: false }
175
+ ]
176
+
177
+ const coreCases = [
178
+ ...['FasterEMA', 'FasterSMA', 'FasterWSMA', 'FasterRSI'].map(name => ({ name, create: (lib, size) => new lib[name](size) })),
179
+ ...['FasterTR', 'FasterATR', 'FasterDX', 'FasterADX'].map(name => ({ name, candle: true, create: (lib, size) => new lib[name](size) })),
180
+ { name: 'FasterBollingerBands', create: (lib, size) => new lib.FasterBollingerBands(size, 2) },
181
+ { name: 'FasterMACD', create: (lib, size) => new lib.FasterMACD(new lib.FasterEMA(size), new lib.FasterEMA(size + 2), new lib.FasterEMA(3)) },
182
+ { name: 'FasterStochasticOscillator', candle: true, create: (lib, size) => new lib.FasterStochasticOscillator(size, 3, 2) }
183
+ ]
184
+ const readCoreResult = instance => {
185
+ try { return { value: instance.getResult() } }
186
+ catch (error) { return { error: error.name, message: error.message } }
187
+ }
188
+ const retainedStorage = (value, seen = new Set()) => {
189
+ if (value === null || typeof value !== 'object' || seen.has(value)) return { slots: 0, bytes: 0 }
190
+ seen.add(value)
191
+ if (ArrayBuffer.isView(value)) return { slots: 0, bytes: value.byteLength }
192
+ const total = { slots: Array.isArray(value) ? value.length : 0, bytes: 0 }
193
+ for (const item of Object.values(value)) {
194
+ const storage = retainedStorage(item, seen)
195
+ total.slots += storage.slots; total.bytes += storage.bytes
196
+ }
197
+ return total
198
+ }
199
+
200
+ // Fixed pre-optimization snapshots, not recomputed from the implementation under test.
201
+ // Each digest covers every update/readiness/result/extrema observation below.
202
+ // Number encoding preserves IEEE-754 values, including -0 and non-finite values;
203
+ // undefined is tagged rather than silently dropped by JSON serialization.
204
+ const traceValue = (hash, value) => hash.update(JSON.stringify(value, (_, item) => {
205
+ if (item === undefined) return { undefined: true }
206
+ if (typeof item === 'number') {
207
+ if (Number.isNaN(item)) return { number: 'NaN' }
208
+ const bytes = Buffer.allocUnsafe(8)
209
+ bytes.writeDoubleBE(item)
210
+ return { float64: bytes.toString('hex') }
211
+ }
212
+ return item
213
+ }))
214
+ const traceState = instance => [instance.isStable, readCoreResult(instance),
215
+ ...['highest', 'lowest', 'previousResult', 'pdi', 'mdi'].map(key => instance[key])]
216
+ const coreTrace = (lib, test) => {
217
+ const hash = createHash('sha256')
218
+ for (const size of [1, 2, 7, 32]) for (const pattern of ['mixed', 'zero', 'invalid', 'coercion']) for (const replacements of [false, true]) {
219
+ const instance = test.create(lib, size)
220
+ traceValue(hash, [size, pattern, replacements, traceState(instance)])
221
+ let previousObject, previousSnapshot
222
+ for (let index = 0; index < 180; index++) {
223
+ // Binary-exact inputs avoid platform differences in transcendental functions.
224
+ let value = pattern === 'zero' ? (index % 2 ? -0 : 0) : 100 + (index % 23 - 11) / 8 + index / 256
225
+ if (pattern === 'invalid') {
226
+ if (index === 13) value = NaN
227
+ if (index === 41) value = Infinity
228
+ if (index === 83) value = -Infinity
229
+ }
230
+ if (pattern === 'coercion') {
231
+ if (index === 9) value = null
232
+ if (index === 10) value = undefined
233
+ if (index === 11) value = '2'
234
+ if (index === 12) value = true
235
+ if (index === 13) value = '3'
236
+ }
237
+ const input = test.candle ? { high: value + 2, low: value - 2, close: value } : value
238
+ const actual = instance.update(input, replacements && index % 5 === 0)
239
+ traceValue(hash, [actual, traceState(instance)])
240
+ if (actual && typeof actual === 'object') {
241
+ if (previousObject) {
242
+ assert.notStrictEqual(actual, previousObject)
243
+ assert.deepStrictEqual(previousObject, previousSnapshot)
244
+ }
245
+ previousObject = actual; previousSnapshot = { ...actual }
246
+ }
247
+ }
248
+ }
249
+ return hash.digest('hex')
250
+ }
251
+ const candleMutationTrace = (lib, test) => {
252
+ const hash = createHash('sha256'), instance = test.create(lib, 3)
253
+ const candles = [{ high: 4, low: 1, close: 2 }, { high: 5, low: 2, close: 3 }]
254
+ for (let index = 0; index < 80; index++) {
255
+ const candle = candles[index % 2]
256
+ candle.high = 100 + index; candle.low = 90 + index; candle.close = 95 + index
257
+ traceValue(hash, [instance.update(candle), traceState(instance)])
258
+ }
259
+ return hash.digest('hex')
260
+ }
261
+ const batchReplacementTrace = (lib, name) => {
262
+ const hash = createHash('sha256'), instance = new lib[name](3)
263
+ traceValue(hash, [instance.updates([1, , 2, 3, 4]), traceState(instance)])
264
+ for (const value of [0, NaN, 7, -0]) traceValue(hash, [instance.replace(value), traceState(instance)])
265
+ return hash.digest('hex')
266
+ }
267
+ // Captured from the untouched local core before the runtime-allocation upgrade.
268
+ // These constants are intentionally not regenerated by npm test or --baseline.
269
+ const coreGolden = {
270
+ FasterEMA: { trace: '78ab81db0dfead350fe56bb3954b0daa5cdefa3106c2a8e0da74dfd680f5f7f8', batch: '151b515d79fa9df1c4a0e107d4499fc5a8ee28a2713f3f51de0bef8f19d50a52' },
271
+ FasterSMA: { trace: 'bcfc82bc43e647da69bdec38c6c42a4606d393af582e2c583616f065866f23e8', batch: '54804fe1d0439cf6f050298fc5bb8e35633df78751c5878d0b1cd9e3c1c61a78' },
272
+ FasterWSMA: { trace: '875a98e2e03267d4af5ad7fb73d54f9bfd57b9e7289c34dc0e452249b6d065e0', batch: 'db6bb9608a9796bc73395259c7faa506994dcb8cfcbcb20ac55a6eba85e135b7' },
273
+ FasterRSI: { trace: 'f6ac351918308e9c3242ed2da9fa67b49c4d51308e7d288f311eaa3710d4c4ed' },
274
+ FasterTR: { trace: '648182a18eedea9f52658b9223029f514edfdcb83c53eacb4b757c8a7b3288d0', mutation: 'd9ed0b39278293e54fd6f170c3956327e2f38e76c8562ff28bde8bb03319233b' },
275
+ FasterATR: { trace: 'd5d85c88d26d7f61c066eb7561ad0735a20a11b9f6380e175b820f845ea9a5c4', mutation: '6ba2e5337c05b61bcec38f0f7abd366ce84576a4813e36847e287476644aa85a' },
276
+ FasterDX: { trace: '87aaf629d97a65e39c71312c6a73c9349bbf6f86ab390e8cd1f4b6381079e1ca', mutation: 'da5b536cec355236455d19c7104fae1791573304b66c4729b91bc4e6fbcb6c5b' },
277
+ FasterADX: { trace: '20c42d867ed187c7d153f019e27404780e29e91a312814dcde7b7c0d66ad0768', mutation: '7cd635bf30ec272f24b335a884f4c386eacbe6699b3c07810117e451c33419a6' },
278
+ FasterBollingerBands: { trace: 'de88f538a1761f8e441e18232961432eb09a37d729a513fa7d88975a088614f9' },
279
+ FasterMACD: { trace: '963a46f587757bc1d2f759a2334aa5d9e7ce6796e94719eb349cb821e08da6ba' },
280
+ FasterStochasticOscillator: { trace: '3f31db0f8601f19e7be61069bb8b6bad2faf89b73c4545ce212269206024eec5', mutation: 'ef88bce5c228f29c5f511a5f5e28ed620b7b10936eaa79179d4b1b6a6bb2fed3' }
281
+ }
282
+
283
+ const runRegressions = Baseline => {
284
+ let passed = 0
285
+ const check = (name, callback) => { callback(); console.log(`PASS ${name}`); passed++ }
286
+ check('core known values, exact warm-up, signed zero and invalid-window recovery', () => {
287
+ const ema = new Core.FasterEMA(3), sma = new Core.FasterSMA(3), wsma = new Core.FasterWSMA(3)
288
+ assert.throws(() => ema.getResult(), { name: 'NotEnoughDataError' })
289
+ assert.deepStrictEqual([1, 2, 3, 4].map(value => ema.update(value)), [1, 1.5, 2.25, 3.125])
290
+ assert.deepStrictEqual([1, 2, 3, 4].map(value => sma.update(value)), [undefined, undefined, 2, 3])
291
+ assert.deepStrictEqual([1, 2, 3, 8].map(value => wsma.update(value)), [undefined, undefined, 2, 4])
292
+ assert.equal(ema.isStable, true)
293
+ sma.update(NaN); sma.update(5); assert.ok(Number.isNaN(sma.getResult()))
294
+ sma.update(6); assert.ok(Number.isNaN(sma.getResult()))
295
+ assert.equal(sma.update(7), 6)
296
+ const rsi = new Core.FasterRSI(3)
297
+ assert.deepStrictEqual([1, 2, 3, 4].map(value => rsi.update(value)), [undefined, undefined, undefined, 100])
298
+ const bands = new Core.FasterBollingerBands(3)
299
+ assert.deepStrictEqual([1, 2, 3].map(value => bands.update(value)), [undefined, undefined, undefined])
300
+ assert.deepStrictEqual(bands.update(4), { lower: 3 - Math.sqrt(2 / 3) * 2, middle: 3, upper: 3 + Math.sqrt(2 / 3) * 2 })
301
+ const tr = new Core.FasterTR()
302
+ assert.equal(tr.update({ high: 4, low: 1, close: 2 }), 3)
303
+ assert.equal(tr.update({ high: 8, low: 5, close: 6 }), 6)
304
+ const zero = new Core.FasterEMA(1)
305
+ assert.ok(Object.is(zero.update(-0), -0))
306
+ })
307
+ for (const test of coreCases) check(`core ${test.name}: fixed pre-change traces`, () => {
308
+ assert.equal(coreTrace(Core, test), coreGolden[test.name].trace, `${test.name}: update/readiness/result/extrema trace changed`)
309
+ })
310
+ check('core storage stays bounded and Wilder smoothing releases its seed window', () => {
311
+ for (const test of coreCases) {
312
+ const instance = test.create(Core, 32)
313
+ let warmedStorage
314
+ for (let index = 0; index < 8192; index++) {
315
+ const value = 100 + Math.sin(index / 7)
316
+ instance.update(test.candle ? { high: value + 2, low: value - 2, close: value } : value)
317
+ if (index === 511) warmedStorage = retainedStorage(instance)
318
+ }
319
+ assert.deepStrictEqual(retainedStorage(instance), warmedStorage, `${test.name}: retained storage grows with history`)
320
+ }
321
+ const wsma = new Core.FasterWSMA(32)
322
+ wsma.updates(Array.from({ length: 32 }, (_, index) => index))
323
+ assert.equal(wsma.isStable, true)
324
+ assert.deepStrictEqual(retainedStorage(wsma), { slots: 0, bytes: 0 })
325
+ const rsi = new Core.FasterRSI(32)
326
+ for (let index = 0; index < 10000; index++) rsi.update(100 + index % 17)
327
+ assert.deepStrictEqual(retainedStorage(rsi), { slots: 0, bytes: 0 })
328
+ })
329
+ check('core candle windows retain reference semantics, including reused objects', () => {
330
+ for (const test of coreCases.filter(test => test.candle)) {
331
+ assert.equal(candleMutationTrace(Core, test), coreGolden[test.name].mutation, `${test.name}: mutable candle trace changed`)
332
+ }
333
+ })
334
+ check('core batch and replacement helpers preserve readiness and extrema', () => {
335
+ for (const name of ['FasterEMA', 'FasterSMA', 'FasterWSMA']) {
336
+ assert.equal(batchReplacementTrace(Core, name), coreGolden[name].batch, `${name}: sparse batch/replacement trace changed`)
337
+ }
338
+ })
339
+ check('coverage includes every public indicator method', () => {
340
+ const lifecycle = new Set(['constructor', '_registerIndicator', 'exportConfig', 'compute', 'getData', 'getLastValues'])
341
+ const methods = Object.getOwnPropertyNames(OHLCV_INDICATORS.prototype).filter(name => !lifecycle.has(name))
342
+ assert.deepStrictEqual(indicatorCases.map(test => test.name).sort(), methods.sort())
343
+ })
344
+ for (const test of indicatorCases) for (const precision of [false, true]) for (const retLogs of [false, true]) {
345
+ check(`${test.name} individually: precision=${precision}, ${retLogs ? 'lagged/log/one-hot' : 'plain'} options`, () => {
346
+ const input = makeInput(160, precision), options = { lag: retLogs ? 2 : 0, retLogs }
347
+ const create = Ctor => test.register(new Ctor({ input, chunkProcess: 100,
348
+ config: { precision, timeZone: 'UTC', skipNull: false } }), options)
349
+ const main = create(OHLCV_INDICATORS), before = main.exportConfig()
350
+ assert.equal(before.inputParams.length, 1)
351
+ assert.equal(before.inputParams[0].key, test.name)
352
+ main.compute()
353
+ const v = main.verticalOhlcv, baseKeys = test.keys(retLogs)
354
+ const expectedKeys = test.autoLag === false ? baseKeys : [
355
+ ...baseKeys, ...baseKeys.flatMap(key => Array.from({ length: options.lag }, (_, i) => `${key}_lag_${i + 1}`))
356
+ ]
357
+ const inputKeys = new Set([...Object.keys(input[0]), 'mid_price'])
358
+ assert.deepStrictEqual(Object.keys(v).filter(key => !inputKeys.has(key)).sort(), [...expectedKeys].sort())
359
+ for (const key of expectedKeys) {
360
+ const column = v[key]
361
+ assert.equal(column.length, input.length, `${key}: column length`)
362
+ const last = column.at(-1)
363
+ if (key.startsWith('one_hot_')) {
364
+ assert.ok(last instanceof Uint8Array, `${key}: one-hot storage`)
365
+ assert.equal(last.reduce((sum, value) => sum + value, 0), 1)
366
+ } else assert.ok(Number.isFinite(last), `${key}: terminal value must be finite`)
367
+ const lagMatch = /^(.*)_lag_(\d+)$/.exec(key)
368
+ if (lagMatch) {
369
+ const source = v[lagMatch[1]], step = Number(lagMatch[2])
370
+ assert.equal(Array.isArray(column), Array.isArray(source), `${key}: lag type`)
371
+ for (let index = 0; index < input.length; index++) {
372
+ const value = index < step || source[index - step] == null
373
+ ? Array.isArray(column) ? null : NaN : source[index - step]
374
+ assert.strictEqual(column[index], value, `${key}[${index}]: lag value/identity`)
375
+ }
376
+ }
377
+ }
378
+ if (test.warmup !== undefined) for (const key of baseKeys) {
379
+ assert.equal(v[key].findIndex(Number.isFinite), test.warmup, `${key}: first valid row`)
380
+ }
381
+ if (['relativeVolume', 'volumeDelta', 'volumeOscillator'].includes(test.name)) {
382
+ for (let index = 0; index < input.length; index++) if (input[index].volume === 0) {
383
+ for (const key of baseKeys) assert.ok(Number.isNaN(v[key][index]), `${key}: zero-volume row`)
384
+ }
385
+ }
386
+ const rows = main.getData({ skipNull: false })
387
+ assert.deepStrictEqual(main.getData({ skipNull: true }), rows.slice(main.invalidValueIndex + 1))
388
+ assert.deepStrictEqual(main.getLastValues(), rows.at(-1))
389
+ assert.deepStrictEqual(main.exportConfig(), before)
390
+ const replay = new OHLCV_INDICATORS({ input, config: deepFreeze(JSON.parse(JSON.stringify(before))) })
391
+ assert.deepStrictEqual(snapshot(replay), snapshot(main))
392
+ assert.throws(() => test.register(main, options), /already computed/)
393
+ // A pre-MFI baseline still exercises every previously supported method.
394
+ if (Baseline && (test.name !== 'mfi' || typeof Baseline.prototype.mfi === 'function')) {
395
+ assert.deepStrictEqual(snapshot(main), snapshot(create(Baseline).compute()))
396
+ }
397
+ })
398
+ }
399
+ check('EMA/SMA known values and target-relative logs preserve warm-up and raw arithmetic', () => {
400
+ const prices = [10, 20, 30, 40, 20, 10]
401
+ const expected = { ema: [NaN, NaN, 22.5, 31.25, 25.625, 17.8125], sma: [NaN, NaN, 20, 30, 30, 70 / 3] }
402
+ for (const precision of [false, true]) for (const method of ['ema', 'sma']) {
403
+ const main = new OHLCV_INDICATORS({ input: mfiInput(prices, undefined, precision), config: { precision } })
404
+ [method](3)[method](3, { retLogs: true }).compute()
405
+ const key = `${method}_3`, v = main.verticalOhlcv
406
+ const scale = precision ? main.precisionMultiplier : 1
407
+ const raw = method === 'ema' ? expected.ema.map(value => value * scale)
408
+ : [NaN, NaN, ...[60, 90, 90, 70].map(sum => sum * scale / 3)]
409
+ assert.deepStrictEqual(Array.from(v[key]), raw)
410
+ assert.deepStrictEqual(Array.from(v[`ret_log_${key}`]), prices.map((price, index) => Math.log(price * scale / raw[index])))
411
+ assert.equal(v[`ret_log_${key}`].findIndex(Number.isFinite), 2)
412
+ }
413
+ })
414
+ check('new log modes select one output with independent raw/log state, target metadata, lags and frozen replay', () => {
415
+ const registrations = [
416
+ ...['ema', 'sma'].flatMap(method => ['close', 'open', 'volume'].map(target => ({
417
+ method, options: { target }, key: `${method}_3${target === 'close' ? '' : `_${target}`}`
418
+ }))),
419
+ { method: 'relativeVolume', options: {}, key: 'relative_volume_3' }
420
+ ]
421
+ for (const precision of [false, true]) for (const { method, options, key } of registrations) {
422
+ const input = makeInput(160, precision), config = { precision, timeZone: 'UTC', skipNull: false }
423
+ const main = new OHLCV_INDICATORS({ input, config })
424
+ [method](3, { ...options, lag: 2 })[method](3, { ...options, lag: 2, retLogs: true })
425
+ const before = main.exportConfig(), saved = deepFreeze(JSON.parse(JSON.stringify(before)))
426
+ main.compute()
427
+ for (const retLogs of [false, true]) {
428
+ const separate = new OHLCV_INDICATORS({ input, config })[method](3, { ...options, lag: 2, retLogs }).compute()
429
+ const selected = `${retLogs ? 'ret_log_' : ''}${key}`, absent = `${retLogs ? '' : 'ret_log_'}${key}`
430
+ for (const suffix of ['', '_lag_1', '_lag_2']) {
431
+ assert.ok(!(absent + suffix in separate.verticalOhlcv))
432
+ assert.deepStrictEqual(main.verticalOhlcv[selected + suffix], separate.verticalOhlcv[selected + suffix])
433
+ assert.equal(main.priceBased.has(selected + suffix), !retLogs && ['close', 'open'].includes(options.target))
434
+ }
435
+ if (method !== 'relativeVolume') for (let index = 0; index < input.length; index++) {
436
+ const value = main.verticalOhlcv[options.target][index], average = main.verticalOhlcv[key][index]
437
+ const ratio = value / average
438
+ const expected = value > 0 && average > 0 && Number.isFinite(ratio) && ratio > 0 ? Math.log(ratio) : NaN
439
+ assert.strictEqual(main.verticalOhlcv[`ret_log_${key}`][index], expected)
440
+ }
441
+ const rows = separate.getData({ skipNull: false })
442
+ if (retLogs) for (let index = 0; index < input.length; index++) {
443
+ assert.strictEqual(rows[index][selected], separate.verticalOhlcv[selected][index])
444
+ }
445
+ }
446
+ assert.deepStrictEqual(main.exportConfig(), before)
447
+ for (let replay = 0; replay < 2; replay++) {
448
+ assert.deepStrictEqual(snapshot(new OHLCV_INDICATORS({ input, config: saved })), snapshot(main))
449
+ }
450
+ }
451
+ })
452
+ check('relativeVolume logs use the prior completed nonzero-volume window and reject negative operands', () => {
453
+ for (const precision of [false, true]) {
454
+ const volumes = [0, 10, 0, 20, 30, 0, 60, 30]
455
+ const input = mfiInput(volumes.map(() => 10.25), volumes, precision)
456
+ const main = new OHLCV_INDICATORS({ input, config: { precision } })
457
+ .relativeVolume(2, { lag: 1 }).relativeVolume(2, { retLogs: true, lag: 1 }).compute()
458
+ const expected = [NaN, NaN, NaN, NaN, 2, NaN, 2.4, 2 / 3]
459
+ const logged = expected.map(Math.log)
460
+ assert.deepStrictEqual(Array.from(main.verticalOhlcv.relative_volume_2), expected)
461
+ assert.deepStrictEqual(Array.from(main.verticalOhlcv.ret_log_relative_volume_2), logged)
462
+ assert.deepStrictEqual(Array.from(main.verticalOhlcv.ret_log_relative_volume_2_lag_1), [NaN, ...logged.slice(0, -1)])
463
+ const negativeVolumes = [10, -100, -100, 10, 10, 10, 10]
464
+ const negative = new OHLCV_INDICATORS({ input: mfiInput(negativeVolumes.map(() => 10.25), negativeVolumes, precision), config: { precision } })
465
+ .relativeVolume(2).relativeVolume(2, { retLogs: true }).compute()
466
+ assert.equal(negative.verticalOhlcv.relative_volume_2[2], -100 / -45)
467
+ assert.deepStrictEqual(Array.from(negative.verticalOhlcv.ret_log_relative_volume_2), [NaN, NaN, NaN, NaN, NaN, 0, 0])
468
+ }
469
+ })
470
+ check('new moving-average logs leave invalid, nonpositive and unrepresentable ratios as NaN', () => {
471
+ for (const precision of [false, true]) for (const method of ['ema', 'sma']) {
472
+ for (const invalid of [0, -1000, NaN, Infinity, -Infinity]) {
473
+ const input = makeInput(12, precision)
474
+ input.forEach((row, index) => { row.signal = index === 4 ? invalid : 10 })
475
+ const main = new OHLCV_INDICATORS({ input, config: { precision } })
476
+ [method](3, { target: 'signal' })[method](3, { target: 'signal', retLogs: true }).compute()
477
+ const v = main.verticalOhlcv, raw = v[`${method}_3_signal`], logged = v[`ret_log_${method}_3_signal`]
478
+ assert.ok(Number.isNaN(logged[4]))
479
+ for (let index = 0; index < input.length; index++) {
480
+ const value = v.signal[index], ratio = value / raw[index]
481
+ const expected = value > 0 && raw[index] > 0 && Number.isFinite(ratio) && ratio > 0 ? Math.log(ratio) : NaN
482
+ assert.strictEqual(logged[index], expected, `${method}: ${invalid} at index ${index}`)
483
+ }
484
+ }
485
+ }
486
+ // The core average can remain finite while a positive ratio underflows.
487
+ for (const method of ['ema', 'sma']) {
488
+ const input = makeInput(5)
489
+ input.forEach((row, index) => { row.signal = index < 3 ? 1e200 : Number.MIN_VALUE })
490
+ const main = new OHLCV_INDICATORS({ input })[method](3, { target: 'signal', retLogs: true }).compute()
491
+ assert.ok(Number.isNaN(main.verticalOhlcv[`ret_log_${method}_3_signal`][3]))
492
+ }
493
+ })
494
+ check('new log options validate booleans, reject duplicates and preserve raw defaults in stored configs', () => {
495
+ const input = makeInput(120)
496
+ for (const method of ['ema', 'sma', 'relativeVolume']) {
497
+ for (const retLogs of [null, 0, 1, 'true', [], {}]) {
498
+ assert.throws(() => new OHLCV_INDICATORS({ input })[method](3, { retLogs }), /options.retLogs/)
499
+ }
500
+ for (const retLogs of [false, true]) {
501
+ assert.throws(() => new OHLCV_INDICATORS({ input })[method](3, { retLogs })[method](3, { retLogs }).compute(), /already|duplicate/i)
502
+ }
503
+ const defaults = new OHLCV_INDICATORS({ input, config: { skipNull: false } })[method](3, { lag: 1 })
504
+ const saved = defaults.exportConfig()
505
+ assert.equal(saved.inputParams[0].params.at(-1).retLogs, false)
506
+ delete saved.inputParams[0].params.at(-1).retLogs
507
+ deepFreeze(saved)
508
+ defaults.compute()
509
+ const restored = new OHLCV_INDICATORS({ input, config: saved })
510
+ assert.deepStrictEqual(restored.verticalOhlcv, defaults.verticalOhlcv)
511
+ assert.deepStrictEqual(restored.priceBased, defaults.priceBased)
512
+ assert.deepStrictEqual(restored.getData(), defaults.getData())
513
+ assert.deepStrictEqual(restored.exportConfig(), saved)
514
+ }
515
+ })
516
+ check('channel logs match independent formulas, preserve raw windows and support both registration orders', () => {
517
+ const cases = [
518
+ { method: 'bollingerBands', prefix: 'bollinger_bands', args: [5, 2], rawParts: ['upper', 'middle', 'lower'], warmup: 5 },
519
+ { method: 'donchianChannels', prefix: 'donchian_channel', args: [5, 2], rawParts: ['upper', 'basis', 'lower'], warmup: 6 }
520
+ ]
521
+ for (const precision of [false, true]) for (const test of cases) for (const reverse of [false, true]) {
522
+ const input = makeInput(120, precision), config = { precision, skipNull: false, timeZone: 'UTC' }
523
+ const main = new OHLCV_INDICATORS({ input, config })
524
+ for (const retLogs of reverse ? [true, false] : [false, true]) main[test.method](...test.args, { retLogs, lag: 2 })
525
+ const before = main.exportConfig(), saved = deepFreeze(JSON.parse(JSON.stringify(before)))
526
+ main.compute()
527
+ const v = main.verticalOhlcv, suffix = `_${test.args.join('_')}`
528
+ const raw = test.method === 'bollingerBands' ? referenceBollinger(v.close, ...test.args)
529
+ : Array.from(v.close, (_, index) => {
530
+ const end = index - test.args[1], start = end - test.args[0] + 1
531
+ if (start < 0) return { upper: NaN, basis: NaN, lower: NaN }
532
+ const upper = Math.max(...v.high.slice(start, end + 1)), lower = Math.min(...v.low.slice(start, end + 1))
533
+ return { upper, basis: (upper + lower) / 2, lower }
534
+ })
535
+ for (const part of test.rawParts) {
536
+ assert.deepStrictEqual(Array.from(v[`${test.prefix}_${part}${suffix}`]), raw.map(row => row[part]))
537
+ }
538
+ for (const part of ['width', 'position']) {
539
+ const key = `ret_log_${test.prefix}_${part}${suffix}`
540
+ assert.equal(v[key].findIndex(Number.isFinite), test.warmup)
541
+ for (let index = 0; index < input.length; index++) {
542
+ const expected = referenceChannelLogs(raw[index].upper, raw[index].lower, v.close[index])
543
+ assertNear(v[key][index], expected[part], `${key}[${index}]`)
544
+ }
545
+ }
546
+ for (const retLogs of [false, true]) {
547
+ const separate = new OHLCV_INDICATORS({ input, config: { ...config, useFullNames: true } })
548
+ [test.method](...test.args, { retLogs, lag: 2 }).compute()
549
+ const parts = retLogs ? ['width', 'position'] : test.rawParts
550
+ const keys = parts.map(part => `${retLogs ? 'ret_log_' : ''}${test.prefix}_${part}${suffix}`)
551
+ const expectedKeys = keys.flatMap(key => [key, `${key}_lag_1`, `${key}_lag_2`])
552
+ assert.deepStrictEqual(Object.keys(separate.verticalOhlcv).filter(key => key.includes(test.prefix)).sort(), expectedKeys.sort())
553
+ const rows = separate.getData({ skipNull: false })
554
+ for (const key of keys) for (let lag = 0; lag <= 2; lag++) {
555
+ const selected = key + (lag ? `_lag_${lag}` : '')
556
+ assert.deepStrictEqual(v[selected], separate.verticalOhlcv[selected])
557
+ assert.equal(main.priceBased.has(selected), !retLogs)
558
+ for (let index = 0; index < input.length; index++) {
559
+ assert.strictEqual(v[selected][index], index < lag ? NaN : v[key][index - lag])
560
+ if (retLogs) assert.strictEqual(rows[index][selected], separate.verticalOhlcv[selected][index])
561
+ }
562
+ }
563
+ }
564
+ assert.deepStrictEqual(main.exportConfig(), before)
565
+ const rows = main.getData({ skipNull: false })
566
+ assert.deepStrictEqual(main.getData({ skipNull: true }), rows.slice(main.invalidValueIndex + 1))
567
+ assert.deepStrictEqual(main.getLastValues(), rows.at(-1))
568
+ for (let replay = 0; replay < 2; replay++) {
569
+ assert.deepStrictEqual(snapshot(new OHLCV_INDICATORS({ input, config: saved })), snapshot(main))
570
+ }
571
+ }
572
+ })
573
+ check('channel log names separate multiple configurations and explicit lags replay unchanged', () => {
574
+ const input = makeInput(120)
575
+ for (const [method, prefix] of [['bollingerBands', 'bollinger_bands'], ['donchianChannels', 'donchian_channel']]) {
576
+ const main = new OHLCV_INDICATORS({ input, config: { skipNull: false } })
577
+ [method](3, 1, { retLogs: true })[method](7, 2, { retLogs: true })
578
+ .lag([`ret_log_${prefix}_width_3_1`, `ret_log_${prefix}_position_7_2`], 2)
579
+ const saved = deepFreeze(JSON.parse(JSON.stringify(main.exportConfig())))
580
+ main.compute()
581
+ for (const args of [[3, 1], [7, 2]]) {
582
+ const separate = new OHLCV_INDICATORS({ input, config: { useFullNames: true } })
583
+ [method](...args, { retLogs: true }).compute()
584
+ for (const part of ['width', 'position']) {
585
+ const key = `ret_log_${prefix}_${part}_${args.join('_')}`
586
+ assert.deepStrictEqual(main.verticalOhlcv[key], separate.verticalOhlcv[key])
587
+ }
588
+ }
589
+ assert.deepStrictEqual(snapshot(new OHLCV_INDICATORS({ input, config: saved })), snapshot(main))
590
+ }
591
+ })
592
+ check('flat channel logs retain zero width, undefined position and lag warm-up', () => {
593
+ for (const precision of [false, true]) for (const size of [1, 3]) {
594
+ const input = mfiInput(Array(12).fill(10), undefined, precision)
595
+ for (const [method, prefix, parameter, warmup] of [
596
+ ['bollingerBands', 'bollinger_bands', 2, size],
597
+ ['donchianChannels', 'donchian_channel', 2, size + 1]
598
+ ]) {
599
+ const main = new OHLCV_INDICATORS({ input, config: { precision } })
600
+ [method](size, parameter, { retLogs: true, lag: 1 }).compute()
601
+ const v = main.verticalOhlcv, width = `ret_log_${prefix}_width`, position = `ret_log_${prefix}_position`
602
+ assert.deepStrictEqual(Array.from(v[width]), input.map((_, index) => index < warmup ? NaN : 0))
603
+ assert.deepStrictEqual(Array.from(v[`${width}_lag_1`]), [NaN, ...v[width].slice(0, -1)])
604
+ assert.ok(v[position].every(Number.isNaN))
605
+ assert.ok(v[`${position}_lag_1`].every(Number.isNaN))
606
+ assert.equal(main.invalidValueIndex, input.length - 1)
607
+ assert.deepStrictEqual(main.getData({ skipNull: true }), [])
608
+ }
609
+ }
610
+ const main = new OHLCV_INDICATORS({ input: mfiInput([10, 20, 30]) })
611
+ .donchianChannels(1, 1, { retLogs: true }).compute()
612
+ assert.deepStrictEqual(Array.from(main.verticalOhlcv.ret_log_donchian_channel_width), [NaN, 0, 0])
613
+ assert.ok(main.verticalOhlcv.ret_log_donchian_channel_position.every(Number.isNaN))
614
+ })
615
+ check('Bollinger logs reject zero/negative lower bands and recover without altering raw bands', () => {
616
+ for (const prices of [[1, 1, 3, 4, 5, 6], [1, 1, 10, 10, 11, 12]]) for (const precision of [false, true]) {
617
+ const input = mfiInput(prices, undefined, precision)
618
+ const main = new OHLCV_INDICATORS({ input, config: { precision } })
619
+ .bollingerBands(2, 2).bollingerBands(2, 2, { retLogs: true }).compute()
620
+ const v = main.verticalOhlcv
621
+ assert.ok(v.bollinger_bands_lower_2_2[2] <= 0)
622
+ assert.ok(Number.isNaN(v.ret_log_bollinger_bands_width_2_2[2]))
623
+ assert.ok(Number.isNaN(v.ret_log_bollinger_bands_position_2_2[2]))
624
+ assert.ok(Number.isFinite(v.ret_log_bollinger_bands_width_2_2[4]))
625
+ assert.ok(Number.isFinite(v.ret_log_bollinger_bands_position_2_2[4]))
626
+ const raw = referenceBollinger(v.close, 2, 2)
627
+ assert.deepStrictEqual(Array.from(v.bollinger_bands_lower_2_2), raw.map(row => row.lower))
628
+ }
629
+ })
630
+ check('Donchian log position uses current close against offset bounds and does not clip breakouts', () => {
631
+ const input = [100, 110, 90, 150, 60].map(close => ({ open: close, high: 110, low: 90, close, volume: 1 }))
632
+ const main = new OHLCV_INDICATORS({ input }).donchianChannels(1, 1, { retLogs: true }).compute()
633
+ const v = main.verticalOhlcv
634
+ assert.ok(Number.isNaN(v.ret_log_donchian_channel_position[0]))
635
+ assertNear(v.ret_log_donchian_channel_position[1], 1, 'upper boundary')
636
+ assertNear(v.ret_log_donchian_channel_position[2], -1, 'lower boundary')
637
+ assert.ok(v.ret_log_donchian_channel_position[3] > 1)
638
+ assert.ok(v.ret_log_donchian_channel_position[4] < -1)
639
+ for (let index = 1; index < input.length; index++) {
640
+ const expected = referenceChannelLogs(110, 90, input[index].close)
641
+ assertNear(v.ret_log_donchian_channel_width[index], expected.width, 'offset width')
642
+ assertNear(v.ret_log_donchian_channel_position[index], expected.position, 'offset position')
643
+ }
644
+ })
645
+ check('channel widths and positions are invariant to price scaling and precision conversion', () => {
646
+ const prices = [10.25, 11.5, 9.75, 12.25, 11.75, 10.5, 13.25, 12.5, 11.25, 14.5]
647
+ for (const [method, prefix] of [['bollingerBands', 'bollinger_bands'], ['donchianChannels', 'donchian_channel']]) {
648
+ const base = new OHLCV_INDICATORS({ input: mfiInput(prices) })[method](3, 2, { retLogs: true }).compute()
649
+ for (const precision of [false, true]) for (const scale of [1, 10, 1000]) {
650
+ const input = mfiInput(prices.map(value => value * scale), undefined, precision)
651
+ const main = new OHLCV_INDICATORS({ input, config: { precision } })[method](3, 2, { retLogs: true }).compute()
652
+ for (const part of ['width', 'position']) {
653
+ const key = `ret_log_${prefix}_${part}`
654
+ for (let index = 0; index < prices.length; index++) {
655
+ assertNear(main.verticalOhlcv[key][index], base.verticalOhlcv[key][index], `${key}: precision=${precision}, scale=${scale}`)
656
+ }
657
+ }
658
+ }
659
+ }
660
+ })
661
+ check('channel logs reject invalid bounds/ratios but preserve width when only current close is invalid', () => {
662
+ const triples = [
663
+ [110, 90, 100], [110, 0, 100], [110, -1, 100], [NaN, 90, 100],
664
+ [Infinity, 90, 100], [90, 110, 100], [110, 90, 0], [110, 90, -1],
665
+ [110, 90, NaN], [110, 90, Infinity], [1e308, 1e-308, 100],
666
+ [110, 90, Number.MIN_VALUE], [110, 90, 100]
667
+ ]
668
+ const input = triples.map(([high, low, close]) => ({ open: 100, high, low, close, volume: 1 }))
669
+ const main = new OHLCV_INDICATORS({ input, config: { skipNull: false } })
670
+ .donchianChannels(1, 0, { retLogs: true, lag: 1 }).compute()
671
+ const v = main.verticalOhlcv
672
+ for (let index = 0; index < input.length; index++) {
673
+ const expected = referenceChannelLogs(...triples[index])
674
+ assertNear(v.ret_log_donchian_channel_width[index], expected.width, `invalid bounds width[${index}]`)
675
+ assertNear(v.ret_log_donchian_channel_position[index], expected.position, `invalid bounds position[${index}]`)
676
+ }
677
+ for (const index of [6, 7, 8, 9, 11]) {
678
+ assert.ok(Number.isFinite(v.ret_log_donchian_channel_width[index]))
679
+ assert.ok(Number.isNaN(v.ret_log_donchian_channel_position[index]))
680
+ }
681
+ assert.ok(Number.isFinite(v.ret_log_donchian_channel_position.at(-1)))
682
+ assert.ok(Number.isNaN(v.ret_log_donchian_channel_position_lag_1.at(-1)))
683
+ })
684
+ check('channel log options validate booleans, reject duplicates and preserve legacy raw config defaults', () => {
685
+ const input = makeInput(120)
686
+ for (const method of ['bollingerBands', 'donchianChannels']) {
687
+ for (const retLogs of [null, 0, 1, 'true', [], {}]) {
688
+ assert.throws(() => new OHLCV_INDICATORS({ input })[method](3, 2, { retLogs }), /retLogs/)
689
+ }
690
+ for (const retLogs of [false, true]) {
691
+ assert.throws(() => new OHLCV_INDICATORS({ input })[method](3, 2, { retLogs })[method](3, 2, { retLogs }).compute(), /already|duplicate/i)
692
+ }
693
+ const main = new OHLCV_INDICATORS({ input, config: { skipNull: false } })[method](3, 2, { lag: 1 })
694
+ const saved = main.exportConfig()
695
+ assert.equal(saved.inputParams[0].params.at(-1).retLogs, false)
696
+ delete saved.inputParams[0].params.at(-1).retLogs
697
+ deepFreeze(saved)
698
+ main.compute()
699
+ const replay = new OHLCV_INDICATORS({ input, config: saved })
700
+ assert.deepStrictEqual(replay.verticalOhlcv, main.verticalOhlcv)
701
+ assert.deepStrictEqual(replay.priceBased, main.priceBased)
702
+ assert.deepStrictEqual(replay.getData(), main.getData())
703
+ assert.deepStrictEqual(replay.exportConfig(), saved)
704
+ }
705
+ })
706
+ check('ATR logs and their lags remain dimensionless during precision output conversion', () => {
707
+ for (const precision of [false, true]) {
708
+ const input = makeInput(120, precision)
709
+ const main = new OHLCV_INDICATORS({ input, config: { precision, skipNull: false } })
710
+ .atr(7, { lag: 2 }).atr(7, { lag: 2, retLogs: true })
711
+ const saved = deepFreeze(main.exportConfig())
712
+ main.compute()
713
+ const v = main.verticalOhlcv, rows = main.getData()
714
+ for (const suffix of ['', '_lag_1', '_lag_2']) {
715
+ assert.ok(main.priceBased.has(`atr_7${suffix}`))
716
+ assert.ok(!main.priceBased.has(`ret_log_atr_7${suffix}`))
717
+ for (let index = 0; index < input.length; index++) {
718
+ assert.strictEqual(rows[index][`ret_log_atr_7${suffix}`], v[`ret_log_atr_7${suffix}`][index])
719
+ }
720
+ }
721
+ for (let index = 0; index < input.length; index++) {
722
+ assert.strictEqual(v.ret_log_atr_7[index], Math.log(v.atr_7[index] / v.close[index]))
723
+ const expected = Number.isNaN(v.atr_7[index]) ? NaN : precision
724
+ ? revertPrecisionAsNumber(v.atr_7[index], main.precisionMultiplier) : v.atr_7[index]
725
+ assert.strictEqual(rows[index].atr_7, expected)
726
+ }
727
+ assert.deepStrictEqual(main.exportConfig(), saved)
728
+ assert.deepStrictEqual(snapshot(new OHLCV_INDICATORS({ input, config: saved })), snapshot(main))
729
+ }
730
+ })
731
+ check('volumeOscillator raw/log variants have independent state, selected lags and config replay', () => {
732
+ for (const precision of [false, true]) {
733
+ const input = makeInput(160, precision)
734
+ const config = { precision, timeZone: 'UTC', skipNull: false }
735
+ const main = new OHLCV_INDICATORS({ input, config })
736
+ .volumeOscillator(3, 8, { lag: 2 })
737
+ .volumeOscillator(3, 8, { retLogs: true, lag: 2 })
738
+ const before = main.exportConfig()
739
+ main.compute()
740
+ for (const retLogs of [false, true]) {
741
+ const separate = new OHLCV_INDICATORS({ input, config })
742
+ .volumeOscillator(3, 8, { retLogs, lag: 2 }).compute()
743
+ const key = `${retLogs ? 'ret_log_' : ''}volume_oscillator_3_8`
744
+ const otherKey = `${retLogs ? '' : 'ret_log_'}volume_oscillator_3_8`
745
+ for (const suffix of ['', '_lag_1', '_lag_2']) {
746
+ assert.ok(!(otherKey + suffix in separate.verticalOhlcv))
747
+ assert.deepStrictEqual(main.verticalOhlcv[key + suffix], separate.verticalOhlcv[key + suffix])
748
+ assert.ok(!main.priceBased.has(key + suffix))
749
+ }
750
+ }
751
+ assert.deepStrictEqual(main.exportConfig(), before)
752
+ const saved = deepFreeze(JSON.parse(JSON.stringify(before)))
753
+ for (let replay = 0; replay < 2; replay++) {
754
+ assert.deepStrictEqual(snapshot(new OHLCV_INDICATORS({ input, config: saved })), snapshot(main))
755
+ }
756
+ }
757
+ })
758
+ check('volumeOscillator explicit lags require their selected source column', () => {
759
+ const input = makeInput(160)
760
+ const missingRaw = new OHLCV_INDICATORS({ input })
761
+ .volumeOscillator(3, 8, { retLogs: true }).lag(['volume_oscillator_3_8'], 1)
762
+ assert.throws(() => new OHLCV_INDICATORS({ input, config: missingRaw.exportConfig() }), /volume_oscillator_3_8.*not found/)
763
+ const main = new OHLCV_INDICATORS({ input })
764
+ .volumeOscillator(3, 8, { retLogs: true })
765
+ .volumeOscillator(3, 8)
766
+ .lag(['volume_oscillator_3_8', 'ret_log_volume_oscillator_3_8'], 1)
767
+ const saved = main.exportConfig()
768
+ assert.deepStrictEqual(snapshot(main.compute()), snapshot(new OHLCV_INDICATORS({ input, config: saved })))
769
+ })
770
+ for (const precision of [false, true]) for (const retLogs of [false, true]) {
771
+ check(`all indicators: precision=${precision}, retLogs=${retLogs}, exact replay/chunks`, () => {
772
+ const input = makeInput(320, precision), options = { precision, retLogs }
773
+ const main = configure(OHLCV_INDICATORS, input, options), before = main.exportConfig()
774
+ const frozen = deepFreeze(JSON.parse(JSON.stringify(before)))
775
+ const expected = snapshot(main.compute())
776
+ assert.deepStrictEqual(main.exportConfig(), before)
777
+ assert.equal(expected.rows.length, input.length)
778
+ assert.ok(expected.validRows.length > 0 && expected.validRows.length < input.length)
779
+ assert.deepStrictEqual(Object.keys(expected.rows[0]), expected.keys.filter(key => !main.verticalOhlcvTempCols.has(key)))
780
+ assert.ok(main.executionParams.length > before.inputParams.length)
781
+ assert.ok(Object.isFrozen(main.inputParams[0].params))
782
+ assert.throws(() => main.ema(3), /already computed/)
783
+ for (let replay = 0; replay < 2; replay++) {
784
+ const restored = new OHLCV_INDICATORS({ input, config: frozen, chunkProcess: 101 })
785
+ assert.deepStrictEqual(snapshot(restored), expected)
786
+ assert.deepStrictEqual(restored.exportConfig(), before)
787
+ }
788
+ assert.deepStrictEqual(snapshot(configure(OHLCV_INDICATORS, input, { ...options, chunkProcess: 2000 }).compute()), expected)
789
+ const exported = main.exportConfig()
790
+ exported.inputParams[0].params[1] = 99
791
+ exported.inputParams.find(job => job.key === 'crossPairs').params[0][0].fast = 'high'
792
+ exported.inputParams.find(job => job.key === 'lag').params[0][0] = 'open'
793
+ assert.deepStrictEqual(main.exportConfig(), before)
794
+ if (Baseline) {
795
+ const includeMfi = typeof Baseline.prototype.mfi === 'function'
796
+ const compatibleExpected = includeMfi ? expected : snapshot(configure(OHLCV_INDICATORS, input, { ...options, includeMfi: false }).compute())
797
+ assert.deepStrictEqual(compatibleExpected, snapshot(configure(Baseline, input, { ...options, includeMfi }).compute()))
798
+ }
799
+ })
800
+ }
801
+ for (const pattern of ['increasing', 'decreasing', 'equal', 'mixed-invalid']) for (const precision of [false, true]) {
802
+ check(`Donchian reference: ${pattern}, precision=${precision}`, () => {
803
+ const input = makeInput(257, precision)
804
+ input.forEach((row, index) => {
805
+ const value = pattern === 'increasing' ? 100 + index : pattern === 'decreasing' ? 400 - index : pattern === 'equal' ? 200 : 200 + index % 7
806
+ row.high = precision ? (value + 3).toFixed(4) : value + 3
807
+ row.low = precision ? (value - 3).toFixed(4) : value - 3
808
+ })
809
+ if (pattern === 'mixed-invalid') {
810
+ delete input[11].high; delete input[12].low
811
+ input[35].high = precision ? 'NaN' : NaN; input[37].low = precision ? 'NaN' : NaN
812
+ }
813
+ const settings = [[1, 0], [2, 1], [17, 5], [128, 0], [256, 1], [257, 257]]
814
+ const create = Ctor => {
815
+ const main = new Ctor({ input, chunkProcess: 100, config: { precision, timeZone: 'UTC' } })
816
+ settings.forEach(([size, offset]) => main.donchianChannels(size, offset, { lag: 1 }))
817
+ return main.compute()
818
+ }
819
+ const main = create(OHLCV_INDICATORS)
820
+ for (const [size, offset] of settings) {
821
+ for (const [part, values] of Object.entries(referenceDonchian(main.verticalOhlcv.high, main.verticalOhlcv.low, size, offset))) {
822
+ assert.deepStrictEqual(Array.from(main.verticalOhlcv[`donchian_channel_${part}_${size}_${offset}`]), values)
823
+ }
824
+ }
825
+ if (Baseline) assert.deepStrictEqual(snapshot(main), snapshot(create(Baseline)))
826
+ })
827
+ }
828
+ check('input formatters: dates, numeric strings, passthrough and undefined values', () => {
829
+ for (const kind of ['iso', 'seconds', 'object', 'milliseconds']) {
830
+ const input = makeInput(120)
831
+ input.forEach(row => {
832
+ const date = new Date(row.date)
833
+ if (kind === 'seconds') row.date = date.getTime() / 1000
834
+ if (kind === 'object') row.date = date
835
+ if (kind === 'milliseconds') row.date = date.getTime()
836
+ row.extra = String(row.extra); row.payload = { note: row.note }
837
+ })
838
+ delete input[4].extra; delete input[5].note; delete input[6].high; input[7].low = NaN
839
+ const create = Ctor => new Ctor({ input, config: { timeZone: 'UTC' } }).lag(['extra', 'note'], 2).compute()
840
+ const main = create(OHLCV_INDICATORS)
841
+ assert.equal(main.verticalOhlcv.extra[3], 0.3)
842
+ assert.equal(main.verticalOhlcv.note[3], 'row-3'); assert.equal(main.verticalOhlcv.flag[3], false)
843
+ assert.strictEqual(main.verticalOhlcv.payload[3], input[3].payload)
844
+ assert.ok(Number.isNaN(main.verticalOhlcv.high[6])); assert.ok(Number.isNaN(main.verticalOhlcv.low[7]))
845
+ // Preserve the existing unrecognized dateMillseconds formatter behavior.
846
+ if (kind === 'milliseconds') {
847
+ assert.equal(main.verticalOhlcv.date[0], input[0].date)
848
+ assert.throws(() => main.getData(), TypeError)
849
+ if (Baseline) assert.deepStrictEqual(main.verticalOhlcv, create(Baseline).verticalOhlcv)
850
+ } else if (Baseline) assert.deepStrictEqual(snapshot(main), snapshot(create(Baseline)))
851
+ }
852
+ })
853
+ check('lag types, missing values, nested ordering and source object identity', () => {
854
+ const input = makeInput(120)
855
+ input.forEach((row, index) => { row.vector = [index, index + 1] })
856
+ delete input[4].extra; input[5].vector = null
857
+ const v = new OHLCV_INDICATORS({ input }).lag(['extra', 'vector'], 2).lag(['extra_lag_1'], 1).compute().verticalOhlcv
858
+ assert.ok(v.extra_lag_1 instanceof Float64Array); assert.ok(Array.isArray(v.vector_lag_1))
859
+ assert.ok(Number.isNaN(v.extra_lag_1[0])); assert.ok(Number.isNaN(v.extra_lag_1[5]))
860
+ assert.equal(v.vector_lag_1[0], null); assert.equal(v.vector_lag_1[6], null)
861
+ assert.strictEqual(v.vector_lag_1[3], input[2].vector)
862
+ assert.deepStrictEqual(v.extra_lag_1_lag_1, v.extra_lag_2)
863
+ const nested = new OHLCV_INDICATORS({ input }).lag(['extra', 'extra_lag_1'], 2).compute().verticalOhlcv
864
+ assert.deepStrictEqual(nested.extra_lag_1_lag_1, nested.extra_lag_2)
865
+ assert.throws(() => new OHLCV_INDICATORS({ input }).lag(['missing'], 1).compute(), /not found/)
866
+ assert.throws(() => new OHLCV_INDICATORS({ input }).lag(['extra', 'extra'], 1).compute(), /already exists/)
867
+ })
868
+ check('removed custom-column APIs are absent and stored declarations are rejected', () => {
869
+ const input = makeInput(120), main = new OHLCV_INDICATORS({ input })
870
+ assert.equal('mapCols' in main, false)
871
+ assert.equal('registerMapCallback' in OHLCV_INDICATORS, false)
872
+ const config = main.exportConfig()
873
+ config.inputParams = [{ key: 'mapCols', params: [['custom'], 'default', { lag: 0, isPriceBased: false }] }]
874
+ assert.throws(() => new OHLCV_INDICATORS({ input, config }), /Unknown indicator "mapCols"/)
875
+ })
876
+ check('configuration rejects non-JSON values and runtime jobs do not share declarations', () => {
877
+ const input = makeInput(120), main = new OHLCV_INDICATORS({ input }).lag(['close', 'high'], 2)
878
+ const before = main.exportConfig()
879
+ main.compute()
880
+ const job = main.executionParams.find(job => job.key === 'lag')
881
+ job.params[0][0] = 'low'; job.params[1] = 1
882
+ assert.deepStrictEqual(main.inputParams[0].params, [['close', 'high'], 2])
883
+ assert.deepStrictEqual(main.exportConfig(), before)
884
+ const circular = {}; circular.self = circular
885
+ for (const value of [() => 1, undefined, NaN, Infinity, -Infinity, new Date(), new Map(), 1n, Symbol('invalid'), circular]) {
886
+ const config = { inputParams: [{ key: 'ema', params: ['ema', 5, { target: 'close', lag: 0, retLogs: false, extra: value }] }] }
887
+ assert.throws(() => new OHLCV_INDICATORS({ input, config }), /JSON-safe|plain objects|circular/)
888
+ }
889
+ })
890
+ check('output conversion: precision, dates, null/NaN, property order and identity', () => {
891
+ const input = makeInput(120, true)
892
+ input.forEach((row, index) => { row.vector = index === 1 ? null : [index] })
893
+ const create = Ctor => new Ctor({ input, config: { precision: true, timeZone: 'UTC', skipNull: false } })
894
+ .lag(['close'], 1).dateTime({ oneHot: true, lag: 1 }).compute()
895
+ const main = create(OHLCV_INDICATORS), expected = Baseline ? create(Baseline) : null
896
+ assert.deepStrictEqual(snapshot(new OHLCV_INDICATORS({ input, config: main.exportConfig() })), snapshot(main))
897
+ for (const dateFormat of ['milliseconds', 'seconds', 'iso', 'toISOString', 'object', 'string', 'toString', 'toUTCString']) for (const skipNull of [false, true]) {
898
+ const rows = main.getData({ dateFormat, skipNull })
899
+ if (expected) assert.deepStrictEqual(rows, expected.getData({ dateFormat, skipNull }))
900
+ if (!skipNull) {
901
+ assert.ok(Number.isNaN(rows[0].close_lag_1)); assert.equal(rows[1].vector, null)
902
+ assert.equal(rows[2].close_lag_1, Number(input[1].close))
903
+ assert.strictEqual(rows[2].vector, main.verticalOhlcv.vector[2])
904
+ assert.strictEqual(rows[2].one_hot_hour, main.verticalOhlcv.one_hot_hour[2])
905
+ assert.equal(rows[0].one_hot_hour_lag_1, null)
906
+ if (dateFormat === 'object') assert.strictEqual(rows[2].date, main.verticalOhlcv.date[2])
907
+ }
908
+ assert.deepStrictEqual(Object.keys(rows[0]), Object.keys(main.verticalOhlcv))
909
+ }
910
+ })
911
+ check('empty declarations replay and first-row validation', () => {
912
+ const input = makeInput(120), main = new OHLCV_INDICATORS({ input }).compute()
913
+ assert.deepStrictEqual(snapshot(new OHLCV_INDICATORS({ input, config: main.exportConfig() })), snapshot(main))
914
+ const invalid = makeInput(120); invalid[0].high = 0
915
+ assert.throws(() => new OHLCV_INDICATORS({ input: invalid }).donchianChannels(3).compute(), /invalid/i)
916
+ if (Baseline) assert.deepStrictEqual(snapshot(main), snapshot(new Baseline({ input }).compute()))
917
+ })
918
+ check('cached validation includes input columns and generated outputs', () => {
919
+ const input = makeInput(120)
920
+ delete input[11].high; input[77].extra = NaN
921
+ const create = Ctor => new Ctor({ input, config: { timeZone: 'UTC' } })
922
+ .ema(5, { lag: 2 }).dateTime().crossPairs([{ fast: 'close', slow: 100 }]).compute()
923
+ const main = create(OHLCV_INDICATORS)
924
+ assert.equal(main.invalidValueIndex, 77)
925
+ assert.equal(main.getData().length, 42)
926
+ if (Baseline) assert.deepStrictEqual(snapshot(main), snapshot(create(Baseline)))
927
+ const generated = new OHLCV_INDICATORS({ input: makeInput(120), config: { timeZone: 'UTC' } })
928
+ .ema(5, { lag: 2 }).dateTime({ oneHot: true, lag: 1 }).compute()
929
+ assert.equal(generated.invalidValueIndex, 5)
930
+ assert.equal(generated.getData().length, 114)
931
+ })
932
+ check('cross counters retain bounded state and independent one-hot output vectors', () => {
933
+ const input = makeInput(2048)
934
+ input.forEach((row, index) => { row.close = row.open + (index % 2 ? -1 : 1) })
935
+ const observed = []
936
+ const main = new OHLCV_INDICATORS({ input })
937
+ .crossPairs([{ fast: 'close', slow: 'open' }], { limit: 3, oneHot: true })
938
+ const push = main.pushToMain
939
+ main.pushToMain = output => {
940
+ push(output)
941
+ if (output.key === 'one_hot_close_x_open' && (output.index === 127 || output.index === input.length - 1)) {
942
+ const run = main.instances.crossPairs.close_x_open.run
943
+ observed.push(retainedStorage(run))
944
+ assert.ok(!('crossIndexes' in run), 'unused event history must not be retained')
945
+ }
946
+ }
947
+ main.compute()
948
+ assert.deepStrictEqual(observed, [{ slots: 0, bytes: 0 }, { slots: 0, bytes: 0 }])
949
+ const v = main.verticalOhlcv
950
+ assert.deepStrictEqual(Array.from(v.close_x_open), input.map((_, index) => index % 2 ? -1 : 1))
951
+ assert.notStrictEqual(v.one_hot_close_x_open[0], v.one_hot_close_x_open[2])
952
+ v.one_hot_close_x_open[0][4] = 0
953
+ assert.equal(v.one_hot_close_x_open[2][4], 1)
954
+ })
955
+ check('calendar scalar writes preserve DST, timezone boundaries and one-hot/lag identity', () => {
956
+ const input = makeInput(5)
957
+ const dates = ['2024-03-10T06:30:00Z', '2024-03-10T07:30:00Z', '2024-11-03T05:30:00Z', '2024-11-03T06:30:00Z', '2025-01-01T04:30:00Z']
958
+ input.forEach((row, index) => { row.date = dates[index] })
959
+ for (const oneHot of [false, true]) {
960
+ const create = Ctor => new Ctor({ input, config: { timeZone: 'America/New_York', skipNull: false } }).dateTime({ oneHot, lag: 1 }).compute()
961
+ const main = create(OHLCV_INDICATORS), v = main.verticalOhlcv
962
+ if (Baseline) assert.deepStrictEqual(snapshot(main), snapshot(create(Baseline)))
963
+ if (oneHot) {
964
+ assert.deepStrictEqual(v.one_hot_hour.map(vector => vector.indexOf(1)), [1, 3, 1, 1, 23])
965
+ assert.deepStrictEqual(v.one_hot_day_of_the_week.map(vector => vector.indexOf(1)), [0, 0, 0, 0, 2])
966
+ assert.notStrictEqual(v.one_hot_hour[2], v.one_hot_hour[3])
967
+ assert.strictEqual(v.one_hot_hour_lag_1[3], v.one_hot_hour[2])
968
+ assert.equal(v.one_hot_hour_lag_1[0], null)
969
+ v.one_hot_hour[2][1] = 0
970
+ assert.equal(v.one_hot_hour[3][1], 1)
971
+ } else {
972
+ assert.deepStrictEqual(Array.from(v.hour), [1, 3, 1, 1, 23])
973
+ assert.deepStrictEqual(Array.from(v.day_of_the_week), [7, 7, 7, 7, 2])
974
+ assert.equal(v.year[4], 2024); assert.equal(v.month[4], 12); assert.equal(v.day_of_the_month[4], 31)
975
+ }
976
+ }
977
+ })
978
+ check('precision metadata caching preserves truncation, unusual values and per-call scale', () => {
979
+ const additions = [
980
+ ['123.456789', 10000, 1234567], ['-0.00019', 10000, -1],
981
+ ['000123.4500', 10000, 1234500], ['12.999', 1, 12], ['-0', 10000, -0],
982
+ ['0.000000000123456789', 1e14, 12345], ['NaN', 10000, NaN], ['1e2', 10000, 1]
983
+ ]
984
+ for (const [value, multiplier, expected] of additions) assert.strictEqual(addPrecisionAsNumber(value, multiplier), expected)
985
+ const reversions = [
986
+ [1234567.999, 10000, 123.4567], [-1234567.999, 10000, -123.4567],
987
+ [2.999, 1, 2], [-2.999, 1, -2], [-0, 10000, 0], [-0.1, 10000, -0],
988
+ [12345, 1e14, 0.00000000012345], [1, 10000, 0.0001]
989
+ ]
990
+ for (const [value, multiplier, expected] of reversions) assert.strictEqual(revertPrecisionAsNumber(value, multiplier), expected)
991
+ assert.throws(() => addPrecisionAsNumber(12, 10000), /expects a string/)
992
+ for (const value of [NaN, Infinity, -Infinity, null, '123']) assert.throws(() => revertPrecisionAsNumber(value, 10000), /finite number/)
993
+ const input = makeInput(120, true)
994
+ const main = new OHLCV_INDICATORS({ input, config: { precision: true } }).compute()
995
+ const before = main.exportConfig()
996
+ for (const multiplier of [10000, 100000, 1, 10000]) {
997
+ main.precisionMultiplier = multiplier
998
+ const rows = main.getData({ skipNull: false })
999
+ assert.equal(rows[0].close, revertPrecisionAsNumber(main.verticalOhlcv.close[0], multiplier))
1000
+ }
1001
+ assert.deepStrictEqual(main.exportConfig(), before)
1002
+ })
1003
+ check('MFI known values: neutral logs, bounds, independent raw/log state and lagged replay', () => {
1004
+ for (const precision of [false, true]) {
1005
+ const input = mfiInput([10, 20, 10, 20, 10], [1, 1, 2, 1, 2], precision)
1006
+ const main = new OHLCV_INDICATORS({ input, config: { precision, useFullNames: true, timeZone: 'UTC', skipNull: false } })
1007
+ .mfi(2, { lag: 1 }).mfi(2, { retLogs: true, lag: 1 }).mfi(1)
1008
+ const before = main.exportConfig(), frozen = deepFreeze(JSON.parse(JSON.stringify(before)))
1009
+ main.compute()
1010
+ assert.deepStrictEqual(Array.from(main.verticalOhlcv.mfi_2), [NaN, NaN, 50, 50, 50])
1011
+ assert.deepStrictEqual(Array.from(main.verticalOhlcv.ret_log_mfi_2), [NaN, NaN, 0, 0, 0])
1012
+ assert.deepStrictEqual(Array.from(main.verticalOhlcv.mfi_2_lag_1), [NaN, NaN, NaN, 50, 50])
1013
+ assert.deepStrictEqual(Array.from(main.verticalOhlcv.mfi_1), [NaN, 100, 0, 100, 0])
1014
+ for (const key of ['mfi_1', 'mfi_2', 'ret_log_mfi_2', 'mfi_2_lag_1', 'ret_log_mfi_2_lag_1']) {
1015
+ assert.ok(main.verticalOhlcv[key] instanceof Float64Array)
1016
+ assert.ok(!main.priceBased.has(key), `${key} must remain dimensionless`)
1017
+ }
1018
+ assert.deepStrictEqual(main.exportConfig(), before)
1019
+ for (let replay = 0; replay < 2; replay++) {
1020
+ assert.deepStrictEqual(snapshot(new OHLCV_INDICATORS({ input, config: frozen })), snapshot(main))
1021
+ }
1022
+ }
1023
+ for (const direction of [1, -1]) {
1024
+ const input = mfiInput(Array.from({ length: 12 }, (_, index) => 20 + direction * index))
1025
+ const main = new OHLCV_INDICATORS({ input }).mfi(3).mfi(3, { retLogs: true }).compute()
1026
+ const raw = direction === 1 ? 100 : 0, logged = Math.log((raw || 0.001) / 50)
1027
+ assert.deepStrictEqual(Array.from(main.verticalOhlcv.mfi_3), [NaN, NaN, NaN, ...Array(9).fill(raw)])
1028
+ assert.deepStrictEqual(Array.from(main.verticalOhlcv.ret_log_mfi_3), [NaN, NaN, NaN, ...Array(9).fill(logged)])
1029
+ }
1030
+ })
1031
+ check('MFI ties and zero-volume candles advance the window; empty directional windows stay NaN', () => {
1032
+ const ties = new OHLCV_INDICATORS({ input: mfiInput([10, 20, 20, 10], [1, 1, 999, 2]) }).mfi(2).compute()
1033
+ assert.deepStrictEqual(Array.from(ties.verticalOhlcv.mfi_2), [NaN, NaN, 100, 0])
1034
+ const zeros = new OHLCV_INDICATORS({ input: mfiInput([10, 20, 30, 20, 10], [0, 0, 0, 1, 1]) }).mfi(2).compute()
1035
+ assert.deepStrictEqual(Array.from(zeros.verticalOhlcv.mfi_2), [NaN, NaN, NaN, 0, 0])
1036
+ const zeroWindow = new OHLCV_INDICATORS({ input: mfiInput([10, 20, 30, 40, 50], [1, 1, 0, 0, 1]) }).mfi(2).compute()
1037
+ assert.deepStrictEqual(Array.from(zeroWindow.verticalOhlcv.mfi_2), [NaN, NaN, 100, NaN, 100])
1038
+ for (const input of [mfiInput([10, 10, 10, 10, 10]), mfiInput([10, 20, 10, 20, 10], [0, 0, 0, 0, 0])]) {
1039
+ const main = new OHLCV_INDICATORS({ input }).mfi(2).mfi(2, { retLogs: true }).compute()
1040
+ assert.ok(Array.from(main.verticalOhlcv.mfi_2).every(Number.isNaN))
1041
+ assert.ok(Array.from(main.verticalOhlcv.ret_log_mfi_2).every(Number.isNaN))
1042
+ assert.deepStrictEqual(main.getData(), [])
1043
+ }
1044
+ })
1045
+ check('MFI exact independent chronological-window reference across precision, logs and ring wraps', () => {
1046
+ for (const precision of [false, true]) for (const retLogs of [false, true]) {
1047
+ for (const pattern of ['mixed', 'flat', 'invalid', 'negative-volume']) for (const size of [1, 3, 7, 128]) {
1048
+ const prices = Array.from({ length: 257 }, (_, index) => pattern === 'flat' ? 100 : 100 + (index % 23 - 11) / 8 + index / 256)
1049
+ const volumes = prices.map((_, index) => index % 17 === 0 ? 0 : 20 + index % 11)
1050
+ if (pattern === 'invalid') prices[55] = NaN
1051
+ if (pattern === 'negative-volume') volumes[55] = -10
1052
+ const main = new OHLCV_INDICATORS({ input: mfiInput(prices, volumes, precision), chunkProcess: 100, config: { precision } })
1053
+ .mfi(size, { retLogs }).compute()
1054
+ const key = `${retLogs ? 'ret_log_' : ''}mfi_${size}`
1055
+ assert.deepStrictEqual(Array.from(main.verticalOhlcv[key]), referenceMfi(main.verticalOhlcv, size, retLogs), `${key}: ${pattern}, precision=${precision}`)
1056
+ if (!retLogs) for (const value of main.verticalOhlcv[key]) {
1057
+ assert.ok(Number.isNaN(value) || (value >= 0 && value <= 100))
1058
+ }
1059
+ }
1060
+ }
1061
+ })
1062
+ check('MFI invalid samples recover only after affected flows leave the window', () => {
1063
+ const prices = Array.from({ length: 14 }, (_, index) => 10 + index)
1064
+ const invalidTypical = mfiInput(prices)
1065
+ invalidTypical[5].high = NaN
1066
+ const main = new OHLCV_INDICATORS({ input: invalidTypical }).mfi(3).compute()
1067
+ assert.equal(main.verticalOhlcv.mfi_3[4], 100)
1068
+ for (const index of [5, 6, 7, 8]) assert.ok(Number.isNaN(main.verticalOhlcv.mfi_3[index]))
1069
+ assert.equal(main.verticalOhlcv.mfi_3[9], 100)
1070
+ for (const kind of ['money-overflow', 'negative-flow']) {
1071
+ const input = mfiInput(prices)
1072
+ if (kind === 'money-overflow') Object.assign(input[5], { high: 1e300, low: 1e300, close: 1e300, volume: 1000000000 })
1073
+ else input[5].volume = -1
1074
+ const changed = new OHLCV_INDICATORS({ input }).mfi(3).compute()
1075
+ for (const index of [5, 6, 7]) assert.ok(Number.isNaN(changed.verticalOhlcv.mfi_3[index]))
1076
+ assert.ok(Number.isFinite(changed.verticalOhlcv.mfi_3[8]))
1077
+ if (kind === 'negative-flow') assert.equal(changed.verticalOhlcv.mfi_3[8], 100)
1078
+ assert.deepStrictEqual(Array.from(changed.verticalOhlcv.mfi_3), referenceMfi(changed.verticalOhlcv, 3))
1079
+ }
1080
+ })
1081
+ check('MFI argument/source validation, defaults, duplicates and computation guards', () => {
1082
+ const input = makeInput(120)
1083
+ const defaults = new OHLCV_INDICATORS({ input }).mfi()
1084
+ assert.deepStrictEqual(defaults.exportConfig().inputParams, [{ key: 'mfi', params: [14, { lag: 0, retLogs: false }] }])
1085
+ for (const size of [0, -1, 1.5, '2', 121, NaN, Infinity]) assert.throws(() => new OHLCV_INDICATORS({ input }).mfi(size))
1086
+ for (const lag of [-1, 1.5, 121, NaN, Infinity]) assert.throws(() => new OHLCV_INDICATORS({ input }).mfi(3, { lag }))
1087
+ for (const retLogs of [null, 0, 1, 'true']) assert.throws(() => new OHLCV_INDICATORS({ input }).mfi(3, { retLogs }))
1088
+ assert.throws(() => new OHLCV_INDICATORS({ input }).mfi(3).mfi(3).compute(), /already|duplicate/i)
1089
+ assert.throws(() => new OHLCV_INDICATORS({ input }).mfi(3, { retLogs: true }).mfi(3, { retLogs: true }).compute(), /already|duplicate/i)
1090
+ const computed = defaults.compute()
1091
+ assert.throws(() => computed.mfi(3), /already computed/)
1092
+ const guarded = new OHLCV_INDICATORS({ input }).mfi(3)
1093
+ const push = guarded.pushToMain
1094
+ let checked = false
1095
+ guarded.pushToMain = output => {
1096
+ if (output.index === 3) {
1097
+ assert.throws(() => guarded.mfi(3), /computation is in progress/)
1098
+ assert.throws(() => guarded.compute(), /already in progress/)
1099
+ checked = true
1100
+ }
1101
+ push(output)
1102
+ }
1103
+ guarded.compute()
1104
+ assert.equal(checked, true)
1105
+ for (const key of ['high', 'low', 'close', 'volume']) {
1106
+ const missing = makeInput(120); delete missing[0][key]
1107
+ assert.throws(() => new OHLCV_INDICATORS({ input: missing }).mfi(3).compute())
1108
+ const invalid = makeInput(120); invalid[0][key] = key === 'volume' ? -1 : 0
1109
+ assert.throws(() => new OHLCV_INDICATORS({ input: invalid }).mfi(3).compute(), /Invalid or missing/)
1110
+ }
1111
+ })
1112
+ check('MFI retains one period-sized Float64Array per independent configuration', () => {
1113
+ const sizes = [1, 7, 37], observed = []
1114
+ const main = new OHLCV_INDICATORS({ input: makeInput(8192) })
1115
+ for (const size of sizes) main.mfi(size).mfi(size, { retLogs: true })
1116
+ const push = main.pushToMain
1117
+ main.pushToMain = output => {
1118
+ push(output)
1119
+ if (output.key === 'ret_log_mfi_37' && (output.index === 255 || output.index === main.len - 1)) {
1120
+ const buffers = []
1121
+ for (const size of sizes) for (const prefix of ['', 'ret_log_']) {
1122
+ const state = main.instances[`${prefix}mfi_${size}`]
1123
+ assert.deepStrictEqual(retainedStorage(state), { slots: 0, bytes: size * 8 })
1124
+ const arrays = Object.values(state).filter(ArrayBuffer.isView)
1125
+ assert.equal(arrays.length, 1)
1126
+ assert.ok(arrays[0] instanceof Float64Array)
1127
+ assert.ok(!buffers.includes(arrays[0])); buffers.push(arrays[0])
1128
+ }
1129
+ observed.push(buffers.map(buffer => buffer.byteLength))
1130
+ }
1131
+ }
1132
+ main.compute()
1133
+ assert.deepStrictEqual(observed[0], observed[1])
1134
+ assert.equal(observed.length, 2)
1135
+ })
1136
+ console.log(`\n${passed} deterministic regression groups passed${Baseline ? '; exact baseline comparisons passed' : ''}.`)
1137
+ }
1138
+
1139
+ const benchmark = Baseline => {
1140
+ const rows = Number(option('--rows') ?? 10000), repeats = Number(option('--repeats') ?? 7)
1141
+ assert.ok(Number.isInteger(rows) && rows >= 256 && rows <= 100000, '--rows must be 256..100000')
1142
+ assert.ok(Number.isInteger(repeats) && repeats >= 3 && repeats <= 30, '--repeats must be 3..30')
1143
+ const entries = Baseline ? [['baseline', Baseline], ['current', OHLCV_INDICATORS]] : [['current', OHLCV_INDICATORS]]
1144
+ const profiles = [
1145
+ { name: 'numeric', input: makeInput(rows), create: (Ctor, input) =>
1146
+ new Ctor({ input, config: { timeZone: 'UTC' } })
1147
+ .ema(10, { lag: 3 }).sma(20, { lag: 3 }).rsi(14, { retLogs: true, lag: 2 })
1148
+ .donchianChannels(128, 0, { lag: 2 }).donchianChannels(255, 5, { lag: 1 })
1149
+ .volumeOscillator(5, 10, { retLogs: true, lag: 2 }).lag(['close', 'volume'], 3) },
1150
+ { name: 'features+precision', input: makeInput(rows, true), create: (Ctor, input) =>
1151
+ new Ctor({ input, config: { timeZone: 'America/New_York', precision: true } })
1152
+ .sma(10, { lag: 2 }).heikenAshi(3, 4, { retLogs: true, lag: 1 })
1153
+ .candleFeatures({ retLogs: true }).dateTime({ lag: 1 })
1154
+ .crossPairs([{ fast: 'close', slow: 'open' }], { limit: 5, oneHot: true })
1155
+ }
1156
+ ]
1157
+ const samples = {}
1158
+ for (const profile of profiles) {
1159
+ for (const [name] of entries) samples[`${profile.name} / ${name}`] = { compute: [], output: [] }
1160
+ for (let repetition = -2; repetition < repeats; repetition++) {
1161
+ // Alternate order to reduce warm-up/GC order bias. No timing assertions.
1162
+ for (const [name, Ctor] of repetition % 2 ? [...entries].reverse() : entries) {
1163
+ global.gc?.()
1164
+ const main = profile.create(Ctor, profile.input)
1165
+ const start = performance.now()
1166
+ main.compute()
1167
+ const computed = performance.now()
1168
+ const output = main.getData({ skipNull: false, dateFormat: 'milliseconds' })
1169
+ const converted = performance.now()
1170
+ assert.equal(output.length, rows)
1171
+ if (repetition >= 0) {
1172
+ const sample = samples[`${profile.name} / ${name}`]
1173
+ sample.compute.push(computed - start); sample.output.push(converted - computed)
1174
+ }
1175
+ }
1176
+ }
1177
+ }
1178
+ const median = values => {
1179
+ const sorted = [...values].sort((a, b) => a - b), middle = Math.floor(sorted.length / 2)
1180
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
1181
+ }
1182
+ console.log(`\nBenchmark: ${rows} rows, 2 warm-ups, ${repeats} samples; constructor excluded; median milliseconds`)
1183
+ console.table(Object.fromEntries(Object.entries(samples).map(([name, values]) => [name, {
1184
+ compute: Number(median(values.compute).toFixed(2)), getData: Number(median(values.output).toFixed(2))
1185
+ }])))
1186
+ }
1187
+
1188
+ const benchmarkCore = () => {
1189
+ const rows = Number(option('--rows') ?? 10000), repeats = Number(option('--repeats') ?? 7)
1190
+ const prices = Array.from({ length: rows }, (_, index) => 100 + Math.sin(index / 7) * 10)
1191
+ const candles = prices.map(close => ({ high: close + 2, low: close - 2, close }))
1192
+ const results = {}
1193
+ for (const test of coreCases) {
1194
+ const samples = []
1195
+ let storage
1196
+ for (let repetition = -2; repetition < repeats; repetition++) {
1197
+ const instance = test.create(Core, 32), input = test.candle ? candles : prices
1198
+ const start = performance.now()
1199
+ for (const value of input) instance.update(value)
1200
+ const elapsed = performance.now() - start
1201
+ assert.equal(instance.isStable, true)
1202
+ if (repetition >= 0) samples.push(elapsed)
1203
+ if (repetition === repeats - 1) storage = retainedStorage(instance)
1204
+ }
1205
+ const median = values => {
1206
+ const sorted = [...values].sort((a, b) => a - b), mid = Math.floor(sorted.length / 2)
1207
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
1208
+ }
1209
+ results[test.name] = {
1210
+ 'core ms': Number(median(samples).toFixed(3)),
1211
+ 'core array slots': storage.slots,
1212
+ 'core typed bytes': storage.bytes
1213
+ }
1214
+ }
1215
+ console.log(`\nCore benchmark: ${rows} updates, period 32, 2 warm-ups, ${repeats} samples; median milliseconds`)
1216
+ console.log('Storage counts describe retained arrays only, not total heap or peak/transient allocations.')
1217
+ console.table(results)
1218
+ }
1219
+
1220
+ if (args.includes('--live')) {
1221
+ const { getNasdaqOHLCV } = await import('./utilities/fetchNasdaq.js')
1222
+ const input = await getNasdaqOHLCV({ symbol: 'TQQQ', interval: '1d', type: 'index', limit: 1000 })
1223
+ console.log(new OHLCV_INDICATORS({ input, config: { precision: false, useFullNames: false } })
1224
+ .volumeOscillator(5, 10, { retLogs: true }).ema(10).ema(20).getLastValues())
1225
+ } else {
1226
+ const path = option('--baseline')
1227
+ const Baseline = path ? (await import(pathToFileURL(resolve(path)).href)).default : null
1228
+ runRegressions(Baseline)
1229
+ if (args.includes('--benchmark')) { benchmark(Baseline); benchmarkCore() }
1230
+ }