tardis-machine 18.6.2 → 18.8.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/ws/subscriptionsmappers.d.ts.map +1 -1
- package/dist/ws/subscriptionsmappers.js +5 -0
- package/dist/ws/subscriptionsmappers.js.map +1 -1
- package/node_modules/tardis-dev/dist/consts.d.ts +2 -1
- package/node_modules/tardis-dev/dist/consts.d.ts.map +1 -1
- package/node_modules/tardis-dev/dist/consts.js +13 -0
- package/node_modules/tardis-dev/dist/consts.js.map +1 -1
- package/node_modules/tardis-dev/dist/downloaddatasets.d.ts +2 -0
- package/node_modules/tardis-dev/dist/downloaddatasets.d.ts.map +1 -1
- package/node_modules/tardis-dev/dist/downloaddatasets.js +21 -11
- package/node_modules/tardis-dev/dist/downloaddatasets.js.map +1 -1
- package/node_modules/tardis-dev/dist/handy.d.ts +16 -0
- package/node_modules/tardis-dev/dist/handy.d.ts.map +1 -1
- package/node_modules/tardis-dev/dist/handy.js +211 -15
- package/node_modules/tardis-dev/dist/handy.js.map +1 -1
- package/node_modules/tardis-dev/dist/instrumentinfo.d.ts +1 -1
- package/node_modules/tardis-dev/dist/instrumentinfo.d.ts.map +1 -1
- package/node_modules/tardis-dev/dist/mappers/ascendex.d.ts +4 -4
- package/node_modules/tardis-dev/dist/mappers/aster.d.ts +8 -8
- package/node_modules/tardis-dev/dist/mappers/binance.d.ts +18 -18
- package/node_modules/tardis-dev/dist/mappers/binanceeuropeanoptions.d.ts +1 -1
- package/node_modules/tardis-dev/dist/mappers/bitfinex.d.ts +8 -8
- package/node_modules/tardis-dev/dist/mappers/bitflyer.d.ts +1 -1
- package/node_modules/tardis-dev/dist/mappers/bitget.d.ts +1 -1
- package/node_modules/tardis-dev/dist/mappers/bithumb.d.ts +2 -2
- package/node_modules/tardis-dev/dist/mappers/bitmex.d.ts +2 -2
- package/node_modules/tardis-dev/dist/mappers/bitnomial.d.ts +1 -1
- package/node_modules/tardis-dev/dist/mappers/bitstamp.d.ts +2 -2
- package/node_modules/tardis-dev/dist/mappers/bitvavo.d.ts +3 -3
- package/node_modules/tardis-dev/dist/mappers/blockchaincom.d.ts +2 -2
- package/node_modules/tardis-dev/dist/mappers/bullish.d.ts +5 -5
- package/node_modules/tardis-dev/dist/mappers/bybit.d.ts +10 -10
- package/node_modules/tardis-dev/dist/mappers/bybitspot.d.ts +1 -1
- package/node_modules/tardis-dev/dist/mappers/coinbase.d.ts +1 -1
- package/node_modules/tardis-dev/dist/mappers/coinbasederivatives.d.ts +4 -4
- package/node_modules/tardis-dev/dist/mappers/coinbaseinternational.d.ts +2 -2
- package/node_modules/tardis-dev/dist/mappers/coinflex.d.ts +1 -1
- package/node_modules/tardis-dev/dist/mappers/cryptocom.d.ts +5 -5
- package/node_modules/tardis-dev/dist/mappers/cryptofacilities.d.ts +2 -2
- package/node_modules/tardis-dev/dist/mappers/delta.d.ts +1 -1
- package/node_modules/tardis-dev/dist/mappers/delta_india.d.ts +11 -0
- package/node_modules/tardis-dev/dist/mappers/delta_india.d.ts.map +1 -0
- package/node_modules/tardis-dev/dist/mappers/delta_india.js +197 -0
- package/node_modules/tardis-dev/dist/mappers/delta_india.js.map +1 -0
- package/node_modules/tardis-dev/dist/mappers/deribit.d.ts +3 -3
- package/node_modules/tardis-dev/dist/mappers/dydx.d.ts +3 -3
- package/node_modules/tardis-dev/dist/mappers/dydxv4.d.ts +4 -4
- package/node_modules/tardis-dev/dist/mappers/ftx.d.ts +8 -8
- package/node_modules/tardis-dev/dist/mappers/gateio.d.ts +1 -1
- package/node_modules/tardis-dev/dist/mappers/gateiofutures.d.ts +4 -4
- package/node_modules/tardis-dev/dist/mappers/gemini.d.ts +1 -1
- package/node_modules/tardis-dev/dist/mappers/huobi.d.ts +26 -26
- package/node_modules/tardis-dev/dist/mappers/hyperliquid.d.ts +3 -3
- package/node_modules/tardis-dev/dist/mappers/index.d.ts +202 -190
- package/node_modules/tardis-dev/dist/mappers/index.d.ts.map +1 -1
- package/node_modules/tardis-dev/dist/mappers/index.js +2 -1
- package/node_modules/tardis-dev/dist/mappers/index.js.map +1 -1
- package/node_modules/tardis-dev/dist/mappers/kucoin.d.ts +3 -3
- package/node_modules/tardis-dev/dist/mappers/kucoinfutures.d.ts +4 -4
- package/node_modules/tardis-dev/dist/mappers/lighter.d.ts +10 -10
- package/node_modules/tardis-dev/dist/mappers/mexc.d.ts +3 -3
- package/node_modules/tardis-dev/dist/mappers/mexcfutures.d.ts +3 -3
- package/node_modules/tardis-dev/dist/mappers/okex.d.ts +15 -15
- package/node_modules/tardis-dev/dist/mappers/phemex.d.ts +1 -1
- package/node_modules/tardis-dev/dist/mappers/polymarket.d.ts +3 -3
- package/node_modules/tardis-dev/dist/mappers/serum.d.ts +9 -9
- package/node_modules/tardis-dev/dist/mappers/upbit.d.ts +2 -2
- package/node_modules/tardis-dev/dist/realtimefeeds/delta_india.d.ts +32 -0
- package/node_modules/tardis-dev/dist/realtimefeeds/delta_india.d.ts.map +1 -0
- package/node_modules/tardis-dev/dist/realtimefeeds/delta_india.js +117 -0
- package/node_modules/tardis-dev/dist/realtimefeeds/delta_india.js.map +1 -0
- package/node_modules/tardis-dev/dist/realtimefeeds/dydx_v4.d.ts +1 -0
- package/node_modules/tardis-dev/dist/realtimefeeds/dydx_v4.d.ts.map +1 -1
- package/node_modules/tardis-dev/dist/realtimefeeds/dydx_v4.js +1 -0
- package/node_modules/tardis-dev/dist/realtimefeeds/dydx_v4.js.map +1 -1
- package/node_modules/tardis-dev/dist/realtimefeeds/index.d.ts.map +1 -1
- package/node_modules/tardis-dev/dist/realtimefeeds/index.js +2 -0
- package/node_modules/tardis-dev/dist/realtimefeeds/index.js.map +1 -1
- package/node_modules/tardis-dev/dist/realtimefeeds/realtimefeed.d.ts +1 -0
- package/node_modules/tardis-dev/dist/realtimefeeds/realtimefeed.d.ts.map +1 -1
- package/node_modules/tardis-dev/dist/realtimefeeds/realtimefeed.js +3 -1
- package/node_modules/tardis-dev/dist/realtimefeeds/realtimefeed.js.map +1 -1
- package/node_modules/tardis-dev/dist/worker.d.ts.map +1 -1
- package/node_modules/tardis-dev/dist/worker.js +1 -2
- package/node_modules/tardis-dev/dist/worker.js.map +1 -1
- package/node_modules/tardis-dev/dist/zstd.d.ts +1 -2
- package/node_modules/tardis-dev/dist/zstd.d.ts.map +1 -1
- package/node_modules/tardis-dev/dist/zstd.js +4 -118
- package/node_modules/tardis-dev/dist/zstd.js.map +1 -1
- package/node_modules/tardis-dev/package.json +1 -1
- package/node_modules/tardis-dev/src/consts.ts +14 -0
- package/node_modules/tardis-dev/src/downloaddatasets.ts +31 -12
- package/node_modules/tardis-dev/src/handy.ts +261 -14
- package/node_modules/tardis-dev/src/instrumentinfo.ts +1 -1
- package/node_modules/tardis-dev/src/mappers/delta_india.ts +290 -0
- package/node_modules/tardis-dev/src/mappers/index.ts +2 -0
- package/node_modules/tardis-dev/src/realtimefeeds/delta_india.ts +131 -0
- package/node_modules/tardis-dev/src/realtimefeeds/dydx_v4.ts +1 -0
- package/node_modules/tardis-dev/src/realtimefeeds/index.ts +2 -0
- package/node_modules/tardis-dev/src/realtimefeeds/realtimefeed.ts +2 -1
- package/node_modules/tardis-dev/src/worker.ts +1 -2
- package/node_modules/tardis-dev/src/zstd.ts +4 -135
- package/package.json +2 -2
- package/src/ws/subscriptionsmappers.ts +6 -0
|
@@ -1,122 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { open } from 'node:fs/promises';
|
|
3
|
-
import { Readable } from 'node:stream';
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
4
2
|
import { createZstdDecompress } from 'node:zlib';
|
|
5
|
-
const ZSTD_FRAME_MAGIC = 0xfd2fb528;
|
|
6
|
-
const DICTIONARY_ID_SIZES = [0, 1, 2, 4];
|
|
7
3
|
export function createZstdDecompressionStream(path, chunkSize) {
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const file = await open(path, 'r');
|
|
12
|
-
try {
|
|
13
|
-
const fileSize = (await file.stat()).size;
|
|
14
|
-
const frames = await findZstdFrames(file, path, fileSize);
|
|
15
|
-
for (const frame of frames) {
|
|
16
|
-
const source = Readable.from(readFileRange(file, frame.offset, frame.length, chunkSize), { objectMode: false });
|
|
17
|
-
const decompressor = createZstdDecompress({ chunkSize });
|
|
18
|
-
source.on('error', (error) => decompressor.destroy(error));
|
|
19
|
-
source.pipe(decompressor);
|
|
20
|
-
try {
|
|
21
|
-
for await (const chunk of decompressor) {
|
|
22
|
-
yield chunk;
|
|
23
|
-
}
|
|
24
|
-
if (decompressor.bytesWritten !== frame.length) {
|
|
25
|
-
throw new Error(`Zstd decoder consumed ${decompressor.bytesWritten} of ${frame.length} bytes at offset ${frame.offset} in ${path}`);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
finally {
|
|
29
|
-
source.unpipe(decompressor);
|
|
30
|
-
source.destroy();
|
|
31
|
-
decompressor.destroy();
|
|
32
|
-
if (!source.closed) {
|
|
33
|
-
await once(source, 'close');
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
finally {
|
|
39
|
-
await file.close();
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
async function findZstdFrames(file, path, fileSize) {
|
|
43
|
-
if (fileSize === 0) {
|
|
44
|
-
throw new Error(`Empty zstd file: ${path}`);
|
|
45
|
-
}
|
|
46
|
-
const bytes = Buffer.allocUnsafe(4);
|
|
47
|
-
const frames = [];
|
|
48
|
-
let offset = 0;
|
|
49
|
-
while (offset < fileSize) {
|
|
50
|
-
const frameStart = offset;
|
|
51
|
-
await readExactly(file, bytes, 4, offset, fileSize, path);
|
|
52
|
-
if (bytes.readUInt32LE(0) !== ZSTD_FRAME_MAGIC) {
|
|
53
|
-
throw new Error(`Invalid zstd frame magic at offset ${offset} in ${path}`);
|
|
54
|
-
}
|
|
55
|
-
offset += 4;
|
|
56
|
-
await readExactly(file, bytes, 1, offset, fileSize, path);
|
|
57
|
-
const descriptor = bytes[0];
|
|
58
|
-
if ((descriptor & 0x08) !== 0) {
|
|
59
|
-
throw new Error(`Unsupported reserved zstd frame descriptor bit at offset ${offset} in ${path}`);
|
|
60
|
-
}
|
|
61
|
-
offset++;
|
|
62
|
-
const singleSegment = (descriptor & 0x20) !== 0;
|
|
63
|
-
const contentChecksum = (descriptor & 0x04) !== 0;
|
|
64
|
-
const dictionaryIdSize = DICTIONARY_ID_SIZES[descriptor & 0x03];
|
|
65
|
-
const contentSizeFlag = descriptor >>> 6;
|
|
66
|
-
const contentSizeSize = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag;
|
|
67
|
-
const windowDescriptorSize = singleSegment ? 0 : 1;
|
|
68
|
-
offset += windowDescriptorSize + dictionaryIdSize + contentSizeSize;
|
|
69
|
-
ensureAvailable(offset, 0, fileSize, path);
|
|
70
|
-
let lastBlock = false;
|
|
71
|
-
while (!lastBlock) {
|
|
72
|
-
await readExactly(file, bytes, 3, offset, fileSize, path);
|
|
73
|
-
const blockHeader = bytes.readUIntLE(0, 3);
|
|
74
|
-
offset += 3;
|
|
75
|
-
lastBlock = (blockHeader & 1) !== 0;
|
|
76
|
-
const blockType = (blockHeader >>> 1) & 3;
|
|
77
|
-
if (blockType === 3) {
|
|
78
|
-
throw new Error(`Invalid reserved zstd block type at offset ${offset - 3} in ${path}`);
|
|
79
|
-
}
|
|
80
|
-
const blockSize = blockHeader >>> 3;
|
|
81
|
-
// RLE blocks store one repeated byte; other block types store blockSize bytes.
|
|
82
|
-
offset += blockType === 1 ? 1 : blockSize;
|
|
83
|
-
ensureAvailable(offset, 0, fileSize, path);
|
|
84
|
-
}
|
|
85
|
-
if (contentChecksum) {
|
|
86
|
-
offset += 4;
|
|
87
|
-
ensureAvailable(offset, 0, fileSize, path);
|
|
88
|
-
}
|
|
89
|
-
frames.push({ offset: frameStart, length: offset - frameStart });
|
|
90
|
-
}
|
|
91
|
-
return frames;
|
|
92
|
-
}
|
|
93
|
-
async function* readFileRange(file, position, length, chunkSize) {
|
|
94
|
-
let bytesRemaining = length;
|
|
95
|
-
while (bytesRemaining > 0) {
|
|
96
|
-
const buffer = Buffer.allocUnsafe(Math.min(bytesRemaining, chunkSize));
|
|
97
|
-
const { bytesRead } = await file.read(buffer, 0, buffer.length, position);
|
|
98
|
-
if (bytesRead === 0) {
|
|
99
|
-
throw new Error(`Expected ${length} compressed bytes, read ${length - bytesRemaining}`);
|
|
100
|
-
}
|
|
101
|
-
yield bytesRead === buffer.length ? buffer : buffer.subarray(0, bytesRead);
|
|
102
|
-
position += bytesRead;
|
|
103
|
-
bytesRemaining -= bytesRead;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
async function readExactly(file, buffer, length, position, fileSize, path) {
|
|
107
|
-
ensureAvailable(position, length, fileSize, path);
|
|
108
|
-
let bytesRead = 0;
|
|
109
|
-
while (bytesRead < length) {
|
|
110
|
-
const result = await file.read(buffer, bytesRead, length - bytesRead, position + bytesRead);
|
|
111
|
-
if (result.bytesRead === 0) {
|
|
112
|
-
throw new Error(`Truncated zstd frame at offset ${position + bytesRead} in ${path}`);
|
|
113
|
-
}
|
|
114
|
-
bytesRead += result.bytesRead;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
function ensureAvailable(position, length, fileSize, path) {
|
|
118
|
-
if (position < 0 || length < 0 || position > fileSize - length) {
|
|
119
|
-
throw new Error(`Truncated zstd frame at offset ${position} in ${path}`);
|
|
120
|
-
}
|
|
4
|
+
// Node supports rejectGarbageAfterEnd for Zstd; @types/node does not declare it yet.
|
|
5
|
+
const decoderOptions = { chunkSize, rejectGarbageAfterEnd: true };
|
|
6
|
+
return createReadStream(path, { highWaterMark: chunkSize }).compose(createZstdDecompress(decoderOptions));
|
|
121
7
|
}
|
|
122
8
|
//# sourceMappingURL=zstd.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"zstd.js","sourceRoot":"","sources":["../src/zstd.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"zstd.js","sourceRoot":"","sources":["../src/zstd.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAC1C,OAAO,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAA;AAEhD,MAAM,UAAU,6BAA6B,CAAC,IAAY,EAAE,SAAiB;IAC3E,qFAAqF;IACrF,MAAM,cAAc,GAAG,EAAE,SAAS,EAAE,qBAAqB,EAAE,IAAI,EAAE,CAAA;IACjE,OAAO,gBAAgB,CAAC,IAAI,EAAE,EAAE,aAAa,EAAE,SAAS,EAAE,CAAC,CAAC,OAAO,CAAC,oBAAoB,CAAC,cAAc,CAAC,CAAC,CAAA;AAC3G,CAAC"}
|
|
@@ -35,6 +35,7 @@ export const EXCHANGES = [
|
|
|
35
35
|
'bybit-spread',
|
|
36
36
|
'phemex',
|
|
37
37
|
'delta',
|
|
38
|
+
'delta-india',
|
|
38
39
|
'ftx-us',
|
|
39
40
|
'binance-us',
|
|
40
41
|
'gate-io-futures',
|
|
@@ -475,6 +476,18 @@ const DELTA_CHANNELS = [
|
|
|
475
476
|
'spot_30mtwap_price'
|
|
476
477
|
] as const
|
|
477
478
|
|
|
479
|
+
const DELTA_INDIA_CHANNELS = [
|
|
480
|
+
'trades',
|
|
481
|
+
'ob_updates',
|
|
482
|
+
'ob_l1',
|
|
483
|
+
'ticker',
|
|
484
|
+
'mark_price',
|
|
485
|
+
'funding_rate',
|
|
486
|
+
'spot_price',
|
|
487
|
+
'product_updates',
|
|
488
|
+
'system_status'
|
|
489
|
+
] as const
|
|
490
|
+
|
|
478
491
|
const GATE_IO_CHANNELS = ['trades', 'depth', 'ticker', 'book_ticker', 'order_book_update', 'obu'] as const
|
|
479
492
|
const GATE_IO_FUTURES_CHANNELS = ['trades', 'order_book', 'tickers', 'book_ticker', 'obu', 'public_liquidates'] as const
|
|
480
493
|
const MEXC_FUTURES_CHANNELS = [
|
|
@@ -654,6 +667,7 @@ export const EXCHANGE_CHANNELS_INFO = {
|
|
|
654
667
|
coinflex: COINFLEX_CHANNELS,
|
|
655
668
|
phemex: PHEMEX_CHANNELS,
|
|
656
669
|
delta: DELTA_CHANNELS,
|
|
670
|
+
'delta-india': DELTA_INDIA_CHANNELS,
|
|
657
671
|
'gate-io': GATE_IO_CHANNELS,
|
|
658
672
|
'gate-io-futures': GATE_IO_FUTURES_CHANNELS,
|
|
659
673
|
'mexc-futures': MEXC_FUTURES_CHANNELS,
|
|
@@ -2,11 +2,11 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { debug } from './debug.ts'
|
|
3
3
|
import { DatasetType } from './exchangedetails.ts'
|
|
4
4
|
import { forEachConcurrent } from './foreachconcurrent.ts'
|
|
5
|
-
import { addDays, doubleDigit,
|
|
5
|
+
import { addDays, doubleDigit, downloadDatasetFile, parseAsUTCDate, RequestSlots, sequence } from './handy.ts'
|
|
6
6
|
import { getOptions } from './options.ts'
|
|
7
7
|
import { Exchange } from './types.ts'
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const DEFAULT_CONCURRENCY = 20
|
|
10
10
|
const MILLISECONDS_IN_SINGLE_DAY = 24 * 60 * 60 * 1000
|
|
11
11
|
const DEFAULT_DOWNLOAD_DIR = './datasets'
|
|
12
12
|
const DATASET_DOWNLOAD_ATTEMPT_TIMEOUT_MS = 120_000
|
|
@@ -19,6 +19,14 @@ export async function downloadDatasets(downloadDatasetsOptions: DownloadDatasets
|
|
|
19
19
|
const format = downloadDatasetsOptions.format !== undefined ? downloadDatasetsOptions.format : 'csv'
|
|
20
20
|
const getFilename = downloadDatasetsOptions.getFilename !== undefined ? downloadDatasetsOptions.getFilename : getFilenameDefault
|
|
21
21
|
const skipIfExists = downloadDatasetsOptions.skipIfExists === undefined ? true : downloadDatasetsOptions.skipIfExists
|
|
22
|
+
const verifyMd5 = downloadDatasetsOptions.verifyMd5 === true
|
|
23
|
+
const concurrency = downloadDatasetsOptions.concurrency !== undefined ? downloadDatasetsOptions.concurrency : DEFAULT_CONCURRENCY
|
|
24
|
+
|
|
25
|
+
if (concurrency < 1) {
|
|
26
|
+
throw new Error('Invalid "concurrency" argument. Please provide value greater than 0.')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const requestSlots = new RequestSlots(concurrency)
|
|
22
30
|
|
|
23
31
|
// in case someone provided 'api/exchange' symbol, transform it to symbol that is accepted by datasets API
|
|
24
32
|
const datasetsSymbols = symbols.map((s) => s.replace(/\/|:/g, '-').toUpperCase())
|
|
@@ -42,9 +50,11 @@ export async function downloadDatasets(downloadDatasetsOptions: DownloadDatasets
|
|
|
42
50
|
dataType,
|
|
43
51
|
format,
|
|
44
52
|
getFilename,
|
|
53
|
+
verifyMd5,
|
|
45
54
|
date: addDays(startDate, daysCountToFetch - 1)
|
|
46
55
|
}),
|
|
47
|
-
skipIfExists
|
|
56
|
+
skipIfExists,
|
|
57
|
+
requestSlots
|
|
48
58
|
)
|
|
49
59
|
}
|
|
50
60
|
|
|
@@ -60,15 +70,17 @@ export async function downloadDatasets(downloadDatasetsOptions: DownloadDatasets
|
|
|
60
70
|
dataType,
|
|
61
71
|
format,
|
|
62
72
|
getFilename,
|
|
73
|
+
verifyMd5,
|
|
63
74
|
date: startDate
|
|
64
75
|
}),
|
|
65
|
-
skipIfExists
|
|
76
|
+
skipIfExists,
|
|
77
|
+
requestSlots
|
|
66
78
|
)
|
|
67
79
|
|
|
68
|
-
// download the rest concurrently up to the
|
|
80
|
+
// download the rest concurrently up to the concurrency limit
|
|
69
81
|
await forEachConcurrent(
|
|
70
82
|
sequence(daysCountToFetch - 1, 1), // this will produce Iterable sequence from 1 to daysCountToFetch - 1 (as we already downloaded data for the first and last day)
|
|
71
|
-
|
|
83
|
+
concurrency,
|
|
72
84
|
(offset) =>
|
|
73
85
|
downloadDataSet(
|
|
74
86
|
getDownloadOptions({
|
|
@@ -81,9 +93,11 @@ export async function downloadDatasets(downloadDatasetsOptions: DownloadDatasets
|
|
|
81
93
|
dataType,
|
|
82
94
|
format,
|
|
83
95
|
getFilename,
|
|
96
|
+
verifyMd5,
|
|
84
97
|
date: addDays(startDate, offset)
|
|
85
98
|
}),
|
|
86
|
-
skipIfExists
|
|
99
|
+
skipIfExists,
|
|
100
|
+
requestSlots
|
|
87
101
|
)
|
|
88
102
|
)
|
|
89
103
|
const elapsedSeconds = (new Date().valueOf() - startTimestamp) / 1000
|
|
@@ -93,12 +107,12 @@ export async function downloadDatasets(downloadDatasetsOptions: DownloadDatasets
|
|
|
93
107
|
}
|
|
94
108
|
}
|
|
95
109
|
|
|
96
|
-
async function downloadDataSet(downloadOptions: DownloadOptions, skipIfExists: boolean) {
|
|
110
|
+
async function downloadDataSet(downloadOptions: DownloadOptions, skipIfExists: boolean, requestSlots: RequestSlots) {
|
|
97
111
|
if (skipIfExists && existsSync(downloadOptions.downloadPath)) {
|
|
98
112
|
debug('dataset %s already exists, skipping download', downloadOptions.downloadPath)
|
|
99
113
|
return
|
|
100
114
|
} else {
|
|
101
|
-
return await
|
|
115
|
+
return await downloadDatasetFile({ ...downloadOptions, requestSlots })
|
|
102
116
|
}
|
|
103
117
|
}
|
|
104
118
|
|
|
@@ -112,7 +126,8 @@ function getDownloadOptions({
|
|
|
112
126
|
symbol,
|
|
113
127
|
format,
|
|
114
128
|
downloadDir,
|
|
115
|
-
getFilename
|
|
129
|
+
getFilename,
|
|
130
|
+
verifyMd5
|
|
116
131
|
}: {
|
|
117
132
|
exchange: Exchange
|
|
118
133
|
dataType: DatasetType
|
|
@@ -124,6 +139,7 @@ function getDownloadOptions({
|
|
|
124
139
|
userAgent: string
|
|
125
140
|
downloadDir: string
|
|
126
141
|
getFilename: (options: GetFilenameOptions) => string
|
|
142
|
+
verifyMd5: boolean
|
|
127
143
|
}): DownloadOptions {
|
|
128
144
|
const year = date.getUTCFullYear()
|
|
129
145
|
const month = doubleDigit(date.getUTCMonth() + 1)
|
|
@@ -145,11 +161,12 @@ function getDownloadOptions({
|
|
|
145
161
|
downloadPath,
|
|
146
162
|
userAgent,
|
|
147
163
|
apiKey,
|
|
148
|
-
attemptTimeoutMS: DATASET_DOWNLOAD_ATTEMPT_TIMEOUT_MS
|
|
164
|
+
attemptTimeoutMS: DATASET_DOWNLOAD_ATTEMPT_TIMEOUT_MS,
|
|
165
|
+
verifyMd5
|
|
149
166
|
}
|
|
150
167
|
}
|
|
151
168
|
|
|
152
|
-
type DownloadOptions = Parameters<typeof
|
|
169
|
+
type DownloadOptions = Omit<Parameters<typeof downloadDatasetFile>[0], 'requestSlots'>
|
|
153
170
|
|
|
154
171
|
export function sanitizeForFilename(s: string) {
|
|
155
172
|
return s.replace(/[:\\/?*<>|"]/g, '-')
|
|
@@ -201,4 +218,6 @@ type DownloadDatasetsOptions = {
|
|
|
201
218
|
downloadDir?: string
|
|
202
219
|
getFilename?: (options: GetFilenameOptions) => string
|
|
203
220
|
skipIfExists?: boolean
|
|
221
|
+
verifyMd5?: boolean
|
|
222
|
+
concurrency?: number
|
|
204
223
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import crypto, { createHash } from 'crypto'
|
|
2
|
-
import { createWriteStream, mkdirSync, rmSync } from 'node:fs'
|
|
2
|
+
import { createReadStream, createWriteStream, mkdirSync, rmSync } from 'node:fs'
|
|
3
3
|
import { rename } from 'node:fs/promises'
|
|
4
4
|
import { Agent as HttpAgent, request as httpRequest, type IncomingMessage } from 'node:http'
|
|
5
5
|
import { Agent as HttpsAgent, request as httpsRequest, type RequestOptions } from 'node:https'
|
|
6
6
|
import { pipeline } from 'node:stream/promises'
|
|
7
7
|
import path from 'path'
|
|
8
8
|
import { debug } from './debug.ts'
|
|
9
|
+
import { forEachConcurrent } from './foreachconcurrent.ts'
|
|
9
10
|
import { Mapper } from './mappers/index.ts'
|
|
10
11
|
import { Disconnect, Exchange, Filter, FilterForExchange } from './types.ts'
|
|
11
12
|
|
|
@@ -500,7 +501,7 @@ async function openDownloadResponse(url: string, options: DownloadRequestOptions
|
|
|
500
501
|
const statusCode = response.statusCode ?? 0
|
|
501
502
|
const location = response.headers.location
|
|
502
503
|
if (location === undefined || statusCode < 300 || statusCode >= 400) {
|
|
503
|
-
return response
|
|
504
|
+
return { response }
|
|
504
505
|
}
|
|
505
506
|
|
|
506
507
|
response.destroy()
|
|
@@ -512,10 +513,13 @@ async function openDownloadResponse(url: string, options: DownloadRequestOptions
|
|
|
512
513
|
delete headers.authorization
|
|
513
514
|
}
|
|
514
515
|
|
|
515
|
-
return
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
516
|
+
return {
|
|
517
|
+
response: await sendHttpRequest(redirectUrl, {
|
|
518
|
+
...options,
|
|
519
|
+
headers
|
|
520
|
+
}),
|
|
521
|
+
redirectUrl: redirectUrl.toString()
|
|
522
|
+
}
|
|
519
523
|
}
|
|
520
524
|
|
|
521
525
|
async function readResponseText(response: IncomingMessage) {
|
|
@@ -619,6 +623,12 @@ export async function download({
|
|
|
619
623
|
}
|
|
620
624
|
}
|
|
621
625
|
|
|
626
|
+
return withDownloadRetries(url, downloadPath, (attemptUrl) =>
|
|
627
|
+
_downloadFile(httpRequestOptions, attemptUrl, downloadPath, appendContentEncodingExtension)
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function withDownloadRetries<T>(url: string, downloadPath: string, attempt: (attemptUrl: string) => Promise<T>) {
|
|
622
632
|
const MAX_ATTEMPTS = 30
|
|
623
633
|
const MAX_RETRY_AFTER_MS = 300 * ONE_SEC_IN_MS
|
|
624
634
|
let attempts = 0
|
|
@@ -628,11 +638,7 @@ export async function download({
|
|
|
628
638
|
attempts++
|
|
629
639
|
try {
|
|
630
640
|
const addRetryAttempt = attempts - 1 > 0 && url.endsWith('gz')
|
|
631
|
-
|
|
632
|
-
return await _downloadFile(httpRequestOptions, `${url}?retryAttempt=${attempts - 1}`, downloadPath, appendContentEncodingExtension)
|
|
633
|
-
} else {
|
|
634
|
-
return await _downloadFile(httpRequestOptions, url, downloadPath, appendContentEncodingExtension)
|
|
635
|
-
}
|
|
641
|
+
return await attempt(addRetryAttempt ? `${url}?retryAttempt=${attempts - 1}` : url)
|
|
636
642
|
} catch (error) {
|
|
637
643
|
const unsupportedDataFeedEncoding = error instanceof Error && error.message.startsWith('Unsupported data feed content encoding')
|
|
638
644
|
const nonRetryableHttpError =
|
|
@@ -640,13 +646,20 @@ export async function download({
|
|
|
640
646
|
((error.status === 400 && error.message.includes('ISO 8601 format') === false) ||
|
|
641
647
|
error.status === 401 ||
|
|
642
648
|
error.status === 403 ||
|
|
643
|
-
error.status === 404
|
|
649
|
+
error.status === 404 ||
|
|
650
|
+
error.status === 416)
|
|
644
651
|
const tooManyRequests = error instanceof HttpError && error.status === 429
|
|
645
652
|
const retryAfterMS = tooManyRequests ? error.retryAfterMS : undefined
|
|
646
653
|
const retryAfterTooLong = retryAfterMS !== undefined && retryAfterMS > MAX_RETRY_AFTER_MS
|
|
647
654
|
const internalServiceError = error instanceof HttpError && error.status === 500
|
|
648
655
|
// do not retry when we've got bad or unauthorized request, too long Retry-After or enough attempts
|
|
649
|
-
if (
|
|
656
|
+
if (
|
|
657
|
+
unsupportedDataFeedEncoding ||
|
|
658
|
+
error instanceof DatasetMismatchError ||
|
|
659
|
+
nonRetryableHttpError ||
|
|
660
|
+
retryAfterTooLong ||
|
|
661
|
+
attempts === MAX_ATTEMPTS
|
|
662
|
+
) {
|
|
650
663
|
throw error
|
|
651
664
|
}
|
|
652
665
|
|
|
@@ -703,7 +716,7 @@ async function _downloadFile(
|
|
|
703
716
|
|
|
704
717
|
try {
|
|
705
718
|
// based on https://github.com/nodejs/node/issues/28172 - only reliable way to consume response stream and avoiding all the 'gotchas'
|
|
706
|
-
const response = await openDownloadResponse(url, requestOptions)
|
|
719
|
+
const { response } = await openDownloadResponse(url, requestOptions)
|
|
707
720
|
const { statusCode } = response
|
|
708
721
|
const responseHeaders = parseNodeResponseHeaders(response.headers)
|
|
709
722
|
if (statusCode !== 200) {
|
|
@@ -741,6 +754,240 @@ async function _downloadFile(
|
|
|
741
754
|
}
|
|
742
755
|
}
|
|
743
756
|
|
|
757
|
+
const DATASET_RANGE_SIZE = 64 * 1024 * 1024
|
|
758
|
+
// A dataset is rarely exported again while it downloads, so repeated mismatches mean another restart won't help.
|
|
759
|
+
const MAX_DATASET_RESTARTS = 3
|
|
760
|
+
|
|
761
|
+
// The downloaded ranges don't make up one dataset file, because it was exported again during the download or doesn't
|
|
762
|
+
// match its announced MD5, so the download starts over.
|
|
763
|
+
class DatasetMismatchError extends Error {}
|
|
764
|
+
|
|
765
|
+
// One pool for all files and ranges of a download, so splitting files into ranges does not add connections.
|
|
766
|
+
export class RequestSlots {
|
|
767
|
+
private active = 0
|
|
768
|
+
private readonly waiting: (() => void)[] = []
|
|
769
|
+
|
|
770
|
+
constructor(readonly limit: number) {}
|
|
771
|
+
|
|
772
|
+
async run<T>(request: () => Promise<T>) {
|
|
773
|
+
if (this.active < this.limit) {
|
|
774
|
+
this.active++
|
|
775
|
+
} else {
|
|
776
|
+
await new Promise<void>((resolve) => this.waiting.push(resolve))
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
try {
|
|
780
|
+
return await request()
|
|
781
|
+
} finally {
|
|
782
|
+
const next = this.waiting.shift()
|
|
783
|
+
if (next === undefined) {
|
|
784
|
+
this.active--
|
|
785
|
+
} else {
|
|
786
|
+
next()
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Downloads byte ranges in parallel into one temp file, and a failed range retries alone.
|
|
793
|
+
export async function downloadDatasetFile({
|
|
794
|
+
apiKey,
|
|
795
|
+
downloadPath,
|
|
796
|
+
url,
|
|
797
|
+
userAgent,
|
|
798
|
+
attemptTimeoutMS,
|
|
799
|
+
verifyMd5,
|
|
800
|
+
requestSlots
|
|
801
|
+
}: {
|
|
802
|
+
url: string
|
|
803
|
+
downloadPath: string
|
|
804
|
+
userAgent: string
|
|
805
|
+
apiKey: string
|
|
806
|
+
attemptTimeoutMS: number
|
|
807
|
+
verifyMd5: boolean
|
|
808
|
+
requestSlots: RequestSlots
|
|
809
|
+
}) {
|
|
810
|
+
// The API key goes only to the API, never to the storage the API redirects to.
|
|
811
|
+
const storageRequestOptions = {
|
|
812
|
+
timeout: attemptTimeoutMS,
|
|
813
|
+
headers: {
|
|
814
|
+
'Accept-Encoding': 'gzip',
|
|
815
|
+
'User-Agent': userAgent
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
const apiRequestOptions = {
|
|
819
|
+
...storageRequestOptions,
|
|
820
|
+
headers: { ...storageRequestOptions.headers, Authorization: apiKey ? `Bearer ${apiKey}` : '' }
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
mkdirSync(path.dirname(downloadPath), { recursive: true })
|
|
824
|
+
const tmpFilePath = `${downloadPath}${crypto.randomBytes(8).toString('hex')}.unconfirmed`
|
|
825
|
+
const cleanup = () => {
|
|
826
|
+
try {
|
|
827
|
+
rmSync(tmpFilePath, { force: true })
|
|
828
|
+
} catch {}
|
|
829
|
+
}
|
|
830
|
+
tmpFileCleanups.set(tmpFilePath, cleanup)
|
|
831
|
+
|
|
832
|
+
try {
|
|
833
|
+
for (let restarts = 0; ; restarts++) {
|
|
834
|
+
try {
|
|
835
|
+
const announcedMd5 = await downloadDatasetRanges(url, apiRequestOptions, storageRequestOptions, tmpFilePath, requestSlots)
|
|
836
|
+
if (verifyMd5) {
|
|
837
|
+
await checkDatasetMd5(tmpFilePath, announcedMd5, url)
|
|
838
|
+
}
|
|
839
|
+
break
|
|
840
|
+
} catch (error) {
|
|
841
|
+
if (!(error instanceof DatasetMismatchError) || restarts === MAX_DATASET_RESTARTS) {
|
|
842
|
+
throw error
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
await rename(tmpFilePath, downloadPath)
|
|
848
|
+
} finally {
|
|
849
|
+
tmpFileCleanups.delete(tmpFilePath)
|
|
850
|
+
cleanup()
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function downloadDatasetRanges(
|
|
855
|
+
url: string,
|
|
856
|
+
apiRequestOptions: DownloadRequestOptions,
|
|
857
|
+
storageRequestOptions: DownloadRequestOptions,
|
|
858
|
+
tmpFilePath: string,
|
|
859
|
+
requestSlots: RequestSlots
|
|
860
|
+
) {
|
|
861
|
+
const first = await withDownloadRetries(url, tmpFilePath, (attemptUrl) =>
|
|
862
|
+
requestSlots.run(() => downloadDatasetRange(attemptUrl, apiRequestOptions, tmpFilePath, 0))
|
|
863
|
+
)
|
|
864
|
+
const datasetSize = first.datasetSize
|
|
865
|
+
if (datasetSize === undefined || datasetSize <= DATASET_RANGE_SIZE) {
|
|
866
|
+
return first.md5
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// A low-cost account gets a storage redirect that stays valid for two hours, and every redirect counts the whole
|
|
870
|
+
// dataset toward its transfer limit, so the remaining ranges reuse it and only an expired one is renewed.
|
|
871
|
+
let storageUrl = first.redirectUrl
|
|
872
|
+
let renewal: ReturnType<typeof downloadDatasetRange> | undefined
|
|
873
|
+
|
|
874
|
+
const downloadRemainingRange = async (attemptUrl: string, start: number) => {
|
|
875
|
+
// Read the URL only once the range has a slot, so a range that waited long uses the newest URL.
|
|
876
|
+
const rangeUrl = storageUrl
|
|
877
|
+
if (rangeUrl === undefined) {
|
|
878
|
+
return downloadDatasetRange(attemptUrl, apiRequestOptions, tmpFilePath, start, datasetSize, first.md5)
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
try {
|
|
882
|
+
return await downloadDatasetRange(rangeUrl, storageRequestOptions, tmpFilePath, start, datasetSize, first.md5)
|
|
883
|
+
} catch (error) {
|
|
884
|
+
if (!(error instanceof HttpError && error.status === 403)) {
|
|
885
|
+
throw error
|
|
886
|
+
}
|
|
887
|
+
// The first range to find the URL expired downloads through the API again, like the first range, and the
|
|
888
|
+
// redirect it gets replaces the URL. The others wait for it and retry with the new URL, or fail this attempt
|
|
889
|
+
// with its error.
|
|
890
|
+
if (renewal === undefined && storageUrl === rangeUrl) {
|
|
891
|
+
renewal = downloadDatasetRange(attemptUrl, apiRequestOptions, tmpFilePath, start, datasetSize, first.md5)
|
|
892
|
+
.then((renewedRange) => {
|
|
893
|
+
storageUrl = renewedRange.redirectUrl
|
|
894
|
+
return renewedRange
|
|
895
|
+
})
|
|
896
|
+
.finally(() => {
|
|
897
|
+
renewal = undefined
|
|
898
|
+
})
|
|
899
|
+
return await renewal
|
|
900
|
+
}
|
|
901
|
+
await renewal
|
|
902
|
+
throw new Error('Dataset redirect expired')
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const rangeStarts = [...sequence(Math.ceil(datasetSize / DATASET_RANGE_SIZE), 1)].map((index) => index * DATASET_RANGE_SIZE)
|
|
907
|
+
// Unlike Promise.all, forEachConcurrent rejects only after the ranges already started have finished, retries included,
|
|
908
|
+
// so a restart or cleanup never overlaps a range still writing.
|
|
909
|
+
await forEachConcurrent(rangeStarts, requestSlots.limit, (start) =>
|
|
910
|
+
withDownloadRetries(url, tmpFilePath, (attemptUrl) => requestSlots.run(() => downloadRemainingRange(attemptUrl, start)))
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
return first.md5
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async function downloadDatasetRange(
|
|
917
|
+
url: string,
|
|
918
|
+
requestOptions: DownloadRequestOptions,
|
|
919
|
+
tmpFilePath: string,
|
|
920
|
+
start: number,
|
|
921
|
+
expectedDatasetSize?: number,
|
|
922
|
+
expectedMd5?: string
|
|
923
|
+
) {
|
|
924
|
+
const end = start + DATASET_RANGE_SIZE - 1
|
|
925
|
+
const { response, redirectUrl } = await openDownloadResponse(url, {
|
|
926
|
+
...requestOptions,
|
|
927
|
+
headers: { ...requestOptions.headers, Range: `bytes=${start}-${end}` }
|
|
928
|
+
})
|
|
929
|
+
const statusCode = response.statusCode ?? 0
|
|
930
|
+
// A later range that comes back whole or out of bounds belongs to a dataset exported again with another size.
|
|
931
|
+
if (start > 0 && (statusCode === 200 || statusCode === 416)) {
|
|
932
|
+
response.destroy()
|
|
933
|
+
throw new DatasetMismatchError(`Dataset changed during download: bytes=${start}-${end} returned ${statusCode}`)
|
|
934
|
+
}
|
|
935
|
+
// 200 means the storage ignored the range and sends the whole dataset.
|
|
936
|
+
if (statusCode !== 206 && statusCode !== 200) {
|
|
937
|
+
throw new HttpError(statusCode, await readResponseText(response), url, parseNodeResponseHeaders(response.headers))
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
let datasetSize: number | undefined
|
|
941
|
+
if (statusCode === 206) {
|
|
942
|
+
const contentRange = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(asSingleHeaderValue(response.headers['content-range']) ?? '')
|
|
943
|
+
datasetSize = Number(contentRange?.[3])
|
|
944
|
+
if (contentRange === null || Number(contentRange[1]) !== start || Number(contentRange[2]) !== Math.min(end, datasetSize - 1)) {
|
|
945
|
+
response.destroy()
|
|
946
|
+
throw new Error(`Unexpected Content-Range for bytes=${start}-${end}: ${response.headers['content-range']}`)
|
|
947
|
+
}
|
|
948
|
+
if (expectedDatasetSize !== undefined && datasetSize !== expectedDatasetSize) {
|
|
949
|
+
response.destroy()
|
|
950
|
+
throw new DatasetMismatchError(`Dataset size changed from ${expectedDatasetSize} to ${datasetSize} bytes during download`)
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
// A dataset exported again with the same size announces another MD5.
|
|
954
|
+
const md5 = getAnnouncedMd5(parseNodeResponseHeaders(response.headers))
|
|
955
|
+
if (expectedMd5 !== undefined && md5 !== undefined && md5 !== expectedMd5) {
|
|
956
|
+
response.destroy()
|
|
957
|
+
throw new DatasetMismatchError(`Dataset MD5 changed from ${expectedMd5} to ${md5} during download`)
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
await pipeline(response, createWriteStream(tmpFilePath, { flags: start === 0 ? 'w' : 'r+', start }))
|
|
961
|
+
if (!response.complete) {
|
|
962
|
+
throw new Error('The connection was terminated while the message was still being sent')
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return { datasetSize, redirectUrl, md5 }
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Ranges are written in parallel, so the MD5 is computed from the finished file.
|
|
969
|
+
async function checkDatasetMd5(tmpFilePath: string, announcedMd5: string | undefined, url: string) {
|
|
970
|
+
if (announcedMd5 === undefined) {
|
|
971
|
+
return
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const md5 = createHash('md5')
|
|
975
|
+
for await (const chunk of createReadStream(tmpFilePath)) {
|
|
976
|
+
md5.update(chunk)
|
|
977
|
+
}
|
|
978
|
+
const actualMd5 = md5.digest('hex')
|
|
979
|
+
if (actualMd5 !== announcedMd5) {
|
|
980
|
+
throw new DatasetMismatchError(`MD5 mismatch for ${url}: expected ${announcedMd5}, got ${actualMd5}`)
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// The API sends x-md5, while a redirect to the storage returns the storage's x-amz-meta-md5 and ETag.
|
|
985
|
+
// A multipart ETag ("<hash>-<parts>") is not the file MD5, so such a file is saved without a check.
|
|
986
|
+
function getAnnouncedMd5(headers: Record<string, string>) {
|
|
987
|
+
const md5 = (headers['x-md5'] ?? headers['x-amz-meta-md5'] ?? headers['etag'] ?? '').replace(/"/g, '').toLowerCase()
|
|
988
|
+
return /^[0-9a-f]{32}$/.test(md5) ? md5 : undefined
|
|
989
|
+
}
|
|
990
|
+
|
|
744
991
|
function asSingleHeaderValue(headerValue: string | string[] | undefined) {
|
|
745
992
|
if (Array.isArray(headerValue)) {
|
|
746
993
|
return headerValue[0]
|
|
@@ -162,7 +162,7 @@ export interface InstrumentInfo {
|
|
|
162
162
|
contractMultiplier?: number
|
|
163
163
|
/** only for quanto instruments */
|
|
164
164
|
quanto?: boolean
|
|
165
|
-
/**
|
|
165
|
+
/** currency in which the derivative contract settles, when provided */
|
|
166
166
|
settlementCurrency?: string
|
|
167
167
|
/** strike price, only for options */
|
|
168
168
|
strikePrice?: number
|