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