ohlcv-indicators 3.5.78 → 3.5.79

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 (40) hide show
  1. package/AGENTS.md +105 -0
  2. package/README.md +415 -0
  3. package/dist/ohlcv-indicators.min.js +2 -1
  4. package/dist/ohlcv-indicators.min.js.LICENSE.txt +23 -0
  5. package/index.js +93 -55
  6. package/package.json +1 -1
  7. package/src/core-functions/initializeColumns.js +40 -0
  8. package/src/core-functions/mainLoop.js +47 -22
  9. package/src/core-functions/pushToMain.js +1 -3
  10. package/src/core-indicators/README.md +61 -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 +82 -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 +11 -26
  18. package/src/moving-averages/donchianChannel.js +30 -29
  19. package/src/moving-averages/heikenAshi.js +23 -33
  20. package/src/moving-averages/macd.js +10 -26
  21. package/src/moving-averages/movingAverages.js +7 -23
  22. package/src/moving-averages/relativeVolume.js +23 -21
  23. package/src/oscillators/rsi.js +7 -26
  24. package/src/oscillators/stochastic.js +6 -19
  25. package/src/oscillators/volumeDelta.js +36 -28
  26. package/src/oscillators/volumeOscillator.js +29 -31
  27. package/src/studies/candleFeatures.js +12 -20
  28. package/src/studies/dateTime.js +22 -27
  29. package/src/studies/findCrosses.js +16 -7
  30. package/src/studies/lag.js +26 -13
  31. package/src/studies/mapCols.js +14 -15
  32. package/src/utilities/assignTypes.js +1 -1
  33. package/src/utilities/config.js +64 -0
  34. package/src/utilities/numberUtilities.js +2 -0
  35. package/src/utilities/validators.js +40 -1
  36. package/src/utilities/verticalToHorizontal.js +21 -16
  37. package/src/volatility/adx.js +7 -20
  38. package/src/volatility/atr.js +10 -23
  39. package/test/test.js +533 -0
  40. package/src/moving-averages/vidya.js +0 -181
package/test/test.js ADDED
@@ -0,0 +1,533 @@
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 OHLCV_INDICATORS from '../index.js'
6
+ import * as Core from '../src/core-indicators/index.js'
7
+ import * as TradingSignals from 'trading-signals'
8
+
9
+ // npm test: offline deterministic regressions.
10
+ // npm test -- --baseline /path/to/pre-change/index.js --benchmark --rows 10000 --repeats 7
11
+ // npm test -- --live: original Nasdaq smoke test (requires its market-data service).
12
+ const args = process.argv.slice(2)
13
+ const option = name => {
14
+ const index = args.indexOf(name)
15
+ if (index === -1) return undefined
16
+ assert.ok(args[index + 1] && !args[index + 1].startsWith('--'), `${name} requires a value`)
17
+ return args[index + 1]
18
+ }
19
+ const makeInput = (length = 320, precision = false) => Array.from({ length }, (_, index) => {
20
+ const open = 100 + index / 100 + Math.sin(index / 7) * 3
21
+ const close = open + Math.cos(index / 3) * 1.5
22
+ const price = value => precision ? value.toFixed(4) : Number(value.toFixed(4))
23
+ return {
24
+ date: new Date(Date.UTC(2024, 0, 1, 1, 15) + index * 3600000).toISOString(),
25
+ open: price(open), high: price(Math.max(open, close) + 2),
26
+ low: price(Math.min(open, close) - 2), close: price(close),
27
+ volume: index > 0 && index % 37 === 0 ? 0 : 1000 + index % 29 * 17,
28
+ note: `row-${index}`, flag: index % 2 === 0, extra: index / 10
29
+ }
30
+ })
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 } })
38
+ .ema(5, { lag: 2 }).ema(9, { target: 'open' }).sma(7, { lag: 1 })
39
+ .bollingerBands(8, 2, { lag: 1 }).macd(4, 9, 3, { lag: 1 })
40
+ .relativeVolume(6, { lag: 2 }).volumeDelta({ lag: 1 }).volumeOscillator(3, 8, { retLogs, lag: 2 })
41
+ .rsi(7, { retLogs, lag: 1 }).stochastic(7, 3, 3, { retLogs, lag: 1 })
42
+ .atr(7, { retLogs, lag: 1 }).adx(7, { retLogs, lag: 1 })
43
+ .heikenAshi(null, null, { retLogs, lag: 1 }).heikenAshi(3, 4, { retLogs })
44
+ .donchianChannels(1, 0, { lag: 1 }).donchianChannels(8, 2, { lag: 2 }).donchianChannels(31, 9)
45
+ .dateTime({ oneHot: retLogs, lag: 1 }).candleFeatures({ colKeys: ['close'], retLogs, lag: 1 })
46
+ .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
+ .lag(['close', 'volume'], 2).lag(['close_lag_1'], 2).lag(['one_hot_close_x_open'], 1)
49
+ }
50
+ const deepFreeze = value => {
51
+ if (value && typeof value === 'object') { Object.values(value).forEach(deepFreeze); Object.freeze(value) }
52
+ return value
53
+ }
54
+ // Observe results/metadata/types, not the intentionally changed runtime cache layout.
55
+ const snapshot = main => ({
56
+ columns: structuredClone(main.verticalOhlcv),
57
+ types: Object.entries(main.verticalOhlcv).map(([key, values]) => [key, values.constructor.name]),
58
+ keys: [...main.verticalOhlcvKeyNames], temp: [...main.verticalOhlcvTempCols],
59
+ priceBased: [...main.priceBased], scaledGroups: structuredClone(main.scaledGroups),
60
+ invalidValueIndex: main.invalidValueIndex, config: main.exportConfig(),
61
+ rows: main.getData({ skipNull: false, dateFormat: 'object' }),
62
+ validRows: main.getData({ skipNull: true, dateFormat: 'iso' }), last: main.getLastValues({ dateFormat: 'seconds' })
63
+ })
64
+ // Keep the old shift-based algorithm as an independent oracle, including its
65
+ // NaN comparisons (Math.max/Math.min would have different invalid-value behavior).
66
+ const referenceDonchian = (highs, lows, size, offset) => {
67
+ const max = [], min = [], upper = [], lower = [], basis = []
68
+ for (let index = 0; index < highs.length; index++) {
69
+ upper.push(NaN); lower.push(NaN); basis.push(NaN)
70
+ const current = index - offset, start = current - size + 1
71
+ if (current < 0 || current >= highs.length) continue
72
+ while (max.length && max[0] < start) max.shift()
73
+ while (max.length && highs[max.at(-1)] <= highs[current]) max.pop()
74
+ max.push(current)
75
+ while (min.length && min[0] < start) min.shift()
76
+ while (min.length && lows[min.at(-1)] >= lows[current]) min.pop()
77
+ min.push(current)
78
+ if (start < 0) continue
79
+ upper[index] = highs[max[0]]; lower[index] = lows[min[0]]; basis[index] = (upper[index] + lower[index]) / 2
80
+ }
81
+ return { upper, lower, basis }
82
+ }
83
+
84
+ // One independent registration per public indicator/study. The combined chain
85
+ // above remains useful for detecting interactions between different handlers.
86
+ 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 },
89
+ { 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 },
92
+ { name: 'macd', register: (m, o) => m.macd(4, 9, 3, o),
93
+ keys: () => ['diff', 'dea', 'histogram'].map(k => `macd_${k}`) },
94
+ { name: 'relativeVolume', register: (m, o) => m.relativeVolume(6, o),
95
+ keys: () => ['relative_volume_6'], warmup: 6 },
96
+ { name: 'volumeDelta', register: (m, o) => m.volumeDelta(o),
97
+ keys: () => ['high', 'low', 'close', 'cross'].map(k => `volume_delta_${k}`) },
98
+ { 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 },
100
+ { name: 'rsi', register: (m, o) => m.rsi(7, o),
101
+ keys: logs => ['rsi_7', 'rsi_sma_7'].map(k => `${logs ? 'ret_log_' : ''}${k}`) },
102
+ { name: 'stochastic', register: (m, o) => m.stochastic(7, 3, 3, o),
103
+ keys: logs => ['d', 'k'].map(k => `${logs ? 'ret_log_' : ''}stochastic_${k}_7_3_3`) },
104
+ { name: 'atr', register: (m, o) => m.atr(7, o), keys: logs => [`${logs ? 'ret_log_' : ''}atr_7`] },
105
+ { name: 'adx', register: (m, o) => m.adx(7, o), keys: logs => [`${logs ? 'ret_log_' : ''}adx_7`] },
106
+ { name: 'heikenAshi', register: (m, o) => m.heikenAshi(null, null, o),
107
+ keys: logs => [...['body', 'upper_wick', 'lower_wick', 'range'].map(k => `${logs ? 'ret_log_' : 'ret_'}heiken_ashi_${k}`), 'heiken_ashi_cross'] },
108
+ { name: 'donchianChannels', register: (m, o) => m.donchianChannels(8, 2, o),
109
+ keys: () => ['upper', 'basis', 'lower'].map(k => `donchian_channel_${k}`), warmup: 9 },
110
+ { name: 'dateTime', register: (m, o) => m.dateTime({ lag: o.lag, oneHot: o.retLogs }),
111
+ keys: oneHot => oneHot
112
+ ? ['month', 'day_of_the_month', 'day_of_the_week', 'hour', 'minute'].map(k => `one_hot_${k}`)
113
+ : ['month', 'day_of_the_month', 'day_of_the_week', 'hour', 'minute', 'year'] },
114
+ { name: 'candleFeatures', register: (m, o) => m.candleFeatures({ ...o, colKeys: ['close'] }),
115
+ keys: logs => ['change', 'mid_price_change', 'upper_wick', 'lower_wick', 'gap', 'body', 'range', 'close'].map(k => `${logs ? 'ret_log_' : 'ret_'}${k}`), warmup: 1 },
116
+ { name: 'crossPairs', register: (m, o) => m.crossPairs([{ fast: 'close', slow: 'open' }], { limit: 5, oneHot: o.retLogs }),
117
+ 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
+ { name: 'lag', register: m => m.lag(['close', 'volume'], 2),
120
+ keys: () => ['close_lag_1', 'close_lag_2', 'volume_lag_1', 'volume_lag_2'], autoLag: false }
121
+ ]
122
+
123
+ const coreCases = [
124
+ ...['FasterEMA', 'FasterSMA', 'FasterWSMA', 'FasterRSI'].map(name => ({ name, create: (lib, size) => new lib[name](size) })),
125
+ ...['FasterTR', 'FasterATR', 'FasterDX', 'FasterADX'].map(name => ({ name, candle: true, create: (lib, size) => new lib[name](size) })),
126
+ { name: 'FasterBollingerBands', create: (lib, size) => new lib.FasterBollingerBands(size, 2) },
127
+ { name: 'FasterMACD', create: (lib, size) => new lib.FasterMACD(new lib.FasterEMA(size), new lib.FasterEMA(size + 2), new lib.FasterEMA(3)) },
128
+ { name: 'FasterStochasticOscillator', candle: true, create: (lib, size) => new lib.FasterStochasticOscillator(size, 3, 2) }
129
+ ]
130
+ const readCoreResult = instance => {
131
+ try { return { value: instance.getResult() } }
132
+ catch (error) { return { error: error.name, message: error.message } }
133
+ }
134
+ const retainedStorage = (value, seen = new Set()) => {
135
+ if (value === null || typeof value !== 'object' || seen.has(value)) return { slots: 0, bytes: 0 }
136
+ seen.add(value)
137
+ if (ArrayBuffer.isView(value)) return { slots: 0, bytes: value.byteLength }
138
+ const total = { slots: Array.isArray(value) ? value.length : 0, bytes: 0 }
139
+ for (const item of Object.values(value)) {
140
+ const storage = retainedStorage(item, seen)
141
+ total.slots += storage.slots; total.bytes += storage.bytes
142
+ }
143
+ return total
144
+ }
145
+
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 }
184
+ }
185
+ }
186
+ }
187
+ })
188
+ check('core storage stays bounded and Wilder smoothing releases its seed window', () => {
189
+ for (const test of coreCases) {
190
+ const instance = test.create(Core, 32)
191
+ let warmedStorage
192
+ for (let index = 0; index < 8192; index++) {
193
+ const value = 100 + Math.sin(index / 7)
194
+ instance.update(test.candle ? { high: value + 2, low: value - 2, close: value } : value)
195
+ if (index === 511) warmedStorage = retainedStorage(instance)
196
+ }
197
+ assert.deepStrictEqual(retainedStorage(instance), warmedStorage, `${test.name}: retained storage grows with history`)
198
+ }
199
+ const wsma = new Core.FasterWSMA(32)
200
+ wsma.updates(Array.from({ length: 32 }, (_, index) => index))
201
+ assert.equal(wsma.isStable, true)
202
+ assert.deepStrictEqual(retainedStorage(wsma), { slots: 0, bytes: 0 })
203
+ const rsi = new Core.FasterRSI(32)
204
+ for (let index = 0; index < 10000; index++) rsi.update(100 + index % 17)
205
+ assert.deepStrictEqual(retainedStorage(rsi), { slots: 0, bytes: 0 })
206
+ })
207
+ check('core candle windows retain reference semantics, including reused objects', () => {
208
+ 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
+ }
217
+ }
218
+ })
219
+ check('core batch and replacement helpers preserve readiness and extrema', () => {
220
+ 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
+ }
229
+ }
230
+ })
231
+ check('coverage includes every public indicator method', () => {
232
+ const lifecycle = new Set(['constructor', '_registerIndicator', 'exportConfig', 'compute', 'getData', 'getLastValues'])
233
+ const methods = Object.getOwnPropertyNames(OHLCV_INDICATORS.prototype).filter(name => !lifecycle.has(name))
234
+ assert.deepStrictEqual(indicatorCases.map(test => test.name).sort(), methods.sort())
235
+ })
236
+ for (const test of indicatorCases) for (const precision of [false, true]) for (const retLogs of [false, true]) {
237
+ check(`${test.name} individually: precision=${precision}, ${retLogs ? 'lagged/log/one-hot' : 'plain'} options`, () => {
238
+ const input = makeInput(160, precision), options = { lag: retLogs ? 2 : 0, retLogs }
239
+ const create = Ctor => test.register(new Ctor({ input, chunkProcess: 100,
240
+ config: { precision, timeZone: 'UTC', skipNull: false } }), options)
241
+ const main = create(OHLCV_INDICATORS), before = main.exportConfig()
242
+ assert.equal(before.inputParams.length, 1)
243
+ assert.equal(before.inputParams[0].key, test.name)
244
+ main.compute()
245
+ const v = main.verticalOhlcv, baseKeys = test.keys(retLogs)
246
+ const expectedKeys = test.autoLag === false ? baseKeys : [
247
+ ...baseKeys, ...baseKeys.flatMap(key => Array.from({ length: options.lag }, (_, i) => `${key}_lag_${i + 1}`))
248
+ ]
249
+ const inputKeys = new Set([...Object.keys(input[0]), 'mid_price'])
250
+ assert.deepStrictEqual(Object.keys(v).filter(key => !inputKeys.has(key)).sort(), [...expectedKeys].sort())
251
+ for (const key of expectedKeys) {
252
+ const column = v[key]
253
+ assert.equal(column.length, input.length, `${key}: column length`)
254
+ const last = column.at(-1)
255
+ if (key.startsWith('one_hot_')) {
256
+ assert.ok(last instanceof Uint8Array, `${key}: one-hot storage`)
257
+ assert.equal(last.reduce((sum, value) => sum + value, 0), 1)
258
+ } else assert.ok(Number.isFinite(last), `${key}: terminal value must be finite`)
259
+ const lagMatch = /^(.*)_lag_(\d+)$/.exec(key)
260
+ if (lagMatch) {
261
+ const source = v[lagMatch[1]], step = Number(lagMatch[2])
262
+ assert.equal(Array.isArray(column), Array.isArray(source), `${key}: lag type`)
263
+ for (let index = 0; index < input.length; index++) {
264
+ const value = index < step || source[index - step] == null
265
+ ? Array.isArray(column) ? null : NaN : source[index - step]
266
+ assert.strictEqual(column[index], value, `${key}[${index}]: lag value/identity`)
267
+ }
268
+ }
269
+ }
270
+ if (test.warmup !== undefined) for (const key of baseKeys) {
271
+ assert.equal(v[key].findIndex(Number.isFinite), test.warmup, `${key}: first valid row`)
272
+ }
273
+ if (['relativeVolume', 'volumeDelta', 'volumeOscillator'].includes(test.name)) {
274
+ for (let index = 0; index < input.length; index++) if (input[index].volume === 0) {
275
+ for (const key of baseKeys) assert.ok(Number.isNaN(v[key][index]), `${key}: zero-volume row`)
276
+ }
277
+ }
278
+ const rows = main.getData({ skipNull: false })
279
+ assert.deepStrictEqual(main.getData({ skipNull: true }), rows.slice(main.invalidValueIndex + 1))
280
+ assert.deepStrictEqual(main.getLastValues(), rows.at(-1))
281
+ assert.deepStrictEqual(main.exportConfig(), before)
282
+ const replay = new OHLCV_INDICATORS({ input, config: deepFreeze(JSON.parse(JSON.stringify(before))) })
283
+ assert.deepStrictEqual(snapshot(replay), snapshot(main))
284
+ assert.throws(() => test.register(main, options), /already computed/)
285
+ if (Baseline) assert.deepStrictEqual(snapshot(main), snapshot(create(Baseline).compute()))
286
+ })
287
+ }
288
+ for (const precision of [false, true]) for (const retLogs of [false, true]) {
289
+ check(`all indicators: precision=${precision}, retLogs=${retLogs}, exact replay/chunks`, () => {
290
+ const input = makeInput(320, precision), options = { precision, retLogs }
291
+ const main = configure(OHLCV_INDICATORS, input, options), before = main.exportConfig()
292
+ const frozen = deepFreeze(JSON.parse(JSON.stringify(before)))
293
+ const expected = snapshot(main.compute())
294
+ assert.deepStrictEqual(main.exportConfig(), before)
295
+ assert.equal(expected.rows.length, input.length)
296
+ assert.ok(expected.validRows.length > 0 && expected.validRows.length < input.length)
297
+ assert.deepStrictEqual(Object.keys(expected.rows[0]), expected.keys.filter(key => !main.verticalOhlcvTempCols.has(key)))
298
+ assert.ok(main.executionParams.length > before.inputParams.length)
299
+ assert.ok(Object.isFrozen(main.inputParams[0].params))
300
+ assert.throws(() => main.ema(3), /already computed/)
301
+ for (let replay = 0; replay < 2; replay++) {
302
+ const restored = new OHLCV_INDICATORS({ input, config: frozen, chunkProcess: 101 })
303
+ assert.deepStrictEqual(snapshot(restored), expected)
304
+ assert.deepStrictEqual(restored.exportConfig(), before)
305
+ }
306
+ assert.deepStrictEqual(snapshot(configure(OHLCV_INDICATORS, input, { ...options, chunkProcess: 2000 }).compute()), expected)
307
+ const exported = main.exportConfig()
308
+ exported.inputParams[0].params[1] = 99
309
+ exported.inputParams.find(job => job.key === 'mapCols').params[2].callbackParams.factor = 999
310
+ assert.deepStrictEqual(main.exportConfig(), before)
311
+ if (Baseline) assert.deepStrictEqual(expected, snapshot(configure(Baseline, input, options).compute()))
312
+ })
313
+ }
314
+ for (const pattern of ['increasing', 'decreasing', 'equal', 'mixed-invalid']) for (const precision of [false, true]) {
315
+ check(`Donchian reference: ${pattern}, precision=${precision}`, () => {
316
+ const input = makeInput(257, precision)
317
+ input.forEach((row, index) => {
318
+ const value = pattern === 'increasing' ? 100 + index : pattern === 'decreasing' ? 400 - index : pattern === 'equal' ? 200 : 200 + index % 7
319
+ row.high = precision ? (value + 3).toFixed(4) : value + 3
320
+ row.low = precision ? (value - 3).toFixed(4) : value - 3
321
+ })
322
+ if (pattern === 'mixed-invalid') {
323
+ delete input[11].high; delete input[12].low
324
+ input[35].high = precision ? 'NaN' : NaN; input[37].low = precision ? 'NaN' : NaN
325
+ }
326
+ const settings = [[1, 0], [2, 1], [17, 5], [128, 0], [256, 1], [257, 257]]
327
+ const create = Ctor => {
328
+ const main = new Ctor({ input, chunkProcess: 100, config: { precision, timeZone: 'UTC' } })
329
+ settings.forEach(([size, offset]) => main.donchianChannels(size, offset, { lag: 1 }))
330
+ return main.compute()
331
+ }
332
+ const main = create(OHLCV_INDICATORS)
333
+ for (const [size, offset] of settings) {
334
+ for (const [part, values] of Object.entries(referenceDonchian(main.verticalOhlcv.high, main.verticalOhlcv.low, size, offset))) {
335
+ assert.deepStrictEqual(Array.from(main.verticalOhlcv[`donchian_channel_${part}_${size}_${offset}`]), values)
336
+ }
337
+ }
338
+ if (Baseline) assert.deepStrictEqual(snapshot(main), snapshot(create(Baseline)))
339
+ })
340
+ }
341
+ check('input formatters: dates, numeric strings, passthrough and undefined values', () => {
342
+ for (const kind of ['iso', 'seconds', 'object', 'milliseconds']) {
343
+ const input = makeInput(120)
344
+ input.forEach(row => {
345
+ const date = new Date(row.date)
346
+ if (kind === 'seconds') row.date = date.getTime() / 1000
347
+ if (kind === 'object') row.date = date
348
+ if (kind === 'milliseconds') row.date = date.getTime()
349
+ row.extra = String(row.extra); row.payload = { note: row.note }
350
+ })
351
+ delete input[4].extra; delete input[5].note; delete input[6].high; input[7].low = NaN
352
+ const create = Ctor => new Ctor({ input, config: { timeZone: 'UTC' } }).lag(['extra', 'note'], 2).compute()
353
+ const main = create(OHLCV_INDICATORS)
354
+ assert.equal(main.verticalOhlcv.extra[3], 0.3)
355
+ assert.equal(main.verticalOhlcv.note[3], 'row-3'); assert.equal(main.verticalOhlcv.flag[3], false)
356
+ assert.strictEqual(main.verticalOhlcv.payload[3], input[3].payload)
357
+ assert.ok(Number.isNaN(main.verticalOhlcv.high[6])); assert.ok(Number.isNaN(main.verticalOhlcv.low[7]))
358
+ // Preserve the existing unrecognized dateMillseconds formatter behavior.
359
+ if (kind === 'milliseconds') {
360
+ assert.equal(main.verticalOhlcv.date[0], input[0].date)
361
+ assert.throws(() => main.getData(), TypeError)
362
+ if (Baseline) assert.deepStrictEqual(main.verticalOhlcv, create(Baseline).verticalOhlcv)
363
+ } else if (Baseline) assert.deepStrictEqual(snapshot(main), snapshot(create(Baseline)))
364
+ }
365
+ })
366
+ check('lag types, missing values, nested ordering and source object identity', () => {
367
+ const input = makeInput(120)
368
+ input.forEach((row, index) => { row.vector = [index, index + 1] })
369
+ delete input[4].extra; input[5].vector = null
370
+ const v = new OHLCV_INDICATORS({ input }).lag(['extra', 'vector'], 2).lag(['extra_lag_1'], 1).compute().verticalOhlcv
371
+ assert.ok(v.extra_lag_1 instanceof Float64Array); assert.ok(Array.isArray(v.vector_lag_1))
372
+ assert.ok(Number.isNaN(v.extra_lag_1[0])); assert.ok(Number.isNaN(v.extra_lag_1[5]))
373
+ assert.equal(v.vector_lag_1[0], null); assert.equal(v.vector_lag_1[6], null)
374
+ assert.strictEqual(v.vector_lag_1[3], input[2].vector)
375
+ assert.deepStrictEqual(v.extra_lag_1_lag_1, v.extra_lag_2)
376
+ const nested = new OHLCV_INDICATORS({ input }).lag(['extra', 'extra_lag_1'], 2).compute().verticalOhlcv
377
+ assert.deepStrictEqual(nested.extra_lag_1_lag_1, nested.extra_lag_2)
378
+ assert.throws(() => new OHLCV_INDICATORS({ input }).lag(['missing'], 1).compute(), /not found/)
379
+ assert.throws(() => new OHLCV_INDICATORS({ input }).lag(['extra', 'extra'], 1).compute(), /already exists/)
380
+ })
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)
418
+ }
419
+ })
420
+ check('output conversion: precision, dates, null/NaN, property order and identity', () => {
421
+ 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
+ }
428
+ const main = create(OHLCV_INDICATORS), expected = Baseline ? create(Baseline) : null
429
+ assert.throws(() => main.exportConfig(), /callback/i)
430
+ for (const dateFormat of ['milliseconds', 'seconds', 'iso', 'toISOString', 'object', 'string', 'toString', 'toUTCString']) for (const skipNull of [false, true]) {
431
+ const rows = main.getData({ dateFormat, skipNull })
432
+ if (expected) assert.deepStrictEqual(rows, expected.getData({ dateFormat, skipNull }))
433
+ 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))
436
+ assert.strictEqual(rows[2].vector, main.verticalOhlcv.vector[2])
437
+ if (dateFormat === 'object') assert.strictEqual(rows[2].date, main.verticalOhlcv.date[2])
438
+ }
439
+ assert.deepStrictEqual(Object.keys(rows[0]), Object.keys(main.verticalOhlcv))
440
+ }
441
+ })
442
+ check('default callback replay and first-row validation', () => {
443
+ const input = makeInput(120), main = new OHLCV_INDICATORS({ input }).mapCols().compute()
444
+ assert.deepStrictEqual(snapshot(new OHLCV_INDICATORS({ input, config: main.exportConfig() })), snapshot(main))
445
+ const invalid = makeInput(120); invalid[0].high = 0
446
+ 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()))
448
+ })
449
+ console.log(`\n${passed} deterministic regression groups passed${Baseline ? '; exact baseline comparisons passed' : ''}.`)
450
+ }
451
+
452
+ const benchmark = Baseline => {
453
+ const rows = Number(option('--rows') ?? 10000), repeats = Number(option('--repeats') ?? 7)
454
+ assert.ok(Number.isInteger(rows) && rows >= 256 && rows <= 100000, '--rows must be 256..100000')
455
+ assert.ok(Number.isInteger(repeats) && repeats >= 3 && repeats <= 30, '--repeats must be 3..30')
456
+ const input = makeInput(rows)
457
+ 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' } })
464
+ .ema(10, { lag: 3 }).sma(20, { lag: 3 }).rsi(14, { retLogs: true, lag: 2 })
465
+ .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) }
474
+ }
475
+ }
476
+ const median = values => {
477
+ const sorted = [...values].sort((a, b) => a - b), middle = Math.floor(sorted.length / 2)
478
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
479
+ }
480
+ console.log(`\nBenchmark: ${rows} rows, 2 warm-ups, ${repeats} samples; constructor excluded; median milliseconds`)
481
+ console.table(Object.fromEntries(Object.entries(samples).map(([name, values]) => [name, {
482
+ compute: Number(median(values.compute).toFixed(2)), getData: Number(median(values.output).toFixed(2))
483
+ }])))
484
+ }
485
+
486
+ const benchmarkCore = () => {
487
+ const rows = Number(option('--rows') ?? 10000), repeats = Number(option('--repeats') ?? 7)
488
+ const prices = Array.from({ length: rows }, (_, index) => 100 + Math.sin(index / 7) * 10)
489
+ const candles = prices.map(close => ({ high: close + 2, low: close - 2, close }))
490
+ const results = {}
491
+ for (const test of coreCases) {
492
+ const samples = { reference: [], core: [] }, storage = {}, last = {}
493
+ const entries = [['reference', TradingSignals], ['core', Core]]
494
+ 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
+ }
504
+ }
505
+ assert.deepStrictEqual(last.core, last.reference)
506
+ const median = values => {
507
+ const sorted = [...values].sort((a, b) => a - b), mid = Math.floor(sorted.length / 2)
508
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
509
+ }
510
+ 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
516
+ }
517
+ }
518
+ console.log(`\nCore benchmark: ${rows} updates, period 32, 2 warm-ups, ${repeats} samples; median milliseconds`)
519
+ console.log('Storage counts describe retained arrays only, not total heap or peak/transient allocations.')
520
+ console.table(results)
521
+ }
522
+
523
+ if (args.includes('--live')) {
524
+ const { getNasdaqOHLCV } = await import('./utilities/fetchNasdaq.js')
525
+ const input = await getNasdaqOHLCV({ symbol: 'TQQQ', interval: '1d', type: 'index', limit: 1000 })
526
+ console.log(new OHLCV_INDICATORS({ input, config: { precision: false, useFullNames: false } })
527
+ .volumeOscillator(5, 10, { retLogs: true }).ema(10).ema(20).getLastValues())
528
+ } else {
529
+ const path = option('--baseline')
530
+ const Baseline = path ? (await import(pathToFileURL(resolve(path)).href)).default : null
531
+ runRegressions(Baseline)
532
+ if (args.includes('--benchmark')) { benchmark(Baseline); benchmarkCore() }
533
+ }