tardis-dev 16.6.2 → 17.0.0
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/dist/binarysplit.d.ts +1 -2
- package/dist/binarysplit.d.ts.map +1 -1
- package/dist/binarysplit.js +20 -20
- package/dist/binarysplit.js.map +1 -1
- package/dist/handy.d.ts.map +1 -1
- package/dist/handy.js +6 -0
- package/dist/handy.js.map +1 -1
- package/dist/mappers/bullish.js +6 -2
- package/dist/mappers/bullish.js.map +1 -1
- package/dist/mappers/coinbase.js +13 -2
- package/dist/mappers/coinbase.js.map +1 -1
- package/dist/mappers/kraken.js +3 -0
- package/dist/mappers/kraken.js.map +1 -1
- package/dist/mappers/phemex.js +73 -21
- package/dist/mappers/phemex.js.map +1 -1
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +156 -70
- package/dist/replay.js.map +1 -1
- package/package.json +1 -1
- package/src/binarysplit.ts +21 -23
- package/src/handy.ts +7 -0
- package/src/mappers/bullish.ts +7 -2
- package/src/mappers/coinbase.ts +13 -2
- package/src/mappers/kraken.ts +3 -0
- package/src/mappers/phemex.ts +80 -21
- package/src/replay.ts +216 -77
package/src/mappers/phemex.ts
CHANGED
|
@@ -41,6 +41,9 @@ function isExcludedFromNormalizedOutput(symbol: string) {
|
|
|
41
41
|
return symbol === 'sOLUSDT'
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// Uppercase normalized spot symbols can be indistinguishable from native Phemex perpetual symbols.
|
|
45
|
+
// Keep this list aligned with tardis-api and tardis-exporter so aliases such as SBTCUSDT can be
|
|
46
|
+
// converted back to sBTCUSDT without changing native perpetual symbols such as SPKUSDT.
|
|
44
47
|
const COINS_STARTING_WITH_S = [
|
|
45
48
|
'SOLUSD',
|
|
46
49
|
'SUSHIUSD',
|
|
@@ -122,13 +125,46 @@ const COINS_STARTING_WITH_S = [
|
|
|
122
125
|
'STXXUSDT',
|
|
123
126
|
'SPYXUSDT',
|
|
124
127
|
'SP500USDT',
|
|
125
|
-
'SNDKUSDT'
|
|
128
|
+
'SNDKUSDT',
|
|
129
|
+
'SKHYUSDT',
|
|
130
|
+
'SMCIUSDT',
|
|
131
|
+
'SONYUSDT',
|
|
132
|
+
'SQQQUSDT',
|
|
133
|
+
'STRCUSDT'
|
|
126
134
|
]
|
|
135
|
+
|
|
136
|
+
// Phemex used the V2 `_p` channels for this short-lived, now delisted PerpetualPilot market family.
|
|
137
|
+
// Exact casing matters: SIRENPUSD was a pilot, while sIRENPUSD was a separate spot instrument.
|
|
138
|
+
const PERPETUAL_PILOT_SYMBOLS = new Set([
|
|
139
|
+
'ANGLERFISHPUSD',
|
|
140
|
+
'BNBCARDPUSD',
|
|
141
|
+
'BNBXBTPUSD',
|
|
142
|
+
'BUTTCOINPUSD',
|
|
143
|
+
'CRYPTOAIPUSD',
|
|
144
|
+
'CTDPUSD',
|
|
145
|
+
'DOGEAIPUSD',
|
|
146
|
+
'FULLSENDPUSD',
|
|
147
|
+
'GHIBLIPUSD',
|
|
148
|
+
'GREED3PUSD',
|
|
149
|
+
'IMGPUSD',
|
|
150
|
+
'MCPOSPUSD',
|
|
151
|
+
'PAINPUSD',
|
|
152
|
+
'PERRYPUSD',
|
|
153
|
+
'SIRENPUSD',
|
|
154
|
+
'TITCOINPUSD',
|
|
155
|
+
'TOLYPUSD',
|
|
156
|
+
'WILDNOUTPUSD'
|
|
157
|
+
])
|
|
158
|
+
|
|
127
159
|
function getInstrumentType(symbol: string) {
|
|
128
160
|
if (/\d+$/.test(symbol)) {
|
|
129
161
|
return 'future'
|
|
130
162
|
}
|
|
131
163
|
|
|
164
|
+
if (symbol.startsWith('s')) {
|
|
165
|
+
return 'spot'
|
|
166
|
+
}
|
|
167
|
+
|
|
132
168
|
if (
|
|
133
169
|
COINS_STARTING_WITH_S.some((coin) => (coin === 'SUSDT' ? symbol === coin : symbol.startsWith(coin))) ||
|
|
134
170
|
symbol.startsWith('S') === false
|
|
@@ -140,6 +176,16 @@ function getInstrumentType(symbol: string) {
|
|
|
140
176
|
}
|
|
141
177
|
|
|
142
178
|
function getApiSymbolId(symbolId: string) {
|
|
179
|
+
// These are already exchange-native mixed-case identifiers.
|
|
180
|
+
if (symbolId.startsWith('s') || symbolId.startsWith('u100') || symbolId.startsWith('c')) {
|
|
181
|
+
return symbolId
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// SIRENPUSD must not be mistaken for the uppercase alias of spot sIRENPUSD.
|
|
185
|
+
if (PERPETUAL_PILOT_SYMBOLS.has(symbolId)) {
|
|
186
|
+
return symbolId
|
|
187
|
+
}
|
|
188
|
+
|
|
143
189
|
const type = getInstrumentType(symbolId)
|
|
144
190
|
if (type === 'spot' && symbolId.startsWith('S')) {
|
|
145
191
|
return symbolId.charAt(0).toLowerCase() + symbolId.slice(1)
|
|
@@ -155,13 +201,26 @@ function getApiSymbolId(symbolId: string) {
|
|
|
155
201
|
return symbolId
|
|
156
202
|
}
|
|
157
203
|
|
|
158
|
-
function
|
|
159
|
-
const
|
|
160
|
-
const
|
|
204
|
+
function splitSymbolsByChannelFamily(symbols: string[]) {
|
|
205
|
+
const v2Symbols: string[] = []
|
|
206
|
+
const legacySymbols: string[] = []
|
|
207
|
+
|
|
208
|
+
for (const symbol of symbols) {
|
|
209
|
+
const apiSymbolId = getApiSymbolId(symbol)
|
|
210
|
+
const usesV2Channels =
|
|
211
|
+
PERPETUAL_PILOT_SYMBOLS.has(apiSymbolId) ||
|
|
212
|
+
(getInstrumentType(symbol) === 'perpetual' && (apiSymbolId.endsWith('USDT') || apiSymbolId.endsWith('USDC')))
|
|
213
|
+
|
|
214
|
+
if (usesV2Channels) {
|
|
215
|
+
v2Symbols.push(apiSymbolId)
|
|
216
|
+
} else {
|
|
217
|
+
legacySymbols.push(apiSymbolId)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
161
220
|
|
|
162
221
|
return {
|
|
163
|
-
|
|
164
|
-
|
|
222
|
+
v2Symbols,
|
|
223
|
+
legacySymbols
|
|
165
224
|
}
|
|
166
225
|
}
|
|
167
226
|
|
|
@@ -182,20 +241,20 @@ const phemexTradesMapper: Mapper<'phemex', Trade> = {
|
|
|
182
241
|
]
|
|
183
242
|
}
|
|
184
243
|
|
|
185
|
-
const {
|
|
244
|
+
const { v2Symbols, legacySymbols } = splitSymbolsByChannelFamily(symbols)
|
|
186
245
|
|
|
187
246
|
const filters = []
|
|
188
247
|
|
|
189
|
-
if (
|
|
248
|
+
if (v2Symbols.length > 0) {
|
|
190
249
|
filters.push({
|
|
191
250
|
channel: 'trades_p',
|
|
192
|
-
symbols:
|
|
251
|
+
symbols: v2Symbols
|
|
193
252
|
} as const)
|
|
194
253
|
}
|
|
195
|
-
if (
|
|
254
|
+
if (legacySymbols.length > 0) {
|
|
196
255
|
filters.push({
|
|
197
256
|
channel: 'trades',
|
|
198
|
-
symbols:
|
|
257
|
+
symbols: legacySymbols
|
|
199
258
|
} as const)
|
|
200
259
|
}
|
|
201
260
|
|
|
@@ -273,19 +332,19 @@ const phemexBookChangeMapper: Mapper<'phemex', BookChange> = {
|
|
|
273
332
|
]
|
|
274
333
|
}
|
|
275
334
|
|
|
276
|
-
const {
|
|
335
|
+
const { v2Symbols, legacySymbols } = splitSymbolsByChannelFamily(symbols)
|
|
277
336
|
const filters = []
|
|
278
337
|
|
|
279
|
-
if (
|
|
338
|
+
if (v2Symbols.length > 0) {
|
|
280
339
|
filters.push({
|
|
281
340
|
channel: 'orderbook_p',
|
|
282
|
-
symbols:
|
|
341
|
+
symbols: v2Symbols
|
|
283
342
|
} as const)
|
|
284
343
|
}
|
|
285
|
-
if (
|
|
344
|
+
if (legacySymbols.length > 0) {
|
|
286
345
|
filters.push({
|
|
287
346
|
channel: 'book',
|
|
288
|
-
symbols:
|
|
347
|
+
symbols: legacySymbols
|
|
289
348
|
} as const)
|
|
290
349
|
}
|
|
291
350
|
|
|
@@ -345,19 +404,19 @@ class PhemexDerivativeTickerMapper implements Mapper<'phemex', DerivativeTicker>
|
|
|
345
404
|
]
|
|
346
405
|
}
|
|
347
406
|
|
|
348
|
-
const {
|
|
407
|
+
const { v2Symbols, legacySymbols } = splitSymbolsByChannelFamily(symbols)
|
|
349
408
|
const filters = []
|
|
350
409
|
|
|
351
|
-
if (
|
|
410
|
+
if (v2Symbols.length > 0) {
|
|
352
411
|
filters.push({
|
|
353
412
|
channel: 'perp_market24h_pack_p',
|
|
354
|
-
symbols:
|
|
413
|
+
symbols: v2Symbols
|
|
355
414
|
} as const)
|
|
356
415
|
}
|
|
357
|
-
if (
|
|
416
|
+
if (legacySymbols.length > 0) {
|
|
358
417
|
filters.push({
|
|
359
418
|
channel: 'market24h',
|
|
360
|
-
symbols:
|
|
419
|
+
symbols: legacySymbols
|
|
361
420
|
} as const)
|
|
362
421
|
}
|
|
363
422
|
|
package/src/replay.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { createReadStream } from 'node:fs'
|
|
2
2
|
import { rm } from 'node:fs/promises'
|
|
3
|
-
import { EventEmitter } from 'events'
|
|
3
|
+
import { EventEmitter, once } from 'events'
|
|
4
4
|
import { Worker } from 'worker_threads'
|
|
5
5
|
import { constants, createGunzip, createZstdDecompress } from 'zlib'
|
|
6
|
-
import {
|
|
6
|
+
import { BinarySplitBatchStream } from './binarysplit.ts'
|
|
7
7
|
import { clearCacheSync } from './clearcache.ts'
|
|
8
8
|
import { EXCHANGES, EXCHANGE_CHANNELS_INFO } from './consts.ts'
|
|
9
9
|
import { debug } from './debug.ts'
|
|
10
|
-
import { addDays, createNormalizedSymbolFilter, getFilters,
|
|
11
|
-
import { MapperFactory, normalizeBookChanges } from './mappers/index.ts'
|
|
10
|
+
import { addDays, createNormalizedSymbolFilter, getFilters, parseAsUTCDate, wait } from './handy.ts'
|
|
11
|
+
import { Mapper, MapperFactory, normalizeBookChanges } from './mappers/index.ts'
|
|
12
12
|
import { getOptions } from './options.ts'
|
|
13
13
|
import { Disconnect, Exchange, FilterForExchange } from './types.ts'
|
|
14
14
|
import { WorkerJobPayload, WorkerMessage, WorkerSignal } from './worker.ts'
|
|
@@ -18,6 +18,19 @@ type ReplayNormalizedMessage<U extends readonly MapperFactory<any, any>[], Z ext
|
|
|
18
18
|
? MapperOutput<U[number]> | Disconnect
|
|
19
19
|
: MapperOutput<U[number]>
|
|
20
20
|
|
|
21
|
+
const DATE_MESSAGE_SPLIT_INDEX = 28
|
|
22
|
+
const CHUNK_SIZE = 256 * 1024
|
|
23
|
+
// Keep decompression lenient for partial gzip responses.
|
|
24
|
+
// See https://github.com/request/request/pull/2492 and https://github.com/node-fetch/node-fetch/pull/239.
|
|
25
|
+
const GZIP_OPTIONS = {
|
|
26
|
+
chunkSize: CHUNK_SIZE,
|
|
27
|
+
flush: constants.Z_SYNC_FLUSH,
|
|
28
|
+
finishFlush: constants.Z_SYNC_FLUSH
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type DecodedReplayMessage = { localTimestamp: Date; message: any }
|
|
32
|
+
type ReplayMessage = DecodedReplayMessage | { localTimestamp: Buffer; message: Buffer } | undefined
|
|
33
|
+
|
|
21
34
|
export async function* replay<T extends Exchange, U extends boolean = false, Z extends boolean = false>({
|
|
22
35
|
exchange,
|
|
23
36
|
from,
|
|
@@ -38,6 +51,58 @@ export async function* replay<T extends Exchange, U extends boolean = false, Z e
|
|
|
38
51
|
? { localTimestamp: Buffer; message: Buffer }
|
|
39
52
|
: { localTimestamp: Date; message: any }
|
|
40
53
|
> {
|
|
54
|
+
let lastMessageWasUndefined = false
|
|
55
|
+
|
|
56
|
+
const lineBatches = replayLineBatches({ exchange, from, to, filters, apiKey, autoCleanup, waitWhenDataNotYetAvailable })
|
|
57
|
+
for await (const bufferLines of lineBatches) {
|
|
58
|
+
// Decode one line batch in a tight loop, then preserve the public one-message-at-a-time iterator.
|
|
59
|
+
const messages: ReplayMessage[] = []
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < bufferLines.length; i++) {
|
|
62
|
+
const bufferLine = bufferLines[i]
|
|
63
|
+
if (bufferLine.length > 0) {
|
|
64
|
+
lastMessageWasUndefined = false
|
|
65
|
+
if (skipDecoding === true) {
|
|
66
|
+
messages.push({
|
|
67
|
+
localTimestamp: bufferLine.slice(0, DATE_MESSAGE_SPLIT_INDEX),
|
|
68
|
+
message: bufferLine.slice(DATE_MESSAGE_SPLIT_INDEX + 1)
|
|
69
|
+
})
|
|
70
|
+
} else {
|
|
71
|
+
const message = parseReplayMessage(exchange, bufferLine)
|
|
72
|
+
const localTimestamp = parseReplayTimestamp(bufferLine)
|
|
73
|
+
if (withMicroseconds) {
|
|
74
|
+
localTimestamp.μs = parseReplayMicroseconds(bufferLine)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
messages.push({ localTimestamp, message })
|
|
78
|
+
}
|
|
79
|
+
} else if (withDisconnects === true && lastMessageWasUndefined === false) {
|
|
80
|
+
lastMessageWasUndefined = true
|
|
81
|
+
messages.push(undefined)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Drain a batch already received from the stream; its iterator surfaces later stream errors on the next read.
|
|
86
|
+
for (let i = 0; i < messages.length; i++) {
|
|
87
|
+
yield messages[i] as any
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type ReplayLineOptions<T extends Exchange> = Pick<
|
|
93
|
+
ReplayOptions<T, boolean, boolean>,
|
|
94
|
+
'exchange' | 'from' | 'to' | 'filters' | 'apiKey' | 'autoCleanup' | 'waitWhenDataNotYetAvailable'
|
|
95
|
+
>
|
|
96
|
+
|
|
97
|
+
async function* replayLineBatches<T extends Exchange>({
|
|
98
|
+
exchange,
|
|
99
|
+
from,
|
|
100
|
+
to,
|
|
101
|
+
filters,
|
|
102
|
+
apiKey = undefined,
|
|
103
|
+
autoCleanup = undefined,
|
|
104
|
+
waitWhenDataNotYetAvailable = undefined
|
|
105
|
+
}: ReplayLineOptions<T>): AsyncIterableIterator<Buffer[]> {
|
|
41
106
|
validateReplayOptions(exchange, from, to, filters)
|
|
42
107
|
|
|
43
108
|
const fromDate = parseAsUTCDate(from)
|
|
@@ -78,23 +143,6 @@ export async function* replay<T extends Exchange, U extends boolean = false, Z e
|
|
|
78
143
|
})
|
|
79
144
|
|
|
80
145
|
try {
|
|
81
|
-
// date is always formatted to have length of 28 so we can skip looking for first space in line and use it
|
|
82
|
-
// as hardcoded value
|
|
83
|
-
const DATE_MESSAGE_SPLIT_INDEX = 28
|
|
84
|
-
|
|
85
|
-
// more lenient gzip decompression
|
|
86
|
-
// see https://github.com/request/request/pull/2492 and https://github.com/node-fetch/node-fetch/pull/239
|
|
87
|
-
|
|
88
|
-
const CHUNK_SIZE = 256 * 1024
|
|
89
|
-
const GZIP_OPTIONS = {
|
|
90
|
-
chunkSize: CHUNK_SIZE,
|
|
91
|
-
flush: constants.Z_SYNC_FLUSH,
|
|
92
|
-
finishFlush: constants.Z_SYNC_FLUSH
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// helper flag that helps us not yielding two subsequent undefined/disconnect messages
|
|
96
|
-
let lastMessageWasUndefined = false
|
|
97
|
-
|
|
98
146
|
let currentSliceDate = new Date(fromDate)
|
|
99
147
|
// iterate over every minute in <=from,to> date range
|
|
100
148
|
// get cached slice paths, read them as file streams, decompress, split by new lines and yield as messages
|
|
@@ -113,9 +161,9 @@ export async function* replay<T extends Exchange, U extends boolean = false, Z e
|
|
|
113
161
|
}
|
|
114
162
|
|
|
115
163
|
if (cachedSlice === undefined) {
|
|
116
|
-
// if
|
|
164
|
+
// if the requested slice is not ready yet, wait for the worker to report another cached slice
|
|
117
165
|
debug('waiting for slice: %s, exchange: %s', sliceKey, exchange)
|
|
118
|
-
await
|
|
166
|
+
await once(worker, 'message')
|
|
119
167
|
}
|
|
120
168
|
}
|
|
121
169
|
|
|
@@ -130,7 +178,7 @@ export async function* replay<T extends Exchange, U extends boolean = false, Z e
|
|
|
130
178
|
linesStream.destroy(err)
|
|
131
179
|
})
|
|
132
180
|
// and split by new line
|
|
133
|
-
.pipe(new
|
|
181
|
+
.pipe(new BinarySplitBatchStream())
|
|
134
182
|
.on('error', function onBinarySplitStreamError(err) {
|
|
135
183
|
debug('binary split stream error %o', err)
|
|
136
184
|
linesStream.destroy(err)
|
|
@@ -138,47 +186,9 @@ export async function* replay<T extends Exchange, U extends boolean = false, Z e
|
|
|
138
186
|
|
|
139
187
|
let linesCount = 0
|
|
140
188
|
|
|
141
|
-
for await (const
|
|
142
|
-
linesCount
|
|
143
|
-
|
|
144
|
-
lastMessageWasUndefined = false
|
|
145
|
-
// as any due to https://github.com/Microsoft/TypeScript/issues/24929
|
|
146
|
-
if (skipDecoding === true) {
|
|
147
|
-
yield {
|
|
148
|
-
localTimestamp: bufferLine.slice(0, DATE_MESSAGE_SPLIT_INDEX),
|
|
149
|
-
message: bufferLine.slice(DATE_MESSAGE_SPLIT_INDEX + 1)
|
|
150
|
-
} as any
|
|
151
|
-
} else {
|
|
152
|
-
let messageString = bufferLine.toString('utf8', DATE_MESSAGE_SPLIT_INDEX + 1)
|
|
153
|
-
|
|
154
|
-
// hack to handle huobi long numeric id for trades
|
|
155
|
-
if (exchange.startsWith('huobi-') && messageString.includes('.trade.detail')) {
|
|
156
|
-
messageString = messageString.replace(/"id":([0-9]+),/g, '"id":"$1",')
|
|
157
|
-
}
|
|
158
|
-
// hack to handle upbit long numeric id for trades
|
|
159
|
-
if (exchange === 'upbit' && messageString.includes('sequential_id')) {
|
|
160
|
-
messageString = messageString.replace(/"sequential_id":([0-9]+),/g, '"sequential_id":"$1",')
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const message = JSON.parse(messageString)
|
|
164
|
-
|
|
165
|
-
const localTimestamp = new Date(bufferLine.toString('utf8', 0, DATE_MESSAGE_SPLIT_INDEX))
|
|
166
|
-
if (withMicroseconds) {
|
|
167
|
-
localTimestamp.μs = parseReplayMicroseconds(bufferLine)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
yield {
|
|
171
|
-
// when skipDecoding is not set, decode timestamp to Date and message to object
|
|
172
|
-
localTimestamp,
|
|
173
|
-
message
|
|
174
|
-
} as any
|
|
175
|
-
}
|
|
176
|
-
// ignore empty lines unless withDisconnects is set to true
|
|
177
|
-
// do not yield subsequent undefined messages
|
|
178
|
-
} else if (withDisconnects === true && lastMessageWasUndefined === false) {
|
|
179
|
-
lastMessageWasUndefined = true
|
|
180
|
-
yield undefined as any
|
|
181
|
-
}
|
|
189
|
+
for await (const bufferLines of linesStream as AsyncIterable<Buffer[]>) {
|
|
190
|
+
linesCount += bufferLines.length
|
|
191
|
+
yield bufferLines
|
|
182
192
|
}
|
|
183
193
|
|
|
184
194
|
debug('processed slice: %s, exchange: %s, count: %d', sliceKey, exchange, linesCount)
|
|
@@ -232,6 +242,21 @@ export async function* replay<T extends Exchange, U extends boolean = false, Z e
|
|
|
232
242
|
}
|
|
233
243
|
}
|
|
234
244
|
|
|
245
|
+
function parseReplayMessage(exchange: Exchange, bufferLine: Buffer) {
|
|
246
|
+
let messageString = bufferLine.toString('utf8', DATE_MESSAGE_SPLIT_INDEX + 1)
|
|
247
|
+
|
|
248
|
+
// hack to handle huobi long numeric id for trades
|
|
249
|
+
if (exchange.startsWith('huobi-') && messageString.includes('.trade.detail')) {
|
|
250
|
+
messageString = messageString.replace(/"id":([0-9]+),/g, '"id":"$1",')
|
|
251
|
+
}
|
|
252
|
+
// hack to handle upbit long numeric id for trades
|
|
253
|
+
if (exchange === 'upbit' && messageString.includes('sequential_id')) {
|
|
254
|
+
messageString = messageString.replace(/"sequential_id":([0-9]+),/g, '"sequential_id":"$1",')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return JSON.parse(messageString)
|
|
258
|
+
}
|
|
259
|
+
|
|
235
260
|
async function cleanupSlice(slicePath: string) {
|
|
236
261
|
try {
|
|
237
262
|
await rm(slicePath, { force: true })
|
|
@@ -244,6 +269,29 @@ function parseReplayMicroseconds(bufferLine: Buffer) {
|
|
|
244
269
|
return (bufferLine[23] - 48) * 100 + (bufferLine[24] - 48) * 10 + (bufferLine[25] - 48)
|
|
245
270
|
}
|
|
246
271
|
|
|
272
|
+
function parseReplayTimestamp(bufferLine: Buffer) {
|
|
273
|
+
// Recorder writes yyyy-MM-ddTHH:mm:ss.fffffffZ.
|
|
274
|
+
return new Date(
|
|
275
|
+
Date.UTC(
|
|
276
|
+
parseReplayTwoDigits(bufferLine, 0) * 100 + parseReplayTwoDigits(bufferLine, 2),
|
|
277
|
+
parseReplayTwoDigits(bufferLine, 5) - 1,
|
|
278
|
+
parseReplayTwoDigits(bufferLine, 8),
|
|
279
|
+
parseReplayTwoDigits(bufferLine, 11),
|
|
280
|
+
parseReplayTwoDigits(bufferLine, 14),
|
|
281
|
+
parseReplayTwoDigits(bufferLine, 17),
|
|
282
|
+
parseReplayThreeDigits(bufferLine, 20)
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function parseReplayTwoDigits(bufferLine: Buffer, start: number) {
|
|
288
|
+
return (bufferLine[start] - 48) * 10 + (bufferLine[start + 1] - 48)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function parseReplayThreeDigits(bufferLine: Buffer, start: number) {
|
|
292
|
+
return (bufferLine[start] - 48) * 100 + (bufferLine[start + 1] - 48) * 10 + (bufferLine[start + 2] - 48)
|
|
293
|
+
}
|
|
294
|
+
|
|
247
295
|
// gracefully terminate worker
|
|
248
296
|
async function terminateWorker(worker: Worker, waitTimeout: number) {
|
|
249
297
|
let cancelWait = () => {}
|
|
@@ -279,9 +327,9 @@ export function replayNormalized<T extends Exchange, U extends MapperFactory<T,
|
|
|
279
327
|
|
|
280
328
|
validateReplayNormalizedOptions(fromDate, normalizers)
|
|
281
329
|
|
|
282
|
-
const
|
|
283
|
-
const
|
|
284
|
-
const filters = getFilters(
|
|
330
|
+
const createMappersAt = (localTimestamp: Date) => normalizers.map((normalizer) => normalizer(exchange, localTimestamp))
|
|
331
|
+
const initialMappers = createMappersAt(fromDate)
|
|
332
|
+
const filters = getFilters(initialMappers, symbols)
|
|
285
333
|
|
|
286
334
|
// filter normalized messages by symbol as some exchanges do not provide server side filtering so we could end up with messages
|
|
287
335
|
// for symbols we've not requested for
|
|
@@ -289,19 +337,17 @@ export function replayNormalized<T extends Exchange, U extends MapperFactory<T,
|
|
|
289
337
|
|
|
290
338
|
if (segments.length <= 1) {
|
|
291
339
|
const filter = createNormalizedSymbolFilter(symbols, filters)
|
|
292
|
-
const
|
|
340
|
+
const lineBatches = replayLineBatches({
|
|
293
341
|
exchange,
|
|
294
342
|
from,
|
|
295
343
|
to,
|
|
296
|
-
withDisconnects: true,
|
|
297
344
|
filters,
|
|
298
345
|
apiKey,
|
|
299
|
-
withMicroseconds: true,
|
|
300
346
|
autoCleanup,
|
|
301
347
|
waitWhenDataNotYetAvailable
|
|
302
348
|
})
|
|
303
349
|
|
|
304
|
-
return
|
|
350
|
+
return normalizeReplayLineBatches(exchange, undefined, lineBatches, initialMappers, createMappersAt, withDisconnectMessages, filter)
|
|
305
351
|
}
|
|
306
352
|
|
|
307
353
|
return replayNormalizedSegments()
|
|
@@ -309,23 +355,116 @@ export function replayNormalized<T extends Exchange, U extends MapperFactory<T,
|
|
|
309
355
|
async function* replayNormalizedSegments() {
|
|
310
356
|
for (let i = 0; i < segments.length; i++) {
|
|
311
357
|
const { from: segmentFrom, to: segmentTo } = segments[i]
|
|
312
|
-
const segmentMappers = i === 0 ?
|
|
358
|
+
const segmentMappers = i === 0 ? initialMappers : createMappersAt(segmentFrom)
|
|
313
359
|
const segmentFilters = getFilters(segmentMappers, symbols)
|
|
314
360
|
const segmentFilter = createNormalizedSymbolFilter(symbols, segmentFilters)
|
|
315
361
|
|
|
316
|
-
const
|
|
362
|
+
const segmentLineBatches = replayLineBatches({
|
|
317
363
|
exchange,
|
|
318
364
|
from: segmentFrom.toISOString(),
|
|
319
365
|
to: segmentTo.toISOString(),
|
|
320
|
-
withDisconnects: true,
|
|
321
366
|
filters: segmentFilters,
|
|
322
367
|
apiKey,
|
|
323
|
-
withMicroseconds: true,
|
|
324
368
|
autoCleanup,
|
|
325
369
|
waitWhenDataNotYetAvailable
|
|
326
370
|
})
|
|
327
371
|
|
|
328
|
-
yield*
|
|
372
|
+
yield* normalizeReplayLineBatches(
|
|
373
|
+
exchange,
|
|
374
|
+
undefined,
|
|
375
|
+
segmentLineBatches,
|
|
376
|
+
segmentMappers,
|
|
377
|
+
createMappersAt,
|
|
378
|
+
withDisconnectMessages,
|
|
379
|
+
segmentFilter
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function* normalizeReplayLineBatches(
|
|
386
|
+
exchange: Exchange,
|
|
387
|
+
symbols: string[] | undefined,
|
|
388
|
+
lineBatches: AsyncIterableIterator<Buffer[]>,
|
|
389
|
+
initialMappers: Mapper<any, any>[],
|
|
390
|
+
createMappersAt: (localTimestamp: Date) => Mapper<any, any>[],
|
|
391
|
+
withDisconnectMessages: boolean | undefined,
|
|
392
|
+
filter?: (symbol: string) => boolean
|
|
393
|
+
) {
|
|
394
|
+
// This intentionally keeps mapper calls lazy. Custom normalizers must not process later raw messages before the consumer asks for them.
|
|
395
|
+
let previousLocalTimestamp: Date | undefined
|
|
396
|
+
let activeMappers: Mapper<any, any>[] | undefined = initialMappers
|
|
397
|
+
if (activeMappers.length === 0) {
|
|
398
|
+
throw new Error(`Can't normalize data without any normalizers provided`)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
for await (const bufferLines of lineBatches) {
|
|
402
|
+
const decodedMessages: (DecodedReplayMessage | undefined)[] = []
|
|
403
|
+
let decodingFailed = false
|
|
404
|
+
let decodingError: unknown
|
|
405
|
+
try {
|
|
406
|
+
for (let i = 0; i < bufferLines.length; i++) {
|
|
407
|
+
const bufferLine = bufferLines[i]
|
|
408
|
+
if (bufferLine.length === 0) {
|
|
409
|
+
decodedMessages.push(undefined)
|
|
410
|
+
} else {
|
|
411
|
+
const message = parseReplayMessage(exchange, bufferLine)
|
|
412
|
+
const localTimestamp = parseReplayTimestamp(bufferLine)
|
|
413
|
+
localTimestamp.μs = parseReplayMicroseconds(bufferLine)
|
|
414
|
+
decodedMessages.push({ localTimestamp, message })
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
} catch (error) {
|
|
418
|
+
decodingFailed = true
|
|
419
|
+
decodingError = error
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for (let i = 0; i < decodedMessages.length; i++) {
|
|
423
|
+
const decodedMessage = decodedMessages[i]
|
|
424
|
+
if (decodedMessage === undefined) {
|
|
425
|
+
if (activeMappers === undefined) {
|
|
426
|
+
continue
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
activeMappers = undefined
|
|
430
|
+
if (withDisconnectMessages === true && previousLocalTimestamp !== undefined) {
|
|
431
|
+
const disconnect: Disconnect = {
|
|
432
|
+
type: 'disconnect',
|
|
433
|
+
exchange,
|
|
434
|
+
localTimestamp: previousLocalTimestamp,
|
|
435
|
+
symbols
|
|
436
|
+
}
|
|
437
|
+
yield disconnect as any
|
|
438
|
+
}
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const { localTimestamp, message } = decodedMessage
|
|
443
|
+
|
|
444
|
+
if (activeMappers === undefined) {
|
|
445
|
+
activeMappers = createMappersAt(localTimestamp)
|
|
446
|
+
}
|
|
447
|
+
previousLocalTimestamp = localTimestamp
|
|
448
|
+
|
|
449
|
+
for (let mapperIndex = 0; mapperIndex < activeMappers.length; mapperIndex++) {
|
|
450
|
+
const mapper = activeMappers[mapperIndex]
|
|
451
|
+
if (mapper.canHandle(message)) {
|
|
452
|
+
const mappedMessages = mapper.map(message, localTimestamp)
|
|
453
|
+
if (!mappedMessages) {
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
for (const normalizedMessage of mappedMessages) {
|
|
458
|
+
if (filter === undefined || filter(normalizedMessage.symbol)) {
|
|
459
|
+
yield normalizedMessage
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (decodingFailed) {
|
|
467
|
+
throw decodingError
|
|
329
468
|
}
|
|
330
469
|
}
|
|
331
470
|
}
|