laplace-api 1.1.3 → 1.1.5
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "laplace-api",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.5",
|
|
4
4
|
"description": "Client library for Laplace API for the US stock market and BIST (Istanbul stock market) fundamental financial data.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"event-source-polyfill": "^1.0.31",
|
|
43
43
|
"mongodb": "^6.8.0",
|
|
44
44
|
"uuid": "^10.0.0",
|
|
45
|
-
"winston": "^3.14.2"
|
|
45
|
+
"winston": "^3.14.2",
|
|
46
|
+
"ws": "^8.18.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@types/axios": "^0.14.0",
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
"@types/mongodb": "^4.0.7",
|
|
52
53
|
"@types/node": "^22.5.4",
|
|
53
54
|
"@types/winston": "^2.4.4",
|
|
55
|
+
"@types/ws": "^8.5.13",
|
|
54
56
|
"jest": "^29.7.0",
|
|
55
57
|
"ts-jest": "^29.2.5",
|
|
56
58
|
"typescript": "^5.5.4"
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
export interface BISTStockLiveData {
|
|
2
|
+
symbol: string;
|
|
3
|
+
cl: number; // Close
|
|
4
|
+
c: number; // PercentChange
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface WebSocketOptions {
|
|
8
|
+
enableLogging?: boolean;
|
|
9
|
+
reconnectAttempts?: number;
|
|
10
|
+
reconnectDelay?: number;
|
|
11
|
+
maxReconnectDelay?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type WebSocketMessageType = "heartbeat" | "error" | "warning" | "price_update";
|
|
15
|
+
|
|
16
|
+
interface WebSocketMessage<T> {
|
|
17
|
+
type: WebSocketMessageType;
|
|
18
|
+
message?: T;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export enum WebSocketErrorType {
|
|
22
|
+
MAX_RECONNECT_EXCEEDED = "MAX_RECONNECT_EXCEEDED",
|
|
23
|
+
CONNECTION_ERROR = "CONNECTION_ERROR",
|
|
24
|
+
CLOSE_ERROR = "CLOSE_ERROR",
|
|
25
|
+
WEBSOCKET_NOT_INITIALIZED = "WEBSOCKET_NOT_INITIALIZED",
|
|
26
|
+
MESSAGE_PARSE_ERROR = "MESSAGE_PARSE_ERROR",
|
|
27
|
+
WEBSOCKET_NOT_CONNECTED = "WEBSOCKET_NOT_CONNECTED",
|
|
28
|
+
WEBSOCKET_ERROR = "WEBSOCKET_ERROR",
|
|
29
|
+
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export enum WebSocketCloseReason {
|
|
33
|
+
NORMAL_CLOSURE = "NORMAL_CLOSURE",
|
|
34
|
+
CONNECTION_ERROR = "CONNECTION_ERROR",
|
|
35
|
+
MAX_RECONNECT_EXCEEDED = "MAX_RECONNECT_EXCEEDED",
|
|
36
|
+
UNKNOWN = "UNKNOWN",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class WebSocketError extends Error {
|
|
40
|
+
constructor(
|
|
41
|
+
message: string,
|
|
42
|
+
public readonly code: WebSocketErrorType = WebSocketErrorType.UNKNOWN_ERROR
|
|
43
|
+
) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "WebSocketError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class LivePriceWebSocketClient {
|
|
50
|
+
private ws: WebSocket | null = null;
|
|
51
|
+
private subscriptionCounter = 0;
|
|
52
|
+
private subscriptions = new Map<
|
|
53
|
+
number,
|
|
54
|
+
{
|
|
55
|
+
symbols: string[];
|
|
56
|
+
handler: (data: BISTStockLiveData) => void;
|
|
57
|
+
}
|
|
58
|
+
>();
|
|
59
|
+
private reconnectAttempts = 0;
|
|
60
|
+
private reconnectTimeout: NodeJS.Timeout | null = null;
|
|
61
|
+
private isClosed: boolean = false;
|
|
62
|
+
private closedReason: WebSocketCloseReason | null = null;
|
|
63
|
+
private wsUrl: string | null = null;
|
|
64
|
+
private readonly options: Required<WebSocketOptions>;
|
|
65
|
+
|
|
66
|
+
constructor(options: WebSocketOptions = {}) {
|
|
67
|
+
this.options = {
|
|
68
|
+
enableLogging: true,
|
|
69
|
+
reconnectAttempts: 5,
|
|
70
|
+
reconnectDelay: 1000,
|
|
71
|
+
maxReconnectDelay: 30000,
|
|
72
|
+
...options,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private log(message: string, level: "info" | "error" | "warn" = "info") {
|
|
77
|
+
if (!this.options.enableLogging) return;
|
|
78
|
+
|
|
79
|
+
const prefix = `[LivePriceWebSocket][${level.toUpperCase()}]`;
|
|
80
|
+
|
|
81
|
+
switch (level) {
|
|
82
|
+
case "error":
|
|
83
|
+
console.error(`${prefix} ${message}`);
|
|
84
|
+
break;
|
|
85
|
+
case "warn":
|
|
86
|
+
console.warn(`${prefix} ${message}`);
|
|
87
|
+
break;
|
|
88
|
+
default:
|
|
89
|
+
console.info(`${prefix} ${message}`);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async connect(url: string): Promise<WebSocket> {
|
|
95
|
+
this.log("Connecting to WebSocket...");
|
|
96
|
+
this.wsUrl = url;
|
|
97
|
+
|
|
98
|
+
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
|
|
99
|
+
this.ws = new WebSocket(url);
|
|
100
|
+
await this.setupWebSocket();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return this.ws;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async setupWebSocket(): Promise<void> {
|
|
107
|
+
if (!this.ws) {
|
|
108
|
+
throw new WebSocketError(
|
|
109
|
+
"WebSocket not initialized",
|
|
110
|
+
WebSocketErrorType.WEBSOCKET_NOT_INITIALIZED
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
if (!this.ws) {
|
|
116
|
+
return reject(
|
|
117
|
+
new WebSocketError(
|
|
118
|
+
"WebSocket not initialized",
|
|
119
|
+
WebSocketErrorType.WEBSOCKET_NOT_INITIALIZED
|
|
120
|
+
)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.ws.onopen = () => {
|
|
125
|
+
this.reconnectAttempts = 0;
|
|
126
|
+
this.log("WebSocket connected");
|
|
127
|
+
resolve();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
this.ws.onerror = (error) => {
|
|
131
|
+
reject(
|
|
132
|
+
new WebSocketError(
|
|
133
|
+
`WebSocket connection error: ${error}`,
|
|
134
|
+
WebSocketErrorType.CONNECTION_ERROR
|
|
135
|
+
)
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
this.ws.onclose = () => {
|
|
140
|
+
this.isClosed = true;
|
|
141
|
+
this.log("WebSocket closed");
|
|
142
|
+
if (this.closedReason !== WebSocketCloseReason.NORMAL_CLOSURE) {
|
|
143
|
+
try {
|
|
144
|
+
this.attemptReconnect();
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error instanceof WebSocketError) {
|
|
147
|
+
switch (error.code) {
|
|
148
|
+
case WebSocketErrorType.MAX_RECONNECT_EXCEEDED:
|
|
149
|
+
this.closedReason =
|
|
150
|
+
WebSocketCloseReason.MAX_RECONNECT_EXCEEDED;
|
|
151
|
+
break;
|
|
152
|
+
case WebSocketErrorType.CONNECTION_ERROR:
|
|
153
|
+
case WebSocketErrorType.WEBSOCKET_NOT_INITIALIZED:
|
|
154
|
+
this.closedReason = WebSocketCloseReason.CONNECTION_ERROR;
|
|
155
|
+
break;
|
|
156
|
+
default:
|
|
157
|
+
this.closedReason = WebSocketCloseReason.UNKNOWN;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
this.closedReason = WebSocketCloseReason.UNKNOWN;
|
|
162
|
+
}
|
|
163
|
+
this.log(`Failed to reconnect: ${error}`, "error");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
this.ws.onmessage = (event) => {
|
|
169
|
+
try {
|
|
170
|
+
const rawData = JSON.parse(event.data.toString());
|
|
171
|
+
switch (rawData.type) {
|
|
172
|
+
case "price_update":
|
|
173
|
+
const priceData = rawData as WebSocketMessage<BISTStockLiveData>;
|
|
174
|
+
const data = priceData.message;
|
|
175
|
+
if (!data) {
|
|
176
|
+
throw new WebSocketError(
|
|
177
|
+
"Price update message is empty",
|
|
178
|
+
WebSocketErrorType.MESSAGE_PARSE_ERROR
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (data.symbol) {
|
|
182
|
+
const handlers = this.getHandlersForSymbol(data.symbol);
|
|
183
|
+
handlers.forEach((handler) => handler(data));
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
case "heartbeat":
|
|
187
|
+
this.log("Received heartbeat");
|
|
188
|
+
return;
|
|
189
|
+
case "error":
|
|
190
|
+
this.log(`Received error: ${rawData.message}`, "error");
|
|
191
|
+
return;
|
|
192
|
+
case "warning":
|
|
193
|
+
this.log(`Received warning: ${rawData.message}`, "warn");
|
|
194
|
+
return;
|
|
195
|
+
default:
|
|
196
|
+
this.log(`Unknown message type: ${rawData.type}`, "error");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
this.log(`Failed to parse WebSocket message: ${error}`, "error");
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private getActiveSymbols(): string[] {
|
|
207
|
+
const allSymbols = Array.from(this.subscriptions.values()).flatMap(
|
|
208
|
+
(sub) => sub.symbols
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
return [...new Set(allSymbols)];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private async attemptReconnect() {
|
|
215
|
+
const url = this.wsUrl;
|
|
216
|
+
if (!url) {
|
|
217
|
+
throw new WebSocketError(
|
|
218
|
+
"WebSocket URL is not set",
|
|
219
|
+
WebSocketErrorType.WEBSOCKET_NOT_INITIALIZED
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (this.reconnectAttempts >= this.options.reconnectAttempts) {
|
|
223
|
+
throw new WebSocketError(
|
|
224
|
+
`Maximum reconnection attempts (${this.options.reconnectAttempts}) reached`,
|
|
225
|
+
WebSocketErrorType.MAX_RECONNECT_EXCEEDED
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this.reconnectAttempts++;
|
|
230
|
+
const delay = Math.min(
|
|
231
|
+
this.options.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
|
|
232
|
+
this.options.maxReconnectDelay
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
this.log(
|
|
236
|
+
`Attempting to reconnect (${this.reconnectAttempts}/${this.options.reconnectAttempts}) in ${delay}ms...`
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
if (this.reconnectTimeout) {
|
|
240
|
+
clearTimeout(this.reconnectTimeout);
|
|
241
|
+
this.reconnectTimeout = null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.reconnectTimeout = setTimeout(async () => {
|
|
245
|
+
try {
|
|
246
|
+
await this.connect(url);
|
|
247
|
+
const activeSymbols = this.getActiveSymbols();
|
|
248
|
+
if (activeSymbols.length > 0) {
|
|
249
|
+
this.addSymbols(activeSymbols);
|
|
250
|
+
}
|
|
251
|
+
if (this.reconnectTimeout) {
|
|
252
|
+
clearTimeout(this.reconnectTimeout);
|
|
253
|
+
this.reconnectTimeout = null;
|
|
254
|
+
}
|
|
255
|
+
this.reconnectAttempts = 0;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
this.attemptReconnect();
|
|
258
|
+
}
|
|
259
|
+
}, delay).unref();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
subscribe(
|
|
263
|
+
symbols: string[],
|
|
264
|
+
handler: (data: BISTStockLiveData) => void
|
|
265
|
+
): () => void {
|
|
266
|
+
const subscriptionId = this.subscriptionCounter++;
|
|
267
|
+
let symbolsToAdd: string[] = [];
|
|
268
|
+
|
|
269
|
+
this.subscriptions.set(subscriptionId, { symbols, handler });
|
|
270
|
+
|
|
271
|
+
for (const symbol of symbols) {
|
|
272
|
+
if (this.getHandlersForSymbol(symbol).length === 1) {
|
|
273
|
+
symbolsToAdd.push(symbol);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (symbolsToAdd.length > 0) {
|
|
277
|
+
this.addSymbols(symbolsToAdd);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return () => {
|
|
281
|
+
this.subscriptions.delete(subscriptionId);
|
|
282
|
+
const symbolsForRemove = symbols.filter(
|
|
283
|
+
(s) => this.getHandlersForSymbol(s).length === 0
|
|
284
|
+
);
|
|
285
|
+
this.removeSymbols(symbolsForRemove);
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private getHandlersForSymbol(
|
|
290
|
+
symbol: string
|
|
291
|
+
): ((data: BISTStockLiveData) => void)[] {
|
|
292
|
+
return Array.from(this.subscriptions.values())
|
|
293
|
+
.filter((s) => s.symbols.includes(symbol))
|
|
294
|
+
.map((s) => s.handler);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private async removeSymbols(symbols: string[]) {
|
|
298
|
+
if (!this.ws) {
|
|
299
|
+
throw new WebSocketError(
|
|
300
|
+
"WebSocket is not initialized",
|
|
301
|
+
WebSocketErrorType.WEBSOCKET_NOT_INITIALIZED
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (this.ws.readyState !== WebSocket.OPEN) {
|
|
306
|
+
throw new WebSocketError(
|
|
307
|
+
"WebSocket is not connected",
|
|
308
|
+
WebSocketErrorType.WEBSOCKET_NOT_CONNECTED
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
this.ws.send(
|
|
312
|
+
JSON.stringify({
|
|
313
|
+
type: "unsubscribe",
|
|
314
|
+
symbols: symbols,
|
|
315
|
+
})
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private async addSymbols(symbols: string[]) {
|
|
320
|
+
if (!this.ws) {
|
|
321
|
+
throw new WebSocketError(
|
|
322
|
+
"WebSocket is not initialized",
|
|
323
|
+
WebSocketErrorType.WEBSOCKET_NOT_INITIALIZED
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (this.ws.readyState !== WebSocket.OPEN) {
|
|
328
|
+
throw new WebSocketError(
|
|
329
|
+
"WebSocket is not connected",
|
|
330
|
+
WebSocketErrorType.WEBSOCKET_NOT_CONNECTED
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
this.ws.send(
|
|
335
|
+
JSON.stringify({
|
|
336
|
+
type: "subscribe",
|
|
337
|
+
symbols: symbols,
|
|
338
|
+
})
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async close(): Promise<void> {
|
|
343
|
+
try {
|
|
344
|
+
this.subscriptions.clear();
|
|
345
|
+
this.closedReason = WebSocketCloseReason.NORMAL_CLOSURE;
|
|
346
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
347
|
+
await new Promise<void>((resolve, reject) => {
|
|
348
|
+
if (!this.ws) {
|
|
349
|
+
resolve();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
this.ws.onclose = () => {
|
|
354
|
+
this.isClosed = true;
|
|
355
|
+
resolve();
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
this.ws.close();
|
|
360
|
+
} catch (closeError) {
|
|
361
|
+
this.closedReason = null;
|
|
362
|
+
reject(
|
|
363
|
+
new WebSocketError(
|
|
364
|
+
`Failed to initiate close: ${closeError}`,
|
|
365
|
+
WebSocketErrorType.CLOSE_ERROR
|
|
366
|
+
)
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
} catch (error) {
|
|
372
|
+
const errorMessage =
|
|
373
|
+
error instanceof WebSocketError
|
|
374
|
+
? error.message
|
|
375
|
+
: `Unexpected error during close: ${error}`;
|
|
376
|
+
|
|
377
|
+
this.log(errorMessage, "error");
|
|
378
|
+
throw error instanceof WebSocketError
|
|
379
|
+
? error
|
|
380
|
+
: new WebSocketError(errorMessage, WebSocketErrorType.CLOSE_ERROR);
|
|
381
|
+
} finally {
|
|
382
|
+
if (this.reconnectTimeout) {
|
|
383
|
+
clearTimeout(this.reconnectTimeout);
|
|
384
|
+
this.reconnectTimeout = null;
|
|
385
|
+
}
|
|
386
|
+
this.ws = null;
|
|
387
|
+
this.subscriptions.clear();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
isConnectionClosed(): boolean {
|
|
392
|
+
return this.isClosed;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
getCloseReason(): WebSocketCloseReason | null {
|
|
396
|
+
return this.closedReason;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
@@ -1,8 +1,20 @@
|
|
|
1
|
-
import { Client } from
|
|
2
|
-
import { Region } from
|
|
1
|
+
import { Client } from "./client";
|
|
2
|
+
import { Region } from "./collections";
|
|
3
3
|
import { v4 as uuidv4 } from 'uuid';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
export interface BISTStockLiveData {
|
|
6
|
+
s: string; // Symbol
|
|
7
|
+
ch: number; // DailyPercentChange
|
|
8
|
+
p: number; // ClosePrice
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface USStockLiveData {
|
|
12
|
+
s: string; // Symbol
|
|
13
|
+
bp: number; // BidPrice
|
|
14
|
+
ap: number; // AskPrice
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getSSELivePrice<T>(
|
|
6
18
|
client: Client,
|
|
7
19
|
symbols: string[],
|
|
8
20
|
region: Region,
|
|
@@ -16,19 +28,26 @@ function getLivePrice<T>(
|
|
|
16
28
|
return client.sendSSERequest<T>(url);
|
|
17
29
|
}
|
|
18
30
|
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
export class LivePriceWebSocketUrlClient extends Client {
|
|
32
|
+
async getWebSocketUrl(
|
|
33
|
+
externalUserId: string,
|
|
34
|
+
region: Region
|
|
35
|
+
): Promise<string> {
|
|
36
|
+
const url = new URL(`${this["baseUrl"]}/api/v1/ws/url`);
|
|
37
|
+
url.searchParams.append("region", region);
|
|
38
|
+
url.searchParams.append("accessLevel", "KRMD1");
|
|
24
39
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
const response = await this.sendRequest<string>({
|
|
41
|
+
method: "POST",
|
|
42
|
+
url: url.toString(),
|
|
43
|
+
data: {
|
|
44
|
+
externalUserId: externalUserId,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return response;
|
|
49
|
+
}
|
|
30
50
|
|
|
31
|
-
export class LivePriceClient extends Client {
|
|
32
51
|
getLivePriceForBIST(
|
|
33
52
|
symbols: string[],
|
|
34
53
|
region: Region,
|
|
@@ -37,7 +56,7 @@ export class LivePriceClient extends Client {
|
|
|
37
56
|
events: AsyncIterable<BISTStockLiveData>,
|
|
38
57
|
cancel: () => void
|
|
39
58
|
} {
|
|
40
|
-
return
|
|
59
|
+
return getSSELivePrice<BISTStockLiveData>(this, symbols, region, streamId);
|
|
41
60
|
}
|
|
42
61
|
|
|
43
62
|
getLivePriceForUS(
|
|
@@ -48,6 +67,6 @@ export class LivePriceClient extends Client {
|
|
|
48
67
|
events: AsyncIterable<USStockLiveData>,
|
|
49
68
|
cancel: () => void
|
|
50
69
|
} {
|
|
51
|
-
return
|
|
70
|
+
return getSSELivePrice<USStockLiveData>(this, symbols, region, streamId);
|
|
52
71
|
}
|
|
53
|
-
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Logger } from "winston";
|
|
2
|
+
import { LaplaceConfiguration } from "../utilities/configuration";
|
|
3
|
+
import { Region } from "../client/collections";
|
|
4
|
+
import "./client_test_suite";
|
|
5
|
+
import { LivePriceWebSocketUrlClient } from "../client/live-price";
|
|
6
|
+
import {
|
|
7
|
+
BISTStockLiveData,
|
|
8
|
+
LivePriceWebSocketClient,
|
|
9
|
+
} from "../client/live-price-web-socket";
|
|
10
|
+
|
|
11
|
+
describe("LivePrice", () => {
|
|
12
|
+
let livePriceUrlClient: LivePriceWebSocketUrlClient;
|
|
13
|
+
let url: string;
|
|
14
|
+
let ws: LivePriceWebSocketClient;
|
|
15
|
+
|
|
16
|
+
const TEST_CONSTANTS = {
|
|
17
|
+
JEST_TIMEOUT: 30000,
|
|
18
|
+
MAIN_TIMEOUT: 25000,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
const config = (global as any).testSuite.config as LaplaceConfiguration;
|
|
23
|
+
const logger: Logger = {
|
|
24
|
+
info: jest.fn(),
|
|
25
|
+
error: jest.fn(),
|
|
26
|
+
warn: jest.fn(),
|
|
27
|
+
debug: jest.fn(),
|
|
28
|
+
} as unknown as Logger;
|
|
29
|
+
|
|
30
|
+
livePriceUrlClient = new LivePriceWebSocketUrlClient(config, logger);
|
|
31
|
+
url = await livePriceUrlClient.getWebSocketUrl("2459", Region.Tr);
|
|
32
|
+
|
|
33
|
+
ws = new LivePriceWebSocketClient({
|
|
34
|
+
enableLogging: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
await ws.connect(url);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterAll(async () => {
|
|
41
|
+
try {
|
|
42
|
+
await ws.close();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error("Error closing websocket connection", error);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("BIST Live Price Tests", () => {
|
|
49
|
+
const symbols = ["TUPRS", "SASA", "THYAO", "GARAN", "YKBNK"];
|
|
50
|
+
const newSymbols = ["AKBNK", "KCHOL"];
|
|
51
|
+
|
|
52
|
+
it(
|
|
53
|
+
"should receive data for initial and updated symbols",
|
|
54
|
+
async () => {
|
|
55
|
+
const receivedData: BISTStockLiveData[] = [];
|
|
56
|
+
|
|
57
|
+
await new Promise<void>((resolve, reject) => {
|
|
58
|
+
const timeoutId = setTimeout(() => {
|
|
59
|
+
reject(new Error("Test timeout: No data received"));
|
|
60
|
+
}, TEST_CONSTANTS.MAIN_TIMEOUT).unref();
|
|
61
|
+
|
|
62
|
+
let unsubscribeNewSymbols: (() => void) | null = null;
|
|
63
|
+
|
|
64
|
+
const initialHandler = (data: BISTStockLiveData) => {
|
|
65
|
+
receivedData.push(data);
|
|
66
|
+
|
|
67
|
+
if (symbols.includes(data.symbol)) {
|
|
68
|
+
const unsubscribeInitial = ws.subscribe(symbols, initialHandler);
|
|
69
|
+
unsubscribeInitial();
|
|
70
|
+
|
|
71
|
+
unsubscribeNewSymbols = ws.subscribe(newSymbols, (data) => {
|
|
72
|
+
receivedData.push(data);
|
|
73
|
+
|
|
74
|
+
if (newSymbols.includes(data.symbol)) {
|
|
75
|
+
clearTimeout(timeoutId);
|
|
76
|
+
if (unsubscribeNewSymbols) unsubscribeNewSymbols();
|
|
77
|
+
resolve();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
ws.subscribe(symbols, initialHandler);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const newSymbolData = receivedData.filter((data) =>
|
|
87
|
+
newSymbols.includes(data.symbol)
|
|
88
|
+
);
|
|
89
|
+
const oldSymbolData = receivedData.filter((data) =>
|
|
90
|
+
symbols.includes(data.symbol)
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(oldSymbolData.length).toBeGreaterThan(0);
|
|
94
|
+
expect(newSymbolData.length).toBeGreaterThan(0);
|
|
95
|
+
|
|
96
|
+
newSymbolData.forEach((data) => {
|
|
97
|
+
expect(newSymbols).toContain(data.symbol);
|
|
98
|
+
expect(typeof data.symbol).toBe("string");
|
|
99
|
+
expect(typeof data.c).toBe("number");
|
|
100
|
+
expect(typeof data.cl).toBe("number");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const lastNewSymbolIndex = receivedData.findIndex((data) =>
|
|
104
|
+
newSymbols.includes(data.symbol)
|
|
105
|
+
);
|
|
106
|
+
const dataAfterUpdate = receivedData.slice(lastNewSymbolIndex);
|
|
107
|
+
const oldSymbolDataAfterUpdate = dataAfterUpdate.filter((data) =>
|
|
108
|
+
symbols.includes(data.symbol)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(oldSymbolDataAfterUpdate.length).toBe(0);
|
|
112
|
+
},
|
|
113
|
+
TEST_CONSTANTS.JEST_TIMEOUT
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
it(
|
|
117
|
+
"should handle multiple subscriptions for the same symbol",
|
|
118
|
+
async () => {
|
|
119
|
+
const symbol = "GARAN";
|
|
120
|
+
const receivedData1: BISTStockLiveData[] = [];
|
|
121
|
+
const receivedData2: BISTStockLiveData[] = [];
|
|
122
|
+
|
|
123
|
+
await new Promise<void>((resolve, reject) => {
|
|
124
|
+
const timeoutId = setTimeout(() => {
|
|
125
|
+
reject(new Error("Test timeout: No data received"));
|
|
126
|
+
}, TEST_CONSTANTS.MAIN_TIMEOUT).unref();
|
|
127
|
+
|
|
128
|
+
const unsubscribe1 = ws.subscribe([symbol], (data) => {
|
|
129
|
+
receivedData1.push(data);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const unsubscribe2 = ws.subscribe([symbol], (data) => {
|
|
133
|
+
receivedData2.push(data);
|
|
134
|
+
if (receivedData2.length >= 2) {
|
|
135
|
+
clearTimeout(timeoutId);
|
|
136
|
+
unsubscribe1();
|
|
137
|
+
unsubscribe2();
|
|
138
|
+
resolve();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(receivedData1.length).toBeGreaterThan(0);
|
|
144
|
+
expect(receivedData2.length).toBeGreaterThan(0);
|
|
145
|
+
expect(receivedData1).toEqual(receivedData2);
|
|
146
|
+
},
|
|
147
|
+
TEST_CONSTANTS.JEST_TIMEOUT
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// const testLivePrice = async (
|
|
152
|
+
// symbols: string[],
|
|
153
|
+
// region: Region,
|
|
154
|
+
// getLivePriceFunc: (symbols: string[], region: Region) => AsyncGenerator<BISTStockLiveData | USStockLiveData, void, unknown>
|
|
155
|
+
// ) => {
|
|
156
|
+
// const livePriceGenerator = getLivePriceFunc.call(livePriceClient, symbols, region);
|
|
157
|
+
// let livePriceCount = 0;
|
|
158
|
+
|
|
159
|
+
// try {
|
|
160
|
+
// for await (const livePrice of livePriceGenerator) {
|
|
161
|
+
// expect(livePrice).not.toBeEmpty();
|
|
162
|
+
// livePriceCount++;
|
|
163
|
+
// if (livePriceCount > 3) {
|
|
164
|
+
// break;
|
|
165
|
+
// }
|
|
166
|
+
// }
|
|
167
|
+
// } catch (error) {
|
|
168
|
+
// throw new Error(`Error occurred during live price retrieval: ${error}`);
|
|
169
|
+
// }
|
|
170
|
+
|
|
171
|
+
// expect(livePriceCount).toBeGreaterThan(0);
|
|
172
|
+
// };
|
|
173
|
+
|
|
174
|
+
// test('BISTLivePrice', async () => {
|
|
175
|
+
// const symbols = ['TUPRS', 'SASA', 'THYAO', 'GARAN', 'YKBN'];
|
|
176
|
+
// await testLivePrice(symbols, Region.Tr, livePriceClient.getLivePriceForBIST);
|
|
177
|
+
// }, 10000);
|
|
178
|
+
|
|
179
|
+
// test('USLivePrice', async () => {
|
|
180
|
+
// const symbols = ['AAPL', 'GOOGL', 'MSFT', 'AMZN', 'META'];
|
|
181
|
+
// await testLivePrice(symbols, Region.Us, livePriceClient.getLivePriceForUS);
|
|
182
|
+
// }, 10000);
|
|
183
|
+
});
|
package/src/utilities/test.env
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
BASE_URL=
|
|
2
|
-
API_KEY=
|
|
1
|
+
BASE_URL=
|
|
2
|
+
API_KEY=
|
package/src/test/live_price
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
// import { Logger } from 'winston';
|
|
2
|
-
// import { LaplaceConfiguration } from '../utilities/configuration';
|
|
3
|
-
// import { Client, createClient } from '../client/client';
|
|
4
|
-
// import { LivePriceClient, BISTStockLiveData, USStockLiveData } from '../client/live_price';
|
|
5
|
-
// import { Region } from '../client/collections';
|
|
6
|
-
// import './client_test_suite';
|
|
7
|
-
|
|
8
|
-
// describe('LivePrice', () => {
|
|
9
|
-
// let client: Client;
|
|
10
|
-
// let livePriceClient: LivePriceClient;
|
|
11
|
-
|
|
12
|
-
// beforeAll(() => {
|
|
13
|
-
// const config = (global as any).testSuite.config as LaplaceConfiguration;
|
|
14
|
-
// const logger: Logger = {
|
|
15
|
-
// info: jest.fn(),
|
|
16
|
-
// error: jest.fn(),
|
|
17
|
-
// warn: jest.fn(),
|
|
18
|
-
// debug: jest.fn(),
|
|
19
|
-
// } as unknown as Logger;
|
|
20
|
-
|
|
21
|
-
// client = createClient(config, logger);
|
|
22
|
-
// livePriceClient = new LivePriceClient(client);
|
|
23
|
-
// });
|
|
24
|
-
|
|
25
|
-
// const testLivePrice = async (
|
|
26
|
-
// symbols: string[],
|
|
27
|
-
// region: Region,
|
|
28
|
-
// getLivePriceFunc: (symbols: string[], region: Region) => AsyncGenerator<BISTStockLiveData | USStockLiveData, void, unknown>
|
|
29
|
-
// ) => {
|
|
30
|
-
// const livePriceGenerator = getLivePriceFunc.call(livePriceClient, symbols, region);
|
|
31
|
-
// let livePriceCount = 0;
|
|
32
|
-
|
|
33
|
-
// try {
|
|
34
|
-
// for await (const livePrice of livePriceGenerator) {
|
|
35
|
-
// expect(livePrice).not.toBeEmpty();
|
|
36
|
-
// livePriceCount++;
|
|
37
|
-
// if (livePriceCount > 3) {
|
|
38
|
-
// break;
|
|
39
|
-
// }
|
|
40
|
-
// }
|
|
41
|
-
// } catch (error) {
|
|
42
|
-
// throw new Error(`Error occurred during live price retrieval: ${error}`);
|
|
43
|
-
// }
|
|
44
|
-
|
|
45
|
-
// expect(livePriceCount).toBeGreaterThan(0);
|
|
46
|
-
// };
|
|
47
|
-
|
|
48
|
-
// test('BISTLivePrice', async () => {
|
|
49
|
-
// const symbols = ['TUPRS', 'SASA', 'THYAO', 'GARAN', 'YKBN'];
|
|
50
|
-
// await testLivePrice(symbols, Region.Tr, livePriceClient.getLivePriceForBIST);
|
|
51
|
-
// }, 10000);
|
|
52
|
-
|
|
53
|
-
// test('USLivePrice', async () => {
|
|
54
|
-
// const symbols = ['AAPL', 'GOOGL', 'MSFT', 'AMZN', 'META'];
|
|
55
|
-
// await testLivePrice(symbols, Region.Us, livePriceClient.getLivePriceForUS);
|
|
56
|
-
// }, 10000);
|
|
57
|
-
// });
|