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.
- package/AGENTS.md +105 -0
- package/README.md +415 -0
- package/dist/ohlcv-indicators.min.js +2 -1
- package/dist/ohlcv-indicators.min.js.LICENSE.txt +23 -0
- package/index.js +93 -55
- package/package.json +1 -1
- package/src/core-functions/initializeColumns.js +40 -0
- package/src/core-functions/mainLoop.js +47 -22
- package/src/core-functions/pushToMain.js +1 -3
- package/src/core-indicators/README.md +61 -0
- package/src/core-indicators/composites.js +136 -0
- package/src/core-indicators/index.js +5 -0
- package/src/core-indicators/indicator.js +82 -0
- package/src/core-indicators/movingAverages.js +88 -0
- package/src/core-indicators/rsi.js +41 -0
- package/src/core-indicators/volatility.js +107 -0
- package/src/moving-averages/bollingerBands.js +11 -26
- package/src/moving-averages/donchianChannel.js +30 -29
- package/src/moving-averages/heikenAshi.js +23 -33
- package/src/moving-averages/macd.js +10 -26
- package/src/moving-averages/movingAverages.js +7 -23
- package/src/moving-averages/relativeVolume.js +23 -21
- package/src/oscillators/rsi.js +7 -26
- package/src/oscillators/stochastic.js +6 -19
- package/src/oscillators/volumeDelta.js +36 -28
- package/src/oscillators/volumeOscillator.js +29 -31
- package/src/studies/candleFeatures.js +12 -20
- package/src/studies/dateTime.js +22 -27
- package/src/studies/findCrosses.js +16 -7
- package/src/studies/lag.js +26 -13
- package/src/studies/mapCols.js +14 -15
- package/src/utilities/assignTypes.js +1 -1
- package/src/utilities/config.js +64 -0
- package/src/utilities/numberUtilities.js +2 -0
- package/src/utilities/validators.js +40 -1
- package/src/utilities/verticalToHorizontal.js +21 -16
- package/src/volatility/adx.js +7 -20
- package/src/volatility/atr.js +10 -23
- package/test/test.js +533 -0
- 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
|
+
}
|