ohlcv-indicators 3.5.79 → 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.
package/test/test.js CHANGED
@@ -2,9 +2,10 @@ import assert from 'node:assert/strict'
2
2
  import { performance } from 'node:perf_hooks'
3
3
  import { resolve } from 'node:path'
4
4
  import { pathToFileURL } from 'node:url'
5
+ import { createHash } from 'node:crypto'
5
6
  import OHLCV_INDICATORS from '../index.js'
6
7
  import * as Core from '../src/core-indicators/index.js'
7
- import * as TradingSignals from 'trading-signals'
8
+ import { addPrecisionAsNumber, revertPrecisionAsNumber } from '../src/utilities/precisionMultiplier.js'
8
9
 
9
10
  // npm test: offline deterministic regressions.
10
11
  // npm test -- --baseline /path/to/pre-change/index.js --benchmark --rows 10000 --repeats 7
@@ -28,13 +29,8 @@ const makeInput = (length = 320, precision = false) => Array.from({ length }, (_
28
29
  note: `row-${index}`, flag: index % 2 === 0, extra: index / 10
29
30
  }
30
31
  })
31
- const mapCallback = ({ index, main, params }) => ({
32
- custom_value: index === 17 ? null : main.verticalOhlcv.close[index] * params.factor,
33
- custom_vector: index === 19 ? undefined : [index % 2, (index + 1) % 2]
34
- })
35
- const configure = (Ctor, input, { precision = false, retLogs = false, chunkProcess = 100 } = {}) => {
36
- Ctor.registerMapCallback('test.optimizations.v1', mapCallback)
37
- return new Ctor({ input, chunkProcess, config: { precision, useFullNames: retLogs, timeZone: 'America/Panama', dateFormat: 'iso', skipNull: false } })
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 } })
38
34
  .ema(5, { lag: 2 }).ema(9, { target: 'open' }).sma(7, { lag: 1 })
39
35
  .bollingerBands(8, 2, { lag: 1 }).macd(4, 9, 3, { lag: 1 })
40
36
  .relativeVolume(6, { lag: 2 }).volumeDelta({ lag: 1 }).volumeOscillator(3, 8, { retLogs, lag: 2 })
@@ -44,8 +40,9 @@ const configure = (Ctor, input, { precision = false, retLogs = false, chunkProce
44
40
  .donchianChannels(1, 0, { lag: 1 }).donchianChannels(8, 2, { lag: 2 }).donchianChannels(31, 9)
45
41
  .dateTime({ oneHot: retLogs, lag: 1 }).candleFeatures({ colKeys: ['close'], retLogs, lag: 1 })
46
42
  .crossPairs([{ fast: 'close', slow: 'open' }, { fast: 'price', slow: 100 }], { limit: 5, oneHot: true })
47
- .mapCols(['custom_value', 'custom_vector'], 'test.optimizations.v1', { callbackParams: { factor: 2 }, lag: 2 })
48
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
49
46
  }
50
47
  const deepFreeze = value => {
51
48
  if (value && typeof value === 'object') { Object.values(value).forEach(deepFreeze); Object.freeze(value) }
@@ -81,24 +78,81 @@ const referenceDonchian = (highs, lows, size, offset) => {
81
78
  return { upper, lower, basis }
82
79
  }
83
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
+
84
136
  // One independent registration per public indicator/study. The combined chain
85
137
  // above remains useful for detecting interactions between different handlers.
86
138
  const indicatorCases = [
87
- { name: 'ema', register: (m, o) => m.ema(5, o), keys: () => ['ema_5'], warmup: 4 },
88
- { name: 'sma', register: (m, o) => m.sma(5, o), keys: () => ['sma_5'], warmup: 4 },
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 },
89
141
  { name: 'bollingerBands', register: (m, o) => m.bollingerBands(8, 2, o),
90
- // trading-signals v5 exposes the first bands after size + 1 updates.
91
- keys: () => ['upper', 'middle', 'lower'].map(k => `bollinger_bands_${k}`), warmup: 8 },
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 },
92
145
  { name: 'macd', register: (m, o) => m.macd(4, 9, 3, o),
93
146
  keys: () => ['diff', 'dea', 'histogram'].map(k => `macd_${k}`) },
94
147
  { name: 'relativeVolume', register: (m, o) => m.relativeVolume(6, o),
95
- keys: () => ['relative_volume_6'], warmup: 6 },
148
+ keys: logs => [`${logs ? 'ret_log_' : ''}relative_volume_6`], warmup: 6 },
96
149
  { name: 'volumeDelta', register: (m, o) => m.volumeDelta(o),
97
150
  keys: () => ['high', 'low', 'close', 'cross'].map(k => `volume_delta_${k}`) },
98
151
  { name: 'volumeOscillator', register: (m, o) => m.volumeOscillator(3, 8, o),
99
- keys: logs => ['volume_oscillator_3_8', ...(logs ? ['ret_log_volume_oscillator_3_8'] : [])], warmup: 7 },
152
+ keys: logs => [`${logs ? 'ret_log_' : ''}volume_oscillator_3_8`], warmup: 7 },
100
153
  { name: 'rsi', register: (m, o) => m.rsi(7, o),
101
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 },
102
156
  { name: 'stochastic', register: (m, o) => m.stochastic(7, 3, 3, o),
103
157
  keys: logs => ['d', 'k'].map(k => `${logs ? 'ret_log_' : ''}stochastic_${k}_7_3_3`) },
104
158
  { name: 'atr', register: (m, o) => m.atr(7, o), keys: logs => [`${logs ? 'ret_log_' : ''}atr_7`] },
@@ -106,7 +160,8 @@ const indicatorCases = [
106
160
  { name: 'heikenAshi', register: (m, o) => m.heikenAshi(null, null, o),
107
161
  keys: logs => [...['body', 'upper_wick', 'lower_wick', 'range'].map(k => `${logs ? 'ret_log_' : 'ret_'}heiken_ashi_${k}`), 'heiken_ashi_cross'] },
108
162
  { name: 'donchianChannels', register: (m, o) => m.donchianChannels(8, 2, o),
109
- keys: () => ['upper', 'basis', 'lower'].map(k => `donchian_channel_${k}`), warmup: 9 },
163
+ keys: logs => logs ? ['width', 'position'].map(k => `ret_log_donchian_channel_${k}`)
164
+ : ['upper', 'basis', 'lower'].map(k => `donchian_channel_${k}`), warmup: 9 },
110
165
  { name: 'dateTime', register: (m, o) => m.dateTime({ lag: o.lag, oneHot: o.retLogs }),
111
166
  keys: oneHot => oneHot
112
167
  ? ['month', 'day_of_the_month', 'day_of_the_week', 'hour', 'minute'].map(k => `one_hot_${k}`)
@@ -115,7 +170,6 @@ const indicatorCases = [
115
170
  keys: logs => ['change', 'mid_price_change', 'upper_wick', 'lower_wick', 'gap', 'body', 'range', 'close'].map(k => `${logs ? 'ret_log_' : 'ret_'}${k}`), warmup: 1 },
116
171
  { name: 'crossPairs', register: (m, o) => m.crossPairs([{ fast: 'close', slow: 'open' }], { limit: 5, oneHot: o.retLogs }),
117
172
  keys: oneHot => ['close_x_open', ...(oneHot ? ['one_hot_close_x_open'] : [])], autoLag: false },
118
- { name: 'mapCols', register: (m, o) => m.mapCols(['change'], null, { lag: o.lag }), keys: () => ['change'], warmup: 1 },
119
173
  { name: 'lag', register: m => m.lag(['close', 'volume'], 2),
120
174
  keys: () => ['close_lag_1', 'close_lag_2', 'volume_lag_1', 'volume_lag_2'], autoLag: false }
121
175
  ]
@@ -143,47 +197,115 @@ const retainedStorage = (value, seen = new Set()) => {
143
197
  return total
144
198
  }
145
199
 
146
- const runRegressions = Baseline => {
147
- let passed = 0
148
- const check = (name, callback) => { callback(); console.log(`PASS ${name}`); passed++ }
149
- for (const test of coreCases) check(`core ${test.name}: exact trading-signals 5.0.4 compatibility`, () => {
150
- for (const size of [1, 2, 7, 32]) for (const pattern of ['mixed', 'zero', 'invalid', 'coercion']) for (const replacements of [false, true]) {
151
- const current = test.create(Core, size), reference = test.create(TradingSignals, size)
152
- assert.deepStrictEqual(readCoreResult(current), readCoreResult(reference))
153
- let previousObject, previousSnapshot
154
- for (let index = 0; index < 180; index++) {
155
- let value = pattern === 'zero' ? (index % 2 ? -0 : 0) : 100 + Math.sin(index / 3) * 10
156
- if (pattern === 'invalid') {
157
- if (index === 13) value = NaN
158
- if (index === 41) value = Infinity
159
- if (index === 83) value = -Infinity
160
- }
161
- if (pattern === 'coercion') {
162
- if (index === 9) value = null
163
- if (index === 10) value = undefined
164
- if (index === 11) value = '2'
165
- if (index === 12) value = true
166
- if (index === 13) value = '3'
167
- }
168
- const input = test.candle ? { high: value + 2, low: value - 2, close: value } : value
169
- const replace = replacements && index % 5 === 0
170
- const expected = reference.update(input, replace)
171
- const actual = current.update(input, replace)
172
- assert.deepStrictEqual(actual, expected, `${test.name}: update ${index}, size ${size}, ${pattern}`)
173
- assert.equal(current.isStable, reference.isStable)
174
- assert.deepStrictEqual(readCoreResult(current), readCoreResult(reference))
175
- for (const property of ['highest', 'lowest', 'previousResult', 'pdi', 'mdi']) {
176
- assert.deepStrictEqual(current[property], reference[property], `${test.name}.${property}`)
177
- }
178
- if (actual && typeof actual === 'object') {
179
- if (previousObject) {
180
- assert.notStrictEqual(actual, previousObject)
181
- assert.deepStrictEqual(previousObject, previousSnapshot)
182
- }
183
- previousObject = actual; previousSnapshot = { ...actual }
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)
184
244
  }
245
+ previousObject = actual; previousSnapshot = { ...actual }
185
246
  }
186
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`)
187
309
  })
188
310
  check('core storage stays bounded and Wilder smoothing releases its seed window', () => {
189
311
  for (const test of coreCases) {
@@ -206,26 +328,12 @@ const runRegressions = Baseline => {
206
328
  })
207
329
  check('core candle windows retain reference semantics, including reused objects', () => {
208
330
  for (const test of coreCases.filter(test => test.candle)) {
209
- const current = test.create(Core, 3), reference = test.create(TradingSignals, 3)
210
- const candles = [{ high: 4, low: 1, close: 2 }, { high: 5, low: 2, close: 3 }]
211
- for (let index = 0; index < 80; index++) {
212
- const candle = candles[index % 2]
213
- candle.high = 100 + index; candle.low = 90 + index; candle.close = 95 + index
214
- assert.deepStrictEqual(current.update(candle), reference.update(candle))
215
- assert.deepStrictEqual(readCoreResult(current), readCoreResult(reference))
216
- }
331
+ assert.equal(candleMutationTrace(Core, test), coreGolden[test.name].mutation, `${test.name}: mutable candle trace changed`)
217
332
  }
218
333
  })
219
334
  check('core batch and replacement helpers preserve readiness and extrema', () => {
220
335
  for (const name of ['FasterEMA', 'FasterSMA', 'FasterWSMA']) {
221
- const current = new Core[name](3), reference = new TradingSignals[name](3)
222
- const prices = [1, , 2, 3, 4]
223
- assert.deepStrictEqual(current.updates(prices), reference.updates(prices))
224
- for (const value of [0, NaN, 7, -0]) {
225
- assert.deepStrictEqual(current.replace(value), reference.replace(value))
226
- assert.deepStrictEqual(readCoreResult(current), readCoreResult(reference))
227
- assert.deepStrictEqual([current.highest, current.lowest], [reference.highest, reference.lowest])
228
- }
336
+ assert.equal(batchReplacementTrace(Core, name), coreGolden[name].batch, `${name}: sparse batch/replacement trace changed`)
229
337
  }
230
338
  })
231
339
  check('coverage includes every public indicator method', () => {
@@ -282,9 +390,383 @@ const runRegressions = Baseline => {
282
390
  const replay = new OHLCV_INDICATORS({ input, config: deepFreeze(JSON.parse(JSON.stringify(before))) })
283
391
  assert.deepStrictEqual(snapshot(replay), snapshot(main))
284
392
  assert.throws(() => test.register(main, options), /already computed/)
285
- if (Baseline) assert.deepStrictEqual(snapshot(main), snapshot(create(Baseline).compute()))
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
+ }
286
397
  })
287
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
+ })
288
770
  for (const precision of [false, true]) for (const retLogs of [false, true]) {
289
771
  check(`all indicators: precision=${precision}, retLogs=${retLogs}, exact replay/chunks`, () => {
290
772
  const input = makeInput(320, precision), options = { precision, retLogs }
@@ -306,9 +788,14 @@ const runRegressions = Baseline => {
306
788
  assert.deepStrictEqual(snapshot(configure(OHLCV_INDICATORS, input, { ...options, chunkProcess: 2000 }).compute()), expected)
307
789
  const exported = main.exportConfig()
308
790
  exported.inputParams[0].params[1] = 99
309
- exported.inputParams.find(job => job.key === 'mapCols').params[2].callbackParams.factor = 999
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'
310
793
  assert.deepStrictEqual(main.exportConfig(), before)
311
- if (Baseline) assert.deepStrictEqual(expected, snapshot(configure(Baseline, input, options).compute()))
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
+ }
312
799
  })
313
800
  }
314
801
  for (const pattern of ['increasing', 'decreasing', 'equal', 'mixed-invalid']) for (const precision of [false, true]) {
@@ -378,73 +865,273 @@ const runRegressions = Baseline => {
378
865
  assert.throws(() => new OHLCV_INDICATORS({ input }).lag(['missing'], 1).compute(), /not found/)
379
866
  assert.throws(() => new OHLCV_INDICATORS({ input }).lag(['extra', 'extra'], 1).compute(), /already exists/)
380
867
  })
381
- check('callbacks replacing lag buffers and changing inputTypes preserve behavior', () => {
382
- const input = makeInput(120)
383
- input.forEach((row, index) => { row.note = index === 0 ? 'original' : String(index) })
384
- const create = Ctor => new Ctor({ input }).mapCols(['custom'], ({ index, main }) => {
385
- if (index === 0) main.inputTypes.note = 'numberCleanString'
386
- if (index === 1) main.verticalOhlcv.custom = new Float64Array(main.len).fill(NaN)
387
- if (index === 2) {
388
- main.verticalOhlcv.custom = new Array(main.len).fill(null)
389
- main.verticalOhlcv.custom_lag_1 = new Array(main.len).fill('replaced')
390
- }
391
- return { custom: index === 2 ? null : index }
392
- }).lag(['custom'], 1).compute()
393
- const main = create(OHLCV_INDICATORS), v = main.verticalOhlcv
394
- assert.equal(v.note[0], 'original'); assert.equal(v.note[1], 1)
395
- assert.equal(v.custom_lag_1[0], 'replaced'); assert.equal(v.custom_lag_1[2], null)
396
- assert.equal(v.custom_lag_1[3], null); assert.equal(v.custom_lag_1[4], 3)
397
- if (Baseline) assert.deepStrictEqual(v, create(Baseline).verticalOhlcv)
398
- })
399
- check('callbacks changing runtime lag targets/lookback leave persistent declarations intact', () => {
400
- const input = makeInput(120)
401
- for (const mutation of ['lookback', 'splice', 'replace']) {
402
- const create = Ctor => new Ctor({ input }).mapCols(['marker'], ({ index, main }) => {
403
- if (index === 3) {
404
- const job = main.executionParams.find(job => job.key === 'lag')
405
- if (mutation === 'lookback') job.params[1] = 0
406
- if (mutation === 'splice') job.params[0].splice(0, 1)
407
- if (mutation === 'replace') job.params[0] = ['high']
408
- }
409
- return { marker: index }
410
- }).lag(['close', 'high'], 2).compute()
411
- const main = create(OHLCV_INDICATORS)
412
- assert.deepStrictEqual(main.inputParams[1].params, [['close', 'high'], 2])
413
- assert.equal(main.verticalOhlcv.close_lag_1[2], input[1].close)
414
- assert.ok(Number.isNaN(main.verticalOhlcv.close_lag_1[3]))
415
- if (mutation === 'lookback') assert.ok(Number.isNaN(main.verticalOhlcv.high_lag_1[3]))
416
- else assert.equal(main.verticalOhlcv.high_lag_1[3], input[2].high)
417
- if (Baseline) assert.deepStrictEqual(main.verticalOhlcv, create(Baseline).verticalOhlcv)
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/)
418
888
  }
419
889
  })
420
890
  check('output conversion: precision, dates, null/NaN, property order and identity', () => {
421
891
  const input = makeInput(120, true)
422
- const create = Ctor => {
423
- const main = new Ctor({ input, config: { precision: true, timeZone: 'UTC', skipNull: false } })
424
- .mapCols(['scaled'], ({ index, main }) => ({ scaled: index === 0 ? NaN : index === 1 ? null : main.verticalOhlcv.close[index] }), { isPriceBased: true })
425
- .mapCols(['vector'], ({ index }) => ({ vector: [index] })).compute()
426
- return main
427
- }
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()
428
895
  const main = create(OHLCV_INDICATORS), expected = Baseline ? create(Baseline) : null
429
- assert.throws(() => main.exportConfig(), /callback/i)
896
+ assert.deepStrictEqual(snapshot(new OHLCV_INDICATORS({ input, config: main.exportConfig() })), snapshot(main))
430
897
  for (const dateFormat of ['milliseconds', 'seconds', 'iso', 'toISOString', 'object', 'string', 'toString', 'toUTCString']) for (const skipNull of [false, true]) {
431
898
  const rows = main.getData({ dateFormat, skipNull })
432
899
  if (expected) assert.deepStrictEqual(rows, expected.getData({ dateFormat, skipNull }))
433
900
  if (!skipNull) {
434
- assert.ok(Number.isNaN(rows[0].scaled)); assert.equal(rows[1].scaled, null)
435
- assert.equal(rows[2].scaled, Number(input[2].close))
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))
436
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)
437
906
  if (dateFormat === 'object') assert.strictEqual(rows[2].date, main.verticalOhlcv.date[2])
438
907
  }
439
908
  assert.deepStrictEqual(Object.keys(rows[0]), Object.keys(main.verticalOhlcv))
440
909
  }
441
910
  })
442
- check('default callback replay and first-row validation', () => {
443
- const input = makeInput(120), main = new OHLCV_INDICATORS({ input }).mapCols().compute()
911
+ check('empty declarations replay and first-row validation', () => {
912
+ const input = makeInput(120), main = new OHLCV_INDICATORS({ input }).compute()
444
913
  assert.deepStrictEqual(snapshot(new OHLCV_INDICATORS({ input, config: main.exportConfig() })), snapshot(main))
445
914
  const invalid = makeInput(120); invalid[0].high = 0
446
915
  assert.throws(() => new OHLCV_INDICATORS({ input: invalid }).donchianChannels(3).compute(), /invalid/i)
447
- if (Baseline) assert.deepStrictEqual(snapshot(main), snapshot(new Baseline({ input }).mapCols().compute()))
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)
448
1135
  })
449
1136
  console.log(`\n${passed} deterministic regression groups passed${Baseline ? '; exact baseline comparisons passed' : ''}.`)
450
1137
  }
@@ -453,24 +1140,39 @@ const benchmark = Baseline => {
453
1140
  const rows = Number(option('--rows') ?? 10000), repeats = Number(option('--repeats') ?? 7)
454
1141
  assert.ok(Number.isInteger(rows) && rows >= 256 && rows <= 100000, '--rows must be 256..100000')
455
1142
  assert.ok(Number.isInteger(repeats) && repeats >= 3 && repeats <= 30, '--repeats must be 3..30')
456
- const input = makeInput(rows)
457
1143
  const entries = Baseline ? [['baseline', Baseline], ['current', OHLCV_INDICATORS]] : [['current', OHLCV_INDICATORS]]
458
- const samples = Object.fromEntries(entries.map(([name]) => [name, { compute: [], output: [] }]))
459
- for (let repetition = -2; repetition < repeats; repetition++) {
460
- // Alternate order to reduce warm-up/GC order bias. No timing assertions.
461
- for (const [name, Ctor] of repetition % 2 ? [...entries].reverse() : entries) {
462
- global.gc?.()
463
- const main = new Ctor({ input, config: { timeZone: 'UTC' } })
1144
+ const profiles = [
1145
+ { name: 'numeric', input: makeInput(rows), create: (Ctor, input) =>
1146
+ new Ctor({ input, config: { timeZone: 'UTC' } })
464
1147
  .ema(10, { lag: 3 }).sma(20, { lag: 3 }).rsi(14, { retLogs: true, lag: 2 })
465
1148
  .donchianChannels(128, 0, { lag: 2 }).donchianChannels(255, 5, { lag: 1 })
466
- .volumeOscillator(5, 10, { retLogs: true, lag: 2 }).lag(['close', 'volume'], 3)
467
- const start = performance.now()
468
- main.compute()
469
- const computed = performance.now()
470
- const output = main.getData({ skipNull: false, dateFormat: 'milliseconds' })
471
- const converted = performance.now()
472
- assert.equal(output.length, rows)
473
- if (repetition >= 0) { samples[name].compute.push(computed - start); samples[name].output.push(converted - computed) }
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
+ }
474
1176
  }
475
1177
  }
476
1178
  const median = values => {
@@ -489,30 +1191,25 @@ const benchmarkCore = () => {
489
1191
  const candles = prices.map(close => ({ high: close + 2, low: close - 2, close }))
490
1192
  const results = {}
491
1193
  for (const test of coreCases) {
492
- const samples = { reference: [], core: [] }, storage = {}, last = {}
493
- const entries = [['reference', TradingSignals], ['core', Core]]
1194
+ const samples = []
1195
+ let storage
494
1196
  for (let repetition = -2; repetition < repeats; repetition++) {
495
- for (const [name, lib] of repetition % 2 ? [...entries].reverse() : entries) {
496
- const instance = test.create(lib, 32), input = test.candle ? candles : prices
497
- const start = performance.now()
498
- for (const value of input) instance.update(value)
499
- const elapsed = performance.now() - start
500
- if (repetition >= 0) samples[name].push(elapsed)
501
- last[name] = readCoreResult(instance)
502
- if (repetition === repeats - 1) storage[name] = retainedStorage(instance)
503
- }
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)
504
1204
  }
505
- assert.deepStrictEqual(last.core, last.reference)
506
1205
  const median = values => {
507
1206
  const sorted = [...values].sort((a, b) => a - b), mid = Math.floor(sorted.length / 2)
508
1207
  return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
509
1208
  }
510
1209
  results[test.name] = {
511
- 'reference ms': Number(median(samples.reference).toFixed(3)),
512
- 'core ms': Number(median(samples.core).toFixed(3)),
513
- 'reference array slots': storage.reference.slots,
514
- 'core array slots': storage.core.slots,
515
- 'core typed bytes': storage.core.bytes
1210
+ 'core ms': Number(median(samples).toFixed(3)),
1211
+ 'core array slots': storage.slots,
1212
+ 'core typed bytes': storage.bytes
516
1213
  }
517
1214
  }
518
1215
  console.log(`\nCore benchmark: ${rows} updates, period 32, 2 warm-ups, ${repeats} samples; median milliseconds`)